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.
@@ -14,15 +14,7 @@
14
14
  */
15
15
 
16
16
  import { createHash } from "node:crypto";
17
- import {
18
- existsSync,
19
- mkdirSync,
20
- readdirSync,
21
- renameSync,
22
- rmSync,
23
- statSync,
24
- writeFileSync,
25
- } from "node:fs";
17
+ import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
26
18
  import { homedir } from "node:os";
27
19
  import { basename, dirname, join, resolve } from "node:path";
28
20
 
@@ -48,176 +40,165 @@ const INSTALLED = /[\\/]node_modules[\\/]/i.test(PKG_ROOT);
48
40
 
49
41
  /** 是否运行在 `bun build --compile` 产物内 */
50
42
  export function isPackedRuntime(): boolean {
51
- return (
52
- /^[A-Za-z]:[\\/]~BUN[\\/]/i.test(import.meta.dir) ||
53
- import.meta.dir.startsWith("/$bunfs/")
54
- );
43
+ return /^[A-Za-z]:[\\/]~BUN[\\/]/i.test(import.meta.dir) || import.meta.dir.startsWith("/$bunfs/");
55
44
  }
56
45
 
57
46
  function sha256Hex(buf: Uint8Array): string {
58
- return createHash("sha256").update(buf).digest("hex");
47
+ return createHash("sha256").update(buf).digest("hex");
59
48
  }
60
49
 
61
50
  function runtimeDir(appName: string): string {
62
- const base =
63
- process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.length > 0
64
- ? process.env.LOCALAPPDATA
65
- : join(homedir(), "AppData", "Local");
66
- return join(base, appName, "runtime");
51
+ const base =
52
+ process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.length > 0
53
+ ? process.env.LOCALAPPDATA
54
+ : join(homedir(), "AppData", "Local");
55
+ return join(base, appName, "runtime");
67
56
  }
68
57
 
69
58
  function fromPath(path: string, source: NativeLibInfo["source"]): NativeLibInfo {
70
- return {
71
- path,
72
- source,
73
- version: PKG_VERSION,
74
- sha256: "",
75
- bytes: statSync(path).size,
76
- reused: false,
77
- pruned: [],
78
- };
59
+ return {
60
+ path,
61
+ source,
62
+ version: PKG_VERSION,
63
+ sha256: "",
64
+ bytes: statSync(path).size,
65
+ reused: false,
66
+ pruned: [],
67
+ };
79
68
  }
80
69
 
81
70
  /** 写入目标目录;同名同大小视为可复用,写入走临时文件 + rename 保证原子性 */
