bun-app-window 0.7.0 → 0.9.0

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.
@@ -171,14 +171,14 @@ export const BROWSER_UI_ALL =
171
171
  * `Shell.beginResize()` 在 TS 层查表转换,本常量成为真正的入参契约。
172
172
  */
173
173
  export const RESIZE_EDGE = {
174
- LEFT: 10,
175
- RIGHT: 11,
176
- TOP: 12,
177
- TOPLEFT: 13,
178
- TOPRIGHT: 14,
179
- BOTTOM: 15,
180
- BOTTOMLEFT: 16,
181
- BOTTOMRIGHT: 17,
174
+ LEFT: 10,
175
+ RIGHT: 11,
176
+ TOP: 12,
177
+ TOPLEFT: 13,
178
+ TOPRIGHT: 14,
179
+ BOTTOM: 15,
180
+ BOTTOMLEFT: 16,
181
+ BOTTOMRIGHT: 17,
182
182
  } as const;
183
183
 
184
184
  export type ResizeEdge = (typeof RESIZE_EDGE)[keyof typeof RESIZE_EDGE];
@@ -201,14 +201,14 @@ export type ResizeEdge = (typeof RESIZE_EDGE)[keyof typeof RESIZE_EDGE];
201
201
  * 时需要它,而那种时候手写映射必然抄错。
202
202
  */
203
203
  export const RESIZE_INDEX: Readonly<Record<number, number>> = {
204
- [RESIZE_EDGE.LEFT]: 0,
205
- [RESIZE_EDGE.RIGHT]: 1,
206
- [RESIZE_EDGE.TOP]: 2,
207
- [RESIZE_EDGE.BOTTOM]: 3,
208
- [RESIZE_EDGE.TOPLEFT]: 4,
209
- [RESIZE_EDGE.TOPRIGHT]: 5,
210
- [RESIZE_EDGE.BOTTOMLEFT]: 6,
211
- [RESIZE_EDGE.BOTTOMRIGHT]: 7,
204
+ [RESIZE_EDGE.LEFT]: 0,
205
+ [RESIZE_EDGE.RIGHT]: 1,
206
+ [RESIZE_EDGE.TOP]: 2,
207
+ [RESIZE_EDGE.BOTTOM]: 3,
208
+ [RESIZE_EDGE.TOPLEFT]: 4,
209
+ [RESIZE_EDGE.TOPRIGHT]: 5,
210
+ [RESIZE_EDGE.BOTTOMLEFT]: 6,
211
+ [RESIZE_EDGE.BOTTOMRIGHT]: 7,
212
212
  };
213
213
 
214
214
  /**
@@ -218,7 +218,7 @@ export const RESIZE_INDEX: Readonly<Record<number, number>> = {
218
218
  * `rc=-1`,与「窗口已销毁」是同一个外部表现,调用方按 `rc` 处理即可。
219
219
  */
220
220
  export function resizeIndex(edge: number): number {
221
- return RESIZE_INDEX[edge] ?? -1;
221
+ return RESIZE_INDEX[edge] ?? -1;
222
222
  }
223
223
 
224
224
  /**
@@ -230,9 +230,7 @@ export function resizeIndex(edge: number): number {
230
230
  *
231
231
  * 过去消费者只能自己抄一份(抄了 14 个数,没有任何门禁能发现它与 C++ 漂移)。
232
232
  */
233
- export const ZOOM_LADDER = [
234
- 0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0,
235
- ] as const;
233
+ export const ZOOM_LADDER = [0.25, 0.33, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0] as const;
236
234
 
237
235
  /** 缩放倍率的上下限(`ZOOM_LADDER` 的首尾两项) */
238
236
  export const ZOOM_MIN = ZOOM_LADDER[0];
@@ -275,74 +273,201 @@ export const STATE_MINIMIZED = 2;
275
273
  export const STATE_FOCUSED = 4;
276
274
 
277
275
  export type WindowState = {
278
- raw: number;
279
- maximized: boolean;
280
- minimized: boolean;
281
- focused: boolean;
282
- /** 系统当前是否深色(原生侧读 AppsUseLightTheme,并随 WM_SETTINGCHANGE 刷新) */
283
- dark: boolean;
276
+ raw: number;
277
+ maximized: boolean;
278
+ minimized: boolean;
279
+ focused: boolean;
280
+ /** 系统当前是否深色(原生侧读 AppsUseLightTheme,并随 WM_SETTINGCHANGE 刷新) */
281
+ dark: boolean;
284
282
  };
285
283
 
