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
8 changes: 4 additions & 4 deletions docs/steam.md
Original file line number Diff line number Diff line change
Expand Up @@ -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%\<appId>\diagnostics.log`)に記録されます。
Windowsでスリープ(ノートPCのふたを閉じる等)から復帰すると、重ね表示の窓がマウスを受け取れなくなることがあるため、窓を作り直します(ゲームは自動保存を前提にしてください)。電源・ディスプレイ・GPUのイベントは `userData/diagnostics.log`(Windowsは `%APPDATA%\<appId>\diagnostics.log`)に記録されます。

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

Expand Down Expand Up @@ -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%\<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
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.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 <ienaga@next2d.app>",
"license": "MIT",
Expand Down
3 changes: 1 addition & 2 deletions src/electron-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? {
Expand Down
46 changes: 30 additions & 16 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 @@ -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);
Expand Down Expand Up @@ -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}`));
Expand Down
50 changes: 37 additions & 13 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 @@ -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")();
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
2 changes: 1 addition & 1 deletion tests/electron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading