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: 2 additions & 2 deletions docs/steam.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const off = window.next2dWindow.onResume(() => { /* 状態の再送など */ });

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

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

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

Expand Down Expand Up @@ -329,7 +329,7 @@ const off = window.next2dWindow.onResume(() => { /* resend state, etc. */ });

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%\<appId>\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%\<appId>\diagnostics.log`).

Transparency on Linux requires a compositor.

Expand Down
41 changes: 28 additions & 13 deletions templates/electron/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `(() => {
Expand All @@ -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;
Expand Down Expand Up @@ -277,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}`));
Expand Down
46 changes: 35 additions & 11 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 = { 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]); }
Expand All @@ -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"]); }
Expand Down Expand Up @@ -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) => {
Expand Down
Loading