82
- function materialize(
83
- dir: string,
84
- bytes: Uint8Array,
85
- sha256: string,
86
- ): { path: string; reused: boolean } {
87
- mkdirSync(dir, { recursive: true });
71
+ function materialize(dir: string, bytes: Uint8Array, sha256: string): { path: string; reused: boolean } {
72
+ mkdirSync(dir, { recursive: true });
73
+
74
+ const finalName = `libshell-${sha256.slice(0, 16)}.dll`;
75
+ const finalPath = join(dir, finalName);
76
+
77
+ if (existsSync(finalPath)) {
78
+ try {
79
+ if (statSync(finalPath).size === bytes.length) {
80
+ return { path: finalPath, reused: true };
81
+ }
82
+ } catch {
83
+ // 读不到就重写
84
+ }
85
+ }
88
86
 
89
- const finalName = `libshell-${sha256.slice(0, 16)}.dll`;
90
- const finalPath = join(dir, finalName);
87
+ const tmpPath = join(dir, `.tmp-${process.pid}-${Date.now().toString(36)}.dll`);
88
+ writeFileSync(tmpPath, bytes);
91
89
 
92
- if (existsSync(finalPath)) {
93
90
  try {
94
- if (statSync(finalPath).size === bytes.length) {
95
- return { path: finalPath, reused: true };
96
- }
97
- } catch {
98
- // 读不到就重写
91
+ renameSync(tmpPath, finalPath);
92
+ } catch (err) {
93
+ // Windows 上目标已存在时 rename 会失败:另一个实例可能刚好抢先写入
94
+ rmSync(tmpPath, { force: true });
95
+ if (!existsSync(finalPath)) throw err;
99
96
  }
100
- }
101
-
102
- const tmpPath = join(dir, `.tmp-${process.pid}-${Date.now().toString(36)}.dll`);
103
- writeFileSync(tmpPath, bytes);
104
-
105
- try {
106
- renameSync(tmpPath, finalPath);
107
- } catch (err) {
108
- // Windows 上目标已存在时 rename 会失败:另一个实例可能刚好抢先写入
109
- rmSync(tmpPath, { force: true });
110
- if (!existsSync(finalPath)) throw err;
111
- }
112
97
 
113
- return { path: finalPath, reused: false };
98
+ return { path: finalPath, reused: false };
114
99
  }
115
100
 
116
101
  /** 删掉与当前哈希不符的旧副本;正被其它实例占用的删除失败则忽略 */
117
102
  function pruneOldVersions(dir: string, keepName: string): string[] {
118
- const pruned: string[] = [];
119
- let entries: string[];
120
- try {
121
- entries = readdirSync(dir);
122
- } catch {
123
- return pruned;
124
- }
125
-
126
- for (const name of entries) {
127
- if (!DLL_PATTERN.test(name)) continue;
128
- if (name === keepName) continue;
103
+ const pruned: string[] = [];
104
+ let entries: string[];
129
105
  try {
130
- rmSync(join(dir, name), { force: true });
131
- pruned.push(name);
106
+ entries = readdirSync(dir);
132
107
  } catch {
133
- // 正被其它实例加载,跳过
108
+ return pruned;
109
+ }
110
+
111
+ for (const name of entries) {
112
+ if (!DLL_PATTERN.test(name)) continue;
113
+ if (name === keepName) continue;
114
+ try {
115
+ rmSync(join(dir, name), { force: true });
116
+ pruned.push(name);
117
+ } catch {
118
+ // 正被其它实例加载,跳过
119
+ }
134
120
  }
135
- }
136
- return pruned;
121
+ return pruned;
137
122
  }
138
123
 
139
124
  /** 从 Bun.embeddedFiles 找内嵌 DLL;没有则返回 null */
140
125
  async function readBunEmbedded(): Promise<Uint8Array | null> {
141
- const files = (Bun as unknown as { embeddedFiles?: ReadonlyArray<Blob & { name: string }> })
142
- .embeddedFiles;
143
- if (!files || files.length === 0) return null;
144
-
145
- const hit = files.find((f) => DLL_PATTERN.test(f.name));
146
- if (!hit) return null;
147
-
148
- const buf = new Uint8Array(await hit.arrayBuffer());
149
- if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) {
150
- throw new Error(`embedded native library is not a PE file (${hit.name})`);
151
- }
152
- return buf;
126
+ const files = (Bun as unknown as { embeddedFiles?: ReadonlyArray<Blob & { name: string }> }).embeddedFiles;
127
+ if (!files || files.length === 0) return null;
128
+
129
+ const hit = files.find((f) => DLL_PATTERN.test(f.name));
130
+ if (!hit) return null;
131
+
132
+ const buf = new Uint8Array(await hit.arrayBuffer());
133
+ if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) {
134
+ throw new Error(`embedded native library is not a PE file (${hit.name})`);
135
+ }
136
+ return buf;
153
137
  }
154
138
 
155
139
  export type ResolveOptions = {
156
- nativeLibPath?: string;
157
- appName?: string;
158
- embedded?: () => Promise<Uint8Array | null>;
140
+ nativeLibPath?: string;
141
+ appName?: string;
142
+ embedded?: () => Promise<Uint8Array | null>;
159
143
  };
160
144
 
