diff --git a/docs/steam.md b/docs/steam.md index 7a0d859..846e1d1 100644 --- a/docs/steam.md +++ b/docs/steam.md @@ -105,9 +105,9 @@ await window.next2dWindow.quit(); const off = window.next2dWindow.onResume(() => { /* 状態の再送など */ }); ``` -重ね表示が有効なときは、アプリは**1つだけ**起動します(2つ目の起動はすぐに終了します。macOSではDockにアイコンも出しません)。開発中に複数起動したいときは `--next2d-allow-multiple` を付けて起動します。 +重ね表示が有効なときは、アプリは**1つだけ**起動します(2つ目の起動はすぐに終了します)。macOSではDockにアイコンを出しません(エージェントアプリ。終了はゲーム内のボタンで行います)。開発中に複数起動したいときは `--next2d-allow-multiple` を付けて起動します。 -スリープ(ノートPCのふたを閉じる等)から復帰すると、GPUがリセットされて描画が止まることがあるため、画面(renderer)を読み込み直します(ゲームは自動保存を前提にしてください)。電源・ディスプレイ・GPUのイベントは `userData/diagnostics.log`(Windowsは `%APPDATA%\\diagnostics.log`)に記録されます。 +Windowsでスリープ(ノートPCのふたを閉じる等)から復帰すると、重ね表示の窓がマウスを受け取れなくなることがあるため、窓を作り直します(ゲームは自動保存を前提にしてください)。電源・ディスプレイ・GPUのイベントは `userData/diagnostics.log`(Windowsは `%APPDATA%\\diagnostics.log`)に記録されます。 Linuxの透明表示はコンポジタが必要です。 @@ -327,9 +327,9 @@ await window.next2dWindow.quit(); 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. +With the overlay enabled the app runs **once**: a second launch quits at once. On macOS it is an agent app with no Dock icon (quit from inside the game). 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`). +On Windows, after sleep (e.g. closing a laptop lid) the overlay window can stop receiving the mouse, so a fresh window replaces it; 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. diff --git a/package-lock.json b/package-lock.json index 65ff957..ff1e948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next2d/builder", - "version": "0.4.6", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/builder", - "version": "0.4.6", + "version": "0.4.7", "license": "MIT", "bin": { "builder": "dist/index.js" diff --git a/package.json b/package.json index 22cbced..193b236 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/builder", - "version": "0.4.6", + "version": "0.4.7", "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/src/electron-config.ts b/src/electron-config.ts index 595692a..5093c85 100644 --- a/src/electron-config.ts +++ b/src/electron-config.ts @@ -154,8 +154,7 @@ export const createElectronPackagerOptions = ( ...os === "macos" && (config.macos.localNetworkUsageDescription || config.window.overlay?.enabled) ? { "extendInfo": { ...config.macos.localNetworkUsageDescription ? { "NSLocalNetworkUsageDescription": config.macos.localNetworkUsageDescription } : {}, - // An overlay starts as an agent app (no Dock icon) so that a second launch, which quits at once, - // never shows one. The first instance shows its Dock icon itself (templates/electron/index.js). + // An overlay is an agent app: no Dock icon, also for a second launch that quits at once. ...config.window.overlay?.enabled ? { "LSUIElement": true } : {} }, ...config.macos.localNetworkUsageDescription ? { diff --git a/templates/electron/index.js b/templates/electron/index.js index 7a03389..1f67b45 100644 --- a/templates/electron/index.js +++ b/templates/electron/index.js @@ -76,9 +76,6 @@ const diag = (message) => { 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 = `(() => { @@ -103,12 +100,29 @@ const snapshotRenderer = async () => { diag(`renderer snapshot failed: ${error?.message ?? error}`); } }; -const reloadRenderer = () => { - if (!mainWindow || mainWindow.isDestroyed()) return; - diag("reload renderer (resume)"); +// After sleep (closing a laptop lid) Windows can leave the overlay window itself broken: mouse moves are no longer +// forwarded to it, or clicks fall through even where the pet is drawn. Reloading the page does not help (the +// window is the same), so build a new window and close the old one. The game saves automatically, so progress is +// kept. The new window is created first so the app never has zero windows (which would quit it). +let recreating = false; +const recreateWindow = async () => { + if (recreating || !mainWindow || mainWindow.isDestroyed()) return; + recreating = true; + diag("recreate window (resume)"); + const old = mainWindow; overlayIgnore = Boolean(overlay?.clickThrough); - mainWindow.setIgnoreMouseEvents(overlayIgnore, overlayIgnore ? { forward: true } : undefined); - mainWindow.webContents.reloadIgnoringCache(); + try { + await createWindow(); + } catch (error) { + diag(`recreate window failed: ${error?.message ?? error}`); + } finally { + recreating = false; + if (old !== mainWindow && !old.isDestroyed()) { + // Closing on purpose: do not treat the old page going away as a crash. + old.webContents.removeAllListeners("render-process-gone"); + old.destroy(); + } + } }; const createWindow = async () => { const { width, height } = screen.getPrimaryDisplay().workAreaSize; @@ -205,9 +219,8 @@ const createWindow = async () => { app.whenReady().then(async () => { if (!hasLock) return; - // The overlay app is packaged as an agent app (LSUIElement) so a second launch never shows a Dock icon; - // the instance that keeps running shows it. - if (process.platform === "darwin" && overlay) app.dock?.show(); + // The overlay app is packaged as an agent app (LSUIElement): it never shows a Dock icon (a desktop pet stays out + // of the Dock and the app switcher; the game offers its own "quit"). Menu.setApplicationMenu(process.platform === "darwin" ? Menu.buildFromTemplate([ { role: "appMenu" }, { role: "editMenu" }, { role: "windowMenu" } ]) : null); @@ -278,11 +291,12 @@ app.whenReady().then(async () => { 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); + if (name === "resume" && process.platform === "win32") { + // Give the GPU/display time to come back, then start over with a fresh window. + setTimeout(() => { void recreateWindow(); }, 3000); + } else { + setTimeout(recoverOverlay, 1000); + } }); } app.on("child-process-gone", (_event, details) => diag(`child-process-gone ${details.type} ${details.reason}`)); diff --git a/tests/electron-overlay.test.ts b/tests/electron-overlay.test.ts index 3f3c75a..2a50557 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 = { 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: {} }; + 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]), removeAllListeners: (name) => calls.push(["webContents.removeAllListeners", name]), send: (...args) => calls.push(["send", ...args]), mainFrame: {} }; windows.push(this); } setAlwaysOnTop(...args) { calls.push(["setAlwaysOnTop", ...args]); } @@ -51,7 +51,8 @@ const fakeElectron = (options = {}) => { getBounds() { return this.bounds; } setFullScreen(value) { calls.push(["setFullScreen", value]); } isFullScreen() { return false; } - isDestroyed() { return false; } + isDestroyed() { return Boolean(this.destroyed); } + destroy() { this.destroyed = true; calls.push(["destroy"]); this.onceListeners?.closed?.(); } isVisible() { return true; } isAlwaysOnTop() { return true; } moveTop() { calls.push(["moveTop"]); } @@ -236,8 +237,8 @@ test("a desktop overlay runs once: a second launch quits before any window or Do const first = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } }); assert.equal(first.windows.length, 1); assert.equal(first.calls.some(([name]) => name === "exit"), false); - // The running instance shows its Dock icon itself (the app is packaged as an agent app). - assert.equal(first.calls.some(([name]) => name === "dock.show"), process.platform === "darwin"); + // The overlay is an agent app: no instance shows a Dock icon. + assert.equal(first.calls.some(([name]) => name === "dock.show"), false); assert.equal(denied.calls.some(([name]) => name === "dock.show"), false); first.calls.length = 0; first.appListeners.get("second-instance")(); @@ -280,25 +281,48 @@ test("on other platforms there is no periodic raise", async (t) => { 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"] }); +test("on Windows a fresh window replaces the old one after sleep; unlocking the screen only re-applies the overlay", async (t) => { + t.mock.timers.enable({ apis: ["setInterval", "setTimeout"] }); + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "win32" }); + t.after(() => Object.defineProperty(process, "platform", platform)); 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 } } }); + const { powerListeners, calls, windows } = 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); + assert.equal(windows.length, 1); + assert.ok(calls.some(([name]) => name === "send")); 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 }]); + t.mock.timers.tick(3000); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + // A new window exists and the old one was closed without being treated as a crash. + assert.equal(windows.length, 2); + assert.equal(windows[0].destroyed, true); + assert.equal(Boolean(windows[1].destroyed), false); + assert.ok(calls.some(([name, event]) => name === "webContents.removeAllListeners" && event === "render-process-gone")); + assert.equal(calls.some(([name]) => name === "reloadIgnoringCache"), false); + // The new window starts click-through, like a fresh launch. + assert.deepEqual(windows[1].options.focusable, false); 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, "\\$&"))); + for (const word of ["unlock-screen", "suspend", "resume", "recreate window \\(resume\\)"]) assert.match(log, new RegExp(word)); +}); + +test("on other platforms a resume only re-applies the overlay (no new window, no reload)", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + if (process.platform === "win32") return; + const { powerListeners, calls, windows } = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } }); + calls.length = 0; + powerListeners.get("resume")(); + t.mock.timers.tick(3000); + assert.equal(windows.length, 1); + assert.equal(calls.some(([name]) => name === "reloadIgnoringCache" || name === "destroy"), false); + assert.ok(calls.some(([name, event]) => name === "send" && event === "next2d:window:resume")); }); test("the page's mouse event counts are written to the diagnostics log when they change", async (t) => { diff --git a/tests/electron.test.ts b/tests/electron.test.ts index 0aa2c75..f1d2368 100644 --- a/tests/electron.test.ts +++ b/tests/electron.test.ts @@ -62,7 +62,7 @@ test("macOS local network purpose is opt-in and applied to the app and helpers", assert.equal(createElectronPackagerOptions(root, config, "linux", root).extendHelperInfo, undefined); fs.writeFileSync(path.join(root, "electron.config.json"), "{}"); assert.equal(createElectronPackagerOptions(root, readElectronConfig(root), "macos", root).extendInfo, undefined); - // An overlay is packaged as an agent app (no Dock icon until the running instance shows it). + // An overlay is packaged as an agent app (never a Dock icon). fs.writeFileSync(path.join(root, "electron.config.json"), JSON.stringify({ window: { overlay: { enabled: true } } })); const overlay = createElectronPackagerOptions(root, readElectronConfig(root), "macos", root); assert.deepEqual(overlay.extendInfo, { LSUIElement: true });