diff --git a/docs/steam.md b/docs/steam.md index f30f5e0..d2c665b 100644 --- a/docs/steam.md +++ b/docs/steam.md @@ -101,8 +101,12 @@ Steamは複数Depotに対応。builderは各OSに1 IDまでで、一部OSだけ await window.next2dWindow?.setIgnoreMouseEvents(false); // キャラクター上では操作を受け付ける const { x, y, width, height, scaleFactor } = await window.next2dWindow.getBounds(); await window.next2dWindow.quit(); +// スリープ復帰・画面ロック解除のあとに呼ばれる(重ね表示の状態は再適用済み。ページ側の状態を合わせ直す) +const off = window.next2dWindow.onResume(() => { /* 状態の再送など */ }); ``` +重ね表示が有効なときは、アプリは**1つだけ**起動します(2つ目の起動はすぐに終了します。macOSではDockにアイコンも出しません)。開発中に複数起動したいときは `--next2d-allow-multiple` を付けて起動します。 + Linuxの透明表示はコンポジタが必要です。 ### STEP4:認証を用意する @@ -317,8 +321,12 @@ Only when enabled, the renderer gets `window.next2dWindow` (app main frame only) await window.next2dWindow?.setIgnoreMouseEvents(false); // accept input while over the character const { x, y, width, height, scaleFactor } = await window.next2dWindow.getBounds(); await window.next2dWindow.quit(); +// Called after the PC wakes from sleep or the screen is unlocked (the overlay state was applied again; resync the page) +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. + Transparency on Linux requires a compositor. ### STEP4: Prepare credentials diff --git a/package-lock.json b/package-lock.json index 123ec99..eb19a81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next2d/builder", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/builder", - "version": "0.4.1", + "version": "0.4.2", "license": "MIT", "bin": { "builder": "dist/index.js" diff --git a/package.json b/package.json index 3ae45c0..4d28072 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/builder", - "version": "0.4.1", + "version": "0.4.2", "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 480b45b..73608f3 100644 --- a/templates/electron/index.js +++ b/templates/electron/index.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, Menu, net, protocol, screen, session, ipcMain } = require("electron"); +const { app, BrowserWindow, Menu, net, powerMonitor, protocol, screen, session, ipcMain } = require("electron"); const path = require("node:path"); const fs = require("node:fs"); const { pathToFileURL } = require("node:url"); @@ -13,6 +13,15 @@ app.on("before-quit", closeNativeBridge); app.setName(config.appName); const userDataOverride = app.commandLine.getSwitchValue("user-data-dir"); app.setPath("userData", userDataOverride ? path.resolve(userDataOverride) : path.join(app.getPath("appData"), config.appId)); + +// A desktop overlay (a pet that stays on screen) must run once. A second launch quits at once (macOS: before +// it can show a Dock icon). The lock is tied to the userData path set above, so it needs to come after it. +const singleInstance = !app.commandLine.hasSwitch("next2d-allow-multiple") && Boolean(config.window.overlay?.enabled); +const hasLock = !singleInstance || app.requestSingleInstanceLock(); +if (!hasLock) { + if (process.platform === "darwin") app.dock?.hide(); + app.exit(0); +} const assetRoot = app.isPackaged ? path.join(process.resourcesPath, "resources") : path.join(__dirname, "resources"); @@ -26,6 +35,8 @@ protocol.registerSchemesAsPrivileged([{ const startURL = "next2d://game/index.html"; const overlay = config.window.overlay?.enabled ? config.window.overlay : null; let mainWindow; +// The last click-through state asked for, so it can be applied again after sleep/lock. +let overlayIgnore = Boolean(config.window.overlay?.enabled && config.window.overlay.clickThrough); // Bottom anchor: flush with the primary display's work area (above the taskbar/Dock), centred. const overlayBounds = () => { const { workArea } = screen.getPrimaryDisplay(); @@ -111,6 +122,7 @@ const createWindow = async () => { }; app.whenReady().then(async () => { + if (!hasLock) return; Menu.setApplicationMenu(process.platform === "darwin" ? Menu.buildFromTemplate([ { role: "appMenu" }, { role: "editMenu" }, { role: "windowMenu" } ]) : null); @@ -156,6 +168,7 @@ app.whenReady().then(async () => { ipcMain.handle("next2d:window:set-ignore-mouse-events", (event, ignore) => { trusted(event); if (typeof ignore !== "boolean") throw new TypeError("ignore must be a boolean"); + overlayIgnore = ignore; mainWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined); }); ipcMain.handle("next2d:window:get-bounds", (event) => { @@ -163,6 +176,17 @@ app.whenReady().then(async () => { const bounds = mainWindow.getBounds(); return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor }; }); + // After sleep or screen lock the OS can drop the mouse-forwarding hook and reset the window's + // z-order/bounds. Apply the overlay state again and tell the page so it can recover too. + const recoverOverlay = () => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.setAlwaysOnTop(true, "floating"); + placeOverlay(); + mainWindow.setIgnoreMouseEvents(false); + 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)); ipcMain.handle("next2d:window:quit", (event) => { trusted(event); // Reply before the window is torn down. @@ -175,5 +199,15 @@ app.whenReady().then(async () => { app.exit(1); }); +if (singleInstance && hasLock) { + // Launching again does nothing visible; just make sure the overlay is still shown on top. + app.on("second-instance", () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.setAlwaysOnTop(true, "floating"); + if (!mainWindow.isVisible()) mainWindow.showInactive(); + } + }); +} + // Closing the game must stop Steam's "Running" status, including on macOS. app.on("window-all-closed", () => app.quit()); diff --git a/templates/electron/preload.cjs b/templates/electron/preload.cjs index 4e37617..94a146b 100644 --- a/templates/electron/preload.cjs +++ b/templates/electron/preload.cjs @@ -21,6 +21,13 @@ if (flags.has("--next2d-overlay")) { await ipcRenderer.invoke("next2d:window:set-ignore-mouse-events", ignore); }, getBounds: () => ipcRenderer.invoke("next2d:window:get-bounds"), - quit: async () => { await ipcRenderer.invoke("next2d:window:quit"); } + quit: async () => { await ipcRenderer.invoke("next2d:window:quit"); }, + // Fires after the PC wakes from sleep or the screen is unlocked (the overlay state was applied again). + onResume: (listener) => { + if (typeof listener !== "function") throw new TypeError("Expected resume listener"); + const handler = () => listener(); + ipcRenderer.on("next2d:window:resume", handler); + return () => ipcRenderer.removeListener("next2d:window:resume", handler); + } }); } diff --git a/tests/electron-overlay.test.ts b/tests/electron-overlay.test.ts index 0ae68a2..2f91281 100644 --- a/tests/electron-overlay.test.ts +++ b/tests/electron-overlay.test.ts @@ -28,10 +28,12 @@ const loadWithElectron = (t, file, electron) => { require(file); }; -const fakeElectron = () => { +const fakeElectron = (options = {}) => { const listeners = new Map(); const handlers = new Map(); const calls = []; + const powerListeners = new Map(); + const appListeners = new Map(); const windows = []; let ready; const display = { workArea: { x: 0, y: 25, width: 1440, height: 875 }, workAreaSize: { width: 1440, height: 875 }, scaleFactor: 2 }; @@ -39,7 +41,7 @@ const fakeElectron = () => { 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() {}, mainFrame: {} }; + this.webContents = { 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]); } @@ -50,17 +52,22 @@ const fakeElectron = () => { setFullScreen(value) { calls.push(["setFullScreen", value]); } isFullScreen() { return false; } isDestroyed() { return false; } + isVisible() { return true; } + showInactive() { calls.push(["showInactive"]); } once() {} async loadURL() {} } const electron = { app: { - isPackaged: false, commandLine: { getSwitchValue: () => "" }, setName() {}, setPath() {}, - getPath: () => os.tmpdir(), on() {}, quit: () => calls.push(["quit"]), exit: (code) => calls.push(["exit", code]), + isPackaged: false, commandLine: { getSwitchValue: () => "", hasSwitch: () => false }, setName() {}, setPath() {}, + requestSingleInstanceLock: () => { calls.push(["requestSingleInstanceLock"]); return options.lock !== false; }, + dock: { hide: () => calls.push(["dock.hide"]) }, + getPath: () => os.tmpdir(), on: (name, cb) => appListeners.set(name, cb), quit: () => calls.push(["quit"]), exit: (code) => calls.push(["exit", code]), whenReady: () => new Promise((resolve) => { ready = resolve; }) }, BrowserWindow, Menu: { setApplicationMenu() {}, buildFromTemplate: () => ({}) }, + powerMonitor: { on: (name, cb) => powerListeners.set(name, cb) }, net: {}, protocol: { registerSchemesAsPrivileged() {}, handle() {} }, screen: { getPrimaryDisplay: () => display, getDisplayMatching: () => display, @@ -69,16 +76,16 @@ const fakeElectron = () => { session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler: (cb) => calls.push(["permissionCheck", cb]) } }, ipcMain: { handle: (name, cb) => handlers.set(name, cb) } }; - return { electron, listeners, handlers, calls, windows, display, start: async () => { ready(); await new Promise((r) => setImmediate(r)); } }; + return { electron, listeners, powerListeners, appListeners, handlers, calls, windows, display, start: async () => { ready(); await new Promise((r) => setImmediate(r)); } }; }; -const startHost = async (t, config) => { +const startHost = async (t, config, options = {}) => { const root = fixture(t, config); fs.writeFileSync(path.join(root, "index.html"), "hello"); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "next2d overlay host ")); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); await withElectronHost(root, root, readElectronConfig(root), async (host) => fs.cpSync(host, dir, { recursive: true })); - const fake = fakeElectron(); + const fake = fakeElectron(options); loadWithElectron(t, path.join(dir, "index.js"), fake.electron); await fake.start(); return { ...fake, runtime: JSON.parse(fs.readFileSync(path.join(dir, "runtime-config.json"), "utf8")) }; @@ -101,7 +108,7 @@ test("overlay defaults and validation", (t) => { test("existing window behaviour is unchanged without an enabled overlay", async (t) => { for (const config of [{}, { window: { overlay: { enabled: false, clickThrough: true } } }]) { - const { windows, calls, handlers, listeners } = await startHost(t, config); + const { windows, calls, handlers, listeners, powerListeners } = await startHost(t, config); const { options } = windows[0]; assert.equal(options.transparent, undefined); assert.equal(options.frame, undefined); @@ -111,6 +118,7 @@ test("existing window behaviour is unchanged without an enabled overlay", async assert.equal(calls.some(([name]) => /^set(AlwaysOnTop|IgnoreMouseEvents)$/.test(name)), false); assert.equal(handlers.size, 0); assert.equal(listeners.size, 0); + assert.equal(powerListeners.size, 0); const check = calls.find(([name]) => name === "permissionCheck")[1]; assert.equal(check(null, "fullscreen"), true); } @@ -189,8 +197,50 @@ test("preload exposes each renderer API only for its configured feature", (t) => assert.equal(await exposed.next2dWindow.setIgnoreMouseEvents(true), undefined); assert.deepEqual(await exposed.next2dWindow.getBounds(), { x: 1 }); assert.equal(await exposed.next2dWindow.quit(), undefined); + assert.equal(typeof exposed.next2dWindow.onResume(() => {}), "function"); + assert.throws(() => exposed.next2dWindow.onResume("x"), TypeError); assert.deepEqual(invoked, [["next2d:window:set-ignore-mouse-events", true], ["next2d:window:get-bounds"], ["next2d:window:quit"]]); }); } } }); + +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"]); + // 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")(); + t.mock.timers.tick(1000); + assert.deepEqual(calls.filter(([name]) => /^(setAlwaysOnTop|setIgnoreMouseEvents|send)$/.test(name)), [ + ["setAlwaysOnTop", true, "floating"], + ["setIgnoreMouseEvents", false], + ["setIgnoreMouseEvents", false, undefined], + ["send", "next2d:window:resume"] + ]); +}); + +test("a desktop overlay runs once: a second launch quits before any window or Dock icon", async (t) => { + const denied = await startHost(t, { window: { overlay: { enabled: true, clickThrough: true } } }, { lock: false }); + assert.equal(denied.windows.length, 0); + assert.deepEqual(denied.calls.filter(([name]) => /^(requestSingleInstanceLock|exit|dock\.hide)$/.test(name)), + process.platform === "darwin" + ? [["requestSingleInstanceLock"], ["dock.hide"], ["exit", 0]] + : [["requestSingleInstanceLock"], ["exit", 0]]); + + 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); + first.calls.length = 0; + first.appListeners.get("second-instance")(); + assert.deepEqual(first.calls.filter(([name]) => name === "setAlwaysOnTop"), [["setAlwaysOnTop", true, "floating"]]); +}); + +test("a game without the overlay is not limited to one instance", async (t) => { + const { calls, appListeners } = await startHost(t, {}); + assert.equal(calls.some(([name]) => name === "requestSingleInstanceLock"), false); + assert.equal(appListeners.has("second-instance"), false); +});