From 7774fceaa9d353782200671d43e033c3ec3702c2 Mon Sep 17 00:00:00 2001 From: ienaga Date: Sun, 1 Nov 2026 18:02:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?overlay:=20=E3=82=B9=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=83=97=E5=BE=A9=E5=B8=B0=E3=81=A7=E7=94=BB=E9=9D=A2=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E8=BE=BC=E3=81=BF=E7=9B=B4=E3=81=97=EF=BC=88?= =?UTF-8?q?GPU=20=E3=83=AA=E3=82=BB=E3=83=83=E3=83=88=E3=81=A7=E6=8F=8F?= =?UTF-8?q?=E7=94=BB=E3=81=8C=E6=AD=A2=E3=81=BE=E3=82=8B=E3=81=AE=E3=82=92?= =?UTF-8?q?=E9=98=B2=E3=81=90=EF=BC=89=E3=80=81=E9=9B=BB=E6=BA=90=E3=83=BB?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=82=B9=E3=83=97=E3=83=AC=E3=82=A4=E3=83=BB?= =?UTF-8?q?GPU=20=E3=81=AE=E8=A8=BA=E6=96=AD=E3=83=AD=E3=82=B0=E3=82=92?= =?UTF-8?q?=E6=AE=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/steam.md | 4 ++++ package.json | 2 +- templates/electron/index.js | 37 ++++++++++++++++++++++++++++++++-- tests/electron-overlay.test.ts | 27 ++++++++++++++++++++++--- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/steam.md b/docs/steam.md index d2c665b..7a0d859 100644 --- a/docs/steam.md +++ b/docs/steam.md @@ -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%\\diagnostics.log`)に記録されます。 + Linuxの透明表示はコンポジタが必要です。 ### STEP4:認証を用意する @@ -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%\\diagnostics.log`). + Transparency on Linux requires a compositor. ### STEP4: Prepare credentials diff --git a/package.json b/package.json index 4fe51af..7a3f0e7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/builder", - "version": "0.4.4", + "version": "0.4.5", "description": "Multi-platform builder for Next2d Framework, supporting export to various platforms such as macOS, Windows, iOS, Android and Web(HTML)", "author": "Toshiyuki Ienaga ", "license": "MIT", diff --git a/templates/electron/index.js b/templates/electron/index.js index 2457cea..d2423d7 100644 --- a/templates/electron/index.js +++ b/templates/electron/index.js @@ -66,6 +66,26 @@ 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. +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. @@ -103,8 +123,10 @@ const createWindow = async () => { if (overlay.clickThrough) { mainWindow.setIgnoreMouseEvents(true, { forward: true }); } + 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); @@ -210,7 +232,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. diff --git a/tests/electron-overlay.test.ts b/tests/electron-overlay.test.ts index 9420289..6cc0f73 100644 --- a/tests/electron-overlay.test.ts +++ b/tests/electron-overlay.test.ts @@ -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 = { 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]); } @@ -209,12 +209,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"], @@ -278,3 +278,24 @@ 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, "\\$&"))); +}); From 6b8f0fd618b8c7af5ec8f73a8cb24789fbf8c92d Mon Sep 17 00:00:00 2001 From: ienaga Date: Sun, 1 Nov 2026 18:05:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?overlay:=20=E7=94=BB=E9=9D=A2=E5=81=B4?= =?UTF-8?q?=E3=81=AE=E3=83=9E=E3=82=A6=E3=82=B9=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E6=95=B0=E3=83=BB=E3=82=B2=E3=83=BC=E3=83=A0=E3=81=AE?= =?UTF-8?q?=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=BB=E7=AA=93API=E3=81=AE?= =?UTF-8?q?=E6=8B=92=E5=90=A6=E3=82=92=E8=A8=BA=E6=96=AD=E3=83=AD=E3=82=B0?= =?UTF-8?q?=E3=81=AB=E6=AE=8B=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- templates/electron/index.js | 44 +++++++++++++++++++++++++++++++++- tests/electron-overlay.test.ts | 38 ++++++++++++++++++++++++++++- 2 files changed, 80 insertions(+), 2 deletions(-) diff --git a/templates/electron/index.js b/templates/electron/index.js index d2423d7..7a03389 100644 --- a/templates/electron/index.js +++ b/templates/electron/index.js @@ -79,6 +79,30 @@ const diag = (message) => { // 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)"); @@ -123,6 +147,21 @@ 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"]; @@ -209,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); diff --git a/tests/electron-overlay.test.ts b/tests/electron-overlay.test.ts index 6cc0f73..3f3c75a 100644 --- a/tests/electron-overlay.test.ts +++ b/tests/electron-overlay.test.ts @@ -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 = { reloadIgnoringCache: () => calls.push(["reloadIgnoringCache"]), 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]); } @@ -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 }; } @@ -299,3 +300,38 @@ test("after sleep the renderer is loaded again (the GPU may have been reset); un 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\/\)/); +}); From af9e79c47b52604cb0d03736718b6f751b85c84c Mon Sep 17 00:00:00 2001 From: ienaga Date: Sun, 1 Nov 2026 18:32:36 +0900 Subject: [PATCH 3/3] =?UTF-8?q?0.4.6:=20develop(0.4.5)=E3=82=92=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E8=BE=BC=E3=81=BF=E3=80=81=E3=82=B9=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=83=97=E5=BE=A9=E5=B8=B0=E3=81=AE=E8=AA=AD=E3=81=BF=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E7=9B=B4=E3=81=97=E3=81=A8=E8=A8=BA=E6=96=AD=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=82=92=E5=90=AB=E3=82=81=E3=81=A6=E7=89=88=E3=82=92?= =?UTF-8?q?=E4=B8=8A=E3=81=92=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe479f6..65ff957 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next2d/builder", - "version": "0.4.5", + "version": "0.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/builder", - "version": "0.4.5", + "version": "0.4.6", "license": "MIT", "bin": { "builder": "dist/index.js" diff --git a/package.json b/package.json index 7a3f0e7..22cbced 100644 --- a/package.json +++ b/package.json @@ -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 ", "license": "MIT",