284
+ /**
285
+ * 窗口矩形的一次读数(物理像素,屏幕坐标)。
286
+ *
287
+ * 与 `WindowState` 的分工:`WindowState` 是廉价的位掩码(最大化 / 最小化 / 焦点 /
288
+ * 深色),不含几何;本类型是几何快照,唯一来源是 `Shell.getRect()` 对原生那行
289
+ * 文本的解析。
290
+ *
291
+ * `maximized` 与 `dpi` 跟矩形来自**同一次**原生读取,所以一起返回:判「这次读到的
292
+ * 尺寸是不是整屏」时不必再发一次 FFI 问 `state()`,也不会在两拍之间撞上状态变化,
293
+ * 读出一个 `x` 是旧的、`maximized` 是新的的撕裂值。最大化时四个数值是「还原后」的
294
+ * 矩形,不是整屏尺寸。
295
+ */
296
+ export type WindowRect = {
297
+ /** 物理像素,屏幕坐标。主屏在右、副屏在左时 `x` 真的是负数,属正常 */
298
+ x: number;
299
+ y: number;
300
+ width: number;
301
+ height: number;
302
+ /** 最大化时 `x/y/width/height` 为「还原后」的矩形 */
303
+ maximized: boolean;
304
+ /** 读取时的窗口 DPI;跨缩放比恢复矩形时用它换算 */
305
+ dpi: number;
306
+ };
307
+
308
+ /**
309
+ * Win32 的「窗口已最小化」哨兵坐标。
310
+ *
311
+ * 窗口最小化时 `GetWindowRect` 返回的不是真实位置,而是一对哨兵值——实测
312
+ * `-32000,-32000` 加一个图标尺寸(典型 `314x50`)。它们**看起来完全合法**:
313
+ * 坐标是整数,尺寸也大于 0,所以只判「非有限 / 尺寸 ≤ 0」的形状校验拦不住。
314
+ *
315
+ * 成因在原生 `format_bounds`:它只在 `IsZoomed` 为真时走
316
+ * `WINDOWPLACEMENT.rcNormalPosition`,最小化时 `IsZoomed` 返回 false,于是落进
317
+ * `GetWindowRect` 分支——**`IsIconic` 从未被判断**。于是这对哨兵值会一路写成
318
+ * `{"x":-32000,"y":-32000,"width":314,"height":50}`,下次启动恢复成一个畸形小窗
319
+ * (实测:666×471,页面被挤到换行并出现滚动条)。
320
+ *
321
+ * 阈值取 -30000 而不是精确 -32000:不同 DPI / 主题下这对值会带几个像素偏移。
322
+ * 真实坐标到不了这个量级——8K 屏横向也只有 7680,多屏横排也远不到 30000,
323
+ * 所以用阈值不会误伤合法的负坐标(主屏在右、副屏在左时 x 确实是负数)。
324
+ */
325
+ const MINIMIZED_SENTINEL = -30000;
326
+
327
+ /**
328
+ * 是不是 Win32 的最小化哨兵坐标。任一维命中即判否——两维是同时写的。
329
+ *
330
+ * 导出是因为**读原生字符串**与**读磁盘 JSON** 两条路都必须判同一对值:
331
+ * `parseBounds()` 判前者,`./state` 的 `parsePlacement()` 判后者。两条路各抄一份
332
+ * 阈值,某天调了一处就是其中一条静默失效。
333
+ */
334
+ export function isMinimizedSentinel(x: number, y: number): boolean {
335
+ return x <= MINIMIZED_SENTINEL || y <= MINIMIZED_SENTINEL;
336
+ }
337
+
338
+ /**
339
+ * 把原生 `shell_get_bounds()` 返回的一行文本解析成 `WindowRect`;格式不对返回 `null`。
340
+ *
341
+ * 输入形如 `"x,y,w,h,maximized,dpi"`(物理像素)。三种返回 `null` 的情形都是
342
+ * **实测会真的出现**的,不是防御性编程:
343
+ *
344
+ * | 输入 | 成因 | 为什么不能给个默认值 |
345
+ * | --- | --- | --- |
346
+ * | 空串 | 窗口未启动或已销毁:原生 `format_bounds` 里 `!g_hwnd` 直接返回 0 | 编个矩形出来会让调用方以为窗口还在 |
347
+ * | 尺寸或 DPI 为 0 | 窗口还没建好,同一帧里读到的中途态 | 0 宽高的矩形画不对任何东西,DPI 为 0 也没法换算 |
348
+ * | 最小化哨兵坐标 | 见 `MINIMIZED_SENTINEL` | 那对数字形状合法,但不是位置 |
349
+ *
350
+ * 放在 `types.ts` 而不是 `./state` 的理由是分层:主入口要调用它,而 `./state`
351
+ * 依赖 `node:fs`(持久化按定义是副作用),主入口不能反过来依赖那个可选件。
352
+ * 本函数纯计算、零 import,主入口与页面侧都拿得动。
353
+ */
354
+ export function parseBounds(text: string): WindowRect | null {
355
+ const parts = text.trim().split(",");
356
+ if (parts.length !== 6) {
357
+ return null;
358
+ }
359
+ const nums = parts.map((p) => Number(p));
360
+ if (nums.some((n) => !Number.isFinite(n))) {
361
+ return null;
362
+ }
363
+ const [x, y, width, height, maximized, dpi] = nums as [number, number, number, number, number, number];
364
+ // 尺寸为 0 或负数说明窗口还没建好(原生在无窗口时返回 0 长度文本,这里再兜一层)
365
+ if (width <= 0 || height <= 0 || dpi <= 0) {
366
+ return null;
367
+ }
368
+ // 最小化时原生给的是哨兵坐标,不是真实位置——宁可返回 null 让调用方保留上一次的值
369
+ if (isMinimizedSentinel(x, y)) {
370
+ return null;
371
+ }
372
+ return { x, y, width, height, maximized: maximized !== 0, dpi };
373
+ }
374
+
375
+ /**
376
+ * 窗口的最小尺寸(**逻辑像素**,96 DPI 基准)。
377
+ *
378
+ * 与 `WindowRect` 分开:后者的四个数值是物理像素的屏幕坐标,本类型是逻辑像素的
379
+ * 约束值。两者混用会让高 DPI 下的窗口大小差一倍,而症状只是「比我设的小」。
380
+ */
381
+ export type WindowSize = {
382
+ /** 逻辑像素,96 DPI 基准 */
383
+ width: number;
384
+ height: number;
385
+ };
386
+
387
+ /**
388
+ * 把原生 `shell_get_min_size()` 返回的 `"w,h"` 解析成 `WindowSize`;格式不对返回 `null`。
389
+ *
390
+ * 为什么原生给字符串而不是两个输出参数:与 `shell_get_bounds` 同一种「写入文本 +
391
+ * 返回长度」的形态,`bun:ffi` 侧一行 `readBuffer` 就够,不必为它引入第二套指针传参。
392
+ *
393
+ * 尺寸为 0 或负数返回 `null`:`shell_set_min_size()` 对非正值是忽略的(保持旧值),
394
+ * 所以真出现 0 说明数据来自别处(比如被手改过的持久化文件),不该当成合法约束。
395
+ */
396
+ export function parseSize(text: string): WindowSize | null {
397
+ const parts = text.trim().split(",");
398
+ if (parts.length !== 2) {
399
+ return null;
400
+ }
401
+ const [width, height] = parts.map((p) => Number(p)) as [number, number];
402
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
403
+ return null;
404
+ }
405
+ if (width <= 0 || height <= 0) {
406
+ return null;
407
+ }
408
+ return { width, height };
409
+ }
410
+
286
411
  /** 原生库的解析结果 */
