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,291 @@
1
+ /**
2
+ * index.ts — bun-app-window 内核门面
3
+ *
4
+ * 这是包的唯一稳定入口。它只做三件事:解析原生库、暴露窗口操作、暴露排障探针。
5
+ * 不带任何业务逻辑、不读环境变量、不写日志文件、不调用 process.exit。
6
+ *
7
+ * ```ts
8
+ * import { createShell, FLAG_FRAMELESS, FLAG_CENTER } from "bun-app-window";
9
+ *
10
+ * const shell = await createShell({
11
+ * url: "http://127.0.0.1:40001",
12
+ * title: "My App",
13
+ * width: 1000, height: 680,
14
+ * flags: FLAG_FRAMELESS | FLAG_CENTER,
15
+ * });
16
+ * if (shell.start().rc !== 0) throw new Error(shell.lastError());
17
+ * shell.post(JSON.stringify({ t: "hello" }));
18
+ * ```
19
+ *
20
+ * 线程模型:`start()` 立即返回(原生层自建 UI 线程),调用方主线程不被阻塞。
21
+ */
22
+
23
+ import { cstr, openNativeLib, readBuffer } from "./native";
24
+ import { ptr } from "bun:ffi";
25
+ import type { NativeSymbols } from "./native";
26
+ import { resolveNativeLib } from "./resolve";
27
+ import {
28
+ STATE_FOCUSED,
29
+ STATE_MAXIMIZED,
30
+ STATE_MINIMIZED,
31
+ type NativeLibInfo,
32
+ type ShellOptions,
33
+ type StartResult,
34
+ type WindowState,
35
+ } from "./types";
36
+
37
+ export * from "./types";
38
+ export { resolveNativeLib, isPackedRuntime } from "./resolve";
39
+ export type { ResolveOptions } from "./resolve";
40
+ export { serveOnSafePort, isRestrictedPort } from "./port";
41
+ export type { ServeOptions } from "./port";
42
+ export { cstr, readCStr } from "./native";
43
+ export type { NativeSymbols, NativeLib } from "./native";
44
+
45
+ export type Shell = {
46
+ /** 创建窗口并加载 URL。返回码 0 表示成功;失败原因见 lastError() */
47
+ start(): StartResult;
48
+ /** 宿 → 页面:投递 JSON 文本(单向,不等结果) */
49
+ post(json: string): number;
50
+ setTitle(title: string): number;
51
+ minimize(): number;
52
+ toggleMaximize(): number;
53
+ close(): number;
54
+ /** 由页面 pointerdown 触发原生拖拽;保留贴边分屏 */
55
+ drag(): number;
56
+ /** 由页面 pointermove 触发边框缩放;edge 取 RESIZE_EDGE 的值 */
57
+ beginResize(edge: number): number;
58
+ /** 最小尺寸,逻辑像素 */
59
+ setMinSize(width: number, height: number): void;
60
+ /**
61
+ * 显示窗口。配合 `FLAG_DEFER_SHOW` 使用:页面就绪后再调用,消除首帧闪烁。
62
+ * 未启用延迟显示时是幂等 no-op。
63
+ */
64
+ show(): number;
65
+ /**
66
+ * 设置 AppUserModelID(任务栏分组与图标归属)。
67
+ * 只在 `start()` 之前调用才对本窗口生效。
68
+ */
69
+ setAppId(appId: string): number;
70
+ /**
71
+ * 弹出系统文件夹选择框,返回选中路径;用户取消返回 null。
72
+ *
73
+ * 直接调 shell32,不起子进程、不落临时脚本——比「spawn powershell 跑 WinForms」
74
+ * 快一个数量级,也不受执行策略限制。
75
+ *
76
+ * **同步阻塞**:SHBrowseForFolder 自带模态循环,调用期间调用线程被占住
77
+ * (实测:Bun 主线程上的定时器在此期间不会触发)。同理,被阻塞的调用链
78
+ * 无法取消或超时。需要并发时把它放到 Worker 里调用。
79
+ */
80
+ pickFolder(title?: string): string | null;
81
+ state(): WindowState;
82
+ /**
83
+ * 窗口是否仍然存在。
84
+ *
85
+ * `state()` 无法区分「未启动」与「已销毁」(两者都返回全 false),
86
+ * 宿主需要这个信号才能判断窗口是否已被用户关掉,进而回收自己的进程——
87
+ * 窗口销毁后原生 UI 线程会返回,但宿主进程与它起的本地服务不会自己退出。
88
+ */
89
+ isAlive(): boolean;
90
+ /** 最近一次失败的原因;无错误时为空串。走拷贝式读出,不受并发写影响 */
91
+ lastError(): string;
92
+
93
+ // ---- 应用化 ----
94
+ /**
95
+ * 关掉指定的浏览器行为(`BROWSER_UI_*` 按位或)。运行时生效。
96
+ *
97
+ * 一般通过 `ShellOptions.browserUi` 声明即可;这个方法用于运行中切换
98
+ * (例如调试模式临时放开 `Ctrl+F`)。
99
+ */
100
+ setBrowserUi(mask: number): number;
101
+ /**
102
+ * 在屏幕坐标弹出**原生**上下文菜单。
103
+ *
104
+ * 关掉 WebView2 默认右键菜单后,右键就成了「点了没反应」,那也不像原生应用。
105
+ * 原生应用右键是有菜单的,只是菜单属于应用:
106
+ *
107
+ * ```ts
108
+ * document.addEventListener("contextmenu", (e) => {
109
+ * e.preventDefault();
110
+ * fetch("/api/menu", { method: "POST", body: JSON.stringify({ x: e.screenX, y: e.screenY }) });
111
+ * });
112
+ * ```
113
+ *
114
+ * `items` 用行描述,`id|label` 每行一项,单独 `-` 是分隔线:
115
+ * `"open|打开题库\nrefresh|刷新\n-\nquit|退出"`。
116
+ * 选中后通过 shell-msg 推回 `{ t: "menu", id: "<id>" }`。
117
+ */
118
+ showContextMenu(x: number, y: number, items: string): number;
119
+
120
+ // ---- 窗口位置 ----
121
+ /**
122
+ * 当前窗口矩形,格式 "x,y,w,h,maximized,dpi"(物理像素)。
123
+ * 最大化时返回的是「还原后」的矩形。窗口未启动时返回空串。
124
+ *
125
+ * 一般不需要直接解析它——用 bun-app-window/state 的 capturePlacement()。
126
+ */
127
+ getBounds(): string;
128
+ /**
129
+ * 恢复窗口矩形。savedDpi 是保存时的 DPI,用于跨缩放比例换算;
130
+ * 原生侧还会校验矩形是否仍在某块屏上,不在则居中。
131
+ *
132
+ * 一般不需要直接调用——用 bun-app-window/state 的
133
+ */
134
+ restoreBounds(
135
+ x: number,
136
+ y: number,
137
+ width: number,
138
+ height: number,
139
+ savedDpi: number,
140
+ maximized: boolean,
141
+ ): number;
142
+
143
+ // ---- 输入与视图 ----
144
+ /**
145
+ * 页面缩放倍率(0.25 ~ 3.0)。也支持用户键盘操作:Ctrl± / Ctrl+0 / Ctrl+滚轮。
146
+ * 生效是异步的(投到 UI 线程),getZoom() 读的是缓存值。
147
+ */
148
+ setZoom(factor: number): number;
149
+ /** 当前缩放倍率(缓存值) */
150
+ getZoom(): number;
151
+ /** 重新加载页面(等同 Ctrl+R / F5) */
152
+ reload(): number;
153
+ /** 打开开发者工具(等同 F12) */
154
+ openDevTools(): number;
155
+ /** 切换全屏(等同 F11);全屏与最大化不同,连边框与任务栏一起让出 */
156
+ toggleFullscreen(): number;
157
+ /** 停止并释放窗口(可重复调用) */
158
+ stop(): void;
159
+
160
+ readonly started: boolean;
161
+ readonly libInfo: NativeLibInfo;
162
+ /** 逃生舱:直接访问 dlopen 出来的符号 */
163
+ readonly symbols: NativeSymbols;
164
+
165
+ // 排障面(hittest / metrics / dpi / selfBase / selfPath)已移到独立入口
166
+ // import { debugOf } from "bun-app-window/debug"
167
+ // 目的:公共面只承诺产品向能力。把排障面留在主入口,等于把它也变成契约,
168
+ // 将来改一次诊断输出就成了破坏性变更。
169
+ };
170
+
171
+ export async function createShell(opts: ShellOptions): Promise<Shell> {
172
+ if (!opts.url) throw new Error("createShell: url is required");
173
+
174
+ const libInfo = await resolveNativeLib({
175
+ ...(opts.nativeLibPath === undefined ? {} : { nativeLibPath: opts.nativeLibPath }),
176
+ ...(opts.appName === undefined ? {} : { appName: opts.appName }),
177
+ ...(opts.embedded === undefined ? {} : { embedded: opts.embedded }),
178
+ });
179
+ const { symbols } = openNativeLib(libInfo.path);
180
+
181
+ let started = false;
182
+ let stopped = false;
183
+
184
+ // 拷贝式读出:shell_error() 返回共享缓冲区的指针,解锁后可能被别的线程改写
185
+ const lastError = () => readBuffer((p, len) => symbols.shell_error_text(p, len), 512);
186
+
187
+ const shell: Shell = {
188
+ start(): StartResult {
189
+ if (started) return { rc: -1, error: "shell already started" };
190
+ const rc = symbols.shell_start_ex(
191
+ cstr(opts.url),
192
+ cstr(opts.title ?? "bun-app-window"),
193
+ opts.width ?? 1000,
194
+ opts.height ?? 680,
195
+ opts.flags ?? 0,
196
+ );
197
+ started = rc === 0;
198
+ if (started && opts.minWidth && opts.minHeight) {
199
+ symbols.shell_set_min_size(opts.minWidth, opts.minHeight);
200
+ }
201
+ if (started && opts.browserUi) {
202
+ // 放在 start 之后:需要 webview 已存在。这些是 UI 行为开关,
203
+ // 运行时改即时生效,不涉及渲染,不必赶在 navigate 之前。
204
+ symbols.shell_set_browser_ui(opts.browserUi);
205
+ }
206
+ return { rc, error: rc === 0 ? "" : lastError() };
207
+ },
208
+
209
+ post: (json) => symbols.shell_post(cstr(json)),
210
+ setTitle: (title) => symbols.shell_title(cstr(title)),
211
+ minimize: () => symbols.shell_minimize(),
212
+ toggleMaximize: () => symbols.shell_toggle_maximize(),
213
+ close: () => symbols.shell_close(),
214
+ drag: () => symbols.shell_drag(),
215
+ beginResize: (edge) => symbols.shell_begin_resize(edge),
216
+ setMinSize: (w, h) => symbols.shell_set_min_size(w, h),
217
+ show: () => symbols.shell_show(),
218
+ setAppId: (appId) => symbols.shell_set_app_id(cstr(appId)),
219
+
220
+ pickFolder(title?: string): string | null {
221
+ const buf = new Uint8Array(4096);
222
+ const rc = symbols.shell_pick_folder(
223
+ cstr(title ?? "选择文件夹"),
224
+ ptr(buf),
225
+ buf.length,
226
+ );
227
+ if (rc !== 0) return null; // 1 = 取消,-1 = 失败
228
+ const end = buf.indexOf(0);
229
+ return new TextDecoder().decode(buf.subarray(0, end < 0 ? buf.length : end));
230
+ },
231
+
232
+ state(): WindowState {
233
+ const raw = symbols.shell_get_state();
234
+ return {
235
+ raw,
236
+ maximized: (raw & STATE_MAXIMIZED) !== 0,
237
+ minimized: (raw & STATE_MINIMIZED) !== 0,
238
+ focused: (raw & STATE_FOCUSED) !== 0,
239
+ dark: symbols.shell_is_dark() !== 0,
240
+ };
241
+ },
242
+
243
+ isAlive: () => symbols.shell_is_alive() !== 0,
244
+
245
+ lastError,
246
+
247
+ setBrowserUi: (mask) => symbols.shell_set_browser_ui(mask),
248
+ showContextMenu: (x, y, items) => symbols.shell_show_app_menu(x, y, cstr(items)),
249
+
250
+ getBounds: () => readBuffer((p, len) => symbols.shell_get_bounds(p, len), 256),
251
+ restoreBounds: (x, y, w, h, savedDpi, maximized) =>
252
+ symbols.shell_restore_bounds(x, y, w, h, savedDpi, maximized ? 1 : 0),
253
+
254
+ setZoom: (factor) => symbols.shell_set_zoom(factor),
255
+ getZoom: () => symbols.shell_get_zoom(),
256
+ reload: () => symbols.shell_reload(),
257
+ openDevTools: () => symbols.shell_devtools(),
258
+ toggleFullscreen: () => symbols.shell_toggle_fullscreen(),
259
+
260
+ stop() {
261
+ if (stopped) return;
262
+ stopped = true;
263
+ started = false;
264
+ try {
265
+ symbols.shell_stop();
266
+ } catch {
267
+ // 窗口已销毁时无需处理
268
+ }
269
+ },
270
+
271
+ get started() {
272
+ return started;
273
+ },
274
+ libInfo,
275
+ symbols,
276
+
277
+
278
+ };
279
+
280
+ return shell;
281
+ }
282
+
283
+ /** 语法糖:创建并启动;失败直接抛错 */
284
+ export async function openShell(opts: ShellOptions): Promise<Shell> {
285
+ const shell = await createShell(opts);
286
+ const { rc, error } = shell.start();
287
+ if (rc !== 0) {
288
+ throw new Error(`shell_start_ex failed (rc=${rc}): ${error || "unknown"}`);
289
+ }
290
+ return shell;
291
+ }