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: 8 additions & 0 deletions docs/steam.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:認証を用意する
Expand Down Expand Up @@ -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
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.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 <ienaga@next2d.app>",
"license": "MIT",
Expand Down
36 changes: 35 additions & 1 deletion templates/electron/index.js
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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");
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -156,13 +168,25 @@ 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) => {
trusted(event);
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.
Expand All @@ -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());
9 changes: 8 additions & 1 deletion templates/electron/preload.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
}
66 changes: 58 additions & 8 deletions tests/electron-overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,20 @@ 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 };
class BrowserWindow {
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]); }
Expand All @@ -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,
Expand All @@ -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")) };
Expand All @@ -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);
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
});
Loading