bun-app-window 0.8.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.
- package/CHANGELOG.md +73 -1
- package/README.md +27 -23
- package/README.zh-CN.md +27 -23
- package/README.zh-TW.md +27 -23
- package/dist/libshell.dll +0 -0
- package/package.json +1 -1
- package/src/shell/index.ts +81 -2
- package/src/shell/native.ts +8 -0
- package/src/shell/state.ts +21 -56
- package/src/shell/types.ts +127 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,77 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-10-25
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`getTitle()` / `getMinSize()` / `getBrowserUi()` read back what the setters wrote.** Until now
|
|
14
|
+
every window capability was one-way: `setTitle()`, `setMinSize()` and `setBrowserUi()` returned
|
|
15
|
+
`rc === 0`, which only means "the message reached the UI thread". A failed `SetWindowTextW`, a
|
|
16
|
+
size the system clamped, a browser behaviour that could not be disabled — all silent.
|
|
17
|
+
|
|
18
|
+
The three are **not** the same shape, and copying one template across all three would have been
|
|
19
|
+
wrong:
|
|
20
|
+
|
|
21
|
+
| Method | Where the truth lives |
|
|
22
|
+
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
23
|
+
| `getMinSize()` | `g_min_w` / `g_min_h` — the same values `WM_GETMINMAXINFO` consumes, so the cache _is_ the truth |
|
|
24
|
+
| `getTitle()` | The window itself (`GetWindowTextW`). `g_title` is assigned once in `shell_start_ex` and `shell_title()` never updates it, so reading it would return the startup title forever |
|
|
25
|
+
| `getBrowserUi()` | A new `g_browser_ui`, accumulated with `\|=` |
|
|
26
|
+
|
|
27
|
+
`getTitle()` is the one that matters: reading a cached _input_ would make it an echo rather than
|
|
28
|
+
a getter, and it would report success for a `SetWindowTextW` that never took effect — exactly the
|
|
29
|
+
failure the whole read-back surface exists to expose.
|
|
30
|
+
|
|
31
|
+
- **`getBrowserUi()` accumulates, because that is what the window actually does.** `apply_browser_ui()`
|
|
32
|
+
only ever calls `put_XXX(FALSE)` and returns early on `mask == 0` — there is no path that turns a
|
|
33
|
+
behaviour back on. So `setBrowserUi(MENU)` then `setBrowserUi(KEYS)` leaves _both_ disabled.
|
|
34
|
+
Recording the last mask would make the getter report `KEYS` alone, which reads as "the menu came
|
|
35
|
+
back". It is a bitwise OR at the setter instead.
|
|
36
|
+
|
|
37
|
+
- **`Shell.getRect()` reads the window rectangle as a structure.** `getBounds()` has always returned
|
|
38
|
+
the native line `"x,y,w,h,maximized,dpi"` as a string, so every consumer had to split and validate
|
|
39
|
+
it themselves — and the minimised sentinel rectangle (`-32000,-32000` plus an icon-sized box)
|
|
40
|
+
passes a naive shape check while being a completely fake position. Reading geometry from the host
|
|
41
|
+
also meant importing `bun-app-window/state` (which pulls in `node:fs`) for a single value.
|
|
42
|
+
|
|
43
|
+
`getRect()` returns `{ x, y, width, height, maximized, dpi } | null`, applying the same three
|
|
44
|
+
rejections as `parseBounds()`: window not started, zero size or DPI, or the sentinel rectangle.
|
|
45
|
+
All six fields come from one native read, so a caller asking "is this the whole screen?" does not
|
|
46
|
+
need a second FFI call that could observe a different state.
|
|
47
|
+
|
|
48
|
+
- **`parseBounds` and `WindowRect` moved to `bun-app-window/types`.** `bun-app-window/state` still
|
|
49
|
+
re-exports both — `WindowPlacement` is now an alias of `WindowRect`, not a second structurally
|
|
50
|
+
identical type — so existing imports keep working. The move lets the main entry use the parser
|
|
51
|
+
without depending on `node:fs`, and both paths expose the same function object (`toBe` pins it).
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **`getTitle()` no longer returns a silently truncated title.** The first version read into a
|
|
56
|
+
fixed 512-character buffer and _then_ checked against the caller's capacity — but
|
|
57
|
+
`GetWindowTextW(hwnd, wbuf, 512)` only writes 511 characters and returns 511 for anything longer,
|
|
58
|
+
and 511 ASCII bytes fit comfortably under the 1024-byte buffer. Measured: a 600-character title
|
|
59
|
+
came back as 511 characters, **89 short, with no signal at all**. That is worse than failing: the
|
|
60
|
+
caller has no way to know the string is incomplete.
|
|
61
|
+
|
|
62
|
+
It now asks `GetWindowTextLengthW` for the real length first and returns `-1` (→ `null`) when it
|
|
63
|
+
exceeds the internal buffer. The buffer size and the bound check share one constant, so raising
|
|
64
|
+
one without the other cannot reintroduce the truncation.
|
|
65
|
+
|
|
66
|
+
The guard for this has its own cautionary note: the first version counted occurrences in the
|
|
67
|
+
un-stripped function body, and the mutation test left `// if (len >= kTitleBufW) return -1;` in a
|
|
68
|
+
comment — the count stayed at 3 and **the mutation did not turn red**. Counting after stripping
|
|
69
|
+
comments is what made it fail correctly.
|
|
70
|
+
|
|
71
|
+
- **`setMinSize()` no longer leaks across windows.** `g_min_w` / `g_min_h` are process-level globals
|
|
72
|
+
and nothing reset them, so a window that set 800×600 handed those limits to the _next_ window in
|
|
73
|
+
the same process — one that never asked for them. `g_browser_ui` would have inherited the same
|
|
74
|
+
defect. All three now reset in `shell_start_ex` alongside the other per-life globals.
|
|
75
|
+
|
|
76
|
+
Behaviour change: calling `setMinSize()` _before_ `start()` no longer has any effect. The public
|
|
77
|
+
surface never promised it (the documented path is `ShellOptions.minWidth` / `minHeight`, applied
|
|
78
|
+
after `start()`), and the TS side already works that way.
|
|
79
|
+
|
|
9
80
|
## [0.8.0] - 2026-10-25
|
|
10
81
|
|
|
11
82
|
### Added
|
|
@@ -882,7 +953,8 @@ First public release.
|
|
|
882
953
|
- Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
|
|
883
954
|
- No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
|
|
884
955
|
|
|
885
|
-
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.
|
|
956
|
+
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.9.0...HEAD
|
|
957
|
+
[0.9.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.8.0...v0.9.0
|
|
886
958
|
[0.8.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.7.0...v0.8.0
|
|
887
959
|
[0.7.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.2...v0.7.0
|
|
888
960
|
[0.6.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.1...v0.6.2
|
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ bun add bun-app-window
|
|
|
48
48
|
|
|
49
49
|
### What the tarball contains
|
|
50
50
|
|
|
51
|
-
The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources — 22 files, 0.
|
|
51
|
+
The published package holds the prebuilt `dist/libshell.dll` plus the TypeScript sources — 22 files, 0.51 MB packed (1.50 MB unpacked). The native C++ layer (`native/`) and the vendored upstream headers (`vendor/`) are **not** shipped: they are only needed to rebuild `libshell.dll`, and rebuilding requires cloning the repository. `bun run build:native` and `build:exe` are therefore repository-only scripts that do nothing useful inside `node_modules`.
|
|
52
52
|
|
|
53
53
|
## Quick Start
|
|
54
54
|
|
|
@@ -86,28 +86,32 @@ returns a stale message that looks like a fresh failure.
|
|
|
86
86
|
|
|
87
87
|
### Instance Methods & Properties
|
|
88
88
|
|
|
89
|
-
| Member | Description
|
|
90
|
-
| ---------------------------------------------------- |
|
|
91
|
-
| `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread
|
|
92
|
-
| `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent)
|
|
93
|
-
| `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready
|
|
94
|
-
| `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states). To wait for close, use `pollEvents()` and watch for `CLOSED` instead of polling this
|
|
95
|
-
| `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below
|
|
96
|
-
| `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }`
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
89
|
+
| Member | Description |
|
|
90
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
91
|
+
| `start()` | Opens the window and returns `{ rc, error }`. Returns immediately while the UI message loop runs on a dedicated native thread |
|
|
92
|
+
| `stop()` | Destroys the window and releases native resources. Safe to call multiple times (idempotent) |
|
|
93
|
+
| `show()` | Reveals the window. Typically paired with `FLAG_DEFER_SHOW` to display the window only after the web UI is ready |
|
|
94
|
+
| `isAlive()` | Checks whether the native window still exists (unlike `state()`, which cannot distinguish between unstarted and destroyed states). To wait for close, use `pollEvents()` and watch for `CLOSED` instead of polling this |
|
|
95
|
+
| `pollEvents(max?)` | Drains window lifecycle events (`STATE` / `CLOSED`); synchronous and consuming. See "Window events" below |
|
|
96
|
+
| `state()` | Returns current window state: `{ maximized, minimized, focused, dark, raw }` |
|
|
97
|
+
| `getRect()` | Structured read of the current window rectangle — `{ x, y, width, height, maximized, dpi }` in physical pixels — or `null` when there is nothing usable: window not started, zero size/DPI, or the Win32 minimised sentinel rectangle |
|
|
98
|
+
| `getTitle()` | Reads the window's **actual** title (`GetWindowTextW`, not any cached input). `null` when there is no window **or** the title exceeds 511 characters — never a silently truncated string; `""` for an empty title. `setTitle()`'s `rc === 0` only proves the message was posted, so read here to know the effect happened |
|
|
99
|
+
| `getMinSize()` | Reads the **effective** minimum size as `{ width, height }` in logical pixels, or `null` when there is no window. Not "the last value you passed": `setMinSize(0, 0)` ignores non-positive values, so this is the one `WM_GETMINMAXINFO` actually uses |
|
|
100
|
+
| `getBrowserUi()` | The bitmask of browser behaviours already disabled. **Cumulative**: `BROWSER_UI_MENU` then `BROWSER_UI_KEYS` reads back as both, because `setBrowserUi()` only turns things off. Returns `0` before a window exists — an accurate answer, not a failure |
|
|
101
|
+
| `lastError()` | Returns the most recent failure message, or an empty string if no error occurred |
|
|
102
|
+
| `setTitle` / `minimize` / `toggleMaximize` / `close` | Standard window control operations; each returns a status code `rc` |
|
|
103
|
+
| `setMinSize(w, h)` | Sets the minimum window dimensions in logical pixels |
|
|
104
|
+
| `drag()` | Initiates native window dragging from a page `pointerdown` event (preserves Windows Snap Layouts) |
|
|
105
|
+
| `beginResize(edge)` | Initiates native border resizing from a page `pointermove` event (`edge` uses `RESIZE_EDGE.*` constants) |
|
|
106
|
+
| `post(json)` | Sends a fire-and-forget JSON message from the host to the web page |
|
|
107
|
+
| `setZoom(f)` / `getZoom()` | Gets or sets the page zoom factor (clamped automatically to `0.25`–`3.0`) |
|
|
108
|
+
| `reload()` / `openDevTools()` / `toggleFullscreen()` | Equivalent to `Ctrl+R`, `F12`, and `F11` respectively |
|
|
109
|
+
| `setFrameless(frameless)` / `isFrameless()` | Switches the window form at runtime and reports the current one. Only a window **created** with `FLAG_FRAMELESS` can switch; a framed-created window refuses with `rc=-1` and a reason in `lastError()`. See "Window Flags" below |
|
|
110
|
+
| `setBrowserUi(mask)` | Disables specific default browser behaviors using a bitwise OR of `BROWSER_UI_*` flags |
|
|
111
|
+
| `showContextMenu(x, y, items)` | Displays a native Win32 context menu at the specified screen coordinates (see below) |
|
|
112
|
+
| `pickFolder(title?)` | Opens a native folder selection dialog (synchronously blocks the calling thread; **when exposed over IPC, pair it with `timeoutMs: 0`**, see below) |
|
|
113
|
+
| `libInfo` | Metadata of the resolved native library (file path, resolution source, SHA-256 hash) |
|
|
114
|
+
| `symbols` | Escape hatch providing direct access to the raw `dlopen` FFI symbols |
|
|
111
115
|
|
|
112
116
|
### Window Flags
|
|
113
117
|
|
package/README.zh-CN.md
CHANGED
|
@@ -48,7 +48,7 @@ bun add bun-app-window
|
|
|
48
48
|
|
|
49
49
|
### 包内包含什么
|
|
50
50
|
|
|
51
|
-
发布的 tarball 只有预编译的 `dist/libshell.dll` 与 TypeScript 源码,共 22 个文件(0.
|
|
51
|
+
发布的 tarball 只有预编译的 `dist/libshell.dll` 与 TypeScript 源码,共 22 个文件(0.51 MB,解包后 1.50 MB)。原生 C++ 层(`native/`)与上游 vendored 头文件(`vendor/`)**不随包分发**:它们只在重编 `libshell.dll` 时需要,而重编必须 clone 仓库。因此 `bun run build:native` 与 `build:exe` 是本仓库专用脚本,装进 `node_modules` 后执行没有意义。
|
|
52
52
|
|
|
53
53
|
## 快速开始
|
|
54
54
|
|
|
@@ -84,28 +84,32 @@ shell.setTitle("Doing work…");
|
|
|
84
84
|
|
|
85
85
|
### 实例方法与属性
|
|
86
86
|
|
|
87
|
-
| 成员 | 说明
|
|
88
|
-
| ---------------------------------------------------- |
|
|
89
|
-
| `start()` | 创建并启动窗口,返回 `{ rc, error }`。调用后立即返回,UI 消息循环运行于独立原生线程
|
|
90
|
-
| `stop()` | 销毁窗口并释放底层资源,支持幂等重复调用
|
|
91
|
-
| `show()` | 显示窗口。常配合 `FLAG_DEFER_SHOW` 使用,实现在页面渲染就绪后再展现窗口
|
|
92
|
-
| `isAlive()` | 检测窗口是否存活(注:`state()` 无法区分「尚未启动」与「已销毁」状态)。等窗口关闭请用 `pollEvents()` 的 `CLOSED`,别轮询它
|
|
93
|
-
| `pollEvents(max?)` | 批量拉取窗口生命周期事件(`STATE` / `CLOSED`),同步、消费式。详见下文「窗口事件」
|
|
94
|
-
| `state()` | 获取当前窗口状态:`{ maximized, minimized, focused, dark, raw }`
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `setMinSize(
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
87
|
+
| 成员 | 说明 |
|
|
88
|
+
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `start()` | 创建并启动窗口,返回 `{ rc, error }`。调用后立即返回,UI 消息循环运行于独立原生线程 |
|
|
90
|
+
| `stop()` | 销毁窗口并释放底层资源,支持幂等重复调用 |
|
|
91
|
+
| `show()` | 显示窗口。常配合 `FLAG_DEFER_SHOW` 使用,实现在页面渲染就绪后再展现窗口 |
|
|
92
|
+
| `isAlive()` | 检测窗口是否存活(注:`state()` 无法区分「尚未启动」与「已销毁」状态)。等窗口关闭请用 `pollEvents()` 的 `CLOSED`,别轮询它 |
|
|
93
|
+
| `pollEvents(max?)` | 批量拉取窗口生命周期事件(`STATE` / `CLOSED`),同步、消费式。详见下文「窗口事件」 |
|
|
94
|
+
| `state()` | 获取当前窗口状态:`{ maximized, minimized, focused, dark, raw }` |
|
|
95
|
+
| `getRect()` | 读取当前窗口矩形的结构化读数 `{ x, y, width, height, maximized, dpi }`(物理像素);读不到或数据不可用时返回 `null`。窗口未启动、尺寸/DPI 为 0、最小化哨兵坐标三种情况都返回 `null` |
|
|
96
|
+
| `getTitle()` | 读回窗口标题的**真实值**(原生 `GetWindowTextW`,不是任何缓存)。窗口不存在、或标题超过 511 个字符时返回 `null`——**绝不返回被截断的字符串**;空标题返回 `""`。`setTitle()` 的 `rc === 0` 只说明消息投递成功,要判「效果发生」必须读这里 |
|
|
97
|
+
| `getMinSize()` | 读回**生效中**的最小尺寸 `{ width, height }`(逻辑像素);窗口不存在时返回 `null`。读的不是「最后一次 set 的参数」——`setMinSize(0, 0)` 对非正值是忽略的,所以这里给出的是 `WM_GETMINMAXINFO` 真正会用的那一个 |
|
|
98
|
+
| `getBrowserUi()` | 已请求关闭的浏览器行为位掩码。**累积语义**:先 `BROWSER_UI_MENU` 再 `BROWSER_UI_KEYS`,读回来是两者之和——因为 `setBrowserUi()` 只有「关」的方向。窗口未启动时返回 `0`(准确答案,不是「读不到」) |
|
|
99
|
+
| `lastError()` | 获取最近一次操作失败的错误信息,无错误时返回空字符串 |
|
|
100
|
+
| `setTitle` / `minimize` / `toggleMaximize` / `close` | 基础窗口控制操作,返回状态码 `rc` |
|
|
101
|
+
| `setMinSize(w, h)` | 设置窗口最小尺寸(单位:逻辑像素) |
|
|
102
|
+
| `drag()` | 触发原生窗口拖拽(在页面 `pointerdown` 事件中调用,完整保留 Windows 贴边分屏特性) |
|
|
103
|
+
| `beginResize(edge)` | 触发原生边框缩放(在页面 `pointermove` 事件中调用,`edge` 取值见 `RESIZE_EDGE.*`) |
|
|
104
|
+
| `post(json)` | 宿主向页面单向投递 JSON 消息,异步发送且不等待返回结果 |
|
|
105
|
+
| `setZoom(f)` / `getZoom()` | 设置或获取页面缩放比例(有效范围 `0.25`–`3.0`,越界值自动钳制) |
|
|
106
|
+
| `reload()` / `openDevTools()` / `toggleFullscreen()` | 页面重载、打开开发者工具、切换全屏(分别对应 `Ctrl+R` / `F12` / `F11`) |
|
|
107
|
+
| `setFrameless(frameless)` / `isFrameless()` | 运行时切换窗口形态并读取当前形态。只有**创建时**给了 `FLAG_FRAMELESS` 的窗口能切;带边框起点的窗口返回 `rc=-1`,理由在 `lastError()`。详见下文「窗口标志位」 |
|
|
108
|
+
| `setBrowserUi(mask)` | 禁用指定的浏览器默认行为,传入 `BROWSER_UI_*` 标志位的按位或组合 |
|
|
109
|
+
| `showContextMenu(x, y, items)` | 在指定屏幕坐标弹出 Win32 原生上下文菜单(详见下文用法) |
|
|
110
|
+
| `pickFolder(title?)` | 调用原生文件夹选择对话框(同步阻塞当前调用线程;**经 IPC 暴露时务必配 `timeoutMs: 0`**,见下文) |
|
|
111
|
+
| `libInfo` | 原生动态库解析元数据(包含实际路径、加载来源、SHA-256 校验值) |
|
|
112
|
+
| `symbols` | 底层直通接口:直接访问通过 `dlopen` 导出的底层 C 符号 |
|
|
109
113
|
|
|
110
114
|
### 窗口标志位(Flags)
|
|
111
115
|
|
package/README.zh-TW.md
CHANGED
|
@@ -48,7 +48,7 @@ bun add bun-app-window
|
|
|
48
48
|
|
|
49
49
|
### 套件內容包含什麼
|
|
50
50
|
|
|
51
|
-
發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 22 個檔案(0.
|
|
51
|
+
發佈的 tarball 只有預先編譯的 `dist/libshell.dll` 與 TypeScript 原始碼,共 22 個檔案(0.51 MB,解包後 1.50 MB)。原生 C++ 層(`native/`)與上游 vendored 標頭檔(`vendor/`)**不隨套件散布**:它們只在重新編譯 `libshell.dll` 時需要,而重新編譯必須 clone 儲存庫。因此 `bun run build:native` 與 `build:exe` 是本儲存庫專用指令碼,安裝進 `node_modules` 後執行沒有意義。
|
|
52
52
|
|
|
53
53
|
## 快速開始
|
|
54
54
|
|
|
@@ -84,28 +84,32 @@ shell.setTitle("Doing work…");
|
|
|
84
84
|
|
|
85
85
|
### 實例方法與屬性
|
|
86
86
|
|
|
87
|
-
| 成員 | 說明
|
|
88
|
-
| ---------------------------------------------------- |
|
|
89
|
-
| `start()` | 建立並啟動視窗,回傳 `{ rc, error }`。呼叫後立即回傳,UI 訊息迴圈執行於獨立原生執行緒
|
|
90
|
-
| `stop()` | 銷毀視窗並釋放底層資源,支援冪等重複呼叫
|
|
91
|
-
| `show()` | 顯示視窗。常搭配 `FLAG_DEFER_SHOW` 使用,實現在頁面渲染就緒後再展現視窗
|
|
92
|
-
| `isAlive()` | 檢測視窗是否存活(註:`state()` 無法區分「尚未啟動」與「已銷毀」狀態)。等視窗關閉請用 `pollEvents()` 的 `CLOSED`,別輪詢它
|
|
93
|
-
| `pollEvents(max?)` | 批次拉取視窗生命週期事件(`STATE` / `CLOSED`),同步、消費式。詳見下文「視窗事件」
|
|
94
|
-
| `state()` | 取得目前視窗狀態:`{ maximized, minimized, focused, dark, raw }`
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `setMinSize(
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
87
|
+
| 成員 | 說明 |
|
|
88
|
+
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `start()` | 建立並啟動視窗,回傳 `{ rc, error }`。呼叫後立即回傳,UI 訊息迴圈執行於獨立原生執行緒 |
|
|
90
|
+
| `stop()` | 銷毀視窗並釋放底層資源,支援冪等重複呼叫 |
|
|
91
|
+
| `show()` | 顯示視窗。常搭配 `FLAG_DEFER_SHOW` 使用,實現在頁面渲染就緒後再展現視窗 |
|
|
92
|
+
| `isAlive()` | 檢測視窗是否存活(註:`state()` 無法區分「尚未啟動」與「已銷毀」狀態)。等視窗關閉請用 `pollEvents()` 的 `CLOSED`,別輪詢它 |
|
|
93
|
+
| `pollEvents(max?)` | 批次拉取視窗生命週期事件(`STATE` / `CLOSED`),同步、消費式。詳見下文「視窗事件」 |
|
|
94
|
+
| `state()` | 取得目前視窗狀態:`{ maximized, minimized, focused, dark, raw }` |
|
|
95
|
+
| `getRect()` | 讀取目前視窗矩形的結構化讀數 `{ x, y, width, height, maximized, dpi }`(物理像素);讀不到或資料不可用時回傳 `null`。視窗未啟動、尺寸/DPI 為 0、最小化哨兵座標三種情況都回傳 `null` |
|
|
96
|
+
| `getTitle()` | 讀回視窗標題的**真實值**(原生 `GetWindowTextW`,不是任何快取)。視窗不存在、或標題超過 511 個字元時回傳 `null`——**絕不回傳被截斷的字串**;空標題回傳 `""`。`setTitle()` 的 `rc === 0` 只代表訊息投遞成功,要判斷「效果發生」必須讀這裡 |
|
|
97
|
+
| `getMinSize()` | 讀回**生效中**的最小尺寸 `{ width, height }`(邏輯像素);視窗不存在時回傳 `null`。讀的不是「最後一次 set 的參數」——`setMinSize(0, 0)` 對非正值是忽略的,所以這裡給出的是 `WM_GETMINMAXINFO` 真正會用的那一個 |
|
|
98
|
+
| `getBrowserUi()` | 已請求關閉的瀏覽器行為位元遮罩。**累積語意**:先 `BROWSER_UI_MENU` 再 `BROWSER_UI_KEYS`,讀回來是兩者之和——因為 `setBrowserUi()` 只有「關」的方向。視窗未啟動時回傳 `0`(準確答案,不是「讀不到」) |
|
|
99
|
+
| `lastError()` | 取得最近一次操作失敗的錯誤訊息,無錯誤時回傳空字串 |
|
|
100
|
+
| `setTitle` / `minimize` / `toggleMaximize` / `close` | 基礎視窗控制操作,回傳狀態碼 `rc` |
|
|
101
|
+
| `setMinSize(w, h)` | 設定視窗最小尺寸(單位:邏輯像素) |
|
|
102
|
+
| `drag()` | 觸發原生視窗拖曳(於頁面 `pointerdown` 事件中呼叫,完整保留 Windows 貼齊版面配置特性) |
|
|
103
|
+
| `beginResize(edge)` | 觸發原生邊框縮放(於頁面 `pointermove` 事件中呼叫,`edge` 取值見 `RESIZE_EDGE.*`) |
|
|
104
|
+
| `post(json)` | 宿主向頁面單向投遞 JSON 訊息,非同步發送且不等待回傳結果 |
|
|
105
|
+
| `setZoom(f)` / `getZoom()` | 設定或取得頁面縮放比例(有效範圍 `0.25`–`3.0`,越界值自動鉗制) |
|
|
106
|
+
| `reload()` / `openDevTools()` / `toggleFullscreen()` | 頁面重新載入、開啟開發人員工具、切換全螢幕(分別對應 `Ctrl+R` / `F12` / `F11`) |
|
|
107
|
+
| `setFrameless(frameless)` / `isFrameless()` | 執行時期切換視窗形態並讀取目前形態。只有**建立時**給了 `FLAG_FRAMELESS` 的視窗能切;帶邊框起點的視窗回傳 `rc=-1`,理由在 `lastError()`。詳見下文「視窗旗標」 |
|
|
108
|
+
| `setBrowserUi(mask)` | 停用指定的瀏覽器預設行為,傳入 `BROWSER_UI_*` 旗標的按位元 OR 組合 |
|
|
109
|
+
| `showContextMenu(x, y, items)` | 於指定螢幕座標彈出 Win32 原生右鍵快顯功能表(詳見下文用法) |
|
|
110
|
+
| `pickFolder(title?)` | 呼叫原生資料夾選擇對話方塊(同步阻塞目前呼叫執行緒;**經 IPC 暴露時務必搭配 `timeoutMs: 0`**,見下文) |
|
|
111
|
+
| `libInfo` | 原生動態連結程式庫解析中繼資料(包含實際路徑、載入來源、SHA-256 雜湊值) |
|
|
112
|
+
| `symbols` | 底層直通介面:直接存取透過 `dlopen` 匯出的底層 C 符號 |
|
|
109
113
|
|
|
110
114
|
### 視窗旗標(Flags)
|
|
111
115
|
|
package/dist/libshell.dll
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bun-app-window",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.0",
|
|
5
5
|
"description": "Native WebView2 window shell for Bun — frameless or system-decorated windows, DPI-aware geometry, typed IPC, system-accurate title bar",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
package/src/shell/index.ts
CHANGED
|
@@ -29,11 +29,15 @@ import {
|
|
|
29
29
|
STATE_FOCUSED,
|
|
30
30
|
STATE_MAXIMIZED,
|
|
31
31
|
STATE_MINIMIZED,
|
|
32
|
+
parseBounds,
|
|
33
|
+
parseSize,
|
|
32
34
|
resizeIndex,
|
|
33
35
|
type NativeLibInfo,
|
|
34
36
|
type ResizeEdge,
|
|
35
37
|
type ShellOptions,
|
|
36
38
|
type StartResult,
|
|
39
|
+
type WindowRect,
|
|
40
|
+
type WindowSize,
|
|
37
41
|
type WindowState,
|
|
38
42
|
} from "./types";
|
|
39
43
|
|
|
@@ -81,6 +85,17 @@ export type Shell = {
|
|
|
81
85
|
/** 宿 → 页面:投递 JSON 文本(单向,不等结果) */
|
|
82
86
|
post(json: string): number;
|
|
83
87
|
setTitle(title: string): number;
|
|
88
|
+
/**
|
|
89
|
+
* 读回窗口标题的**真实值**(原生 `GetWindowTextW`)。窗口不存在时返回 `null`。
|
|
90
|
+
*
|
|
91
|
+
* 与 `setTitle()` 的 `rc` 不是一回事:`rc === 0` 只说明消息投递到了 UI 线程,
|
|
92
|
+
* 而 `SetWindowTextW` 可能因为窗口正在销毁而失败。一次写之后读一次,才能区分
|
|
93
|
+
* 「投递成功」与「效果发生」。
|
|
94
|
+
*
|
|
95
|
+
* **读的是窗口,不是输入缓存。** `setTitle()` 不更新任何 TS 侧镜像,所以这里
|
|
96
|
+
* 拿到的一定是窗口现在的标题;空标题返回 `""`,与「窗口不存在」的 `null` 分开。
|
|
97
|
+
*/
|
|
98
|
+
getTitle(): string | null;
|
|
84
99
|
minimize(): number;
|
|
85
100
|
toggleMaximize(): number;
|
|
86
101
|
close(): number;
|
|
@@ -102,6 +117,14 @@ export type Shell = {
|
|
|
102
117
|
beginResize(edge: ResizeEdge): number;
|
|
103
118
|
/** 最小尺寸,逻辑像素 */
|
|
104
119
|
setMinSize(width: number, height: number): void;
|
|
120
|
+
/**
|
|
121
|
+
* 读回**生效中**的最小尺寸(逻辑像素);窗口不存在时返回 `null`。
|
|
122
|
+
*
|
|
123
|
+
* 这个值不是「最后一次 set 的参数」,而是 `WM_GETMINMAXINFO` 真正会用的那一个
|
|
124
|
+
* ——`setMinSize(0, 0)` 对非正值是忽略的(保持旧值),所以读回来的可能与你刚传的
|
|
125
|
+
* 不同。这正是它比 `setMinSize()` 的 `void` 返回值多出来的信息。
|
|
126
|
+
*/
|
|
127
|
+
getMinSize(): WindowSize | null;
|
|
105
128
|
/**
|
|
106
129
|
* 显示窗口。配合 `FLAG_DEFER_SHOW` 使用:页面就绪后再调用,消除首帧闪烁。
|
|
107
130
|
* 未启用延迟显示时是幂等 no-op。
|
|
@@ -166,6 +189,19 @@ export type Shell = {
|
|
|
166
189
|
* (例如调试模式临时放开 `Ctrl+F`)。
|
|
167
190
|
*/
|
|
168
191
|
setBrowserUi(mask: number): number;
|
|
192
|
+
/**
|
|
193
|
+
* 已请求关闭的浏览器行为位掩码(`BROWSER_UI_*` 的按位或)。
|
|
194
|
+
*
|
|
195
|
+
* **累积语义**:`setBrowserUi()` 只有「关」的方向,没有恢复分支(它内部的
|
|
196
|
+
* `apply_browser_ui()` 对 `mask === 0` 直接 return)。所以
|
|
197
|
+
* `setBrowserUi(BROWSER_UI_MENU)` 之后再 `setBrowserUi(BROWSER_UI_KEYS)`,
|
|
198
|
+
* 结果是**两者都关**,这里读回来是 `MENU | KEYS`。
|
|
199
|
+
*
|
|
200
|
+
* 窗口未启动时返回 `0`——那是一个准确的答案(没有窗口,没有任何行为被关),
|
|
201
|
+
* 不是「读不到」。这一点与 `getTitle()` / `getMinSize()` 的 `null` 不对称,
|
|
202
|
+
* 因为它们在没有窗口时给不出有意义的字符串。
|
|
203
|
+
*/
|
|
204
|
+
getBrowserUi(): number;
|
|
169
205
|
/**
|
|
170
206
|
* 在屏幕坐标弹出**原生**上下文菜单。
|
|
171
207
|
*
|
|
@@ -190,9 +226,31 @@ export type Shell = {
|
|
|
190
226
|
* 当前窗口矩形,格式 "x,y,w,h,maximized,dpi"(物理像素)。
|
|
191
227
|
* 最大化时返回的是「还原后」的矩形。窗口未启动时返回空串。
|
|
192
228
|
*
|
|
193
|
-
*
|
|
229
|
+
* 这是原生那一行文本的**直传**:不解析、不校验,字符串长什么样就什么样。
|
|
230
|
+
* 要可用的结构就用 `getRect()`——两者读的是同一个原生符号,只是后者把
|
|
231
|
+
* 同一份文本按判据解析了一遍。要连持久化一起办,用 `bun-app-window/state`
|
|
232
|
+
* 的 `capturePlacement()`。
|
|
194
233
|
*/
|
|
195
234
|
getBounds(): string;
|
|
235
|
+
/**
|
|
236
|
+
* 当前窗口矩形的**结构化**读数;读不到或数据不可用时返回 `null`。
|
|
237
|
+
*
|
|
238
|
+
* 与 `getBounds()` 的分工:后者是逃生舱(原生原文直传),本方法是正式出口。
|
|
239
|
+
* 判据与 `parseBounds()` 同源,返回 `null` 的三种情形见那里——不只是
|
|
240
|
+
* 「窗口没开」,还包括**最小化时的 Win32 哨兵坐标**(形状合法但位置是假的,
|
|
241
|
+
* 详见 `MINIMIZED_SENTINEL` 的说明)。
|
|
242
|
+
*
|
|
243
|
+
* ```ts
|
|
244
|
+
* const r = shell.getRect();
|
|
245
|
+
* if (r) console.log(`${r.width}×${r.height} @ ${r.x},${r.y} dpi=${r.dpi}`);
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* 为什么不让 `state()` 带矩形:`state()` 是廉价位掩码,加一次 FFI 字符串读
|
|
249
|
+
* 与解析会改变它的成本模型,而 `WindowState` 的形状变更本身就是破坏性改动。
|
|
250
|
+
* 需要「最大化标记 + 矩形」一起判时,本方法返回的 `maximized` 与 `x/y/width/height`
|
|
251
|
+
* 来自**同一次**原生读取,不存在两拍之间的撕裂。
|
|
252
|
+
*/
|
|
253
|
+
getRect(): WindowRect | null;
|
|
196
254
|
/**
|
|
197
255
|
* 恢复窗口矩形。savedDpi 是保存时的 DPI,用于跨缩放比例换算;
|
|
198
256
|
* 原生侧还会校验矩形是否仍在某块屏上,不在则居中。
|
|
@@ -260,6 +318,8 @@ export async function createShell(opts: ShellOptions): Promise<Shell> {
|
|
|
260
318
|
|
|
261
319
|
// 拷贝式读出:shell_error() 返回共享缓冲区的指针,解锁后可能被别的线程改写
|
|
262
320
|
const lastError = () => readBuffer((p, len) => symbols.shell_error_text(p, len), 512);
|
|
321
|
+
// 原生那一行 "x,y,w,h,maximized,dpi" 最长也不过几十字节,256 是余量
|
|
322
|
+
const readBounds = () => readBuffer((p, len) => symbols.shell_get_bounds(p, len), 256);
|
|
263
323
|
|
|
264
324
|
const shell: Shell = {
|
|
265
325
|
start(): StartResult {
|
|
@@ -285,12 +345,27 @@ export async function createShell(opts: ShellOptions): Promise<Shell> {
|
|
|
285
345
|
|
|
286
346
|
post: (json) => symbols.shell_post(cstr(json)),
|
|
287
347
|
setTitle: (title) => symbols.shell_title(cstr(title)),
|
|
348
|
+
getTitle(): string | null {
|
|
349
|
+
// 1024 B 够放约 340 个汉字;原生侧装不下会回 -1 而不是给半个字符
|
|
350
|
+
const buf = new Uint8Array(1024);
|
|
351
|
+
const n = symbols.shell_get_title(ptr(buf), buf.length);
|
|
352
|
+
// n < 0 覆盖「窗口不存在」与「装不下」。空标题(n === 0)是 "",不是 null。
|
|
353
|
+
if (n < 0) return null;
|
|
354
|
+
return new TextDecoder().decode(buf.subarray(0, n));
|
|
355
|
+
},
|
|
288
356
|
minimize: () => symbols.shell_minimize(),
|
|
289
357
|
toggleMaximize: () => symbols.shell_toggle_maximize(),
|
|
290
358
|
close: () => symbols.shell_close(),
|
|
291
359
|
drag: () => symbols.shell_drag(),
|
|
292
360
|
beginResize: (edge) => symbols.shell_begin_resize(resizeIndex(edge)),
|
|
293
361
|
setMinSize: (w, h) => symbols.shell_set_min_size(w, h),
|
|
362
|
+
getMinSize(): WindowSize | null {
|
|
363
|
+
// "320,200" 这种形态最长几十字节
|
|
364
|
+
const buf = new Uint8Array(64);
|
|
365
|
+
const n = symbols.shell_get_min_size(ptr(buf), buf.length);
|
|
366
|
+
if (n < 0) return null;
|
|
367
|
+
return parseSize(new TextDecoder().decode(buf.subarray(0, n)));
|
|
368
|
+
},
|
|
294
369
|
show: () => symbols.shell_show(),
|
|
295
370
|
setAppId: (appId) => symbols.shell_set_app_id(cstr(appId)),
|
|
296
371
|
|
|
@@ -326,9 +401,13 @@ export async function createShell(opts: ShellOptions): Promise<Shell> {
|
|
|
326
401
|
lastError,
|
|
327
402
|
|
|
328
403
|
setBrowserUi: (mask) => symbols.shell_set_browser_ui(mask),
|
|
404
|
+
// 累积值,0 是「什么都没关」——不是「读不到」,所以不套 null
|
|
405
|
+
getBrowserUi: () => symbols.shell_get_browser_ui(),
|
|
329
406
|
showContextMenu: (x, y, items) => symbols.shell_show_app_menu(x, y, cstr(items)),
|
|
330
407
|
|
|
331
|
-
getBounds:
|
|
408
|
+
getBounds: readBounds,
|
|
409
|
+
// 与 getBounds 读同一个原生符号,区别只在「解析过没有」。这条链不能省。
|
|
410
|
+
getRect: () => parseBounds(readBounds()),
|
|
332
411
|
restoreBounds: (x, y, w, h, savedDpi, maximized) =>
|
|
333
412
|
symbols.shell_restore_bounds(x, y, w, h, savedDpi, maximized ? 1 : 0),
|
|
334
413
|
|
package/src/shell/native.ts
CHANGED
|
@@ -45,6 +45,14 @@ export function openNativeLib(path: string) {
|
|
|
45
45
|
shell_set_browser_ui: { args: [FFIType.i32], returns: FFIType.i32 },
|
|
46
46
|
shell_show_app_menu: { args: [FFIType.i32, FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
|
|
47
47
|
|
|
48
|
+
// 读回面(ADR 0023):三个 setter 的同步直读。不进命令队列——getter 必须当场给值。
|
|
49
|
+
// shell_get_title 写入 UTF-8,返回字节数;< 0 表示窗口不存在或装不下
|
|
50
|
+
// shell_get_min_size 写入 "w,h",返回长度;< 0 表示窗口不存在
|
|
51
|
+
// shell_get_browser_ui 返回累积的位掩码,0 = 什么都没关
|
|
52
|
+
shell_get_title: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
53
|
+
shell_get_min_size: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
54
|
+
shell_get_browser_ui: { args: [], returns: FFIType.i32 },
|
|
55
|
+
|
|
48
56
|
// 窗口位置面
|
|
49
57
|
shell_get_bounds: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
|
|
50
58
|
shell_restore_bounds: {
|
package/src/shell/state.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import { EVENT_STATE, attachEventPump, type EventPump, unrefTimer } from "./events";
|
|
5
5
|
import type { Shell } from "./index";
|
|
6
|
+
import { isMinimizedSentinel, parseBounds, type WindowRect } from "./types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* 窗口位置与尺寸的持久化。
|
|
@@ -32,16 +33,26 @@ import type { Shell } from "./index";
|
|
|
32
33
|
* 位置变化就永久丢了——用户「拖完立刻点 X」是最常见的关闭方式。
|
|
33
34
|
*/
|
|
34
35
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
/**
|
|
37
|
+
* 窗口矩形的持久化形态——与 `Shell.getRect()` 返回的 `WindowRect` 是同一个类型。
|
|
38
|
+
*
|
|
39
|
+
* 不再各自定义一份形状相同的结构:两份类型会让 `savePlacement(file, shell.getRect())`
|
|
40
|
+
* 这种直接传递,在字段漂移时**静默丢字段**(结构宽的那个能赋给窄的,反过来不行,
|
|
41
|
+
* 而 TS 只报错不提「这是同一份数据」)。`tests/rect.test.ts` 钉住两者可互换。
|
|
42
|
+
*
|
|
43
|
+
* 语义差别只在用途上:`WindowRect` 说的是「此刻读到什么」,本别名说的是
|
|
44
|
+
* 「这份读数要落盘」。字段集合必须一致,因为它们本来就是同一次原生读取。
|
|
45
|
+
*/
|
|
46
|
+
export type WindowPlacement = WindowRect;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 从 `./types` 转出解析器,保持 `bun-app-window/state` 的既有导入面不破。
|
|
50
|
+
*
|
|
51
|
+
* 真正实现在 `types.ts`:主入口 `getRect()` 也要用它,而本模块依赖 `node:fs`,
|
|
52
|
+
* 主入口不能反过来依赖这个可选件。两个子路径拿到的是**同一个函数对象**
|
|
53
|
+
* (`tests/rect.test.ts` 用 `toBe` 钉住),不会出现两份判据各说各话。
|
|
54
|
+
*/
|
|
55
|
+
export { parseBounds };
|
|
45
56
|
|
|
46
57
|
const DEFAULTS = {
|
|
47
58
|
/** 收到变化事件后攒多久再写盘。拖动期间事件每帧一条,攒到停手写一次 */
|
|
@@ -50,52 +61,6 @@ const DEFAULTS = {
|
|
|
50
61
|
pumpMs: 16,
|
|
51
62
|
} as const;
|
|
52
63
|
|
|
53
|
-
/**
|
|
54
|
-
* Win32 的「窗口已最小化」哨兵坐标。
|
|
55
|
-
*
|
|
56
|
-
* 窗口最小化时 `GetWindowRect` 返回的不是真实位置,而是一对哨兵值——实测
|
|
57
|
-
* `-32000,-32000` 加一个图标尺寸(典型 `314x50`)。它们**看起来完全合法**:
|
|
58
|
-
* 坐标是整数,尺寸也大于 0,所以只判「非有限 / 尺寸 ≤ 0」的形状校验拦不住。
|
|
59
|
-
*
|
|
60
|
-
* 成因在原生 `format_bounds`:它只在 `IsZoomed` 为真时走
|
|
61
|
-
* `WINDOWPLACEMENT.rcNormalPosition`,最小化时 `IsZoomed` 返回 false,于是落进
|
|
62
|
-
* `GetWindowRect` 分支——**`IsIconic` 从未被判断**。于是这对哨兵值会一路写成
|
|
63
|
-
* `{"x":-32000,"y":-32000,"width":314,"height":50}`,下次启动恢复成一个畸形小窗
|
|
64
|
-
* (实测:666×471,页面被挤到换行并出现滚动条)。
|
|
65
|
-
*
|
|
66
|
-
* 阈值取 -30000 而不是精确 -32000:不同 DPI / 主题下这对值会带几个像素偏移。
|
|
67
|
-
* 真实坐标到不了这个量级——8K 屏横向也只有 7680,多屏横排也远不到 30000,
|
|
68
|
-
* 所以用阈值不会误伤合法的负坐标(主屏在右、副屏在左时 x 确实是负数)。
|
|
69
|
-
*/
|
|
70
|
-
const MINIMIZED_SENTINEL = -30000;
|
|
71
|
-
|
|
72
|
-
/** 是不是 Win32 的最小化哨兵坐标。任一维命中即判否——两维是同时写的 */
|
|
73
|
-
function isMinimizedSentinel(x: number, y: number): boolean {
|
|
74
|
-
return x <= MINIMIZED_SENTINEL || y <= MINIMIZED_SENTINEL;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** 把原生返回的一行文本解析成 WindowPlacement;格式不对返回 null。 */
|
|
78
|
-
export function parseBounds(text: string): WindowPlacement | null {
|
|
79
|
-
const parts = text.trim().split(",");
|
|
80
|
-
if (parts.length !== 6) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
const nums = parts.map((p) => Number(p));
|
|
84
|
-
if (nums.some((n) => !Number.isFinite(n))) {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
const [x, y, width, height, maximized, dpi] = nums as [number, number, number, number, number, number];
|
|
88
|
-
// 尺寸为 0 或负数说明窗口还没建好(原生在无窗口时返回 0 长度文本,这里再兜一层)
|
|
89
|
-
if (width <= 0 || height <= 0 || dpi <= 0) {
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
// 最小化时原生给的是哨兵坐标,不是真实位置——宁可返回 null 让调用方保留上一次的值
|
|
93
|
-
if (isMinimizedSentinel(x, y)) {
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
return { x, y, width, height, maximized: maximized !== 0, dpi };
|
|
97
|
-
}
|
|
98
|
-
|
|
99
64
|
/**
|
|
100
65
|
* 校验外部来源(磁盘 JSON)里的 placement。
|
|
101
66
|
*
|
package/src/shell/types.ts
CHANGED
|
@@ -281,6 +281,133 @@ export type WindowState = {
|
|
|
281
281
|
dark: boolean;
|
|
282
282
|
};
|
|
283
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
|
+
|
|
284
411
|
/** 原生库的解析结果 */
|
|
285
412
|
export type NativeLibInfo = {
|
|
286
413
|
path: string;
|