From 7a9c36691cbe341b214632840a9243c102680d32 Mon Sep 17 00:00:00 2001 From: ienaga Date: Tue, 3 Nov 2026 09:13:14 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E5=85=A8=E7=94=BB=E9=9D=A2=E3=81=A8?= =?UTF-8?q?=E5=B0=8F=E7=AA=93=E3=81=AE=E5=88=87=E3=82=8A=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=81=AE=E6=9B=B8=E3=81=8D=E5=87=BA=E3=81=97=E3=81=AB=E5=AF=BE?= =?UTF-8?q?=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/steam.md | 64 +++++++ src/electron-config.ts | 32 +++- templates/electron/index.js | 136 +++++++++++-- templates/electron/preload.cjs | 21 ++ tests/electron-window-mode.test.ts | 295 +++++++++++++++++++++++++++++ 5 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 tests/electron-window-mode.test.ts diff --git a/docs/steam.md b/docs/steam.md index 846e1d1..9518cac 100644 --- a/docs/steam.md +++ b/docs/steam.md @@ -111,6 +111,38 @@ Windowsでスリープ(ノートPCのふたを閉じる等)から復帰す Linuxの透明表示はコンポジタが必要です。 +#### ミニウィンドウ(通常の窓 ⇄ 小窓) + +ゲーム中に、通常の窓(全画面を含む)と、デスクトップの隅に置く最前面の小窓を切り替えられます。放置ゲームの「作業しながら遊ぶ」表示などに使います。`window.mini` 未指定なら従来どおりです。重ね表示(`window.overlay.enabled: true`)とは同時に使えません。 + +```json +"window": { + "width": 1920, "height": 1080, "fullscreen": true, + "mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true } +} +``` + +| 項目 | 内容 | +|---|---| +| `width` / `height` | 小窓の中身(タイトルバーを除く)の大きさ。既定 720×180 | +| `anchor` | 初めて小窓にしたときの位置。`"bottom-right"`(既定)/ `"bottom-left"` / `"top-right"` / `"top-left"`。窓のある画面の作業領域(タスクバー・Dockを除く)の隅 | +| `margin` | 隅からの間隔(px)。既定 `16` | +| `alwaysOnTop` | `true`(既定)で小窓を最前面に置く(macOSは全ワークスペース・全画面アプリ上にも表示) | + +小窓は通常の窓のまま(タイトルバーあり。タイトルバーで動かせる)で、キー入力も受け取ります。リサイズ・最大化・全画面はできません。動かした位置は、アプリを終了するまで次の小窓で使います。通常の窓に戻すと、小窓にする前の位置・大きさ・最大化・全画面に戻ります。小窓の間は F11/Alt+Enter/Esc で全画面を切り替えません(Esc はゲームに届きます)。 + +rendererへ `window.next2dWindow` を公開します(アプリのメインフレームのみ)。 + +```ts +const { mode, width, height } = await window.next2dWindow.getMode(); // mode は "full" か "mini" +await window.next2dWindow.setMode("mini"); // 切り替えが終わると { mode, width, height } で解決 +const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* 表示を合わせ直す */ }); +const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds(); +await window.next2dWindow.quit(); +``` + +`width` / `height` は窓の中身の大きさ(CSS ピクセル)です。Next2D のステージは縦横比を保ったまま窓に合わせて縮むため、1920×1080 のステージを 720×180 の小窓で表示すると、ステージの範囲は高さ 180・幅 320 になります(`options.fullScreen: true` なら、左右のステージの外も描かれます)。小窓用の表示は、この大きさに合わせてゲーム側で配置してください。 + ### STEP4:認証を用意する #### macOSの署名・公証 @@ -333,6 +365,38 @@ On Windows, after sleep (e.g. closing a laptop lid) the overlay window can stop Transparency on Linux requires a compositor. +#### Mini window (normal window ⇄ small window) + +At run time the game can switch between its normal window (fullscreen included) and a small always-on-top window in a corner of the desktop, for "play while you work" idle modes and the like. Without `window.mini` the window behaves as before. It cannot be combined with the overlay (`window.overlay.enabled: true`). + +```json +"window": { + "width": 1920, "height": 1080, "fullscreen": true, + "mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true } +} +``` + +| Field | Behaviour | +|---|---| +| `width` / `height` | Content size of the small window (title bar excluded). Default 720×180. | +| `anchor` | Where the small window first appears: `"bottom-right"` (default), `"bottom-left"`, `"top-right"` or `"top-left"` corner of the work area (taskbar/Dock excluded) of the display the window is on. | +| `margin` | Distance from that corner in px. Default `16`. | +| `alwaysOnTop` | `true` (default) keeps the small window on top (on macOS also on every workspace and above fullscreen apps). | + +The small window stays a normal window: it has a title bar (drag it to move the window) and takes keyboard input. It cannot be resized, maximized or made fullscreen. Where the player moves it is used for the next small window until the app quits. Switching back restores the position, size, maximized and fullscreen state from before. In the small window F11 / Alt+Enter / Escape do not toggle fullscreen (Escape reaches the game). + +The renderer gets `window.next2dWindow` (app main frame only): + +```ts +const { mode, width, height } = await window.next2dWindow.getMode(); // mode is "full" or "mini" +await window.next2dWindow.setMode("mini"); // resolves with { mode, width, height } once applied +const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* lay the page out again */ }); +const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds(); +await window.next2dWindow.quit(); +``` + +`width` / `height` are the content size in CSS pixels. The Next2D stage keeps its aspect ratio when it is fitted into the window, so a 1920×1080 stage shown in a 720×180 window covers 320×180 (with `options.fullScreen: true` the area left and right of the stage is drawn too). Lay out the small-window view for that size in the game. + ### STEP4: Prepare credentials #### macOS signing and notarization diff --git a/src/electron-config.ts b/src/electron-config.ts index 5093c85..ab3f7bb 100644 --- a/src/electron-config.ts +++ b/src/electron-config.ts @@ -7,6 +7,10 @@ import type { INativeBridgeConfig } from "./electron-native.js"; export type ElectronOS = "windows" | "macos" | "linux"; export interface IElectronOverlayConfig { enabled: boolean; anchor: "bottom"; clickThrough: boolean } +export type ElectronMiniAnchor = "bottom-right" | "bottom-left" | "top-right" | "top-left"; +/** A small always-on-top window the game can switch to and back from at run time (idle/companion modes). */ +export interface IElectronMiniConfig { width: number; height: number; anchor: ElectronMiniAnchor; margin: number; alwaysOnTop: boolean } +const MINI_ANCHORS: readonly ElectronMiniAnchor[] = ["bottom-right", "bottom-left", "top-right", "top-left"]; export interface IElectronConfig { nativeBridge?: INativeBridgeConfig; appId: string; @@ -16,7 +20,7 @@ export interface IElectronConfig { companyName: string; icons: Partial>; architectures: Partial>; - window: { width: number; height: number; fullscreen: boolean; overlay?: IElectronOverlayConfig }; + window: { width: number; height: number; fullscreen: boolean; overlay?: IElectronOverlayConfig; mini?: IElectronMiniConfig }; steam?: { appId: string | null; depots: Partial>; branch?: string }; macos: { sign: boolean; notarize: boolean; localNetworkUsageDescription?: string }; } @@ -93,6 +97,32 @@ export const readElectronConfig = (root: string): IElectronConfig => { config.window.fullscreen = false; } } + const mini: Partial | undefined = config.window.mini; + if (mini !== undefined) { + if (!mini || typeof mini !== "object" || Array.isArray(mini)) { + throw new Error("electron.config.json window.mini must be an object."); + } + config.window.mini = { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true, ...mini }; + const { width, height, anchor, margin, alwaysOnTop } = config.window.mini; + for (const dimension of [width, height]) { + if (!Number.isInteger(dimension) || dimension < 1 || dimension > 16384) { + throw new Error("electron.config.json window.mini.width/height must be integers between 1 and 16384."); + } + } + if (!MINI_ANCHORS.includes(anchor)) { + throw new Error(`electron.config.json window.mini.anchor must be one of ${MINI_ANCHORS.map((a) => `"${a}"`).join(", ")}.`); + } + if (!Number.isInteger(margin) || margin < 0 || margin > 1000) { + throw new Error("electron.config.json window.mini.margin must be an integer between 0 and 1000."); + } + if (typeof alwaysOnTop !== "boolean") { + throw new Error("electron.config.json window.mini.alwaysOnTop must be a boolean."); + } + // The overlay already is the small always-on-top window, and it never takes keyboard focus. + if (config.window.overlay?.enabled) { + throw new Error("electron.config.json window.mini cannot be combined with window.overlay.enabled."); + } + } if (config.macos.notarize && !config.macos.sign) { throw new Error("macos.notarize requires macos.sign."); } diff --git a/templates/electron/index.js b/templates/electron/index.js index 1f67b45..17cf71e 100644 --- a/templates/electron/index.js +++ b/templates/electron/index.js @@ -34,7 +34,92 @@ protocol.registerSchemesAsPrivileged([{ const startURL = "next2d://game/index.html"; const overlay = config.window.overlay?.enabled ? config.window.overlay : null; +// The game can switch between its normal window ("full") and a small always-on-top window ("mini") at run time. +const mini = !overlay && config.window.mini ? config.window.mini : null; let mainWindow; +let windowMode = "full"; +// How the window looked before it became small, to put it back exactly (position, size, maximized, fullscreen). +let fullState = null; +// Where the player last left the small window (it has a title bar and can be moved). +let miniPosition = null; +// Mode changes are applied one after another (leaving fullscreen is animated on macOS). +let modeQueue = Promise.resolve(); +const alive = () => Boolean(mainWindow && !mainWindow.isDestroyed()); +const modeInfo = () => { + const [width, height] = mainWindow.getContentSize(); + return { mode: windowMode, width, height }; +}; +// Entering and leaving fullscreen is animated on macOS: wait for it, so the size the page is told is the real one. +const setFullScreenAndWait = (win, value) => new Promise((resolve) => { + if (win.isFullScreen() === value) { + resolve(); + return; + } + let timer; + const finish = () => { + clearTimeout(timer); + resolve(); + }; + // Some window managers never report it; do not wait forever. + timer = setTimeout(finish, 1500); + win.once(value ? "enter-full-screen" : "leave-full-screen", finish); + win.setFullScreen(value); +}); +const cornerOf = (win) => { + const { width, height } = win.getBounds(); + const { workArea } = screen.getDisplayMatching(win.getBounds()); + const right = mini.anchor.endsWith("right"); + const bottom = mini.anchor.startsWith("bottom"); + return { + x: right ? workArea.x + workArea.width - width - mini.margin : workArea.x + mini.margin, + y: bottom ? workArea.y + workArea.height - height - mini.margin : workArea.y + mini.margin + }; +}; +const toMini = async () => { + const win = mainWindow; + fullState = { bounds: win.getNormalBounds(), maximized: win.isMaximized(), fullScreen: win.isFullScreen() }; + await setFullScreenAndWait(win, false); + if (win.isMaximized()) win.unmaximize(); + // Resize first: some platforms ignore size changes of a window that is no longer resizable. + win.setContentSize(mini.width, mini.height); + win.setFullScreenable(false); + win.setMaximizable(false); + win.setResizable(false); + const { x, y } = miniPosition ?? cornerOf(win); + win.setPosition(x, y); + if (mini.alwaysOnTop) { + win.setAlwaysOnTop(true, "floating"); + if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + } + windowMode = "mini"; +}; +const toFull = async () => { + const win = mainWindow; + const [x, y] = win.getPosition(); + miniPosition = { x, y }; + if (mini.alwaysOnTop) { + win.setAlwaysOnTop(false); + if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(false); + } + win.setResizable(true); + win.setMaximizable(true); + win.setFullScreenable(true); + if (fullState) { + win.setBounds(fullState.bounds); + if (fullState.maximized) win.maximize(); + if (fullState.fullScreen) await setFullScreenAndWait(win, true); + } + win.focus(); + windowMode = "full"; +}; +const setWindowMode = (mode) => { + modeQueue = modeQueue.then(async () => { + if (!alive() || mode === windowMode) return; + await (mode === "mini" ? toMini() : toFull()); + mainWindow.webContents.send("next2d:window:mode", modeInfo()); + }).catch((error) => console.error("Window mode change failed:", error)); + return modeQueue.then(() => (alive() ? modeInfo() : { mode: windowMode, width: 0, height: 0 })); +}; // 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. @@ -129,7 +214,8 @@ const createWindow = async () => { // The preload reads these flags; each API is exposed only when its feature is configured. const preloadFlags = [ ...(config.nativeBridge ? ["--next2d-native-bridge"] : []), - ...(overlay ? ["--next2d-overlay"] : []) + ...(overlay ? ["--next2d-overlay"] : []), + ...(mini ? ["--next2d-window-mode"] : []) ]; mainWindow = new BrowserWindow({ width: Math.min(config.window.width, width), @@ -197,7 +283,8 @@ const createWindow = async () => { } }); mainWindow.webContents.on("before-input-event", (event, input) => { - if (input.type !== "keyDown" || overlay) { + // The small window never goes fullscreen; Escape belongs to the game there. + if (input.type !== "keyDown" || overlay || windowMode === "mini") { return; } if (input.key === "F11" || (input.alt && input.key === "Enter")) { @@ -259,24 +346,42 @@ app.whenReady().then(async () => { return nativeBridge.request(method, params); }); } + const trusted = (event) => { + if (!isTrustedSender(event, mainWindow)) { + diag(`window API access denied (frame url: ${event?.senderFrame?.url})`); + throw new Error("Window API access denied"); + } + }; + if (overlay || mini) { + ipcMain.handle("next2d:window:get-bounds", (event) => { + trusted(event); + const bounds = mainWindow.getBounds(); + return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor }; + }); + ipcMain.handle("next2d:window:quit", (event) => { + trusted(event); + // Reply before the window is torn down. + setImmediate(() => app.quit()); + }); + } + if (mini) { + ipcMain.handle("next2d:window:get-mode", (event) => { + trusted(event); + return modeInfo(); + }); + ipcMain.handle("next2d:window:set-mode", (event, mode) => { + trusted(event); + if (mode !== "full" && mode !== "mini") throw new TypeError("mode must be \"full\" or \"mini\""); + return setWindowMode(mode); + }); + } if (overlay) { - const trusted = (event) => { - if (!isTrustedSender(event, mainWindow)) { - diag(`window API access denied (frame url: ${event?.senderFrame?.url})`); - throw new Error("Window API access denied"); - } - }; 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 = () => { @@ -300,11 +405,6 @@ app.whenReady().then(async () => { }); } app.on("child-process-gone", (_event, details) => diag(`child-process-gone ${details.type} ${details.reason}`)); - ipcMain.handle("next2d:window:quit", (event) => { - trusted(event); - // Reply before the window is torn down. - setImmediate(() => app.quit()); - }); } await createWindow(); }).catch((error) => { diff --git a/templates/electron/preload.cjs b/templates/electron/preload.cjs index 94a146b..4e28cd4 100644 --- a/templates/electron/preload.cjs +++ b/templates/electron/preload.cjs @@ -31,3 +31,24 @@ if (flags.has("--next2d-overlay")) { } }); } + +// window.mini: switch between the normal window ("full") and the small always-on-top window ("mini"). +// Never set together with the overlay (the config is rejected), so the two never expose next2dWindow twice. +if (flags.has("--next2d-window-mode")) { + contextBridge.exposeInMainWorld("next2dWindow", { + getMode: () => ipcRenderer.invoke("next2d:window:get-mode"), + setMode: async (mode) => { + if (mode !== "full" && mode !== "mini") throw new TypeError("Expected \"full\" or \"mini\""); + return ipcRenderer.invoke("next2d:window:set-mode", mode); + }, + getBounds: () => ipcRenderer.invoke("next2d:window:get-bounds"), + quit: async () => { await ipcRenderer.invoke("next2d:window:quit"); }, + // Fires after every change with { mode, width, height } (content size in CSS pixels). + onModeChange: (listener) => { + if (typeof listener !== "function") throw new TypeError("Expected mode listener"); + const handler = (_event, info) => listener(info); + ipcRenderer.on("next2d:window:mode", handler); + return () => ipcRenderer.removeListener("next2d:window:mode", handler); + } + }); +} diff --git a/tests/electron-window-mode.test.ts b/tests/electron-window-mode.test.ts new file mode 100644 index 0000000..1e4af6b --- /dev/null +++ b/tests/electron-window-mode.test.ts @@ -0,0 +1,295 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import Module, { createRequire } from "node:module"; +import { readElectronConfig } from "../dist/electron-config.js"; +import { withElectronHost } from "../dist/electron-host.js"; +const require = createRequire(import.meta.url); +const templates = path.resolve(import.meta.dirname, "../templates/electron"); + +const fixture = (t, electron) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "next2d mini ")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "test-game", version: "1.0.0" })); + if (electron !== undefined) fs.writeFileSync(path.join(root, "electron.config.json"), JSON.stringify(electron)); + return root; +}; + +const loadWithElectron = (t, file, electron) => { + const load = Module._load; + Module._load = function (request, ...rest) { + return request === "electron" ? electron : load.call(this, request, ...rest); + }; + t.after(() => { Module._load = load; }); + delete require.cache[file]; + require(file); +}; + +/** A fake Electron whose window keeps its state (bounds, fullscreen, flags) like the real one. */ +const fakeElectron = (options = {}) => { + const handlers = new Map(); + const calls = []; + const windows = []; + let ready; + // A title bar of 28px: the outer size is the content size plus the frame. + const FRAME = options.frame ?? 28; + const display = { workArea: { x: 0, y: 25, width: 1440, height: 875 }, workAreaSize: { width: 1440, height: 875 }, scaleFactor: 2 }; + class BrowserWindow { + constructor(windowOptions) { + this.options = windowOptions; + this.fullScreen = Boolean(windowOptions.fullscreen); + this.maximized = false; + this.flags = { resizable: true, maximizable: true, fullScreenable: true, alwaysOnTop: false }; + this.bounds = { x: 100, y: 80, width: windowOptions.width, height: windowOptions.height + FRAME }; + this.once_ = {}; + this.webContents = { + executeJavaScript: async () => "null", setWindowOpenHandler() {}, + on: (name, cb) => calls.push(["webContents.on", name, cb]), + removeAllListeners() {}, send: (...args) => calls.push(["send", ...args]), mainFrame: {} + }; + windows.push(this); + } + isDestroyed() { return false; } + isFullScreen() { return this.fullScreen; } + setFullScreen(value) { + calls.push(["setFullScreen", value]); + this.fullScreen = value; + // Like macOS: the change is reported a little later (after the animation). + if (options.reportFullScreen !== false) setImmediate(() => this.once_[value ? "enter-full-screen" : "leave-full-screen"]?.()); + } + isMaximized() { return this.maximized; } + maximize() { calls.push(["maximize"]); this.maximized = true; } + unmaximize() { calls.push(["unmaximize"]); this.maximized = false; } + getBounds() { return { ...this.bounds }; } + getNormalBounds() { return { ...this.bounds }; } + setBounds(bounds) { calls.push(["setBounds", bounds]); this.bounds = { ...bounds }; } + // In fullscreen the content fills the display; the normal bounds are kept for later. + getContentSize() { return this.fullScreen ? [1440, 900] : [this.bounds.width, this.bounds.height - FRAME]; } + setContentSize(width, height) { + calls.push(["setContentSize", width, height, this.flags.resizable]); + this.bounds = { ...this.bounds, width, height: height + FRAME }; + } + getPosition() { return [this.bounds.x, this.bounds.y]; } + setPosition(x, y) { calls.push(["setPosition", x, y]); this.bounds = { ...this.bounds, x, y }; } + setResizable(value) { this.flags.resizable = value; } + setMaximizable(value) { this.flags.maximizable = value; } + setFullScreenable(value) { this.flags.fullScreenable = value; } + setAlwaysOnTop(value, level) { calls.push(["setAlwaysOnTop", value, level]); this.flags.alwaysOnTop = value; } + setVisibleOnAllWorkspaces(...args) { calls.push(["setVisibleOnAllWorkspaces", ...args]); } + setIgnoreMouseEvents(...args) { calls.push(["setIgnoreMouseEvents", ...args]); } + focus() { calls.push(["focus"]); } + show() {} + showInactive() {} + once(name, cb) { this.once_[name] = cb; } + async loadURL() {} + } + const electron = { + app: { + isPackaged: false, commandLine: { getSwitchValue: () => "", hasSwitch: () => false }, setName() {}, setPath() {}, + requestSingleInstanceLock: () => true, dock: { hide() {}, show() {} }, + getPath: () => os.tmpdir(), on() {}, quit: () => calls.push(["quit"]), exit: (code) => calls.push(["exit", code]), + whenReady: () => new Promise((resolve) => { ready = resolve; }) + }, + BrowserWindow, + Menu: { setApplicationMenu() {}, buildFromTemplate: () => ({}) }, + powerMonitor: { on() {} }, + net: {}, protocol: { registerSchemesAsPrivileged() {}, handle() {} }, + screen: { getPrimaryDisplay: () => display, getDisplayMatching: () => display, on() {}, removeListener() {} }, + session: { defaultSession: { setPermissionRequestHandler() {}, setPermissionCheckHandler() {} } }, + ipcMain: { handle: (name, cb) => handlers.set(name, cb) } + }; + return { electron, handlers, calls, windows, display, start: async () => { ready(); await new Promise((r) => setImmediate(r)); } }; +}; + +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 mini 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(options); + loadWithElectron(t, path.join(dir, "index.js"), fake.electron); + await fake.start(); + const window = fake.windows[0]; + const trusted = { sender: window.webContents, senderFrame: Object.assign(window.webContents.mainFrame, { url: "next2d://game/index.html" }) }; + return { ...fake, window, trusted, runtime: JSON.parse(fs.readFileSync(path.join(dir, "runtime-config.json"), "utf8")) }; +}; + +test("mini window defaults and validation", (t) => { + assert.equal(readElectronConfig(fixture(t)).window.mini, undefined); + assert.deepEqual(readElectronConfig(fixture(t, { window: { mini: {} } })).window.mini, + { width: 720, height: 180, anchor: "bottom-right", margin: 16, alwaysOnTop: true }); + assert.deepEqual(readElectronConfig(fixture(t, { window: { mini: { width: 480, anchor: "top-left", margin: 0, alwaysOnTop: false } } })).window.mini, + { width: 480, height: 180, anchor: "top-left", margin: 0, alwaysOnTop: false }); + for (const [mini, message] of [ + [null, /window.mini must be an object/], [[], /must be an object/], [true, /must be an object/], + [{ width: 0 }, /width\/height must be integers/], [{ height: 1.5 }, /width\/height must be integers/], [{ width: "720" }, /width\/height/], + [{ anchor: "bottom" }, /anchor must be one of "bottom-right", "bottom-left", "top-right", "top-left"/], + [{ margin: -1 }, /margin must be an integer/], [{ margin: 1001 }, /margin/], + [{ alwaysOnTop: "yes" }, /alwaysOnTop must be a boolean/] + ]) { + assert.throws(() => readElectronConfig(fixture(t, { window: { mini } })), message); + } + assert.throws(() => readElectronConfig(fixture(t, { window: { mini: {}, overlay: { enabled: true } } })), + /window.mini cannot be combined with window.overlay.enabled/); + // A disabled overlay may stay in the file next to the mini window. + assert.equal(readElectronConfig(fixture(t, { window: { mini: {}, overlay: { enabled: false } } })).window.mini.width, 720); +}); + +test("without window.mini nothing is exposed and the window behaves as before", async (t) => { + const { window, handlers } = await startHost(t, { window: { width: 1920, height: 1080, fullscreen: true } }); + assert.equal(window.options.webPreferences.preload, undefined); + assert.equal(handlers.size, 0); +}); + +test("the renderer gets the window mode API only for window.mini", async (t) => { + const { window, handlers, runtime } = await startHost(t, { window: { width: 1280, height: 720, mini: {} } }); + assert.deepEqual(runtime.window.mini, { width: 720, height: 180, anchor: "bottom-right", margin: 16, alwaysOnTop: true }); + assert.deepEqual(window.options.webPreferences.additionalArguments, ["--next2d-window-mode"]); + assert.equal(window.options.webPreferences.sandbox, true); + assert.equal(window.options.webPreferences.contextIsolation, true); + assert.equal(window.options.focusable, undefined); + assert.deepEqual([...handlers.keys()].sort(), + ["next2d:window:get-bounds", "next2d:window:get-mode", "next2d:window:quit", "next2d:window:set-mode"]); + const untrusted = { sender: {}, senderFrame: { url: "https://example.com/" } }; + for (const name of handlers.keys()) assert.throws(() => handlers.get(name)(untrusted, "mini"), /access denied/); +}); + +test("full → mini → full keeps the window and puts it back where it was", async (t) => { + const { window, handlers, trusted, calls } = await startHost(t, { window: { width: 1280, height: 720, fullscreen: true, mini: {} } }); + const setMode = handlers.get("next2d:window:set-mode"); + for (const invalid of [undefined, "small", 1, {}]) assert.throws(() => setMode(trusted, invalid), /mode must be "full" or "mini"/); + assert.deepEqual(handlers.get("next2d:window:get-mode")(trusted), { mode: "full", width: 1440, height: 900 }); + assert.equal(window.isFullScreen(), true); + const before = window.getNormalBounds(); + + assert.deepEqual(await setMode(trusted, "mini"), { mode: "mini", width: 720, height: 180 }); + assert.equal(window.isFullScreen(), false); + // Resized while still resizable, then locked. + assert.deepEqual(calls.find(([name]) => name === "setContentSize"), ["setContentSize", 720, 180, true]); + assert.deepEqual(window.flags, { resizable: false, maximizable: false, fullScreenable: false, alwaysOnTop: true }); + // Bottom-right of the work area, 16px in: x = 1440 - 720 - 16, y = 25 + 875 - (180 + 28) - 16. + assert.deepEqual(window.getPosition(), [704, 676]); + assert.deepEqual(calls.find(([name]) => name === "setAlwaysOnTop"), ["setAlwaysOnTop", true, "floating"]); + assert.equal(calls.some(([name]) => name === "setVisibleOnAllWorkspaces"), process.platform === "darwin"); + assert.deepEqual(calls.filter(([name, channel]) => name === "send" && channel === "next2d:window:mode").at(-1), + ["send", "next2d:window:mode", { mode: "mini", width: 720, height: 180 }]); + // Asking for the current mode again changes nothing and sends nothing. + const sent = calls.length; + assert.deepEqual(await setMode(trusted, "mini"), { mode: "mini", width: 720, height: 180 }); + assert.equal(calls.length, sent); + + // Resolved once fullscreen is entered again, with the fullscreen size. + assert.deepEqual(await setMode(trusted, "full"), { mode: "full", width: 1440, height: 900 }); + assert.deepEqual(window.getBounds(), before); + assert.equal(window.isFullScreen(), true); + assert.deepEqual(window.flags, { resizable: true, maximizable: true, fullScreenable: true, alwaysOnTop: false }); + assert.ok(calls.some(([name]) => name === "focus")); +}); + +test("the small window comes back where the player moved it", async (t) => { + const { window, handlers, trusted } = await startHost(t, { window: { mini: { anchor: "top-left", margin: 8 } } }); + const setMode = handlers.get("next2d:window:set-mode"); + await setMode(trusted, "mini"); + assert.deepEqual(window.getPosition(), [8, 33]); + window.setPosition(300, 400); + await setMode(trusted, "full"); + await setMode(trusted, "mini"); + assert.deepEqual(window.getPosition(), [300, 400]); +}); + +test("a maximized window is restored maximized; alwaysOnTop false never raises it", async (t) => { + const { window, handlers, trusted, calls } = await startHost(t, { window: { mini: { alwaysOnTop: false } } }); + window.maximize(); + const setMode = handlers.get("next2d:window:set-mode"); + await setMode(trusted, "mini"); + assert.equal(window.isMaximized(), false); + assert.equal(calls.some(([name]) => name === "setAlwaysOnTop"), false); + await setMode(trusted, "full"); + assert.equal(window.isMaximized(), true); +}); + +test("fullscreen changes do not wait forever when the OS never reports them", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { window, handlers, trusted } = await startHost(t, { window: { fullscreen: true, mini: {} } }, { reportFullScreen: false }); + const setMode = handlers.get("next2d:window:set-mode"); + const toMini = setMode(trusted, "mini"); + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1500); + assert.deepEqual(await toMini, { mode: "mini", width: 720, height: 180 }); + assert.deepEqual(window.getContentSize(), [720, 180]); + const toFull = setMode(trusted, "full"); + await new Promise((resolve) => setImmediate(resolve)); + t.mock.timers.tick(1500); + assert.deepEqual(await toFull, { mode: "full", width: 1440, height: 900 }); +}); + +test("changes are applied one after another", async (t) => { + const { handlers, trusted, calls } = await startHost(t, { window: { fullscreen: true, mini: {} } }); + const setMode = handlers.get("next2d:window:set-mode"); + const results = await Promise.all([setMode(trusted, "mini"), setMode(trusted, "full"), setMode(trusted, "mini")]); + assert.deepEqual(results.map(({ mode }) => mode), ["mini", "full", "mini"]); + assert.deepEqual(calls.filter(([name, channel]) => name === "send" && channel === "next2d:window:mode").map(([, , info]) => info.mode), + ["mini", "full", "mini"]); +}); + +test("F11 / Alt+Enter / Escape toggle fullscreen only in the full window", async (t) => { + const { window, handlers, trusted, calls } = await startHost(t, { window: { mini: {} } }); + const keys = calls.find(([name, event]) => name === "webContents.on" && event === "before-input-event")[2]; + let prevented = 0; + const press = (input) => keys({ preventDefault: () => { prevented++; } }, { type: "keyDown", ...input }); + press({ key: "F11" }); + assert.equal(window.isFullScreen(), true); + assert.equal(prevented, 1); + press({ key: "F11" }); + await handlers.get("next2d:window:set-mode")(trusted, "mini"); + prevented = 0; + for (const input of [{ key: "F11" }, { key: "Enter", alt: true }, { key: "Escape" }]) press(input); + assert.equal(prevented, 0); + assert.equal(window.isFullScreen(), false); +}); + +test("get-bounds and quit work for the mini window", async (t) => { + const { handlers, trusted, calls } = await startHost(t, { window: { mini: {} } }); + assert.deepEqual(handlers.get("next2d:window:get-bounds")(trusted), { x: 100, y: 80, width: 1280, height: 748, scaleFactor: 2 }); + handlers.get("next2d:window:quit")(trusted); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(calls.at(-1), ["quit"]); +}); + +test("preload exposes the mode API and checks its arguments", async (t) => { + const exposed = {}; + const invoked = []; + const listeners = new Map(); + const argv = process.argv; + process.argv = [...argv, "--next2d-window-mode"]; + try { + loadWithElectron(t, path.join(templates, "preload.cjs"), { + contextBridge: { exposeInMainWorld: (key, api) => { exposed[key] = api; } }, + ipcRenderer: { + invoke: async (...args) => { invoked.push(args); return { mode: "mini", width: 720, height: 180 }; }, + on: (name, cb) => listeners.set(name, cb), removeListener: (name) => listeners.delete(name) + } + }); + } finally { + process.argv = argv; + } + assert.deepEqual(Object.keys(exposed), ["next2dWindow"]); + const api = exposed.next2dWindow; + assert.deepEqual(Object.keys(api).sort(), ["getBounds", "getMode", "onModeChange", "quit", "setMode"]); + await assert.rejects(api.setMode("small"), TypeError); + assert.deepEqual(await api.setMode("mini"), { mode: "mini", width: 720, height: 180 }); + await api.getMode(); + await api.getBounds(); + assert.equal(await api.quit(), undefined); + assert.deepEqual(invoked, [["next2d:window:set-mode", "mini"], ["next2d:window:get-mode"], ["next2d:window:get-bounds"], ["next2d:window:quit"]]); + assert.throws(() => api.onModeChange("x"), TypeError); + const seen = []; + const off = api.onModeChange((info) => seen.push(info)); + listeners.get("next2d:window:mode")({}, { mode: "full", width: 1280, height: 720 }); + assert.deepEqual(seen, [{ mode: "full", width: 1280, height: 720 }]); + off(); + assert.equal(listeners.has("next2d:window:mode"), false); +}); From 4926e1b45233dd0aecab5f565beae4619591294a Mon Sep 17 00:00:00 2001 From: ienaga Date: Tue, 3 Nov 2026 09:16:34 +0900 Subject: [PATCH 2/2] update packages --- package-lock.json | 18 +++++++++--------- package.json | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff1e948..22e4b58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@next2d/builder", - "version": "0.4.7", + "version": "0.4.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@next2d/builder", - "version": "0.4.7", + "version": "0.4.8", "license": "MIT", "bin": { "builder": "dist/index.js" @@ -19,7 +19,7 @@ "@typescript-eslint/parser": "^8.72.1", "eslint": "^10.14.0", "eslint-plugin-unused-imports": "^4.4.1", - "globals": "^17.13.0", + "globals": "^17.14.0", "typescript": "^6.0.3", "vite": "^8.3.5" } @@ -1459,9 +1459,9 @@ } }, "node_modules/globals": { - "version": "17.13.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", - "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "version": "17.14.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz", + "integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==", "dev": true, "license": "MIT", "engines": { @@ -2171,9 +2171,9 @@ } }, "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.8.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz", + "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==", "dev": true, "license": "ISC", "bin": { diff --git a/package.json b/package.json index 193b236..2eac094 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@next2d/builder", - "version": "0.4.7", + "version": "0.4.8", "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", @@ -47,7 +47,7 @@ "@typescript-eslint/parser": "^8.72.1", "eslint": "^10.14.0", "eslint-plugin-unused-imports": "^4.4.1", - "globals": "^17.13.0", + "globals": "^17.14.0", "typescript": "^6.0.3", "vite": "^8.3.5" }