Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/steam.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ const off = window.next2dWindow.onResume(() => { /* 状態の再送など */ });

重ね表示が有効なときは、アプリは**1つだけ**起動します(2つ目の起動はすぐに終了します。macOSではDockにアイコンも出しません)。開発中に複数起動したいときは `--next2d-allow-multiple` を付けて起動します。

スリープ(ノートPCのふたを閉じる等)から復帰すると、GPUがリセットされて描画が止まることがあるため、画面(renderer)を読み込み直します(ゲームは自動保存を前提にしてください)。電源・ディスプレイ・GPUのイベントは `userData/diagnostics.log`(Windowsは `%APPDATA%\<appId>\diagnostics.log`)に記録されます。

Linuxの透明表示はコンポジタが必要です。

### STEP4:認証を用意する
Expand Down Expand Up @@ -327,6 +329,8 @@ const off = window.next2dWindow.onResume(() => { /* resend state, etc. */ });

With the overlay enabled the app runs **once**: a second launch quits at once (and, on macOS, never shows a Dock icon). Start with `--next2d-allow-multiple` to run several copies while developing.

After sleep (e.g. closing a laptop lid) the GPU may be reset and drawing stops, so the page (renderer) is loaded again; the game should save automatically. Power, display and GPU events are logged to `userData/diagnostics.log` (Windows: `%APPDATA%\<appId>\diagnostics.log`).

Transparency on Linux requires a compositor.