161
145
  export async function resolveNativeLib(opts: ResolveOptions = {}): Promise<NativeLibInfo> {
162
- const appName = opts.appName ?? "BunAppWindow";
163
-
164
- // 1. 显式指定
165
- if (opts.nativeLibPath) {
166
- if (!existsSync(opts.nativeLibPath)) {
167
- throw new Error(`nativeLibPath does not exist: ${opts.nativeLibPath}`);
146
+ const appName = opts.appName ?? "BunAppWindow";
147
+
148
+ // 1. 显式指定
149
+ if (opts.nativeLibPath) {
150
+ if (!existsSync(opts.nativeLibPath)) {
151
+ throw new Error(`nativeLibPath does not exist: ${opts.nativeLibPath}`);
152
+ }
153
+ return fromPath(opts.nativeLibPath, "explicit");
168
154
  }
169
- return fromPath(opts.nativeLibPath, "explicit");
170
- }
171
-
172
- if (isPackedRuntime()) {
173
- // 2. 内嵌字节 → 释放到用户目录
174
- const bytes = opts.embedded ? await opts.embedded() : await readBunEmbedded();
175
- if (bytes) {
176
- const sha256 = sha256Hex(bytes);
177
- try {
178
- const { path, reused } = materialize(runtimeDir(appName), bytes, sha256);
179
- return {
180
- path,
181
- source: "embedded",
182
- version: PKG_VERSION,
183
- sha256,
184
- bytes: bytes.length,
185
- reused,
186
- pruned: pruneOldVersions(dirname(path), basename(path)),
187
- };
188
- } catch {
189
- // %LOCALAPPDATA% 不可写(受限环境):继续尝试同级文件
190
- }
155
+
156
+ if (isPackedRuntime()) {
157
+ // 2. 内嵌字节 → 释放到用户目录
158
+ const bytes = opts.embedded ? await opts.embedded() : await readBunEmbedded();
159
+ if (bytes) {
160
+ const sha256 = sha256Hex(bytes);
161
+ try {
162
+ const { path, reused } = materialize(runtimeDir(appName), bytes, sha256);
163
+ return {
164
+ path,
165
+ source: "embedded",
166
+ version: PKG_VERSION,
167
+ sha256,
168
+ bytes: bytes.length,
169
+ reused,
170
+ pruned: pruneOldVersions(dirname(path), basename(path)),
171
+ };
172
+ } catch {
173
+ // %LOCALAPPDATA% 不可写(受限环境):继续尝试同级文件
174
+ }
175
+ }
176
+
177
+ // 3. 可执行文件同级
178
+ for (const candidate of [
179
+ join(dirname(process.execPath), "libshell.dll"),
180
+ join(dirname(process.execPath), "dist", "libshell.dll"),
181
+ ]) {
182
+ if (existsSync(candidate)) return fromPath(candidate, "package");
183
+ }
184
+
185
+ throw new Error(
186
+ "cannot locate libshell.dll in a compiled executable. " +
187
+ "Either place libshell.dll next to the executable, or import " +
188
+ "`bun-app-window/embedded` so it gets bundled. " +
189
+ `Searched: ${dirname(process.execPath)}`,
190
+ );
191
191
  }
192
192
 
193
- // 3. 可执行文件同级
194
- for (const candidate of [
195
- join(dirname(process.execPath), "libshell.dll"),
196
- join(dirname(process.execPath), "dist", "libshell.dll"),
197
- ]) {
198
- if (existsSync(candidate)) return fromPath(candidate, "package");
193
+ // 4. 开发态 / 包安装态:包根下的 .build/ 再 dist/
194
+ const source: NativeLibInfo["source"] = INSTALLED ? "package" : "dev";
195
+ for (const candidate of [join(PKG_ROOT, ".build", "libshell.dll"), join(PKG_ROOT, "dist", "libshell.dll")]) {
196
+ if (existsSync(candidate)) return fromPath(candidate, source);
199
197
  }
200
198
 
201
199
  throw new Error(
202
- "cannot locate libshell.dll in a compiled executable. " +
203
- "Either place libshell.dll next to the executable, or import " +
204
- "`bun-app-window/embedded` so it gets bundled. " +
205
- `Searched: ${dirname(process.execPath)}`,
200
+ "libshell.dll not found. Build the native layer first: " +
201
+ "`bun run native/build.ts`. " +
202
+ `Searched under ${PKG_ROOT}`,
206
203
  );
207
- }
208
-
209
- // 4. 开发态 / 包安装态:包根下的 .build/ 再 dist/
210
- const source: NativeLibInfo["source"] = INSTALLED ? "package" : "dev";
211
- for (const candidate of [
212
- join(PKG_ROOT, ".build", "libshell.dll"),
213
- join(PKG_ROOT, "dist", "libshell.dll"),
214
- ]) {
215
- if (existsSync(candidate)) return fromPath(candidate, source);
216
- }
217
-
218
- throw new Error(
219
- "libshell.dll not found. Build the native layer first: " +
220
- "`bun run native/build.ts`. " +
221
- `Searched under ${PKG_ROOT}`,
222
- );
223
204
  }
@@ -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
- export type WindowPlacement = {
36
- /** 物理像素,屏幕坐标 */
37
- x: number;
38
- y: number;
39
- width: number;
40
- height: number;
41
- maximized: boolean;
42
- /** 保存时的窗口 DPI;恢复时用于换算尺寸 */
43
- dpi: number;
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,59 +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 [
88
- number,
89
- number,
90
- number,
91
- number,
92
- number,
93
- number,
94
- ];
95
- // 尺寸为 0 或负数说明窗口还没建好(原生在无窗口时返回 0 长度文本,这里再兜一层)
96
- if (width <= 0 || height <= 0 || dpi <= 0) {
97
- return null;
98
- }
99
- // 最小化时原生给的是哨兵坐标,不是真实位置——宁可返回 null 让调用方保留上一次的值
100
- if (isMinimizedSentinel(x, y)) {
101
- return null;
102
- }
103
- return { x, y, width, height, maximized: maximized !== 0, dpi };
104
- }
105
-
106
64
  /**
107
65
  * 校验外部来源(磁盘 JSON)里的 placement。
108
66
  *
@@ -217,15 +175,9 @@ export type PlacementStoreOptions = {
217
175
  * 首次 `attach` 会立刻抓一次快照并写盘——程序若在用户操作前崩溃,也还有
218
176
  * 初始位置可恢复。
219
177
  */
220
- export function attachPlacementStore(
221
- shell: Shell,
222
- file: string,
223
- opts: PlacementStoreOptions = {},
224
- ): PlacementStore {
178
+ export function attachPlacementStore(shell: Shell, file: string, opts: PlacementStoreOptions = {}): PlacementStore {
225
179
  const debounceMs = Math.max(0, opts.debounceMs ?? DEFAULTS.debounceMs);
226
- const pump =
227
- opts.events ??
228
- attachEventPump(shell, { intervalMs: Math.max(1, opts.pumpMs ?? DEFAULTS.pumpMs) });
180
+ const pump = opts.events ?? attachEventPump(shell, { intervalMs: Math.max(1, opts.pumpMs ?? DEFAULTS.pumpMs) });
229
181
  const ownsPump = opts.events === undefined;
230
182
 
231
183
  let last: WindowPlacement | null = null;
@@ -277,9 +229,11 @@ export function attachPlacementStore(
277
229
  return;
278
230
  }
279
231
  // 串行化写入:并发 rename 同一目标会互相踩
280
- pending = pending.then(() => savePlacement(file, now)).catch(() => {
281
- // 写盘失败不该打断应用;下一次变化会重试
282
- });
232
+ pending = pending
233
+ .then(() => savePlacement(file, now))
234
+ .catch(() => {
235
+ // 写盘失败不该打断应用;下一次变化会重试
236
+ });
283
237
  };
284
238
 
285
239
  const scheduleWrite = (): void => {