bun-app-window 0.0.0-stage → 0.2.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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * port.ts — 为本地回环服务挑选浏览器允许访问的端口
3
+ *
4
+ * 背景:`Bun.serve({ port: 0 })` 由系统随意分配,可能拿到 6000、1719、1720 等
5
+ * 「不安全端口」。WebView2 / Chromium 内核对这批端口一律拒绝连接,页面直接
6
+ * 显示 ERR_UNSAFE_PORT,表现为窗口打开但白屏。本机实测命中过 1720(H.323 RAS)。
7
+ *
8
+ * 策略:40000–60000 区间随机挑选,避开受限端口表;该区间远离常见开发端口。
9
+ */
10
+
11
+ import { randomInt } from "node:crypto";
12
+
13
+ /**
14
+ * Chromium 的受限端口表(net/base/port_util.cc 的 kRestrictedPorts)。
15
+ * 凡在此表内的端口,浏览器内核直接拒绝连接,不发任何网络请求。
16
+ */
17
+ const RESTRICTED_PORTS = new Set<number>([
18
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102,
19
+ 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 138, 139, 143, 161, 179, 389, 427,
20
+ 465, 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990,
21
+ 993, 995, 1719, 1720, 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667,
22
+ 6668, 6669, 6679, 6697, 10080,
23
+ ]);
24
+
25
+ export function isRestrictedPort(port: number): boolean {
26
+ return RESTRICTED_PORTS.has(port);
27
+ }
28
+
29
+ const RANGE_MIN = 40000;
30
+ const RANGE_MAX = 60000;
31
+
32
+ export type ServeOptions = {
33
+ hostname?: string;
34
+ /** 最多尝试几次(默认 20);超出则抛错 */
35
+ attempts?: number;
36
+ fetch: (req: Request, server: unknown) => Response | undefined | Promise<Response | undefined>;
37
+ websocket?: unknown;
38
+ error?: (err: Error) => Response;
39
+ };
40
+
41
+ /**
42
+ * 用已知安全的端口启动 Bun.serve。
43
+ * 相比 `Bun.serve({ port: 0 })`,这里保证端口不在浏览器受限表内。
44
+ */
45
+ export function serveOnSafePort(opts: ServeOptions) {
46
+ const attempts = opts.attempts ?? 20;
47
+ let lastErr: unknown = null;
48
+
49
+ for (let i = 0; i < attempts; i++) {
50
+ const port = randomInt(RANGE_MIN, RANGE_MAX);
51
+ if (isRestrictedPort(port)) continue;
52
+
53
+ try {
54
+ const server = Bun.serve({
55
+ hostname: opts.hostname ?? "127.0.0.1",
56
+ port,
57
+ fetch: opts.fetch as never,
58
+ ...(opts.websocket === undefined ? {} : { websocket: opts.websocket as never }),
59
+ ...(opts.error === undefined ? {} : { error: opts.error }),
60
+ });
61
+
62
+ // 双保险:即使指定了端口也再确认一次
63
+ const bound = server.port;
64
+ if (bound !== undefined && isRestrictedPort(bound)) {
65
+ server.stop(true);
66
+ lastErr = new Error(`allocated restricted port ${bound}`);
67
+ continue;
68
+ }
69
+ return server;
70
+ } catch (err) {
71
+ lastErr = err;
72
+ // 端口被占用等情况,继续尝试下一个
73
+ }
74
+ }
75
+
76
+ throw new Error(`could not bind a safe port after ${attempts} attempts: ${String(lastErr)}`);
77
+ }
@@ -0,0 +1,211 @@
1
+ /**
2
+ * resolve.ts — 定位可加载的 libshell.dll
3
+ *
4
+ * 与旧实现的差别(这是「可作依赖包」的关键):
5
+ * 旧实现把 `import dll from "../build/libshell.dll" with { type: "file" }` 写死,
6
+ * 路径相对仓库结构;装进 node_modules 后会指向 `node_modules/build/`,必崩。
7
+ * 现在改为按「包自身根目录」解析,仓库结构与安装位置都不影响。
8
+ *
9
+ * 解析优先级:
10
+ * 1. options.nativeLibPath 显式指定,跳过一切推断
11
+ * 2. 编译产物 + 内嵌字节 释放到 %LOCALAPPDATA%\<appName>\runtime,内容哈希命名
12
+ * 3. 编译产物 + 可执行文件同级 exe 旁放 libshell.dll(无需内嵌)
13
+ * 4. 开发态 包根 .build/libshell.dll → dist/libshell.dll
14
+ */
15
+
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";
26
+ import { homedir } from "node:os";
27
+ import { basename, dirname, join, resolve } from "node:path";
28
+
29
+ import type { NativeLibInfo } from "./types";
30
+
31
+ const DLL_PATTERN = /^libshell.*\.dll$/i;
32
+
33
+ /** 包根目录:src/shell → 上两级。打包态下该值不可用于文件系统访问。 */
34
+ const PKG_ROOT = resolve(import.meta.dir, "..", "..");
35
+
36
+ /** 从 node_modules 里被引用时,来源应报 package 而不是 dev */
37
+ const INSTALLED = /[\\/]node_modules[\\/]/i.test(PKG_ROOT);
38
+
39
+ /** 是否运行在 `bun build --compile` 产物内 */
40
+ export function isPackedRuntime(): boolean {
41
+ return (
42
+ /^[A-Za-z]:[\\/]~BUN[\\/]/i.test(import.meta.dir) ||
43
+ import.meta.dir.startsWith("/$bunfs/")
44
+ );
45
+ }
46
+
47
+ function sha256Hex(buf: Uint8Array): string {
48
+ return createHash("sha256").update(buf).digest("hex");
49
+ }
50
+
51
+ function runtimeDir(appName: string): string {
52
+ const base =
53
+ process.env.LOCALAPPDATA && process.env.LOCALAPPDATA.length > 0
54
+ ? process.env.LOCALAPPDATA
55
+ : join(homedir(), "AppData", "Local");
56
+ return join(base, appName, "runtime");
57
+ }
58
+
59
+ function fromPath(path: string, source: NativeLibInfo["source"]): NativeLibInfo {
60
+ return {
61
+ path,
62
+ source,
63
+ sha256: "",
64
+ bytes: statSync(path).size,
65
+ reused: false,
66
+ pruned: [],
67
+ };
68
+ }
69
+
70
+ /** 写入目标目录;同名同大小视为可复用,写入走临时文件 + rename 保证原子性 */
71
+ function materialize(
72
+ dir: string,
73
+ bytes: Uint8Array,
74
+ sha256: string,
75
+ ): { path: string; reused: boolean } {
76
+ mkdirSync(dir, { recursive: true });
77
+
78
+ const finalName = `libshell-${sha256.slice(0, 16)}.dll`;
79
+ const finalPath = join(dir, finalName);
80
+
81
+ if (existsSync(finalPath)) {
82
+ try {
83
+ if (statSync(finalPath).size === bytes.length) {
84
+ return { path: finalPath, reused: true };
85
+ }
86
+ } catch {
87
+ // 读不到就重写
88
+ }
89
+ }
90
+
91
+ const tmpPath = join(dir, `.tmp-${process.pid}-${Date.now().toString(36)}.dll`);
92
+ writeFileSync(tmpPath, bytes);
93
+
94
+ try {
95
+ renameSync(tmpPath, finalPath);
96
+ } catch (err) {
97
+ // Windows 上目标已存在时 rename 会失败:另一个实例可能刚好抢先写入
98
+ rmSync(tmpPath, { force: true });
99
+ if (!existsSync(finalPath)) throw err;
100
+ }
101
+
102
+ return { path: finalPath, reused: false };
103
+ }
104
+
105
+ /** 删掉与当前哈希不符的旧副本;正被其它实例占用的删除失败则忽略 */
106
+ function pruneOldVersions(dir: string, keepName: string): string[] {
107
+ const pruned: string[] = [];
108
+ let entries: string[];
109
+ try {
110
+ entries = readdirSync(dir);
111
+ } catch {
112
+ return pruned;
113
+ }
114
+
115
+ for (const name of entries) {
116
+ if (!DLL_PATTERN.test(name)) continue;
117
+ if (name === keepName) continue;
118
+ try {
119
+ rmSync(join(dir, name), { force: true });
120
+ pruned.push(name);
121
+ } catch {
122
+ // 正被其它实例加载,跳过
123
+ }
124
+ }
125
+ return pruned;
126
+ }
127
+
128
+ /** 从 Bun.embeddedFiles 找内嵌 DLL;没有则返回 null */
129
+ async function readBunEmbedded(): Promise<Uint8Array | null> {
130
+ const files = (Bun as unknown as { embeddedFiles?: ReadonlyArray<Blob & { name: string }> })
131
+ .embeddedFiles;
132
+ if (!files || files.length === 0) return null;
133
+
134
+ const hit = files.find((f) => DLL_PATTERN.test(f.name));
135
+ if (!hit) return null;
136
+
137
+ const buf = new Uint8Array(await hit.arrayBuffer());
138
+ if (buf.length < 2 || buf[0] !== 0x4d || buf[1] !== 0x5a) {
139
+ throw new Error(`embedded native library is not a PE file (${hit.name})`);
140
+ }
141
+ return buf;
142
+ }
143
+
144
+ export type ResolveOptions = {
145
+ nativeLibPath?: string;
146
+ appName?: string;
147
+ embedded?: () => Promise<Uint8Array | null>;
148
+ };
149
+
150
+ export async function resolveNativeLib(opts: ResolveOptions = {}): Promise<NativeLibInfo> {
151
+ const appName = opts.appName ?? "BunAppWindow";
152
+
153
+ // 1. 显式指定
154
+ if (opts.nativeLibPath) {
155
+ if (!existsSync(opts.nativeLibPath)) {
156
+ throw new Error(`nativeLibPath does not exist: ${opts.nativeLibPath}`);
157
+ }
158
+ return fromPath(opts.nativeLibPath, "explicit");
159
+ }
160
+
161
+ if (isPackedRuntime()) {
162
+ // 2. 内嵌字节 → 释放到用户目录
163
+ const bytes = opts.embedded ? await opts.embedded() : await readBunEmbedded();
164
+ if (bytes) {
165
+ const sha256 = sha256Hex(bytes);
166
+ try {
167
+ const { path, reused } = materialize(runtimeDir(appName), bytes, sha256);
168
+ return {
169
+ path,
170
+ source: "embedded",
171
+ sha256,
172
+ bytes: bytes.length,
173
+ reused,
174
+ pruned: pruneOldVersions(dirname(path), basename(path)),
175
+ };
176
+ } catch {
177
+ // %LOCALAPPDATA% 不可写(受限环境):继续尝试同级文件
178
+ }
179
+ }
180
+
181
+ // 3. 可执行文件同级
182
+ for (const candidate of [
183
+ join(dirname(process.execPath), "libshell.dll"),
184
+ join(dirname(process.execPath), "dist", "libshell.dll"),
185
+ ]) {
186
+ if (existsSync(candidate)) return fromPath(candidate, "package");
187
+ }
188
+
189
+ throw new Error(
190
+ "cannot locate libshell.dll in a compiled executable. " +
191
+ "Either place libshell.dll next to the executable, or import " +
192
+ "`bun-app-window/embedded` so it gets bundled. " +
193
+ `Searched: ${dirname(process.execPath)}`,
194
+ );
195
+ }
196
+
197
+ // 4. 开发态 / 包安装态:包根下的 .build/ 再 dist/
198
+ const source: NativeLibInfo["source"] = INSTALLED ? "package" : "dev";
199
+ for (const candidate of [
200
+ join(PKG_ROOT, ".build", "libshell.dll"),
201
+ join(PKG_ROOT, "dist", "libshell.dll"),
202
+ ]) {
203
+ if (existsSync(candidate)) return fromPath(candidate, source);
204
+ }
205
+
206
+ throw new Error(
207
+ "libshell.dll not found. Build the native layer first: " +
208
+ "`pwsh -File native/build.ps1`. " +
209
+ `Searched under ${PKG_ROOT}`,
210
+ );
211
+ }
@@ -0,0 +1,211 @@
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, resolve } from "node:path";
3
+
4
+ import type { Shell } from "./index";
5
+
6
+ /**
7
+ * 窗口位置与尺寸的持久化。
8
+ *
9
+ * 单独一个模块而不是塞进 `index.ts`,因为库主体有一条硬约束:不得引入副作用
10
+ * (不写文件、不起服务、不读 env)。持久化按定义就是副作用,所以它是**显式调用**的
11
+ * 可选件,你不调用它就不会有任何文件产生。
12
+ *
13
+ * 跨会话恢复有三个坑,原生侧已经处理,这里只负责序列化与节流:
14
+ * - DPI 变化:存的是物理像素 + 当时的 DPI,恢复时按比例换算
15
+ * - 显示器不在了:矩形要与某块工作区有足够交叠才算可用,否则居中
16
+ * - 最大化状态:存「还原后」的矩形,而不是整屏尺寸
17
+ */
18
+
19
+ export type WindowPlacement = {
20
+ /** 物理像素,屏幕坐标 */
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ maximized: boolean;
26
+ /** 保存时的窗口 DPI;恢复时用于换算尺寸 */
27
+ dpi: number;
28
+ };
29
+
30
+ const DEFAULTS = {
31
+ /** 轮询间隔。GetWindowRect 是微秒级,700 ms 足够了 */
32
+ intervalMs: 700,
33
+ } as const;
34
+
35
+ /** 把原生返回的一行文本解析成 WindowPlacement;格式不对返回 null。 */
36
+ export function parseBounds(text: string): WindowPlacement | null {
37
+ const parts = text.trim().split(",");
38
+ if (parts.length !== 6) {
39
+ return null;
40
+ }
41
+ const nums = parts.map((p) => Number(p));
42
+ if (nums.some((n) => !Number.isFinite(n))) {
43
+ return null;
44
+ }
45
+ const [x, y, width, height, maximized, dpi] = nums as [
46
+ number,
47
+ number,
48
+ number,
49
+ number,
50
+ number,
51
+ number,
52
+ ];
53
+ // 尺寸为 0 或负数说明窗口还没建好(原生在无窗口时返回 0 长度文本,这里再兜一层)
54
+ if (width <= 0 || height <= 0 || dpi <= 0) {
55
+ return null;
56
+ }
57
+ return { x, y, width, height, maximized: maximized !== 0, dpi };
58
+ }
59
+
60
+ /**
61
+ * 校验外部来源(磁盘 JSON)里的 placement。
62
+ *
63
+ * 配置文件可能是手改坏的、被截断的、或者是旧版本写的。这里做形状校验而不是直接
64
+ * 相信它:一个 out-of-range 的坐标会让窗口出现在屏幕外,表现为「程序启动了但看不到」。
65
+ */
66
+ export function parsePlacement(raw: unknown): WindowPlacement | null {
67
+ if (!raw || typeof raw !== "object") {
68
+ return null;
69
+ }
70
+ const o = raw as Record<string, unknown>;
71
+ const num = (k: string): number | null => {
72
+ const v = o[k];
73
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
74
+ };
75
+ const x = num("x");
76
+ const y = num("y");
77
+ const width = num("width");
78
+ const height = num("height");
79
+ const dpi = num("dpi");
80
+ if (x === null || y === null || width === null || height === null || dpi === null) {
81
+ return null;
82
+ }
83
+ if (width <= 0 || height <= 0 || dpi <= 0) {
84
+ return null;
85
+ }
86
+ // 物理像素的合理上限:8K 也只有 7680。超过说明文件被污染了
87
+ if (width > 20000 || height > 20000) {
88
+ return null;
89
+ }
90
+ if (Math.abs(x) > 200000 || Math.abs(y) > 200000) {
91
+ return null;
92
+ }
93
+ return { x, y, width, height, maximized: o.maximized === true, dpi };
94
+ }
95
+
96
+ /** 读当前窗口矩形;窗口不存在或数据不可解析时返回 null。 */
97
+ export function capturePlacement(shell: Shell): WindowPlacement | null {
98
+ return parseBounds(shell.getBounds());
99
+ }
100
+
101
+ /** 把保存的矩形恢复到窗口上(异步生效;原生侧会做 DPI 换算与屏幕校验)。 */
102
+ export function restorePlacement(shell: Shell, p: WindowPlacement): number {
103
+ return shell.restoreBounds(p.x, p.y, p.width, p.height, p.dpi, p.maximized);
104
+ }
105
+
106
+ /** 从磁盘读。文件不存在、损坏、形状不对一律返回 null,不抛错。 */
107
+ export async function loadPlacement(file: string): Promise<WindowPlacement | null> {
108
+ try {
109
+ const f = Bun.file(file);
110
+ if (!(await f.exists())) {
111
+ return null;
112
+ }
113
+ return parsePlacement(JSON.parse(await f.text()));
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 写磁盘。先写临时文件再改名——窗口位置在关闭时写,如果正好被强杀,
121
+ * 半截 JSON 会让下次启动读不到位置;改名是原子的,至少保证要么旧值要么新值。
122
+ */
123
+ export async function savePlacement(file: string, p: WindowPlacement): Promise<void> {
124
+ const target = resolve(file);
125
+ mkdirSync(dirname(target), { recursive: true });
126
+ const tmp = `${target}.tmp`;
127
+ writeFileSync(tmp, `${JSON.stringify(p, null, 4)}\n`, "utf8");
128
+ renameSync(tmp, target);
129
+ }
130
+
131
+ export type PlacementStore = {
132
+ /** 立即抓一次并写盘(关闭前调用) */
133
+ flush(): Promise<void>;
134
+ /** 停止轮询。不清盘——最后写入的值保留 */
135
+ dispose(): void;
136
+ /** 当前已知的 placement(最近一次成功写入的) */
137
+ readonly current: WindowPlacement | null;
138
+ };
139
+
140
+ /**
141
+ * 轮询窗口矩形并在变化时写盘。
142
+ *
143
+ * 为什么轮询而不是事件:原生侧的窗口状态推送是发给**页面**的(shell-msg),宿主
144
+ * 拿不到;加一条原生→宿主的回调通道要多一套生命周期管理,而 GetWindowRect 是
145
+ * 微秒级调用,700 ms 一次的代价可以忽略。
146
+ *
147
+ * 变化检测按内容比较:窗口静止时不产生任何磁盘写入。
148
+ */
149
+ export function attachPlacementStore(
150
+ shell: Shell,
151
+ file: string,
152
+ opts: { intervalMs?: number } = {},
153
+ ): PlacementStore {
154
+ const intervalMs = opts.intervalMs ?? DEFAULTS.intervalMs;
155
+ let last: WindowPlacement | null = null;
156
+ let lastKey = "";
157
+ let disposed = false;
158
+ let pending: Promise<void> = Promise.resolve();
159
+
160
+ const same = (a: WindowPlacement, b: WindowPlacement): boolean =>
161
+ a.x === b.x &&
162
+ a.y === b.y &&
163
+ a.width === b.width &&
164
+ a.height === b.height &&
165
+ a.maximized === b.maximized &&
166
+ a.dpi === b.dpi;
167
+
168
+ const tick = (): void => {
169
+ if (disposed) {
170
+ return;
171
+ }
172
+ const now = capturePlacement(shell);
173
+ if (!now) {
174
+ return;
175
+ }
176
+ // 窗口销毁过程中会短暂读到 0 尺寸,上面已过滤
177
+ if (last && same(now, last)) {
178
+ return;
179
+ }
180
+ const key = JSON.stringify(now);
181
+ if (key === lastKey) {
182
+ return;
183
+ }
184
+ last = now;
185
+ lastKey = key;
186
+ // 串行化写入:并发 rename 同一目标会互相踩
187
+ pending = pending.then(() => savePlacement(file, now)).catch(() => {
188
+ // 写盘失败不该打断应用;下一次 tick 会重试
189
+ });
190
+ };
191
+
192
+ const timer = setInterval(tick, intervalMs);
193
+ // 不阻止进程退出:应用退出时不该因为一个定时器挂住
194
+ if (typeof timer === "object" && timer !== null && "unref" in timer) {
195
+ (timer as { unref(): void }).unref();
196
+ }
197
+
198
+ return {
199
+ async flush(): Promise<void> {
200
+ tick();
201
+ await pending;
202
+ },
203
+ dispose(): void {
204
+ disposed = true;
205
+ clearInterval(timer);
206
+ },
207
+ get current(): WindowPlacement | null {
208
+ return last;
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * types.ts — 内核的公共类型与常量
3
+ *
4
+ * 这里的类型是**稳定面**:改动需要按语义化版本对待。
5
+ * 调试自省类能力单独放在 Shell["debug"] 下,不视为稳定契约。
6
+ */
7
+
8
+ /** 无边框:原生层不画标题栏与边框,由页面自绘 */
9
+ export const FLAG_FRAMELESS = 1;
10
+ /** 置顶 */
11
+ export const FLAG_TOPMOST = 2;
12
+ /**
13
+ * 启动时把窗口居中到所在显示器的工作区(避开任务栏)。
14
+ *
15
+ * 带边框与无边框两种模式都生效。窗口比工作区大时会先收缩到工作区内再居中——
16
+ * 高 DPI 下这很常见(900×940 逻辑像素在 200% 缩放加边框就是 1950 物理像素高,
17
+ * 超过 1824 的工作区),不收缩只能贴顶,垂直方向看不出居中。
18
+ */
19
+ export const FLAG_CENTER = 4;
20
+ /** 可缩放(配合无边框时,边框拖拽区由 shell_begin_resize 提供) */
21
+ export const FLAG_RESIZABLE = 8;
22
+ /**
23
+ * 创建后先不显示,等调用方确认页面就绪再 `shell.show()`。
24
+ * 用于消除「空窗口先出现、内容后填上」的首帧闪烁。
25
+ */
26
+ export const FLAG_DEFER_SHOW = 16;
27
+ /**
28
+ * 接受文件/文件夹拖放。
29
+ *
30
+ * 落进来的路径以 shell-msg 推送:`{ t: "drop", paths: ["C:\\a", "C:\\b"] }`。
31
+ * 提权进程(High 完整性)需要放行 UIPI,壳已用 `ChangeWindowMessageFilterEx`
32
+ * 处理;否则资源管理器拖过来的消息会被静默拦掉。
33
+ */
34
+ export const FLAG_ACCEPT_DROP = 32;
35
+ /**
36
+ * 首次显示时直接以最大化出现。
37
+ *
38
+ * 不要用「先 show() 再 toggleMaximize()」代替:那会有一次「小窗闪一下再撑满」
39
+ * 的跳变。本项把最大化合成到显示的那一次 `ShowWindow`,窗口从隐藏直接变成
40
+ * 最大化,中间没有可见帧。
41
+ *
42
+ * 与 `FLAG_CENTER` 同时给时以本项为准(最大化会覆盖位置)。
43
+ */
44
+ export const FLAG_START_MAXIMIZED = 64;
45
+
46
+ /**
47
+ * 关闭 WebView2 的浏览器行为(`ShellOptions.browserUi` 使用)。
48
+ *
49
+ * WebView2 默认是**浏览器**语义:右键弹 Edge 菜单、Ctrl+P 打印网页、左下角跳状态栏、
50
+ * 触控板横滑前进后退、表单自动填充。这些在浏览器里都对,在桌面应用里每一条都是
51
+ * 「这是个网页」的破绽。位定义与原生 `ShellBrowserUi` 逐位一致。
52
+ */
53
+ export const BROWSER_UI_MENU = 1;
54
+ export const BROWSER_UI_KEYS = 2;
55
+ export const BROWSER_UI_STATUSBAR = 4;
56
+ export const BROWSER_UI_SWIPE = 8;
57
+ export const BROWSER_UI_AUTOFILL = 16;
58
+
59
+ /** 一次性关掉全部浏览器行为。桌面应用通常要这个。 */
60
+ export const BROWSER_UI_ALL =
61
+ BROWSER_UI_MENU | BROWSER_UI_KEYS | BROWSER_UI_STATUSBAR | BROWSER_UI_SWIPE | BROWSER_UI_AUTOFILL;
62
+
63
+ /** 缩放边框的 8 个命中边,取值与 Win32 HT* 常量一致 */
64
+ export const RESIZE_EDGE = {
65
+ LEFT: 10,
66
+ RIGHT: 11,
67
+ TOP: 12,
68
+ TOPLEFT: 13,
69
+ TOPRIGHT: 14,
70
+ BOTTOM: 15,
71
+ BOTTOMLEFT: 16,
72
+ BOTTOMRIGHT: 17,
73
+ } as const;
74
+
75
+ export type ResizeEdge = (typeof RESIZE_EDGE)[keyof typeof RESIZE_EDGE];
76
+
77
+ /** window state 位掩码(与原生 shell_get_state 一致) */
78
+ export const STATE_MAXIMIZED = 1;
79
+ export const STATE_MINIMIZED = 2;
80
+ export const STATE_FOCUSED = 4;
81
+
82
+ export type WindowState = {
83
+ raw: number;
84
+ maximized: boolean;
85
+ minimized: boolean;
86
+ focused: boolean;
87
+ /** 系统当前是否深色(原生侧读 AppsUseLightTheme,并随 WM_SETTINGCHANGE 刷新) */
88
+ dark: boolean;
89
+ };
90
+
91
+ /** 原生库的解析结果 */
92
+ export type NativeLibInfo = {
93
+ path: string;
94
+ /**
95
+ * explicit — 调用方显式指定
96
+ * dev — 本仓开发态(build/ 或 dist/)
97
+ * package — 包内 dist/ 或可执行文件同级
98
+ * embedded — 从编译产物内嵌字节释放
99
+ * fallback-tmp — 受限环境下退回 Bun 默认释放
100
+ */
101
+ source: "explicit" | "dev" | "package" | "embedded" | "fallback-tmp";
102
+ sha256: string;
103
+ bytes: number;
104
+ reused: boolean;
105
+ pruned: string[];
106
+ };
107
+
108
+ export type ShellOptions = {
109
+ /** 要加载的 URL(通常是本地回环地址) */
110
+ url: string;
111
+ title?: string;
112
+ width?: number;
113
+ height?: number;
114
+ /** FLAG_* 位组合 */
115
+ flags?: number;
116
+ /** 启动后立即设置的最小尺寸(逻辑像素) */
117
+ minWidth?: number;
118
+ minHeight?: number;
119
+ /**
120
+ * 原生库路径。给定时跳过所有自动解析。
121
+ * 用于测试或多副本部署。
122
+ */
123
+ nativeLibPath?: string;
124
+ /**
125
+ * 运行时数据目录名(`%LOCALAPPDATA%\<appName>\runtime`)。
126
+ * 同一机器上多个应用共用一个包时应各自指定,避免互相清理 DLL 副本。
127
+ */
128
+ appName?: string;
129
+ /**
130
+ * 关闭哪些浏览器行为,取 `BROWSER_UI_*` 的按位或。缺省 0(保持 WebView2 默认)。
131
+ *
132
+ * 桌面应用一般传 `BROWSER_UI_ALL`:关掉右键 Edge 菜单、`Ctrl+P`/`Ctrl+F`/`Ctrl+S`、
133
+ * 左下角状态栏、触控板滑动导航与表单自动填充。关掉默认右键菜单后若要右键有反应,
134
+ * 用 `showContextMenu()` 弹原生菜单。
135
+ */
136
+ browserUi?: number;
137
+ /**
138
+ * 编译产物内嵌字节的提供者。
139
+ * 使用 `bun build --compile` 时传入 `embeddedDll`(见 `bun-app-window/embedded`)。
140
+ */
141
+ embedded?: () => Promise<Uint8Array | null>;
142
+ };
143
+
144
+ export type StartResult = { rc: number; error: string };