287
412
  export type NativeLibInfo = {
288
- path: string;
289
- /**
290
- * explicit — 调用方显式指定
291
- * dev — 本仓开发态(build/ 或 dist/)
292
- * package — 包内 dist/ 或可执行文件同级
293
- * embedded — 从编译产物内嵌字节释放
294
- * fallback-tmp — 受限环境下退回 Bun 默认释放
295
- */
296
- source: "explicit" | "dev" | "package" | "embedded" | "fallback-tmp";
297
- /**
298
- * `bun-app-window` 自身的版本号(取自包内 `package.json`,不是 DLL 的 PE 版本)。
299
- *
300
- * 加它的理由:消费者要在界面上显示「连上的确实是这个版本」时,过去只能
301
- * `import libPkg from "bun-app-window/package.json"` 再拼字符串——那是一条
302
- * 额外的子路径依赖,且 `package.json` 在 `files` 白名单里所以要跟着发布面走。
303
- * 现在顺手带出来,页面侧直接读 `shell.libInfo.version`。
304
- */
305
- version: string;
306
- sha256: string;
307
- bytes: number;
308
- reused: boolean;
309
- pruned: string[];
413
+ path: string;
414
+ /**
415
+ * explicit — 调用方显式指定
416
+ * dev — 本仓开发态(build/ 或 dist/)
417
+ * package — 包内 dist/ 或可执行文件同级
418
+ * embedded — 从编译产物内嵌字节释放
419
+ * fallback-tmp — 受限环境下退回 Bun 默认释放
420
+ */
421
+ source: "explicit" | "dev" | "package" | "embedded" | "fallback-tmp";
422
+ /**
423
+ * `bun-app-window` 自身的版本号(取自包内 `package.json`,不是 DLL 的 PE 版本)。
424
+ *
425
+ * 加它的理由:消费者要在界面上显示「连上的确实是这个版本」时,过去只能
426
+ * `import libPkg from "bun-app-window/package.json"` 再拼字符串——那是一条
427
+ * 额外的子路径依赖,且 `package.json` 在 `files` 白名单里所以要跟着发布面走。
428
+ * 现在顺手带出来,页面侧直接读 `shell.libInfo.version`。
429
+ */
430
+ version: string;
431
+ sha256: string;
432
+ bytes: number;
433
+ reused: boolean;
434
+ pruned: string[];
310
435
  };
