@next2d/builder 0.4.7 → 0.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,16 @@ export interface IElectronOverlayConfig {
5
5
  anchor: "bottom";
6
6
  clickThrough: boolean;
7
7
  }
8
+ export type ElectronMiniAnchor = "bottom-right" | "bottom-left" | "top-right" | "top-left";
9
+ /** A small always-on-top window the game can switch to and back from at run time (idle/companion modes). */
10
+ export interface IElectronMiniConfig {
11
+ width: number;
12
+ height: number;
13
+ anchor: ElectronMiniAnchor;
14
+ margin: number;
15
+ alwaysOnTop: boolean;
16
+ transparent: boolean;
17
+ }
8
18
  export interface IElectronConfig {
9
19
  nativeBridge?: INativeBridgeConfig;
10
20
  appId: string;
@@ -19,6 +29,7 @@ export interface IElectronConfig {
19
29
  height: number;
20
30
  fullscreen: boolean;
21
31
  overlay?: IElectronOverlayConfig;
32
+ mini?: IElectronMiniConfig;
22
33
  };
23
34
  steam?: {
24
35
  appId: string | null;
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { getTemplateDir } from "./utils.js";
4
4
  import { resolveDescription } from "./description.js";
5
5
  import { validateNativeBridge } from "./electron-native.js";
6
+ const MINI_ANCHORS = ["bottom-right", "bottom-left", "top-right", "top-left"];
6
7
  export const validateSteamId = (value) => {
7
8
  const id = String(value);
8
9
  if (!/^[1-9][0-9]*$/.test(id) || !Number.isSafeInteger(Number(id)) || Number(id) > 4294967295) {
@@ -74,6 +75,35 @@ export const readElectronConfig = (root) => {
74
75
  config.window.fullscreen = false;
75
76
  }
76
77
  }
78
+ const mini = config.window.mini;
79
+ if (mini !== undefined) {
80
+ if (!mini || typeof mini !== "object" || Array.isArray(mini)) {
81
+ throw new Error("electron.config.json window.mini must be an object.");
82
+ }
83
+ config.window.mini = { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true, "transparent": false, ...mini };
84
+ const { width, height, anchor, margin, alwaysOnTop, transparent } = config.window.mini;
85
+ for (const dimension of [width, height]) {
86
+ if (!Number.isInteger(dimension) || dimension < 1 || dimension > 16384) {
87
+ throw new Error("electron.config.json window.mini.width/height must be integers between 1 and 16384.");
88
+ }
89
+ }
90
+ if (!MINI_ANCHORS.includes(anchor)) {
91
+ throw new Error(`electron.config.json window.mini.anchor must be one of ${MINI_ANCHORS.map((a) => `"${a}"`).join(", ")}.`);
92
+ }
93
+ if (!Number.isInteger(margin) || margin < 0 || margin > 1000) {
94
+ throw new Error("electron.config.json window.mini.margin must be an integer between 0 and 1000.");
95
+ }
96
+ if (typeof alwaysOnTop !== "boolean") {
97
+ throw new Error("electron.config.json window.mini.alwaysOnTop must be a boolean.");
98
+ }
99
+ if (typeof transparent !== "boolean") {
100
+ throw new Error("electron.config.json window.mini.transparent must be a boolean.");
101
+ }
102
+ // The overlay already is the small always-on-top window, and it never takes keyboard focus.
103
+ if (config.window.overlay?.enabled) {
104
+ throw new Error("electron.config.json window.mini cannot be combined with window.overlay.enabled.");
105
+ }
106
+ }
77
107
  if (config.macos.notarize && !config.macos.sign) {
78
108
  throw new Error("macos.notarize requires macos.sign.");
79
109
  }
package/docs/steam.md CHANGED
@@ -111,6 +111,60 @@ Windowsでスリープ(ノートPCのふたを閉じる等)から復帰す
111
111
 
112
112
  Linuxの透明表示はコンポジタが必要です。
113
113
 
114
+ #### ミニウィンドウ(通常の窓 ⇄ 小窓)
115
+
116
+ ゲーム中に、通常の窓(全画面を含む)と、デスクトップの隅に置く最前面の小窓を切り替えられます。放置ゲームの「作業しながら遊ぶ」表示などに使います。`window.mini` 未指定なら従来どおりです。重ね表示(`window.overlay.enabled: true`)とは同時に使えません。
117
+
118
+ ```json
119
+ "window": {
120
+ "width": 1920, "height": 1080, "fullscreen": true,
121
+ "mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true, "transparent": false }
122
+ }
123
+ ```
124
+
125
+ | 項目 | 内容 |
126
+ |---|---|
127
+ | `width` / `height` | 小窓の中身(タイトルバーを除く)の大きさ。既定 720×180 |
128
+ | `anchor` | 初めて小窓にしたときの位置。`"bottom-right"`(既定)/ `"bottom-left"` / `"top-right"` / `"top-left"`。窓のある画面の作業領域(タスクバー・Dockを除く)の隅 |
129
+ | `margin` | 隅からの間隔(px)。既定 `16` |
130
+ | `alwaysOnTop` | `true`(既定)で小窓を最前面に置く(macOSは全ワークスペース・全画面アプリ上にも表示) |
131
+ | `transparent` | `true` で背景が透明・枠なしの小窓にする(既定 `false`。下の「透明な小窓」) |
132
+
133
+ 小窓は通常の窓のまま(タイトルバーあり。タイトルバーで動かせる)で、キー入力も受け取ります。リサイズ・最大化・全画面はできません。動かした位置は、アプリを終了するまで次の小窓で使います。通常の窓に戻すと、小窓にする前の位置・大きさ・最大化・全画面に戻ります。小窓の間は F11/Alt+Enter/Esc で全画面を切り替えません(Esc はゲームに届きます)。
134
+
135
+ rendererへ `window.next2dWindow` を公開します(アプリのメインフレームのみ)。
136
+
137
+ ```ts
138
+ const { mode, width, height } = await window.next2dWindow.getMode(); // mode は "full" か "mini"
139
+ await window.next2dWindow.setMode("mini"); // 切り替えが終わると { mode, width, height } で解決
140
+ const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* 表示を合わせ直す */ });
141
+ const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds();
142
+ await window.next2dWindow.quit();
143
+ ```
144
+
145
+ `width` / `height` は窓の中身の大きさ(CSS ピクセル)です。Next2D のステージは縦横比を保ったまま窓に合わせて縮むため、1920×1080 のステージを 720×180 の小窓で表示すると、ステージの範囲は高さ 180・幅 320 になります(`options.fullScreen: true` なら、左右のステージの外も描かれます)。小窓用の表示は、この大きさに合わせてゲーム側で配置してください。
146
+
147
+ ##### 透明な小窓(`transparent: true`)
148
+
149
+ `window.mini.transparent: true`(既定 `false`。`false` なら上の動作のまま)にすると、小窓が「背景が透明・枠なし・影なし」の窓になり、船などの絵だけがデスクトップに浮かびます。透明は窓の作成時にしか決められず(Windows では透明な窓を最大化・全画面にできません)、同じ窓の切り替えでは実現できないため、`setMode` は**窓を作り直します**。
150
+
151
+ - `setMode("mini")`:透明・枠なし・リサイズ不可の新しい窓を `width`×`height` で `anchor`/`margin`(前回動かした位置があればそこ)に作って同じ URL を読み込み、通常の窓の状態(位置・大きさ・最大化・全画面)を記憶して閉じます。最前面(`alwaysOnTop: true`)、macOS は全ワークスペースに表示、キー入力は受け取ります(F10 などで戻せます)。
152
+ - `setMode("full")`:記憶した状態(全画面を含む)の通常の窓を作り直し、小窓を閉じます。
153
+ - ページは作り直しで破棄されます。**ゲームは切り替える前に自分で保存してください**(localStorage は同じ origin のまま引き継がれます)。新しいページは起動時に `getMode()` で今のモードを読んで表示を決めます(アプリ起動時は常に `"full"`)。
154
+ - `setMode` の Promise は、新しい窓の読み込みが終わってから `{ mode, width, height }` で解決します。
155
+
156
+ 透明な小窓(`mode === "mini"`)の間だけ、次の API が `window.next2dWindow` に加わります(`transparent: false` では公開されません)。
157
+
158
+ ```ts
159
+ // 作成直後は ignore=true:透明な部分は下のアプリへクリックを通し、マウスの移動だけページに届く。
160
+ // 船の上にポインタがあるときだけ false にして、クリック・ドラッグを受け取る。
161
+ await window.next2dWindow.setIgnoreMouseEvents(false);
162
+ // 枠がないので、ゲーム側のドラッグで窓を動かす。作業領域の外には出ない。位置は次の小窓にも使う。
163
+ await window.next2dWindow.moveBy(dx, dy);
164
+ ```
165
+
166
+ Windows でスリープから復帰してもマウス転送が切れないよう、重ね表示と同様に転送フックを張り直します。Linux の透明表示にはコンポジタが必要です。
167
+
114
168
  ### STEP4:認証を用意する
115
169
 
116
170
  #### macOSの署名・公証
@@ -333,6 +387,60 @@ On Windows, after sleep (e.g. closing a laptop lid) the overlay window can stop
333
387
 
334
388
  Transparency on Linux requires a compositor.
335
389
 
390
+ #### Mini window (normal window ⇄ small window)
391
+
392
+ 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`).
393
+
394
+ ```json
395
+ "window": {
396
+ "width": 1920, "height": 1080, "fullscreen": true,
397
+ "mini": { "width": 720, "height": 180, "anchor": "bottom-right", "margin": 16, "alwaysOnTop": true, "transparent": false }
398
+ }
399
+ ```
400
+
401
+ | Field | Behaviour |
402
+ |---|---|
403
+ | `width` / `height` | Content size of the small window (title bar excluded). Default 720×180. |
404
+ | `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. |
405
+ | `margin` | Distance from that corner in px. Default `16`. |
406
+ | `alwaysOnTop` | `true` (default) keeps the small window on top (on macOS also on every workspace and above fullscreen apps). |
407
+ | `transparent` | `true` makes it a transparent, frameless window (default `false`; see "Transparent small window" below). |
408
+
409
+ 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).
410
+
411
+ The renderer gets `window.next2dWindow` (app main frame only):
412
+
413
+ ```ts
414
+ const { mode, width, height } = await window.next2dWindow.getMode(); // mode is "full" or "mini"
415
+ await window.next2dWindow.setMode("mini"); // resolves with { mode, width, height } once applied
416
+ const off = window.next2dWindow.onModeChange(({ mode, width, height }) => { /* lay the page out again */ });
417
+ const { x, y, width: w, height: h, scaleFactor } = await window.next2dWindow.getBounds();
418
+ await window.next2dWindow.quit();
419
+ ```
420
+
421
+ `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.
422
+
423
+ ##### Transparent small window (`transparent: true`)
424
+
425
+ With `window.mini.transparent: true` (default `false`; `false` keeps the behaviour above) the small window becomes transparent, frameless and shadowless, so only what the game draws (a ship, say) floats on the desktop. Transparency can only be chosen when a window is created (and on Windows a transparent window cannot be maximized or made fullscreen), so one window cannot be reshaped into it: `setMode` **rebuilds the window**.
426
+
427
+ - `setMode("mini")`: builds a new transparent, frameless, non-resizable window of `width`×`height` at `anchor`/`margin` (or where it was last moved), loads the same URL, remembers the state of the normal window (position, size, maximized, fullscreen) and closes it. It is always on top (`alwaysOnTop: true`), on every workspace on macOS, and takes keys (so a key such as F10 can switch back).
428
+ - `setMode("full")`: rebuilds the normal window in the remembered state (fullscreen included) and closes the small one.
429
+ - The page is destroyed by the rebuild, so **the game must save before switching** (localStorage carries over: same origin). The new page reads the current mode with `getMode()` at startup (the app always starts in `"full"`).
430
+ - The promise of `setMode` resolves with `{ mode, width, height }` after the new window has finished loading.
431
+
432
+ Only while the transparent small window is shown (`mode === "mini"`), `window.next2dWindow` also has these (not exposed when `transparent` is `false`):
433
+
434
+ ```ts
435
+ // ignore=true right after creation: transparent parts let clicks fall through to the app below, and only mouse
436
+ // moves reach the page. Set it to false while the pointer is over the ship to receive clicks and drags.
437
+ await window.next2dWindow.setIgnoreMouseEvents(false);
438
+ // There is no frame, so the game drags the window itself. It stays inside the work area; the position is reused by the next small window.
439
+ await window.next2dWindow.moveBy(dx, dy);
440
+ ```
441
+
442
+ Like the overlay, the mouse-forwarding hook is installed again after Windows resumes from sleep. Transparency on Linux requires a compositor.
443
+
336
444
  ### STEP4: Prepare credentials
337
445
 
338
446
  #### macOS signing and notarization
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@next2d/builder",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Multi-platform builder for Next2d Framework, supporting export to various platforms such as macOS, Windows, iOS, Android and Web(HTML)",
5
5
  "author": "Toshiyuki Ienaga <ienaga@next2d.app>",
6
6
  "license": "MIT",
@@ -43,12 +43,12 @@
43
43
  "@eslint/eslintrc": "^3.3.7",
44
44
  "@eslint/js": "^10.0.1",
45
45
  "@types/node": "^26.6.6",
46
- "@typescript-eslint/eslint-plugin": "^8.72.1",
47
- "@typescript-eslint/parser": "^8.72.1",
46
+ "@typescript-eslint/eslint-plugin": "^8.72.2",
47
+ "@typescript-eslint/parser": "^8.72.2",
48
48
  "eslint": "^10.14.0",
49
49
  "eslint-plugin-unused-imports": "^4.4.1",
50
- "globals": "^17.13.0",
50
+ "globals": "^17.14.0",
51
51
  "typescript": "^6.0.3",
52
- "vite": "^8.3.5"
52
+ "vite": "^8.3.8"
53
53
  }
54
54
  }
@@ -34,7 +34,145 @@ protocol.registerSchemesAsPrivileged([{
34
34
 
35
35
  const startURL = "next2d://game/index.html";
36
36
  const overlay = config.window.overlay?.enabled ? config.window.overlay : null;
37
+ // The game can switch between its normal window ("full") and a small always-on-top window ("mini") at run time.
38
+ const mini = !overlay && config.window.mini ? config.window.mini : null;
39
+ // mini.transparent: the small window is a different window (transparent, frameless), so a mode change builds a new
40
+ // one instead of reshaping the same one. The page is gone after a change; the game saves before it switches.
41
+ const miniTransparent = Boolean(mini?.transparent);
37
42
  let mainWindow;
43
+ let windowMode = "full";
44
+ // How the window looked before it became small, to put it back exactly (position, size, maximized, fullscreen).
45
+ let fullState = null;
46
+ // Where the player last left the small window (it has a title bar and can be moved).
47
+ let miniPosition = null;
48
+ // Transparent mini only: true while the window lets clicks fall through (mouse moves are still forwarded to the page).
49
+ let miniIgnore = true;
50
+ // Mode changes are applied one after another (leaving fullscreen is animated on macOS).
51
+ let modeQueue = Promise.resolve();
52
+ const alive = () => Boolean(mainWindow && !mainWindow.isDestroyed());
53
+ const modeInfo = () => {
54
+ const [width, height] = mainWindow.getContentSize();
55
+ return { mode: windowMode, width, height };
56
+ };
57
+ // Entering and leaving fullscreen is animated on macOS: wait for it, so the size the page is told is the real one.
58
+ const setFullScreenAndWait = (win, value) => new Promise((resolve) => {
59
+ if (win.isFullScreen() === value) {
60
+ resolve();
61
+ return;
62
+ }
63
+ let timer;
64
+ const finish = () => {
65
+ clearTimeout(timer);
66
+ resolve();
67
+ };
68
+ // Some window managers never report it; do not wait forever.
69
+ timer = setTimeout(finish, 1500);
70
+ win.once(value ? "enter-full-screen" : "leave-full-screen", finish);
71
+ win.setFullScreen(value);
72
+ });
73
+ const cornerIn = (workArea, width, height) => {
74
+ const right = mini.anchor.endsWith("right");
75
+ const bottom = mini.anchor.startsWith("bottom");
76
+ return {
77
+ x: right ? workArea.x + workArea.width - width - mini.margin : workArea.x + mini.margin,
78
+ y: bottom ? workArea.y + workArea.height - height - mini.margin : workArea.y + mini.margin
79
+ };
80
+ };
81
+ const cornerOf = (win) => {
82
+ const { width, height } = win.getBounds();
83
+ return cornerIn(screen.getDisplayMatching(win.getBounds()).workArea, width, height);
84
+ };
85
+ // Transparent mini: a frameless window has no title bar, so its outer size is mini.width x mini.height.
86
+ const miniWindowBounds = (display) => {
87
+ const { x, y } = miniPosition ?? cornerIn(display.workArea, mini.width, mini.height);
88
+ return { x, y, width: mini.width, height: mini.height };
89
+ };
90
+ // Keep the small window inside the work area of the display it is on.
91
+ const clampToWorkArea = (bounds) => {
92
+ const { workArea } = screen.getDisplayMatching(bounds);
93
+ return {
94
+ ...bounds,
95
+ x: Math.round(Math.max(workArea.x, Math.min(bounds.x, workArea.x + workArea.width - bounds.width))),
96
+ y: Math.round(Math.max(workArea.y, Math.min(bounds.y, workArea.y + workArea.height - bounds.height)))
97
+ };
98
+ };
99
+ const toMini = async () => {
100
+ const win = mainWindow;
101
+ fullState = { bounds: win.getNormalBounds(), maximized: win.isMaximized(), fullScreen: win.isFullScreen() };
102
+ await setFullScreenAndWait(win, false);
103
+ if (win.isMaximized()) win.unmaximize();
104
+ // Resize first: some platforms ignore size changes of a window that is no longer resizable.
105
+ win.setContentSize(mini.width, mini.height);
106
+ win.setFullScreenable(false);
107
+ win.setMaximizable(false);
108
+ win.setResizable(false);
109
+ const { x, y } = miniPosition ?? cornerOf(win);
110
+ win.setPosition(x, y);
111
+ if (mini.alwaysOnTop) {
112
+ win.setAlwaysOnTop(true, "floating");
113
+ if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
114
+ }
115
+ windowMode = "mini";
116
+ };
117
+ const toFull = async () => {
118
+ const win = mainWindow;
119
+ const [x, y] = win.getPosition();
120
+ miniPosition = { x, y };
121
+ if (mini.alwaysOnTop) {
122
+ win.setAlwaysOnTop(false);
123
+ if (process.platform === "darwin") win.setVisibleOnAllWorkspaces(false);
124
+ }
125
+ win.setResizable(true);
126
+ win.setMaximizable(true);
127
+ win.setFullScreenable(true);
128
+ if (fullState) {
129
+ win.setBounds(fullState.bounds);
130
+ if (fullState.maximized) win.maximize();
131
+ if (fullState.fullScreen) await setFullScreenAndWait(win, true);
132
+ }
133
+ win.focus();
134
+ windowMode = "full";
135
+ };
136
+ // Transparent mini: build the other kind of window first (so the app never has zero windows), then close the old one.
137
+ const swapWindow = async (mode) => {
138
+ const old = mainWindow;
139
+ const previous = windowMode;
140
+ if (mode === "mini") {
141
+ fullState = { bounds: old.getNormalBounds(), maximized: old.isMaximized(), fullScreen: old.isFullScreen() };
142
+ // Do not close a window that is in a fullscreen Space on macOS.
143
+ if (old.isFullScreen()) await setFullScreenAndWait(old, false);
144
+ miniIgnore = true;
145
+ } else {
146
+ const [x, y] = old.getPosition();
147
+ miniPosition = { x, y };
148
+ }
149
+ // The new page reads the mode with getMode() when it starts.
150
+ windowMode = mode;
151
+ const retire = (win) => {
152
+ if (win.isDestroyed()) return;
153
+ // Closing on purpose: do not treat the page going away as a crash.
154
+ win.webContents.removeAllListeners("render-process-gone");
155
+ win.destroy();
156
+ };
157
+ try {
158
+ await createWindow();
159
+ } catch (error) {
160
+ windowMode = previous;
161
+ const failed = mainWindow;
162
+ mainWindow = old;
163
+ if (failed && failed !== old) retire(failed);
164
+ throw error;
165
+ }
166
+ retire(old);
167
+ };
168
+ const setWindowMode = (mode) => {
169
+ modeQueue = modeQueue.then(async () => {
170
+ if (!alive() || mode === windowMode) return;
171
+ await (miniTransparent ? swapWindow(mode) : mode === "mini" ? toMini() : toFull());
172
+ mainWindow.webContents.send("next2d:window:mode", modeInfo());
173
+ }).catch((error) => console.error("Window mode change failed:", error));
174
+ return modeQueue.then(() => (alive() ? modeInfo() : { mode: windowMode, width: 0, height: 0 }));
175
+ };
38
176
  // The last click-through state asked for, so it can be applied again after sleep/lock.
39
177
  let overlayIgnore = Boolean(config.window.overlay?.enabled && config.window.overlay.clickThrough);
40
178
  // Bottom anchor: flush with the primary display's work area (above the taskbar/Dock), centred.
@@ -62,7 +200,8 @@ const keepOnTop = () => {
62
200
  // when forwarding switches on. After sleep (closing the lid) the hook can be gone while Electron still thinks it is
63
201
  // installed, so the page never sees the pointer. Switching forwarding off and on again installs a fresh hook.
64
202
  const refreshForwarding = () => {
65
- if (!overlayIgnore || !mainWindow || mainWindow.isDestroyed()) return;
203
+ const forwarding = overlay ? overlayIgnore : miniTransparent && windowMode === "mini" && miniIgnore;
204
+ if (!forwarding || !mainWindow || mainWindow.isDestroyed()) return;
66
205
  mainWindow.setIgnoreMouseEvents(false);
67
206
  mainWindow.setIgnoreMouseEvents(true, { forward: true });
68
207
  };
@@ -126,16 +265,21 @@ const recreateWindow = async () => {
126
265
  };
127
266
  const createWindow = async () => {
128
267
  const { width, height } = screen.getPrimaryDisplay().workAreaSize;
268
+ const miniWindow = miniTransparent && windowMode === "mini";
269
+ // Coming back to the normal window after a transparent mini: put it back as it was.
270
+ const restore = miniTransparent && windowMode === "full" ? fullState : null;
129
271
  // The preload reads these flags; each API is exposed only when its feature is configured.
130
272
  const preloadFlags = [
131
273
  ...(config.nativeBridge ? ["--next2d-native-bridge"] : []),
132
- ...(overlay ? ["--next2d-overlay"] : [])
274
+ ...(overlay ? ["--next2d-overlay"] : []),
275
+ ...(mini ? ["--next2d-window-mode"] : []),
276
+ ...(miniWindow ? ["--next2d-mini-transparent"] : [])
133
277
  ];
134
278
  mainWindow = new BrowserWindow({
135
279
  width: Math.min(config.window.width, width),
136
280
  height: Math.min(config.window.height, height),
137
281
  title: config.appName,
138
- fullscreen: overlay ? false : config.window.fullscreen,
282
+ fullscreen: overlay || miniWindow ? false : restore ? restore.fullScreen : config.window.fullscreen,
139
283
  ...(config.icon ? { icon: path.join(assetRoot, config.icon) } : {}),
140
284
  useContentSize: true,
141
285
  backgroundColor: "#000000",
@@ -147,12 +291,35 @@ const createWindow = async () => {
147
291
  // The overlay must never take keyboard focus or activation from the apps under it (Word etc.).
148
292
  focusable: false
149
293
  } : {}),
294
+ ...(miniWindow ? {
295
+ ...miniWindowBounds(screen.getDisplayMatching(mainWindow && !mainWindow.isDestroyed() ? mainWindow.getBounds() : screen.getPrimaryDisplay().bounds)),
296
+ transparent: true, frame: false, resizable: false, maximizable: false, hasShadow: false,
297
+ fullscreenable: false, backgroundColor: "#00000000",
298
+ // Unlike the overlay it takes keys (the game's key to go back to the normal window).
299
+ focusable: true
300
+ } : {}),
150
301
  webPreferences: {
151
302
  nodeIntegration: false, contextIsolation: true, sandbox: true,
152
303
  ...(preloadFlags.length ? { preload: path.join(__dirname, "preload.cjs"), additionalArguments: preloadFlags } : {}),
153
304
  ...(config.nativeBridge ? { backgroundThrottling: config.nativeBridge.backgroundThrottling ?? false } : {})
154
305
  }
155
306
  });
307
+ if (restore) {
308
+ mainWindow.setBounds(restore.bounds);
309
+ if (restore.maximized) mainWindow.maximize();
310
+ }
311
+ if (miniWindow) {
312
+ if (mini.alwaysOnTop) {
313
+ mainWindow.setAlwaysOnTop(true, "floating");
314
+ if (process.platform === "darwin") mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
315
+ }
316
+ mainWindow.setIgnoreMouseEvents(true, { forward: true });
317
+ if (process.platform === "win32") {
318
+ const timer = setInterval(() => { if (mini.alwaysOnTop) keepOnTop(); refreshForwarding(); }, 3000);
319
+ timer.unref?.();
320
+ mainWindow.once("closed", () => clearInterval(timer));
321
+ }
322
+ }
156
323
  if (overlay) {
157
324
  mainWindow.setAlwaysOnTop(true, "floating");
158
325
  if (process.platform === "darwin") {
@@ -197,7 +364,8 @@ const createWindow = async () => {
197
364
  }
198
365
  });
199
366
  mainWindow.webContents.on("before-input-event", (event, input) => {
200
- if (input.type !== "keyDown" || overlay) {
367
+ // The small window never goes fullscreen; Escape belongs to the game there.
368
+ if (input.type !== "keyDown" || overlay || windowMode === "mini") {
201
369
  return;
202
370
  }
203
371
  if (input.key === "F11" || (input.alt && input.key === "Enter")) {
@@ -259,23 +427,64 @@ app.whenReady().then(async () => {
259
427
  return nativeBridge.request(method, params);
260
428
  });
261
429
  }
262
- if (overlay) {
263
- const trusted = (event) => {
264
- if (!isTrustedSender(event, mainWindow)) {
265
- diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
266
- throw new Error("Window API access denied");
267
- }
430
+ const trusted = (event) => {
431
+ if (!isTrustedSender(event, mainWindow)) {
432
+ diag(`window API access denied (frame url: ${event?.senderFrame?.url})`);
433
+ throw new Error("Window API access denied");
434
+ }
435
+ };
436
+ if (overlay || mini) {
437
+ ipcMain.handle("next2d:window:get-bounds", (event) => {
438
+ trusted(event);
439
+ const bounds = mainWindow.getBounds();
440
+ return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor };
441
+ });
442
+ ipcMain.handle("next2d:window:quit", (event) => {
443
+ trusted(event);
444
+ // Reply before the window is torn down.
445
+ setImmediate(() => app.quit());
446
+ });
447
+ }
448
+ if (mini) {
449
+ ipcMain.handle("next2d:window:get-mode", (event) => {
450
+ trusted(event);
451
+ return modeInfo();
452
+ });
453
+ ipcMain.handle("next2d:window:set-mode", (event, mode) => {
454
+ trusted(event);
455
+ if (mode !== "full" && mode !== "mini") throw new TypeError("mode must be \"full\" or \"mini\"");
456
+ return setWindowMode(mode);
457
+ });
458
+ }
459
+ if (miniTransparent) {
460
+ const inTransparentMini = () => {
461
+ if (windowMode !== "mini") throw new Error("Only available in the transparent mini window");
268
462
  };
269
463
  ipcMain.handle("next2d:window:set-ignore-mouse-events", (event, ignore) => {
270
464
  trusted(event);
465
+ inTransparentMini();
271
466
  if (typeof ignore !== "boolean") throw new TypeError("ignore must be a boolean");
272
- overlayIgnore = ignore;
467
+ miniIgnore = ignore;
273
468
  mainWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined);
274
469
  });
275
- ipcMain.handle("next2d:window:get-bounds", (event) => {
470
+ ipcMain.handle("next2d:window:move-by", (event, dx, dy) => {
276
471
  trusted(event);
472
+ inTransparentMini();
473
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) throw new TypeError("dx and dy must be finite numbers");
277
474
  const bounds = mainWindow.getBounds();
278
- return { ...bounds, scaleFactor: screen.getDisplayMatching(bounds).scaleFactor };
475
+ const next = clampToWorkArea({ ...bounds, x: bounds.x + Math.round(dx), y: bounds.y + Math.round(dy) });
476
+ mainWindow.setBounds(next);
477
+ miniPosition = { x: next.x, y: next.y };
478
+ });
479
+ // After sleep the forwarding hook can be gone (Windows); install it again.
480
+ for (const name of ["resume", "unlock-screen"]) powerMonitor.on(name, () => setTimeout(refreshForwarding, 1000));
481
+ }
482
+ if (overlay) {
483
+ ipcMain.handle("next2d:window:set-ignore-mouse-events", (event, ignore) => {
484
+ trusted(event);
485
+ if (typeof ignore !== "boolean") throw new TypeError("ignore must be a boolean");
486
+ overlayIgnore = ignore;
487
+ mainWindow.setIgnoreMouseEvents(ignore, ignore ? { forward: true } : undefined);
279
488
  });
280
489
  // After sleep or screen lock the OS can drop the mouse-forwarding hook and reset the window's
281
490
  // z-order/bounds. Apply the overlay state again and tell the page so it can recover too.
@@ -300,11 +509,6 @@ app.whenReady().then(async () => {
300
509
  });
301
510
  }
302
511
  app.on("child-process-gone", (_event, details) => diag(`child-process-gone ${details.type} ${details.reason}`));
303
- ipcMain.handle("next2d:window:quit", (event) => {
304
- trusted(event);
305
- // Reply before the window is torn down.
306
- setImmediate(() => app.quit());
307
- });
308
512
  }
309
513
  await createWindow();
310
514
  }).catch((error) => {
@@ -31,3 +31,35 @@ if (flags.has("--next2d-overlay")) {
31
31
  }
32
32
  });
33
33
  }
34
+
35
+ // window.mini: switch between the normal window ("full") and the small always-on-top window ("mini").
36
+ // Never set together with the overlay (the config is rejected), so the two never expose next2dWindow twice.
37
+ if (flags.has("--next2d-window-mode")) {
38
+ contextBridge.exposeInMainWorld("next2dWindow", {
39
+ // Only in the transparent mini window (window.mini.transparent): click-through and moving the frameless window.
40
+ ...(flags.has("--next2d-mini-transparent") ? {
41
+ setIgnoreMouseEvents: async (ignore) => {
42
+ if (typeof ignore !== "boolean") throw new TypeError("Expected boolean");
43
+ await ipcRenderer.invoke("next2d:window:set-ignore-mouse-events", ignore);
44
+ },
45
+ moveBy: async (dx, dy) => {
46
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) throw new TypeError("Expected finite numbers");
47
+ await ipcRenderer.invoke("next2d:window:move-by", dx, dy);
48
+ }
49
+ } : {}),
50
+ getMode: () => ipcRenderer.invoke("next2d:window:get-mode"),
51
+ setMode: async (mode) => {
52
+ if (mode !== "full" && mode !== "mini") throw new TypeError("Expected \"full\" or \"mini\"");
53
+ return ipcRenderer.invoke("next2d:window:set-mode", mode);
54
+ },
55
+ getBounds: () => ipcRenderer.invoke("next2d:window:get-bounds"),
56
+ quit: async () => { await ipcRenderer.invoke("next2d:window:quit"); },
57
+ // Fires after every change with { mode, width, height } (content size in CSS pixels).
58
+ onModeChange: (listener) => {
59
+ if (typeof listener !== "function") throw new TypeError("Expected mode listener");
60
+ const handler = (_event, info) => listener(info);
61
+ ipcRenderer.on("next2d:window:mode", handler);
62
+ return () => ipcRenderer.removeListener("next2d:window:mode", handler);
63
+ }
64
+ });
65
+ }