### STEP4: Prepare credentials
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@next2d/builder",
"version": "0.4.5",
"version": "0.4.6",
"description": "Multi-platform builder for Next2d Framework, supporting export to various platforms such as macOS, Windows, iOS, Android and Web(HTML)",
"author": "Toshiyuki Ienaga <ienaga@next2d.app>",
"license": "MIT",
Expand Down
81 changes: 78 additions & 3 deletions templates/electron/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,50 @@ const refreshForwarding = () => {
mainWindow.setIgnoreMouseEvents(false);
mainWindow.setIgnoreMouseEvents(true, { forward: true });
};
// A small log of power/display/GPU events (userData/diagnostics.log) to find out what happened after sleep.
const diagnosticsFile = () => path.join(app.getPath("userData"), "diagnostics.log");
const diag = (message) => {
if (!overlay) return;
try {
const file = diagnosticsFile();
if (fs.existsSync(file) && fs.statSync(file).size > 200000) fs.writeFileSync(file, "");
fs.appendFileSync(file, `${new Date().toISOString()} ${message}\n`);
} catch { /* diagnostics must never break the game */ }
};
// After sleep (closing a laptop lid) the GPU is often reset. The renderer then keeps showing its last frame but can
// no longer draw (the web page is not told), so the pet looks alive and menus never appear. Load the page again to
// get a fresh GPU device. The game saves automatically, so progress is kept.
// Counts of mouse events the page really receives (moves are forwarded while click-through; down/up only arrive
// while the window accepts clicks). Logged when they change, to tell "clicks never reach the page" from other causes.
const DIAG_SCRIPT = `(() => {
if (window.__n2dDiag) return;
const d = window.__n2dDiag = { moves: 0, downs: 0, ups: 0, keys: 0, last: null };
const mark = (name) => (event) => { d[name]++; d.last = [event.clientX, event.clientY]; };
addEventListener("mousemove", mark("moves"), true);
addEventListener("pointerdown", mark("downs"), true);
addEventListener("pointerup", mark("ups"), true);
addEventListener("keydown", () => { d.keys++; }, true);
})()`;
let lastSnapshot = "";
const snapshotRenderer = async () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
const raw = await mainWindow.webContents.executeJavaScript("JSON.stringify(window.__n2dDiag || null)");
if (raw !== lastSnapshot) {
lastSnapshot = raw;
diag(`renderer ${raw} ignore=${overlayIgnore} onTop=${mainWindow.isAlwaysOnTop()} bounds=${JSON.stringify(mainWindow.getBounds())}`);
}
} catch (error) {
diag(`renderer snapshot failed: ${error?.message ?? error}`);
}
};
const reloadRenderer = () => {
if (!mainWindow || mainWindow.isDestroyed()) return;
diag("reload renderer (resume)");
overlayIgnore = Boolean(overlay?.clickThrough);
mainWindow.setIgnoreMouseEvents(overlayIgnore, overlayIgnore ? { forward: true } : undefined);
mainWindow.webContents.reloadIgnoringCache();
};
const createWindow = async () => {
const { width, height } = screen.getPrimaryDisplay().workAreaSize;
// The preload reads these flags; each API is exposed only when its feature is configured.
Expand Down Expand Up @@ -103,8 +147,25 @@ const createWindow = async () => {
if (overlay.clickThrough) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
}
// Errors the game prints (a failed window request, for example) are otherwise invisible in a packaged app.
let consoleLogged = 0;
mainWindow.webContents.on("console-message", (...args) => {
const first = args[0];
const detail = first && typeof first === "object" && "message" in first ? first : { level: args[1], message: args[2] };
if (!["error", "warning", 2, 3].includes(detail.level) || ++consoleLogged > 200) return;
diag(`console ${detail.level}: ${String(detail.message).slice(0, 300)}`);
});
mainWindow.webContents.on("did-finish-load", () => {
diag("renderer loaded");
mainWindow.webContents.executeJavaScript(DIAG_SCRIPT).catch(() => {});
});
const snapshotTimer = setInterval(snapshotRenderer, 5000);
snapshotTimer.unref?.();
mainWindow.once("closed", () => clearInterval(snapshotTimer));
mainWindow.webContents.on("unresponsive", () => diag("renderer unresponsive"));
mainWindow.webContents.on("responsive", () => diag("renderer responsive"));
const events = ["display-metrics-changed", "display-added", "display-removed"];
const onDisplayChange = () => { placeOverlay(); keepOnTop(); };
const onDisplayChange = () => { diag("display changed"); placeOverlay(); keepOnTop(); };
for (const name of events) screen.on(name, onDisplayChange);
mainWindow.once("closed", () => {
for (const name of events) screen.removeListener(name, onDisplayChange);
Expand Down Expand Up @@ -187,7 +248,10 @@ app.whenReady().then(async () => {
}
if (overlay) {
const trusted = (event) => {
if (!isTrustedSender(event, mainWindow)) throw new Error("Window API access denied");
if (!isTrustedSender(event, mainWindow)) {
diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
throw new Error("Window API access denied");
}
};
ipcMain.handle("next2d:window:set-ignore-mouse-events", (event, ignore) => {
trusted(event);
Expand All @@ -210,7 +274,18 @@ app.whenReady().then(async () => {
mainWindow.setIgnoreMouseEvents(overlayIgnore, overlayIgnore ? { forward: true } : undefined);
mainWindow.webContents.send("next2d:window:resume");
};
for (const name of ["resume", "unlock-screen"]) powerMonitor.on(name, () => setTimeout(recoverOverlay, 1000));
for (const name of ["suspend", "lock-screen"]) powerMonitor.on(name, () => diag(name));
for (const name of ["resume", "unlock-screen"]) {
powerMonitor.on(name, () => {
diag(name);
setTimeout(() => {
recoverOverlay();
// Only a resume from sleep can reset the GPU (unlocking the screen cannot).
if (name === "resume") reloadRenderer();
}, 1000);
});
}
app.on("child-process-gone", (_event, details) => diag(`child-process-gone ${details.type} ${details.reason}`));
ipcMain.handle("next2d:window:quit", (event) => {
trusted(event);
// Reply before the window is torn down.
Expand Down
63 changes: 60 additions & 3 deletions tests/electron-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const fakeElectron = (options = {}) => {
constructor(options) {
this.options = options;
this.bounds = { x: options.x ?? 0, y: options.y ?? 0, width: options.width, height: options.height };
this.webContents = { setWindowOpenHandler() {}, on: (name, cb) => calls.push(["webContents.on", name, cb]), send: (...args) => calls.push(["send", ...args]), mainFrame: {} };
this.webContents = { executeJavaScript: async (script) => { calls.push(["executeJavaScript", script.slice(0, 20)]); return "{\"moves\":1}"; }, reloadIgnoringCache: () => calls.push(["reloadIgnoringCache"]), setWindowOpenHandler() {}, on: (name, cb) => calls.push(["webContents.on", name, cb]), send: (...args) => calls.push(["send", ...args]), mainFrame: {} };
windows.push(this);
}
setAlwaysOnTop(...args) { calls.push(["setAlwaysOnTop", ...args]); }
Expand All @@ -53,6 +53,7 @@ const fakeElectron = (options = {}) => {
isFullScreen() { return false; }
isDestroyed() { return false; }
isVisible() { return true; }
isAlwaysOnTop() { return true; }
moveTop() { calls.push(["moveTop"]); }
showInactive() { calls.push(["showInactive"]); }
once(name, cb) { this.onceListeners = { ...this.onceListeners, [name]: cb }; }
Expand Down Expand Up @@ -209,12 +210,12 @@ test("preload exposes each renderer API only for its configured feature", (t) =>
test("overlay is applied again and the page is told after sleep or unlock", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const { powerListeners, handlers, calls, windows } = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } });
assert.deepEqual([...powerListeners.keys()].sort(), ["resume", "unlock-screen"]);
assert.deepEqual([...powerListeners.keys()].sort(), ["lock-screen", "resume", "suspend", "unlock-screen"]);
// The page last asked for "capture clicks" (the pointer is over the pet), then the PC slept.
const trusted = { sender: windows[0].webContents, senderFrame: Object.assign(windows[0].webContents.mainFrame, { url: "next2d://game/index.html" }) };
handlers.get("next2d:window:set-ignore-mouse-events")(trusted, false);
calls.length = 0;
powerListeners.get("resume")();
powerListeners.get("unlock-screen")();
t.mock.timers.tick(1000);
assert.deepEqual(calls.filter(([name]) => /^(setAlwaysOnTop|setIgnoreMouseEvents|send)$/.test(name)), [
["setAlwaysOnTop", true, "floating"],
Expand Down Expand Up @@ -278,3 +279,59 @@ test("on other platforms there is no periodic raise", async (t) => {
t.mock.timers.tick(10000);
assert.equal(calls.some(([name]) => name === "moveTop"), false);
});

test("after sleep the renderer is loaded again (the GPU may have been reset); unlocking the screen does not", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const diagnostics = path.join(os.tmpdir(), "diagnostics.log");
fs.rmSync(diagnostics, { force: true });
t.after(() => fs.rmSync(diagnostics, { force: true }));
const { powerListeners, calls } = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } });
assert.deepEqual([...powerListeners.keys()].sort(), ["lock-screen", "resume", "suspend", "unlock-screen"]);
calls.length = 0;
powerListeners.get("unlock-screen")();
t.mock.timers.tick(1000);
assert.equal(calls.some(([name]) => name === "reloadIgnoringCache"), false);
powerListeners.get("suspend")();
powerListeners.get("resume")();
t.mock.timers.tick(1000);
assert.equal(calls.filter(([name]) => name === "reloadIgnoringCache").length, 1);
// Click-through starts again from the configured state after the page was reloaded.
assert.deepEqual(calls.filter(([name]) => name === "setIgnoreMouseEvents").at(-1), ["setIgnoreMouseEvents", true, { forward: true }]);
const log = fs.readFileSync(diagnostics, "utf8");
for (const word of ["unlock-screen", "suspend", "resume", "reload renderer (resume)"]) assert.match(log, new RegExp(word.replace(/[()]/g, "\\$&")));
});

test("the page's mouse event counts are written to the diagnostics log when they change", async (t) => {
t.mock.timers.enable({ apis: ["setInterval", "setTimeout"] });
const diagnostics = path.join(os.tmpdir(), "diagnostics.log");
fs.rmSync(diagnostics, { force: true });
t.after(() => fs.rmSync(diagnostics, { force: true }));
const { calls } = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } });
const loaded = calls.find(([name, event]) => name === "webContents.on" && event === "did-finish-load")[2];
loaded();
assert.ok(calls.some(([name, script]) => name === "executeJavaScript" && /__n2dDiag|\(\(\) =>/.test(script)));
t.mock.timers.tick(5000);
await new Promise((resolve) => setImmediate(resolve));
t.mock.timers.tick(5000);
await new Promise((resolve) => setImmediate(resolve));
const log = fs.readFileSync(diagnostics, "utf8");
assert.match(log, /renderer loaded/);
assert.equal((log.match(/renderer \{"moves":1\} ignore=true onTop=true/g) ?? []).length, 1);
});

test("game errors and denied window requests are written to the diagnostics log", async (t) => {
const diagnostics = path.join(os.tmpdir(), "diagnostics.log");
fs.rmSync(diagnostics, { force: true });
t.after(() => fs.rmSync(diagnostics, { force: true }));
const { calls, handlers } = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } });
const consoleMessage = calls.find(([name, event]) => name === "webContents.on" && event === "console-message")[2];
consoleMessage({ level: "error", message: "Error: boom" });
consoleMessage({ level: "info", message: "ignored" });
consoleMessage({}, 3, "legacy error");
assert.throws(() => handlers.get("next2d:window:set-ignore-mouse-events")({ sender: {}, senderFrame: { url: "https://example.com/" } }, true), /access denied/);
const log = fs.readFileSync(diagnostics, "utf8");
assert.match(log, /console error: Error: boom/);
assert.doesNotMatch(log, /ignored/);
assert.match(log, /console 3: legacy error/);
assert.match(log, /window API access denied \(frame url: https:\/\/example.com\/\)/);
});
Loading