bun-app-window 0.5.1 → 0.6.1
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 +169 -1
- package/README.md +65 -0
- package/README.zh-CN.md +62 -0
- package/README.zh-TW.md +62 -0
- package/dist/libshell.dll +0 -0
- package/package.json +3 -1
- package/src/shell/app.ts +80 -0
- package/src/shell/index.ts +46 -3
- package/src/shell/pack.ts +164 -0
- package/src/shell/resolve.ts +12 -0
- package/src/shell/state.ts +32 -0
- package/src/shell/types.ts +187 -1
- package/src/ui/titlebar.ts +208 -22
package/src/shell/index.ts
CHANGED
|
@@ -29,7 +29,9 @@ import {
|
|
|
29
29
|
STATE_FOCUSED,
|
|
30
30
|
STATE_MAXIMIZED,
|
|
31
31
|
STATE_MINIMIZED,
|
|
32
|
+
resizeIndex,
|
|
32
33
|
type NativeLibInfo,
|
|
34
|
+
type ResizeEdge,
|
|
33
35
|
type ShellOptions,
|
|
34
36
|
type StartResult,
|
|
35
37
|
type WindowState,
|
|
@@ -48,6 +50,35 @@ export type {
|
|
|
48
50
|
WindowEvent,
|
|
49
51
|
} from "./events";
|
|
50
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 原生调用的回执。
|
|
55
|
+
*
|
|
56
|
+
* 刻意不是裸 `number`:`rc` 只回答「有没有投递出去」,`err` 才回答「为什么没投递」。
|
|
57
|
+
* 把两者分开,调用方才能区分两种完全不同的故障:
|
|
58
|
+
*
|
|
59
|
+
* - `rc === 0` 但状态没变 → 时序 / 前置条件问题(原生侧是异步投递到 UI 线程的)
|
|
60
|
+
* - `rc < 0` 且 `err` 非空 → 调用方用错 API(窗口已销毁、参数越界)
|
|
61
|
+
*
|
|
62
|
+
* **`rc === 0` 不等于效果发生了。** 要判效果必须读回状态
|
|
63
|
+
* (`state()` / `getZoom()` / `getBounds()`)。
|
|
64
|
+
*/
|
|
65
|
+
export type NativeAck = {
|
|
66
|
+
/** `0` = 已投递给 UI 线程;负数 = 当场被拒(窗口已销毁、参数越界等) */
|
|
67
|
+
rc: number;
|
|
68
|
+
/** 失败原因;成功时恒为空串 */
|
|
69
|
+
err: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 把裸 `rc` 包成 `NativeAck`。
|
|
74
|
+
*
|
|
75
|
+
* **只在失败时读 `lastError()`**:壳层的错误缓冲区不会被成功的调用清空,
|
|
76
|
+
* 成功时读它只会拿到上一次的残留——显示出来就是一个假故障。
|
|
77
|
+
*/
|
|
78
|
+
export function ackOf(shell: Shell, rc: number): NativeAck {
|
|
79
|
+
return { rc, err: rc === 0 ? "" : shell.lastError() };
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
export type Shell = {
|
|
52
83
|
/** 创建窗口并加载 URL。返回码 0 表示成功;失败原因见 lastError() */
|
|
53
84
|
start(): StartResult;
|
|
@@ -59,8 +90,20 @@ export type Shell = {
|
|
|
59
90
|
close(): number;
|
|
60
91
|
/** 由页面 pointerdown 触发原生拖拽;保留贴边分屏 */
|
|
61
92
|
drag(): number;
|
|
62
|
-
/**
|
|
63
|
-
|
|
93
|
+
/**
|
|
94
|
+
* 由页面触发的边框缩放(进入 Windows 的模态缩放循环,鼠标被系统接管)。
|
|
95
|
+
*
|
|
96
|
+
* **入参是 `RESIZE_EDGE` 的 HT* 值(10–17)**,不是内部数组下标。
|
|
97
|
+
*
|
|
98
|
+
* 0.5.x 的实现把入参直传给原生 `shell_begin_resize()`,而原生收的是
|
|
99
|
+
* `codes[8]` 的**数组下标 0–7**——两者的映射是 `HT*` 常量在 Win32 里的定义顺序,
|
|
100
|
+
* 既不是恒等也不是「减 10」(`HTTOPLEFT`=13 在下标 4、`HTBOTTOM`=15 在下标 3)。
|
|
101
|
+
* 于是照着注释传 `RESIZE_EDGE.LEFT`(=10)会被 `edge > 7` 当场拒掉,
|
|
102
|
+
* `rc=-1`,外部表现是「点了没反应」,而门禁全绿。
|
|
103
|
+
*
|
|
104
|
+
* 0.6.0 起由本方法在 TS 层查表转换,原生 DLL 不需要重编。非法值仍然返回 -1。
|
|
105
|
+
*/
|
|
106
|
+
beginResize(edge: ResizeEdge): number;
|
|
64
107
|
/** 最小尺寸,逻辑像素 */
|
|
65
108
|
setMinSize(width: number, height: number): void;
|
|
66
109
|
/**
|
|
@@ -241,7 +284,7 @@ export async function createShell(opts: ShellOptions): Promise<Shell> {
|
|
|
241
284
|
toggleMaximize: () => symbols.shell_toggle_maximize(),
|
|
242
285
|
close: () => symbols.shell_close(),
|
|
243
286
|
drag: () => symbols.shell_drag(),
|
|
244
|
-
beginResize: (edge) => symbols.shell_begin_resize(edge),
|
|
287
|
+
beginResize: (edge) => symbols.shell_begin_resize(resizeIndex(edge)),
|
|
245
288
|
setMinSize: (w, h) => symbols.shell_set_min_size(w, h),
|
|
246
289
|
show: () => symbols.shell_show(),
|
|
247
290
|
setAppId: (appId) => symbols.shell_set_app_id(cstr(appId)),
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pack.ts — 把页面脚本打成浏览器可加载的单文件 ESM
|
|
3
|
+
*
|
|
4
|
+
* ## 这一步为什么省不掉
|
|
5
|
+
*
|
|
6
|
+
* 页面代码要 `import { connect } from "bun-app-window/ipc"`,裸模块名浏览器解析不了,
|
|
7
|
+
* 必须先打包。而打包只能发生在**构建期**:`bun build --compile` 之后
|
|
8
|
+
* `import.meta.dir` 指向内嵌文件系统(`B:\~BUN\root`),那里没有 `.ts` 源文件,
|
|
9
|
+
* 运行期打包会以 `ENOENT: failed to open root directory` 失败。
|
|
10
|
+
*
|
|
11
|
+
* 所以流程固定为两段:
|
|
12
|
+
*
|
|
13
|
+
* ```text
|
|
14
|
+
* 构建期 bundlePage() → ESM 源码字符串 → 交给 createApp({ page })
|
|
15
|
+
* 运行期 createApp 从内存里吐出 /page.js,不碰磁盘
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* ## 为什么放在壳层而不是各应用自己写
|
|
19
|
+
*
|
|
20
|
+
* 三件事每次都一样,且每一件都容易写错:
|
|
21
|
+
*
|
|
22
|
+
* 1. `target: "browser"` 与 `format: "esm"` 是「页面脚本」这个场景的硬要求,不是可选项;
|
|
23
|
+
* 2. 失败时必须带上 Bun 的完整日志——打包失败在运行期只表现为页面白屏,没有日志就
|
|
24
|
+
* 等于没有任何线索;
|
|
25
|
+
* 3. 报大小要报**字节数**。`string.length` 是 UTF-16 码元数,源码里有中文时与字节数
|
|
26
|
+
* 相差可观,而单位通常写成 `B`。
|
|
27
|
+
*
|
|
28
|
+
* 第 3 条是本仓在消费者工程上实测到的:某处报 `35,085 B`,磁盘实际 `37,319 B`。
|
|
29
|
+
*
|
|
30
|
+
* ## 与 `bun-app-window/types` 的区别
|
|
31
|
+
*
|
|
32
|
+
* `./types` 是零 import 的纯常量面,浏览器可以打包它。本模块用 `Bun.build`,
|
|
33
|
+
* 属于**构建期**工具,不得出现在页面侧代码里。
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { resolve } from "node:path";
|
|
37
|
+
|
|
38
|
+
/** 打包结果 */
|
|
39
|
+
export type PageBundle = {
|
|
40
|
+
/** 单文件 ESM 源码,可直接交给 `createApp({ page })` */
|
|
41
|
+
code: string;
|
|
42
|
+
/**
|
|
43
|
+
* UTF-8 字节数。
|
|
44
|
+
*
|
|
45
|
+
* 不是 `code.length`:那是 UTF-16 码元数,源码含中文时两者不等,
|
|
46
|
+
* 而调用方几乎总是拿这个数当「产物多大」来报。
|
|
47
|
+
*/
|
|
48
|
+
bytes: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type BundlePageOptions = {
|
|
52
|
+
/**
|
|
53
|
+
* 页面脚本入口的绝对路径。
|
|
54
|
+
*
|
|
55
|
+
* 相对路径按 `process.cwd()` 解析,但**建议传绝对路径**:打包的模块解析基准是
|
|
56
|
+
* 入口文件所在目录,而 `cwd` 只影响相对路径的解析。两者混用会让「为什么在这里
|
|
57
|
+
* 能解析、在那里不能」变成一道推理题。
|
|
58
|
+
*/
|
|
59
|
+
entry: string;
|
|
60
|
+
/** 是否压缩。默认 `false`(与不传时的直观预期一致,也便于排查产物) */
|
|
61
|
+
minify?: boolean;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 从 `Bun.build` 的失败里取出可读线索。
|
|
66
|
+
*
|
|
67
|
+
* `Bun.build` 有两种失败形态,**日志位置不同**(2026-10-21 实测):
|
|
68
|
+
*
|
|
69
|
+
* | 形态 | 返回值 | 日志在哪 |
|
|
70
|
+
* | --- | --- | --- |
|
|
71
|
+
* | 编译期诊断(语法错、无法解析的导入) | `{ success: false, logs: [...] }` | `result.logs` |
|
|
72
|
+
* | 入口本身不可达 | **抛 `AggregateError`** | `err.errors`(**没有 `.logs`**) |
|
|
73
|
+
*
|
|
74
|
+
* 第二种的 `message` 只有一句 `Bundle failed`,完全无法定位——所以必须读 `.errors`。
|
|
75
|
+
* 两条路径都要覆盖,否则最常见的一种「入口写错」会变成一句没有信息量的报错。
|
|
76
|
+
*/
|
|
77
|
+
function failureDetail(err: unknown): string {
|
|
78
|
+
if (err instanceof Error) {
|
|
79
|
+
// AggregateError 的成员在 .errors 上;普通 Error 没有这个属性
|
|
80
|
+
const members = (err as { errors?: unknown }).errors;
|
|
81
|
+
if (Array.isArray(members) && members.length > 0) {
|
|
82
|
+
return members.map((item) => String(item)).join("\n");
|
|
83
|
+
}
|
|
84
|
+
return err.message;
|
|
85
|
+
}
|
|
86
|
+
return String(err);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 打包页面脚本,返回 ESM 源码。
|
|
91
|
+
*
|
|
92
|
+
* 失败时抛错并附上 Bun 的完整诊断。抛而不是返回 `null`:调用方拿到 `null` 之后
|
|
93
|
+
* 唯一合理的动作也是终止构建,而返回值多一个空状态就多一处要处理的分支。
|
|
94
|
+
*/
|
|
95
|
+
export async function bundlePage(opts: BundlePageOptions): Promise<PageBundle> {
|
|
96
|
+
const entry = resolve(opts.entry);
|
|
97
|
+
|
|
98
|
+
let result: Awaited<ReturnType<typeof Bun.build>>;
|
|
99
|
+
try {
|
|
100
|
+
result = await Bun.build({
|
|
101
|
+
entrypoints: [entry],
|
|
102
|
+
target: "browser",
|
|
103
|
+
format: "esm",
|
|
104
|
+
...(opts.minify === undefined ? {} : { minify: opts.minify }),
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// 入口不可达走这条:`Bun.build` 抛 AggregateError 而不是返回 success:false
|
|
108
|
+
throw new Error(
|
|
109
|
+
`页面脚本打包失败(entry=${entry})\n${failureDetail(err)}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const first = result.outputs[0];
|
|
114
|
+
if (!result.success || first === undefined) {
|
|
115
|
+
const detail = result.logs.map((item) => String(item)).join("\n");
|
|
116
|
+
throw new Error(
|
|
117
|
+
`页面脚本打包失败(entry=${entry})\n${detail || "(Bun 未提供日志)"}`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const code = await first.text();
|
|
122
|
+
return { code, bytes: Buffer.byteLength(code, "utf8") };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 打包并写盘,供「产物不入库、由 npm scripts 前置生成」的工程使用。
|
|
127
|
+
*
|
|
128
|
+
* ```json
|
|
129
|
+
* "scripts": {
|
|
130
|
+
* "bundle:page": "bun run scripts/bundle-page.ts",
|
|
131
|
+
* "build": "bun run bundle:page && bun build --compile …"
|
|
132
|
+
* }
|
|
133
|
+
* ```
|
|
134
|
+
*
|
|
135
|
+
* ```ts
|
|
136
|
+
* // scripts/bundle-page.ts
|
|
137
|
+
* import { writePageBundle } from "bun-app-window/pack";
|
|
138
|
+
* await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" });
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* 产物是否需要入库由调用方决定。不入库时 `bun check` 仍然通过——类型检查按
|
|
142
|
+
* `declare module "*.txt"` 解析模块,不访问文件系统;但 `bun build --compile`
|
|
143
|
+
* 需要文件真实存在,所以前置不可省。
|
|
144
|
+
*/
|
|
145
|
+
export async function writePageBundle(
|
|
146
|
+
opts: BundlePageOptions & { out: string },
|
|
147
|
+
): Promise<PageBundle & { out: string }> {
|
|
148
|
+
const out = resolve(opts.out);
|
|
149
|
+
const bundle = await bundlePage(opts);
|
|
150
|
+
await Bun.write(out, bundle.code);
|
|
151
|
+
|
|
152
|
+
// 复核一次实际字节数:`Bun.write` 的编码行为不由我们控制,
|
|
153
|
+
// 报出去的数字必须是磁盘上那个,而不是我们以为的那个。
|
|
154
|
+
const written = Bun.file(out).size;
|
|
155
|
+
if (written !== bundle.bytes) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`页面产物写入字节数与预期不符:` +
|
|
158
|
+
`写入 ${out},预期 ${bundle.bytes} B,实际 ${written} B。` +
|
|
159
|
+
`若产物被写成了带 BOM 或 CRLF 的形态,先查写入路径。`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { ...bundle, out };
|
|
164
|
+
}
|
package/src/shell/resolve.ts
CHANGED
|
@@ -27,9 +27,19 @@ import { homedir } from "node:os";
|
|
|
27
27
|
import { basename, dirname, join, resolve } from "node:path";
|
|
28
28
|
|
|
29
29
|
import type { NativeLibInfo } from "./types";
|
|
30
|
+
import pkg from "../../package.json";
|
|
30
31
|
|
|
31
32
|
const DLL_PATTERN = /^libshell.*\.dll$/i;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* 本包版本号。取自包内 `package.json`——它是唯一事实源,`NativeLibInfo.version`
|
|
36
|
+
* 只是把它带到运行期,避免消费者为了显示版本再 `import "…/package.json"`。
|
|
37
|
+
*
|
|
38
|
+
* 与 `native/build-pack.ts` 的 `--windows-version` 是两处会写进产物的版本号,
|
|
39
|
+
* 由 `tests/version.test.ts` 守着同源(那一条和这里无关,别混)。
|
|
40
|
+
*/
|
|
41
|
+
const PKG_VERSION: string = pkg.version;
|
|
42
|
+
|
|
33
43
|
/** 包根目录:src/shell → 上两级。打包态下该值不可用于文件系统访问。 */
|
|
34
44
|
const PKG_ROOT = resolve(import.meta.dir, "..", "..");
|
|
35
45
|
|
|
@@ -60,6 +70,7 @@ function fromPath(path: string, source: NativeLibInfo["source"]): NativeLibInfo
|
|
|
60
70
|
return {
|
|
61
71
|
path,
|
|
62
72
|
source,
|
|
73
|
+
version: PKG_VERSION,
|
|
63
74
|
sha256: "",
|
|
64
75
|
bytes: statSync(path).size,
|
|
65
76
|
reused: false,
|
|
@@ -168,6 +179,7 @@ export async function resolveNativeLib(opts: ResolveOptions = {}): Promise<Nativ
|
|
|
168
179
|
return {
|
|
169
180
|
path,
|
|
170
181
|
source: "embedded",
|
|
182
|
+
version: PKG_VERSION,
|
|
171
183
|
sha256,
|
|
172
184
|
bytes: bytes.length,
|
|
173
185
|
reused,
|
package/src/shell/state.ts
CHANGED
|
@@ -50,6 +50,30 @@ const DEFAULTS = {
|
|
|
50
50
|
pumpMs: 16,
|
|
51
51
|
} as const;
|
|
52
52
|
|
|
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
|
+
|
|
53
77
|
/** 把原生返回的一行文本解析成 WindowPlacement;格式不对返回 null。 */
|
|
54
78
|
export function parseBounds(text: string): WindowPlacement | null {
|
|
55
79
|
const parts = text.trim().split(",");
|
|
@@ -72,6 +96,10 @@ export function parseBounds(text: string): WindowPlacement | null {
|
|
|
72
96
|
if (width <= 0 || height <= 0 || dpi <= 0) {
|
|
73
97
|
return null;
|
|
74
98
|
}
|
|
99
|
+
// 最小化时原生给的是哨兵坐标,不是真实位置——宁可返回 null 让调用方保留上一次的值
|
|
100
|
+
if (isMinimizedSentinel(x, y)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
75
103
|
return { x, y, width, height, maximized: maximized !== 0, dpi };
|
|
76
104
|
}
|
|
77
105
|
|
|
@@ -108,6 +136,10 @@ export function parsePlacement(raw: unknown): WindowPlacement | null {
|
|
|
108
136
|
if (Math.abs(x) > 200000 || Math.abs(y) > 200000) {
|
|
109
137
|
return null;
|
|
110
138
|
}
|
|
139
|
+
// 已写进磁盘的哨兵坐标同样不可用:旧版本写过这种文件,读的时候要判否而不是恢复它
|
|
140
|
+
if (isMinimizedSentinel(x, y)) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
111
143
|
return { x, y, width, height, maximized: o.maximized === true, dpi };
|
|
112
144
|
}
|
|
113
145
|
|
package/src/shell/types.ts
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 这里的类型是**稳定面**:改动需要按语义化版本对待。
|
|
5
5
|
* 调试自省类能力单独放在 Shell["debug"] 下,不视为稳定契约。
|
|
6
|
+
*
|
|
7
|
+
* ## 这个文件必须保持「零 import」
|
|
8
|
+
*
|
|
9
|
+
* 它是 `bun-app-window/types` 子路径的全部内容,而开这个子路径的目的是让
|
|
10
|
+
* **页面侧代码也能 import 这些常量**:主入口 `./index` 从 `./native` 导入
|
|
11
|
+
* `bun:ffi`,浏览器 bundle 解析不了,所以页面过去只能把位值手抄一遍
|
|
12
|
+
* (`BROWSER_UI_*` 抄 5 条、缩放阶梯抄 14 档、边框索引抄 8 条),抄漏抄错
|
|
13
|
+
* 都不会被任何门禁抓到。
|
|
14
|
+
*
|
|
15
|
+
* 因此本文件只允许出现 `export const` / `export type` / `export function`
|
|
16
|
+
* 形式的纯声明,**不得出现任何 `import`**——一旦有了,子路径就不再是浏览器安全的。
|
|
17
|
+
* `tests/types-purity.test.ts` 用一条静态断言守住这条。
|
|
6
18
|
*/
|
|
7
19
|
|
|
8
20
|
/** 无边框:原生层不画标题栏与边框,由页面自绘 */
|
|
@@ -60,6 +72,68 @@ export const FLAG_START_MAXIMIZED = 64;
|
|
|
60
72
|
* `javascript:`、`ms-*:` 以及自定义 scheme 一律静默取消,与本位无关。
|
|
61
73
|
*/
|
|
62
74
|
export const FLAG_ALLOW_EXTERNAL_NAV = 128;
|
|
75
|
+
/**
|
|
76
|
+
* 顶部让出一条给自绘标题栏:把 WebView2 的容器子窗口整体下移 caption 高度(32 逻辑像素)。
|
|
77
|
+
*
|
|
78
|
+
* ## 为什么需要它(命中测试的硬约束,不是布局偏好)
|
|
79
|
+
*
|
|
80
|
+
* WebView2 的容器子窗口(widget)铺满整个客户区,它内部还有三层 Chrome 窗口,
|
|
81
|
+
* 而**真实鼠标的 `WM_NCHITTEST` 走光标下最深那层**:
|
|
82
|
+
*
|
|
83
|
+
* ```text
|
|
84
|
+
* webview_widget → Chrome_WidgetWin_0 → Chrome_WidgetWin_1
|
|
85
|
+
* → Chrome_RenderWidgetHostHWND → Intermediate D3D Window
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* 后三层在**别的进程**里(实测 pid 各不相同),`SetWindowLongPtr` 够不着,
|
|
89
|
+
* 所以「把 `HTTRANSPARENT` 一路传上去」这条子类化路线走不通。它们返回 `HTCLIENT`,
|
|
90
|
+
* 顶层窗口那套 `HTMAXBUTTON` 逻辑永远没机会被问到——**Snap Layouts 因此不弹**。
|
|
91
|
+
*
|
|
92
|
+
* 让 widget 别盖住标题栏,光标就落在顶层窗口自己身上,命中测试正常走到
|
|
93
|
+
* `hit_test_client` → `caption_button_at` → `HTMAXBUTTON`。
|
|
94
|
+
*
|
|
95
|
+
* ## 什么时候该给
|
|
96
|
+
*
|
|
97
|
+
* 只在**自绘标题栏**、而且标题栏真的在客户区顶部时。无边框不等于要自绘标题栏:
|
|
98
|
+
* 有的应用整窗都是内容区,无条件让位会在顶部留一条点不到的死区。
|
|
99
|
+
*
|
|
100
|
+
* ## 与 `FLAG_FRAMELESS` 的关系
|
|
101
|
+
*
|
|
102
|
+
* 本位只在 `FLAG_FRAMELESS` 同时给出时生效——带边框模式的标题栏由系统画,
|
|
103
|
+
* widget 本来就在客户区内,不存在遮挡。
|
|
104
|
+
*/
|
|
105
|
+
export const FLAG_TITLEBAR_RESERVE = 256;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 在客户区右上角盖一层**原生绘制**的 caption 按钮(关闭 / 最大化 / 最小化)。
|
|
109
|
+
*
|
|
110
|
+
* ## 它解决的是「页面拿不到 Snap Layouts」
|
|
111
|
+
*
|
|
112
|
+
* `WM_NCHITTEST` 走光标下最深那层,而 WebView2 的渲染子窗口在**别的进程**里、
|
|
113
|
+
* 返回 `HTCLIENT`,光标的查询根本到不了顶层窗口(`pitfalls` 2.14 的实测链)。
|
|
114
|
+
* 盖一个壳层自己的 layered 子窗口在按钮位置上,命中就落在壳层手里,
|
|
115
|
+
* `HTMAXBUTTON` 才生效,Snap 面板才会弹。
|
|
116
|
+
*
|
|
117
|
+
* ## 它与 `FLAG_TITLEBAR_RESERVE` 是**替代关系**,不要同时给
|
|
118
|
+
*
|
|
119
|
+
* `TITLEBAR_RESERVE` 的做法是把 WebView2 的容器子窗口整体下移 caption 高度,
|
|
120
|
+
* 让光标落到顶层窗口。那条路实测也能让 Snap 弹,但它把页面内容整体下移 32 逻辑
|
|
121
|
+
* 像素,而 caption 命中区固定在客户区 `y ∈ [0, 32)` —— 页面自己画的标题栏会与
|
|
122
|
+
* 命中区**错位 32px**,想对齐只能给页面负偏移,那已经出了 widget 客户区、
|
|
123
|
+
* 被子窗口裁掉。所以「HTML 画标题栏 + 让位」在几何上不成立。
|
|
124
|
+
*
|
|
125
|
+
* 本位不动布局:子窗口只是**盖在**按钮位置上,页面该怎么排还怎么排,
|
|
126
|
+
* 缩放边框也不受影响。
|
|
127
|
+
*
|
|
128
|
+
* ## 消费方要配合的一件事
|
|
129
|
+
*
|
|
130
|
+
* 用了本位就别再让页面画那三个按钮了,改由壳层画;页面的标题栏要在右侧留出
|
|
131
|
+
* `CAPTION_BUTTON_WIDTH * 3` 的空白(`mountTitleBar` 的 `captions: "native"`
|
|
132
|
+
* 就是做这件事的)。否则会看到两套按钮叠在一起。
|
|
133
|
+
*
|
|
134
|
+
* 只在 `FLAG_FRAMELESS` 同时给出时生效——带边框模式的标题栏由系统画。
|
|
135
|
+
*/
|
|
136
|
+
export const FLAG_NATIVE_CAPTIONS = 512;
|
|
63
137
|
|
|
64
138
|
/**
|
|
65
139
|
* 关闭 WebView2 的浏览器行为(`ShellOptions.browserUi` 使用)。
|
|
@@ -78,7 +152,24 @@ export const BROWSER_UI_AUTOFILL = 16;
|
|
|
78
152
|
export const BROWSER_UI_ALL =
|
|
79
153
|
BROWSER_UI_MENU | BROWSER_UI_KEYS | BROWSER_UI_STATUSBAR | BROWSER_UI_SWIPE | BROWSER_UI_AUTOFILL;
|
|
80
154
|
|
|
81
|
-
/**
|
|
155
|
+
/**
|
|
156
|
+
* 缩放边框的 8 个命中边,取值与 Win32 HT* 常量一致。
|
|
157
|
+
*
|
|
158
|
+
* 这是 `Shell.beginResize()` 的**入参取值**。传 `RESIZE_EDGE.LEFT` 即可。
|
|
159
|
+
*
|
|
160
|
+
* 历史(0.6.0 修正):原生 `shell_begin_resize()` 收的是内部数组下标 0–7,
|
|
161
|
+
* 而本位定义是 HT* 常量 10–17,两者差一个**非线性**映射——
|
|
162
|
+
*
|
|
163
|
+
* ```c
|
|
164
|
+
* static const WPARAM codes[8] = {HTLEFT, HTRIGHT, HTTOP, HTBOTTOM,
|
|
165
|
+
* HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT};
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* 注意 `HTTOPLEFT`(13) 在下标 4、`HTBOTTOM`(15) 在下标 3:既不是恒等也不是减 10。
|
|
169
|
+
* 0.5.x 里 `beginResize` 的注释写「取 RESIZE_EDGE 的值」而实现直传下标,
|
|
170
|
+
* 照注释写必然被 `edge > 7` 拒掉(`rc=-1`,表现是「点了没反应」)。0.6.0 起由
|
|
171
|
+
* `Shell.beginResize()` 在 TS 层查表转换,本常量成为真正的入参契约。
|
|
172
|
+
*/
|
|
82
173
|
export const RESIZE_EDGE = {
|
|
83
174
|
LEFT: 10,
|
|
84
175
|
RIGHT: 11,
|
|
@@ -92,6 +183,92 @@ export const RESIZE_EDGE = {
|
|
|
92
183
|
|
|
93
184
|
export type ResizeEdge = (typeof RESIZE_EDGE)[keyof typeof RESIZE_EDGE];
|
|
94
185
|
|
|
186
|
+
/**
|
|
187
|
+
* `RESIZE_EDGE` 的 HT* 值 → 原生 `codes[]` 的**数组下标**。
|
|
188
|
+
*
|
|
189
|
+
* 顺序必须与 `native/shell.cc` 里那张表逐项一致:
|
|
190
|
+
*
|
|
191
|
+
* ```c
|
|
192
|
+
* static const WPARAM codes[8] = {HTLEFT, HTRIGHT, HTTOP, HTBOTTOM,
|
|
193
|
+
* HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT};
|
|
194
|
+
* ```
|
|
195
|
+
*
|
|
196
|
+
* **它既不是恒等也不是 `value - 10`**:`HTTOPLEFT`=13 落在下标 4、`HTBOTTOM`=15
|
|
197
|
+
* 落在下标 3。`tests/types.test.ts` 有一条断言逐项钉住这张表。
|
|
198
|
+
*
|
|
199
|
+
* 消费者一般不需要它——`Shell.beginResize()` 已经替你查表了。放在公共面是因为
|
|
200
|
+
* 它属于「协议的另一半」,逃生机 `shell.symbols.shell_begin_resize()` 要直连原生
|
|
201
|
+
* 时需要它,而那种时候手写映射必然抄错。
|
|
202
|
+
*/
|
|
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,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* HT* 值 → 下标;不认识的值返回 -1。
|
|
216
|
+
*
|
|
217
|
+
* 返回 -1 而不是抛错:原生 `shell_begin_resize()` 用 `edge < 0` 把它当场拒掉并回
|
|
218
|
+
* `rc=-1`,与「窗口已销毁」是同一个外部表现,调用方按 `rc` 处理即可。
|
|
219
|
+
*/
|
|
220
|
+
export function resizeIndex(edge: number): number {
|
|
221
|
+
return RESIZE_INDEX[edge] ?? -1;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 缩放阶梯,与原生 `native/shell.cc` 的 `kZoomLadder` 逐档一致。
|
|
226
|
+
*
|
|
227
|
+
* 原生侧 `apply_zoom` 只把入参夹到首尾两档(0.25 ~ 3.0),不吸附中间档——
|
|
228
|
+
* 传任意值都能生效。这份表是给「按档位走」的界面用的:`−` / `+` 要按相邻档
|
|
229
|
+
* 跳,而档位表属于两边都要知道的常量。
|
|
230
|
+
*
|
|
231
|
+
* 过去消费者只能自己抄一份(抄了 14 个数,没有任何门禁能发现它与 C++ 漂移)。
|
|
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;
|
|
236
|
+
|
|
237
|
+
/** 缩放倍率的上下限(`ZOOM_LADDER` 的首尾两项) */
|
|
238
|
+
export const ZOOM_MIN = ZOOM_LADDER[0];
|
|
239
|
+
export const ZOOM_MAX = ZOOM_LADDER[ZOOM_LADDER.length - 1];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 自绘标题栏的几何规格(**逻辑像素**,96 DPI 基准)。
|
|
243
|
+
*
|
|
244
|
+
* 这两个值有**三处**副本,`tests/types.test.ts` 用判据把它们钉在一起:
|
|
245
|
+
*
|
|
246
|
+
* | 处 | 用途 | 为什么不能省 |
|
|
247
|
+
* | --- | --- | --- |
|
|
248
|
+
* | 这里 | 页面侧布局与命中测试的共同事实源 | 页面要按它画,原生要按它算 |
|
|
249
|
+
* | `src/ui/titlebar.ts` 的 CSS | 视觉尺寸 | 页面真正渲染的是 CSS |
|
|
250
|
+
* | `native/shell.cc` 的 `kCaptionBarHeight` / `kCaptionButtonWidth` | `WM_NCHITTEST` 命中区 | C++ 无法 import TS |
|
|
251
|
+
*
|
|
252
|
+
* 为什么必须一致:`HTMAXBUTTON` 是「悬停弹 Snap Layouts」的**唯一**触发条件
|
|
253
|
+
* (微软文档明写 Win32 应用要返回它)。命中区与视觉按钮错位,表现为
|
|
254
|
+
* 「悬停位置偏了才弹」或「悬停不弹」——两种都极难从现象反推回常量。
|
|
255
|
+
*
|
|
256
|
+
* 规格取自 Win 11:标题栏 32 px、caption button 46×32。
|
|
257
|
+
*/
|
|
258
|
+
export const CAPTION_BAR_HEIGHT = 32;
|
|
259
|
+
export const CAPTION_BUTTON_WIDTH = 46;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* 从右往左的三个 caption button,与 Win 11 的顺序一致。
|
|
263
|
+
*
|
|
264
|
+
* 顺序不是审美选择:`close` 贴右上角,`maximize` 在它左边,`minimize` 再左。
|
|
265
|
+
* `WM_NCHITTEST` 按这个顺序算矩形,页面 CSS 也按这个顺序排 DOM。
|
|
266
|
+
*/
|
|
267
|
+
export const CAPTION_BUTTONS = ["close", "maximize", "minimize"] as const;
|
|
268
|
+
|
|
269
|
+
/** `CAPTION_BUTTONS` 的成员名 */
|
|
270
|
+
export type CaptionButton = (typeof CAPTION_BUTTONS)[number];
|
|
271
|
+
|
|
95
272
|
/** window state 位掩码(与原生 shell_get_state 一致) */
|
|
96
273
|
export const STATE_MAXIMIZED = 1;
|
|
97
274
|
export const STATE_MINIMIZED = 2;
|
|
@@ -117,6 +294,15 @@ export type NativeLibInfo = {
|
|
|
117
294
|
* fallback-tmp — 受限环境下退回 Bun 默认释放
|
|
118
295
|
*/
|
|
119
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;
|
|
120
306
|
sha256: string;
|
|
121
307
|
bytes: number;
|
|
122
308
|
reused: boolean;
|