311
436
 
312
437
  export type ShellOptions = {
313
- /** 要加载的 URL(通常是本地回环地址) */
314
- url: string;
315
- title?: string;
316
- width?: number;
317
- height?: number;
318
- /** FLAG_* 位组合 */
319
- flags?: number;
320
- /** 启动后立即设置的最小尺寸(逻辑像素) */
321
- minWidth?: number;
322
- minHeight?: number;
323
- /**
324
- * 原生库路径。给定时跳过所有自动解析。
325
- * 用于测试或多副本部署。
326
- */
327
- nativeLibPath?: string;
328
- /**
329
- * 运行时数据目录名(`%LOCALAPPDATA%\<appName>\runtime`)。
330
- * 同一机器上多个应用共用一个包时应各自指定,避免互相清理 DLL 副本。
331
- */
332
- appName?: string;
333
- /**
334
- * 关闭哪些浏览器行为,取 `BROWSER_UI_*` 的按位或。缺省 0(保持 WebView2 默认)。
335
- *
336
- * 桌面应用一般传 `BROWSER_UI_ALL`:关掉右键 Edge 菜单、`Ctrl+P`/`Ctrl+F`/`Ctrl+S`、
337
- * 左下角状态栏、触控板滑动导航与表单自动填充。关掉默认右键菜单后若要右键有反应,
338
- * 用 `showContextMenu()` 弹原生菜单。
339
- */
340
- browserUi?: number;
341
- /**
342
- * 编译产物内嵌字节的提供者。
343
- * 使用 `bun build --compile` 时传入 `embeddedDll`(见 `bun-app-window/embedded`)。
344
- */
345
- embedded?: () => Promise<Uint8Array | null>;
438
+ /** 要加载的 URL(通常是本地回环地址) */
439
+ url: string;
440
+ title?: string;
441
+ width?: number;
442
+ height?: number;
443
+ /** FLAG_* 位组合 */
444
+ flags?: number;
445
+ /** 启动后立即设置的最小尺寸(逻辑像素) */
446
+ minWidth?: number;
447
+ minHeight?: number;
448
+ /**
449
+ * 原生库路径。给定时跳过所有自动解析。
450
+ * 用于测试或多副本部署。
451
+ */
452
+ nativeLibPath?: string;
453
+ /**
454
+ * 运行时数据目录名(`%LOCALAPPDATA%\<appName>\runtime`)。
455
+ * 同一机器上多个应用共用一个包时应各自指定,避免互相清理 DLL 副本。
456
+ */
457
+ appName?: string;
458
+ /**
459
+ * 关闭哪些浏览器行为,取 `BROWSER_UI_*` 的按位或。缺省 0(保持 WebView2 默认)。
460
+ *
461
+ * 桌面应用一般传 `BROWSER_UI_ALL`:关掉右键 Edge 菜单、`Ctrl+P`/`Ctrl+F`/`Ctrl+S`、
462
+ * 左下角状态栏、触控板滑动导航与表单自动填充。关掉默认右键菜单后若要右键有反应,
463
+ * 用 `showContextMenu()` 弹原生菜单。
464
+ */
465
+ browserUi?: number;
466
+ /**
467
+ * 编译产物内嵌字节的提供者。
468
+ * 使用 `bun build --compile` 时传入 `embeddedDll`(见 `bun-app-window/embedded`)。
469
+ */
470
+ embedded?: () => Promise<Uint8Array | null>;
346
471
  };
347
472
 
348
473
  export type StartResult = { rc: number; error: string };
@@ -479,8 +479,7 @@ export function mountTitleBar(opts: TitleBarOptions): TitleBarHandle {
479
479
  if (!nativeCaptions) {
480
480
  for (const action of ["minimize", "maximize", "close"] as const) {
481
481
  const btn = doc.createElement("button");
482
- btn.className =
483
- action === "close" ? "ws-titlebar__btn ws-titlebar__btn--close" : "ws-titlebar__btn";
482
+ btn.className = action === "close" ? "ws-titlebar__btn ws-titlebar__btn--close" : "ws-titlebar__btn";
484
483
  btn.setAttribute("type", "button");
485
484
  btn.setAttribute("data-act", action);
486
485
  btn.textContent = glyphFor(icons, action, opts.maximized === true);