bun-app-window 0.5.1 → 0.6.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 +70 -1
- package/README.md +65 -0
- package/README.zh-CN.md +62 -0
- package/README.zh-TW.md +62 -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/types.ts +94 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,74 @@ All notable changes to this project are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
_(nothing yet)_
|
|
10
|
+
|
|
11
|
+
## [0.6.0] - 2026-10-21
|
|
12
|
+
|
|
13
|
+
**`beginResize` now takes `RESIZE_EDGE` values (10–17) instead of raw native indices, and five sources of consumer boilerplate are gone. The first is a behaviour change; the rest are additive.**
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`bun-app-window/types` — a zero-dependency subpath, safe to bundle for the browser.** `src/shell/types.ts` was already import-free, but there was no way to reach it: the main entry pulls in `./native`, which imports `bun:ffi`, and a browser bundle cannot resolve that (`pitfalls` 5.14). So consumers hand-copied constants — `BROWSER_UI_*` (5 bits), `ZOOM_LADDER` (14 steps), the 8 edge indices — with no gate able to notice when a copy drifted from the source.
|
|
18
|
+
|
|
19
|
+
The subpath's entire value rests on one invariant: **`types.ts` must stay import-free**. Adding a single `import` silently makes it browser-unsafe, and the symptom is a `Could not resolve "bun:ffi"` several layers away from the cause. `tests/types-purity.test.ts` pins it with a static assertion (comments stripped first — the file's own prose mentions `import` while explaining why it cannot be used), plus a self-check that the predicate can actually fail.
|
|
20
|
+
|
|
21
|
+
- **`ZOOM_LADDER` / `ZOOM_MIN` / `ZOOM_MAX`.** The ladder previously existed only as `kZoomLadder[]` in `native/shell.cc`. `apply_zoom` clamps to the ends without snapping to intermediate steps, so this table is for UIs that want to walk it — which meant every UI kept its own copy of fourteen numbers.
|
|
22
|
+
|
|
23
|
+
- **`RESIZE_INDEX` and `resizeIndex(edge)`.** The HT* → native-index map, needed by anything calling `shell.symbols.shell_begin_resize()` directly. Exported because hand-writing it is guaranteed to be wrong: it is not `value - 10` (see Fixed).
|
|
24
|
+
|
|
25
|
+
- **`NativeLibInfo.version`.** `resolve.ts` reads it from the packaged `package.json`. Consumers used to need `import libPkg from "bun-app-window/package.json"` just to display which version they were talking to.
|
|
26
|
+
|
|
27
|
+
- **`ackOf(shell, rc)`** on the main entry. Turns a bare `rc` into `{ rc, err }`, and centralises the rule that `lastError()` is only read on failure — the error buffer is not cleared by a successful call, so reading it after one returns the previous fault and displays as a phantom.
|
|
28
|
+
|
|
29
|
+
- **`App.debug`** — equivalent to `debugOf(app.shell)`, saving a call and a subpath import. The stability promise is unchanged: diagnostics may change at any time and production code should not depend on them.
|
|
30
|
+
|
|
31
|
+
- **`App.attachPlacement(file, opts?)`** — wires placement persistence to the skeleton's single event pump. Calling `attachPlacementStore(shell, file)` without `{ events }` makes it start a **second** pump, and `shell.pollEvents()` is consumptive: the two pumps each get part of the stream, window position is dropped at random, and neither side reports anything. This was the library's easiest way to fail silently, so the choice was removed rather than documented harder.
|
|
32
|
+
|
|
33
|
+
- **`bun-app-window/pack`** — the page-script bundler, so consumers stop writing it. `bundlePage({ entry })` returns `{ code, bytes }`; `writePageBundle({ entry, out })` also writes and re-checks the on-disk size. Three things are fixed rather than configurable: `target: "browser"` and `format: "esm"` (that is what a page script *is*), and reporting **bytes** instead of `string.length` — the latter is UTF-16 code units, which on a source containing CJK drifts from the byte count by a wide margin while the unit is printed as `B`. Measured on a consumer: reported `35,085 B`, on-disk `37,319 B`.
|
|
34
|
+
|
|
35
|
+
Failure handling covers both shapes `Bun.build` produces, which was not obvious: compile-time diagnostics come back as `{ success: false, logs }`, but an **unreachable entry point throws an `AggregateError`** whose `message` is only `Bundle failed` and whose members live on `.errors` — `.logs` does not exist on that path. Without the second branch, the most common mistake (a wrong entry path) produced a message with no information in it.
|
|
36
|
+
|
|
37
|
+
- **`AppOptions.page`** — pass the packed page script and `createApp` serves it at `PAGE_JS_PATH` (`/page.js`) with `content-type: text/javascript; charset=utf-8`. This removes the ten-line `routes` callback from every consumer and, more importantly, removes the way it goes wrong: a wrong or missing MIME type makes `<script type="module">` refuse to execute with no syntax error, which reads as "the page logic is broken". A caller's `routes` still wins, so the path stays overridable.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **BREAKING: `Shell.beginResize(edge)` takes `RESIZE_EDGE` values (10–17).** Migrating from 0.5.x:
|
|
42
|
+
|
|
43
|
+
| You were passing | Before | Now |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `RESIZE_EDGE.LEFT` (10) and friends | `rc=-1`, nothing moved | works |
|
|
46
|
+
| raw indices 0–7 | worked | `rc=-1` |
|
|
47
|
+
|
|
48
|
+
If you were passing raw indices, replace them with the matching `RESIZE_EDGE` member — `RESIZE_INDEX` is exported if you need to see the mapping.
|
|
49
|
+
|
|
50
|
+
- `tests/gate.test.ts` shrank with the stage: `parsePs1Summary` and the `.ps1`-failure short-circuit assertions are gone, and `planStages` now asserts three stages.
|
|
51
|
+
- **A new assertion keeps the deletion honest.** `tests/gate.test.ts` now fails if a `.ps1` appears anywhere in the repository. Deleting a stage and leaving nothing behind would mean the next `.ps1` arrives ungoverned and looks exactly like "the migration is complete". Verified in both directions: creating `scripts/__probe.ps1` turns it red naming that path, removing it returns green.
|
|
52
|
+
- `docs/pitfalls.md` 3.18 keeps all five traps and is re-headed as *retired* rather than *paused* — the values and consequences were measured, so the record outlives the script. 5.2 and 5.6 no longer describe a `.ps1` that might come back.
|
|
53
|
+
- Two ghost instructions surfaced while deleting: `scripts/trust-cert.ts` told the user to run `scripts/test-sign.ts`, a path that never existed, and `native/build-pack.ts` told them to run `scripts/make-icon.ps1`, which was migrated to TypeScript long ago. Both now point at something that exists.
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **`RESIZE_EDGE` and `shell_begin_resize()` were never the same numbering** (`pitfalls` 5.13). `RESIZE_EDGE` is the Win32 `HT*` constants (10–17); the native side reads `codes[edge]` as an **array index 0–7** over `{HTLEFT, HTRIGHT, HTTOP, HTBOTTOM, HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT}`. The mapping is therefore not an offset — `HTTOPLEFT` (13) lands at index 4 while `HTBOTTOM` (15) lands at 3. `beginResize` used to pass its argument straight through, so following the documentation produced `rc=-1` and a window that did not resize, while the gate stayed green.
|
|
58
|
+
|
|
59
|
+
Fixed in TypeScript, so **the DLL does not need rebuilding**. Three assertions now hold the mapping down, the important one being that it is *not* `value - 10`: subtracting ten happens to be correct for the first three entries (LEFT/RIGHT/TOP → 0/1/2) and wrong from the fourth on, which is exactly how a partial assertion would have given false confidence.
|
|
60
|
+
|
|
61
|
+
### Removed
|
|
62
|
+
|
|
63
|
+
**The last two PowerShell scripts are deleted rather than migrated, and the gate's `.ps1` stage went with them.**
|
|
64
|
+
|
|
65
|
+
- **`scripts/test-sign.ps1` and `scripts/verify-sign-crypto.ps1` are gone — 195 lines that will not be replaced.** The standing decision (`ADR 0014`) was "these two stay PowerShell forever". Both premises failed in the same direction, so the third outcome applies: the capability itself is no longer wanted.
|
|
66
|
+
|
|
67
|
+
- `verify-sign-crypto` was ported far enough to prove the port feasible: five constant/structure traps were each resolved against a real sample (`pitfalls` 3.18 has all five, including `CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED` being `0x100` and `CMSG_VERIFY_SIGNER_NULL` being `4`). It stopped at the last step — `CryptMsgControl` wants the signer's certificate context, the message does not embed one, and reconstructing a `PCCERT_CONTEXT` from the store never resolved. That path buys exactly one thing: the three facts in `pitfalls` 4.7 (valid maths is not trust; a tampered file keeps a `VALID` PKCS#7; `HashMismatch` is the only reliable verdict) could be asserted in `bun test`. They are already written down, and re-pinning them changes no behaviour.
|
|
68
|
+
- `test-sign` needs `SignerSign` / `SignerSignEx2` from `mssign32.dll`, which Microsoft documents nowhere. A `bun:ffi` declaration is validated by writing it from the documentation against a minimal input — there is no documentation to write it from.
|
|
69
|
+
- Neither was in the release flow: `AGENTS.md` §7.4 records that no commercial code-signing certificate is connected yet. When one is, the tool is `signtool`, not a hand-rolled verifier.
|
|
70
|
+
|
|
71
|
+
- **The gate's second stage (`.ps1` syntax check) is deleted.** It existed as a migration-period fence for scripts that had neither type checking nor tests. With zero `.ps1` left it scans nothing, and a stage that can only ever report green is dead weight. Stages are now `bun check` → `bun test` → consumer acceptance: 1.3 s versus 1.8 s for four, and **the gate no longer needs `pwsh`** — only `bun` and `git`.
|
|
72
|
+
|
|
73
|
+
- **The shell's capture remnants are gone (`ADR 0017`).** `native/gdi.ts` lost 142 of its 380 lines: `captureWindow`, `cropBgra`, `parseCrop`, `nonBlackPercent`, and the `GetDIBits` dlopen block that served only them. No caller remained inside the repository — the one test that referenced `cropBgra` was asserting against a function nothing used. Window capture lives in `echoes-of-the-eye` (formerly `agent-eyes`, renamed in the same window); the shell keeps only the icon-extraction closure that `scripts/extract-icon.ts` needs to prove `--windows-icon` works. **Repository-only**: `native/` is not published, so nothing changes for package consumers.
|
|
74
|
+
|
|
7
75
|
## [0.5.1] - 2026-10-21
|
|
8
76
|
|
|
9
77
|
**0.5.0's drag-and-drop fix was wrong and made dropped files be refused outright. This release replaces it, and adds folder support. The public API is unchanged.**
|
|
@@ -464,7 +532,8 @@ First public release.
|
|
|
464
532
|
- Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
|
|
465
533
|
- No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
|
|
466
534
|
|
|
467
|
-
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.
|
|
535
|
+
[Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.0...HEAD
|
|
536
|
+
[0.6.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.1...v0.6.0
|
|
468
537
|
[0.5.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.0...v0.5.1
|
|
469
538
|
[0.5.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...v0.5.0
|
|
470
539
|
[0.4.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.0...v0.4.1
|
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/bun-app-window)
|
|
4
4
|
[](https://bun.sh)
|
|
5
|
+
[](#requirements--design-boundaries)
|
|
5
6
|
[](#requirements--design-boundaries)
|
|
6
7
|
[](#requirements--design-boundaries)
|
|
7
8
|
[](./LICENSE)
|
|
@@ -29,6 +30,7 @@ Before adopting this package, please review the following architectural constrai
|
|
|
29
30
|
| --- | --- |
|
|
30
31
|
| Windows x64 only | Enforced via `os` and `cpu` fields in `package.json` (the platform-neutral package name is reserved for future expansion). A prebuilt `libshell.dll` is shipped in the package; no install-time compilation is required |
|
|
31
32
|
| Bun runtime required | Ships raw TypeScript source files and requires Bun >= 1.4.3. While Node.js can install the package, it cannot execute it |
|
|
33
|
+
| TypeScript >= 7.0.2 to type-check it | The API is designed and checked on TypeScript 7.0.2, pinned exactly in `devDependencies`; no compatibility work is done for older compilers. Above 7.0.2 the outcome is unasserted — `bun-types` only opens its `typesVersions: ">= 7.1"` gate there, which changes what a `with { type: "text" }` import resolves to |
|
|
32
34
|
| Type-checking needs `@types/bun` | Because the package ships `.ts` sources, your own `bun check` compiles them too. Add `bun add -d @types/bun`; without it the checker reports `TS2868: Cannot find name 'Bun'` from inside `node_modules` |
|
|
33
35
|
| Built on `bun:ffi` | `bun:ffi` is marked as experimental in Bun. While Bun recommends Node-API for production stability, this library chooses `bun:ffi` to achieve a zero-dependency footprint |
|
|
34
36
|
|
|
@@ -175,6 +177,28 @@ await app.wait(); // resolves when the window closes
|
|
|
175
177
|
await app.stop(); // releases the event pump and the server; idempotent
|
|
176
178
|
```
|
|
177
179
|
|
|
180
|
+
Pass `page` to serve a bundled page script without writing a route yourself. `createApp` answers
|
|
181
|
+
`PAGE_JS_PATH` (`/page.js`) with the right `content-type`, which is the part that silently breaks:
|
|
182
|
+
a module script served as `text/plain` is refused with no syntax error, so the page renders and
|
|
183
|
+
nothing runs.
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
import { bundlePage } from "bun-app-window/pack";
|
|
187
|
+
|
|
188
|
+
// Build time, not startup — see the `pack` section below.
|
|
189
|
+
const { code } = await bundlePage({ entry: "src/ui/page.ts" });
|
|
190
|
+
|
|
191
|
+
const app = await createApp({ html, page: code, service });
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Two more things on `App` that save a call and a subpath import:
|
|
195
|
+
|
|
196
|
+
- `app.debug` — the same object `debugOf(app.shell)` returns. Still outside the stability contract.
|
|
197
|
+
- `app.attachPlacement(file)` — wires placement persistence to the skeleton's **single** event pump.
|
|
198
|
+
Call `attachPlacementStore` yourself and forget `{ events: app.events }`, and it starts a second
|
|
199
|
+
pump; since `shell.pollEvents()` is consumptive, the two split the stream and window position is
|
|
200
|
+
dropped at random with no error on either side. This method makes that mistake unrepresentable.
|
|
201
|
+
|
|
178
202
|
```ts
|
|
179
203
|
// page.ts — inside index.html's bundle
|
|
180
204
|
import { connect } from "bun-app-window/ipc";
|
|
@@ -436,6 +460,47 @@ d.hittest(x, y);
|
|
|
436
460
|
`metrics()` and `dpi()` return **formatted text**, not objects. Parse it in throwaway tooling if you
|
|
437
461
|
must; do not put a parser in production code, because the layout is not part of any contract.
|
|
438
462
|
|
|
463
|
+
### `bun-app-window/types` — Constants, Safe for the Browser
|
|
464
|
+
|
|
465
|
+
A zero-dependency module holding the whole constant surface: `FLAG_*`, `BROWSER_UI_*`, `RESIZE_EDGE`,
|
|
466
|
+
`RESIZE_INDEX`, `ZOOM_LADDER`, `ZOOM_MIN`, `ZOOM_MAX`, `STATE_*`, and the related types.
|
|
467
|
+
|
|
468
|
+
**Use this subpath on the page side.** The main entry imports `bun:ffi` through `./native`, which a
|
|
469
|
+
browser bundle cannot resolve — so `import { BROWSER_UI_ALL } from "bun-app-window"` inside page code
|
|
470
|
+
fails at bundle time. This subpath exists precisely so the same constants can be shared instead of
|
|
471
|
+
hand-copied:
|
|
472
|
+
|
|
473
|
+
```ts
|
|
474
|
+
import { BROWSER_UI_ALL, ZOOM_LADDER, RESIZE_EDGE, resizeIndex } from "bun-app-window/types";
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
Its only invariant is that `src/shell/types.ts` must stay import-free; a static assertion enforces it.
|
|
478
|
+
|
|
479
|
+
### `bun-app-window/pack` — Bundling the Page Script
|
|
480
|
+
|
|
481
|
+
Pages have to be bundled before the browser can load them: they import bare module names like
|
|
482
|
+
`bun-app-window/ipc`, which a browser cannot resolve. Do it at **build time**, not at startup —
|
|
483
|
+
after `bun build --compile`, `import.meta.dir` points inside the archive (`B:\~BUN\root`) and the
|
|
484
|
+
`.ts` source is no longer there.
|
|
485
|
+
|
|
486
|
+
```ts
|
|
487
|
+
import { bundlePage, writePageBundle } from "bun-app-window/pack";
|
|
488
|
+
|
|
489
|
+
// In memory — hand the string to createApp({ page })
|
|
490
|
+
const { code, bytes } = await bundlePage({ entry: "src/ui/page.ts" });
|
|
491
|
+
|
|
492
|
+
// Or write it out, e.g. from a `bundle:page` npm script
|
|
493
|
+
await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" });
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
`target: "browser"` and `format: "esm"` are fixed, not configurable — that is what a page script is.
|
|
497
|
+
`bytes` is a UTF-8 byte count, not `string.length`; the two differ on any source containing non-ASCII
|
|
498
|
+
text, and a reported size that only looks right is worse than no reported size.
|
|
499
|
+
|
|
500
|
+
Failure throws with Bun's full diagnostics. Both shapes are handled: compile-time problems come back
|
|
501
|
+
as `{ success: false, logs }`, while an unreachable entry point **throws an `AggregateError`** whose
|
|
502
|
+
`message` is only `Bundle failed` and whose detail lives on `.errors`.
|
|
503
|
+
|
|
439
504
|
### `bun-app-window/native` — The Prebuilt DLL
|
|
440
505
|
|
|
441
506
|
Resolves to `dist/libshell.dll` itself, so `import.meta.resolve()` hands you a real path you can copy next to your executable:
|
package/README.zh-CN.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/bun-app-window)
|
|
4
4
|
[](https://bun.sh)
|
|
5
|
+
[](#系统要求与设计边界)
|
|
5
6
|
[](#系统要求与设计边界)
|
|
6
7
|
[](#系统要求与设计边界)
|
|
7
8
|
[](./LICENSE)
|
|
@@ -29,6 +30,7 @@ await openShell({ url: `http://127.0.0.1:${server.port}`, flags: FLAG_FRAMELESS
|
|
|
29
30
|
| --- | --- |
|
|
30
31
|
| 仅支持 Windows x64 | `package.json` 中的 `os` 与 `cpu` 字段已做硬性限制,拒绝在其他平台安装(包名保持平台中立仅为预留命名空间)。预编译动态库 `libshell.dll` 随包分发,无需在安装期编译 |
|
|
31
32
|
| 仅支持 Bun 运行时 | 包内直接发布 TypeScript 源码,要求 Bun ≥ 1.4.3。Node.js 环境虽可完成包安装,但无法直接运行 |
|
|
33
|
+
| 类型检查要求 TypeScript ≥ 7.0.2 | API 在 TypeScript 7.0.2 下设计与检查,`devDependencies` 中精确锁版;不为更旧的编译器做兼容。高于 7.0.2 的结果未作断言——`bun-types` 只在那一侧才打开 `typesVersions: ">= 7.1"` 门控,`with { type: "text" }` 的解析结果会随之改变 |
|
|
32
34
|
| 类型检查需自备 `@types/bun` | 包内是 `.ts` 源文件,消费方的 `bun check` 会一并编译它们。需执行 `bun add -d @types/bun`,否则类型检查会在 `node_modules` 内报 `TS2868: Cannot find name 'Bun'` |
|
|
33
35
|
| 基于 `bun:ffi` 构建 | `bun:ffi` 在 Bun 中仍标记为实验性特性(Experimental)。官方建议生产环境优先采用 Node-API,本项目选择 `bun:ffi` 是为了换取零第三方依赖的轻量架构 |
|
|
34
36
|
|
|
@@ -173,6 +175,27 @@ await app.wait(); // 窗口关闭时 resolve
|
|
|
173
175
|
await app.stop(); // 释放事件泵与服务;幂等,已关闭时立即返回
|
|
174
176
|
```
|
|
175
177
|
|
|
178
|
+
传 `page` 就能把打包好的页面脚本交给骨架,不必自己写那条路由。`createApp` 会应答
|
|
179
|
+
`PAGE_JS_PATH`(`/page.js`)并带上正确的 `content-type`——而这里正是静默出错的地方:
|
|
180
|
+
模块脚本以 `text/plain` 送出时会被拒绝执行且不报语法错,页面照常渲染、脚本一行不跑。
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { bundlePage } from "bun-app-window/pack";
|
|
184
|
+
|
|
185
|
+
// 构建期,不是启动时——见下文 pack 一节
|
|
186
|
+
const { code } = await bundlePage({ entry: "src/ui/page.ts" });
|
|
187
|
+
|
|
188
|
+
const app = await createApp({ html, page: code, service });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`App` 上另有两个成员,各替我省一次调用和一条子路径导入:
|
|
192
|
+
|
|
193
|
+
- `app.debug` —— 与 `debugOf(app.shell)` 返回同一个对象。同样不在稳定性承诺范围内。
|
|
194
|
+
- `app.attachPlacement(file)` —— 把位置持久化接到骨架的**唯一**事件泵上。自己调
|
|
195
|
+
`attachPlacementStore` 而忘了传 `{ events: app.events }`,它会起第二个泵;而
|
|
196
|
+
`shell.pollEvents()` 是消费式的,两个泵各拿一半事件,窗口位置随机丢失,两端都不报错。
|
|
197
|
+
这个方法让那个错误无法表达。
|
|
198
|
+
|
|
176
199
|
```ts
|
|
177
200
|
// page.ts —— index.html 的 bundle 里
|
|
178
201
|
import { connect } from "bun-app-window/ipc";
|
|
@@ -420,6 +443,45 @@ d.hittest(x, y);
|
|
|
420
443
|
`metrics()` 与 `dpi()` 返回的是**格式化文本**,不是对象。真要解析就写进一次性脚本里,别把解析器
|
|
421
444
|
塞进生产代码——这个格式不属于任何契约。
|
|
422
445
|
|
|
446
|
+
### `bun-app-window/types` — 常量面,浏览器安全
|
|
447
|
+
|
|
448
|
+
一个零依赖模块,承载全部常量:`FLAG_*`、`BROWSER_UI_*`、`RESIZE_EDGE`、`RESIZE_INDEX`、
|
|
449
|
+
`ZOOM_LADDER`、`ZOOM_MIN`、`ZOOM_MAX`、`STATE_*` 及相关类型。
|
|
450
|
+
|
|
451
|
+
**页面侧请用这个子路径。** 主入口经 `./native` 引入 `bun:ffi`,浏览器打包器解析不了它——
|
|
452
|
+
所以在页面代码里写 `import { BROWSER_UI_ALL } from "bun-app-window"` 会在打包期失败。
|
|
453
|
+
开这个子路径就是为了让同一份常量被共享,而不是各自手抄:
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
import { BROWSER_UI_ALL, ZOOM_LADDER, RESIZE_EDGE, resizeIndex } from "bun-app-window/types";
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
它只有一个不变量:`src/shell/types.ts` 必须保持零 import。有静态断言守着。
|
|
460
|
+
|
|
461
|
+
### `bun-app-window/pack` — 打包页面脚本
|
|
462
|
+
|
|
463
|
+
页面脚本必须先打包浏览器才加载得了:它会 import `bun-app-window/ipc` 这类裸模块名,
|
|
464
|
+
浏览器解析不了。打包要放在**构建期**,不是启动时——`bun build --compile` 之后
|
|
465
|
+
`import.meta.dir` 指向归档内部(`B:\~BUN\root`),`.ts` 源码已经不在那里了。
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import { bundlePage, writePageBundle } from "bun-app-window/pack";
|
|
469
|
+
|
|
470
|
+
// 只在内存里:把字符串交给 createApp({ page })
|
|
471
|
+
const { code, bytes } = await bundlePage({ entry: "src/ui/page.ts" });
|
|
472
|
+
|
|
473
|
+
// 或者写盘,例如从一个 bundle:page 的 npm script 里调
|
|
474
|
+
await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`target: "browser"` 与 `format: "esm"` 是固定的,不可配——页面脚本就是这两者。
|
|
478
|
+
`bytes` 是 UTF-8 字节数,不是 `string.length`;源码里只要有非 ASCII 文本两者就不同,
|
|
479
|
+
而一个「看起来对」的体积读数比没有读数更糟。
|
|
480
|
+
|
|
481
|
+
失败时抛错并带上 Bun 的完整诊断。两种形态都覆盖了:编译期问题以
|
|
482
|
+
`{ success: false, logs }` 返回,而入口本身不可达时**抛 `AggregateError`**,
|
|
483
|
+
它的 `message` 只有一句 `Bundle failed`,细节在 `.errors` 上。
|
|
484
|
+
|
|
423
485
|
### `bun-app-window/native` — 预编译动态库本体
|
|
424
486
|
|
|
425
487
|
该子路径解析到 `dist/libshell.dll` 本身,`import.meta.resolve()` 拿到的是可直接拷到 exe 旁边的真实路径:
|
package/README.zh-TW.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/bun-app-window)
|
|
4
4
|
[](https://bun.sh)
|
|
5
|
+
[](#系統需求與設計邊界)
|
|
5
6
|
[](#系統需求與設計邊界)
|
|
6
7
|
[](#系統需求與設計邊界)
|
|
7
8
|
[](./LICENSE)
|
|
@@ -29,6 +30,7 @@ await openShell({ url: `http://127.0.0.1:${server.port}`, flags: FLAG_FRAMELESS
|
|
|
29
30
|
| --- | --- |
|
|
30
31
|
| 僅支援 Windows x64 | `package.json` 中的 `os` 與 `cpu` 欄位已做強制限制,拒絕在其他平台安裝(套件名稱保持平台中立僅為預留命名空間)。預編譯動態連結程式庫 `libshell.dll` 隨套件發佈,無需在安裝期編譯 |
|
|
31
32
|
| 僅支援 Bun 執行環境 | 套件內直接發佈 TypeScript 原始碼,要求 Bun ≥ 1.4.3。Node.js 環境雖可完成套件安裝,但無法直接執行 |
|
|
33
|
+
| 型別檢查要求 TypeScript ≥ 7.0.2 | API 在 TypeScript 7.0.2 下設計與檢查,`devDependencies` 中精確鎖版;不為更舊的編譯器做相容。高於 7.0.2 的結果未作斷言——`bun-types` 只在那一側才開啟 `typesVersions: ">= 7.1"` 閘控,`with { type: "text" }` 的解析結果會隨之改變 |
|
|
32
34
|
| 型別檢查需自備 `@types/bun` | 套件內是 `.ts` 原始碼,消費方的 `bun check` 會一併編譯它們。需執行 `bun add -d @types/bun`,否則型別檢查會在 `node_modules` 內回報 `TS2868: Cannot find name 'Bun'` |
|
|
33
35
|
| 基於 `bun:ffi` 建構 | `bun:ffi` 在 Bun 中仍標記為實驗性功能(Experimental)。官方建議正式環境優先採用 Node-API,本專案選擇 `bun:ffi` 是為了換取零第三方相依性的輕量架構 |
|
|
34
36
|
|
|
@@ -172,6 +174,27 @@ await app.wait(); // 視窗關閉時 resolve
|
|
|
172
174
|
await app.stop(); // 釋放事件泵與服務;冪等,已關閉時立即回傳
|
|
173
175
|
```
|
|
174
176
|
|
|
177
|
+
傳 `page` 就能把打包好的頁面指令碼交給骨架,不必自己寫那條路由。`createApp` 會應答
|
|
178
|
+
`PAGE_JS_PATH`(`/page.js`)並帶上正確的 `content-type`——而這裡正是靜默出錯的地方:
|
|
179
|
+
模組指令碼以 `text/plain` 送出時會被拒絕執行且不報語法錯,頁面照常渲染、指令碼一行不跑。
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
import { bundlePage } from "bun-app-window/pack";
|
|
183
|
+
|
|
184
|
+
// 建置期,不是啟動時——見下文 pack 一節
|
|
185
|
+
const { code } = await bundlePage({ entry: "src/ui/page.ts" });
|
|
186
|
+
|
|
187
|
+
const app = await createApp({ html, page: code, service });
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`App` 上另有兩個成員,各替我省一次呼叫和一條子路徑匯入:
|
|
191
|
+
|
|
192
|
+
- `app.debug` —— 與 `debugOf(app.shell)` 回傳同一個物件。同樣不在穩定性承諾範圍內。
|
|
193
|
+
- `app.attachPlacement(file)` —— 把位置持久化接到骨架的**唯一**事件泵上。自己呼叫
|
|
194
|
+
`attachPlacementStore` 而忘了傳 `{ events: app.events }`,它會起第二個泵;而
|
|
195
|
+
`shell.pollEvents()` 是消費式的,兩個泵各拿一半事件,視窗位置隨機遺失,兩端都不報錯。
|
|
196
|
+
這個方法讓那個錯誤無法表達。
|
|
197
|
+
|
|
175
198
|
```ts
|
|
176
199
|
// page.ts —— index.html 的 bundle 內
|
|
177
200
|
import { connect } from "bun-app-window/ipc";
|
|
@@ -420,6 +443,45 @@ d.hittest(x, y);
|
|
|
420
443
|
`metrics()` 與 `dpi()` 回傳的是**格式化文字**,不是物件。真要解析就寫進一次性指令碼裡,別把
|
|
421
444
|
解析器塞進正式程式碼——這個格式不屬於任何契約。
|
|
422
445
|
|
|
446
|
+
### `bun-app-window/types` — 常數面,瀏覽器安全
|
|
447
|
+
|
|
448
|
+
一個零依賴模組,承載全部常數:`FLAG_*`、`BROWSER_UI_*`、`RESIZE_EDGE`、`RESIZE_INDEX`、
|
|
449
|
+
`ZOOM_LADDER`、`ZOOM_MIN`、`ZOOM_MAX`、`STATE_*` 及相關型別。
|
|
450
|
+
|
|
451
|
+
**頁面側請用這個子路徑。** 主入口經 `./native` 引入 `bun:ffi`,瀏覽器打包器解析不了它——
|
|
452
|
+
所以在頁面程式碼裡寫 `import { BROWSER_UI_ALL } from "bun-app-window"` 會在打包期失敗。
|
|
453
|
+
開這個子路徑就是為了讓同一份常數被共用,而不是各自手抄:
|
|
454
|
+
|
|
455
|
+
```ts
|
|
456
|
+
import { BROWSER_UI_ALL, ZOOM_LADDER, RESIZE_EDGE, resizeIndex } from "bun-app-window/types";
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
它只有一個不變量:`src/shell/types.ts` 必須保持零 import。有靜態斷言守著。
|
|
460
|
+
|
|
461
|
+
### `bun-app-window/pack` — 打包頁面指令碼
|
|
462
|
+
|
|
463
|
+
頁面指令碼必須先打包瀏覽器才載入得了:它會 import `bun-app-window/ipc` 這類裸模組名,
|
|
464
|
+
瀏覽器解析不了。打包要放在**建置期**,不是啟動時——`bun build --compile` 之後
|
|
465
|
+
`import.meta.dir` 指向封存內部(`B:\~BUN\root`),`.ts` 原始碼已經不在那裡了。
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import { bundlePage, writePageBundle } from "bun-app-window/pack";
|
|
469
|
+
|
|
470
|
+
// 只在記憶體裡:把字串交給 createApp({ page })
|
|
471
|
+
const { code, bytes } = await bundlePage({ entry: "src/ui/page.ts" });
|
|
472
|
+
|
|
473
|
+
// 或者寫入磁碟,例如從一個 bundle:page 的 npm script 裡呼叫
|
|
474
|
+
await writePageBundle({ entry: "src/ui/page.ts", out: "src/ui/page.bundle.txt" });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
`target: "browser"` 與 `format: "esm"` 是固定的,不可配置——頁面指令碼就是這兩者。
|
|
478
|
+
`bytes` 是 UTF-8 位元組數,不是 `string.length`;原始碼裡只要有非 ASCII 文字兩者就不同,
|
|
479
|
+
而一個「看起來對」的體積讀數比沒有讀數更糟。
|
|
480
|
+
|
|
481
|
+
失敗時拋錯並帶上 Bun 的完整診斷。兩種形態都覆蓋了:編譯期問題以
|
|
482
|
+
`{ success: false, logs }` 回傳,而入口本身不可達時**拋 `AggregateError`**,
|
|
483
|
+
它的 `message` 只有一句 `Bundle failed`,細節在 `.errors` 上。
|
|
484
|
+
|
|
423
485
|
### `bun-app-window/native` — 預編譯動態連結程式庫本體
|
|
424
486
|
|
|
425
487
|
該子路徑解析到 `dist/libshell.dll` 本身,`import.meta.resolve()` 拿到的是可直接複製到 exe 旁邊的真實路徑:
|
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.6.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",
|
|
@@ -29,12 +29,14 @@
|
|
|
29
29
|
],
|
|
30
30
|
"exports": {
|
|
31
31
|
".": "./src/shell/index.ts",
|
|
32
|
+
"./types": "./src/shell/types.ts",
|
|
32
33
|
"./app": "./src/shell/app.ts",
|
|
33
34
|
"./events": "./src/shell/events.ts",
|
|
34
35
|
"./embedded": "./src/shell/embedded.ts",
|
|
35
36
|
"./debug": "./src/shell/debug.ts",
|
|
36
37
|
"./state": "./src/shell/state.ts",
|
|
37
38
|
"./ipc": "./src/shell/ipc.ts",
|
|
39
|
+
"./pack": "./src/shell/pack.ts",
|
|
38
40
|
"./titlebar": "./src/ui/titlebar.ts",
|
|
39
41
|
"./native": {
|
|
40
42
|
"types": "./src/shell/native-asset.d.ts",
|
package/src/shell/app.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { EVENT_CLOSED, attachEventPump, type EventPump } from "./events";
|
|
|
32
32
|
import { openShell, type Shell } from "./index";
|
|
33
33
|
import { serveOnSafePort } from "./port";
|
|
34
34
|
import { FLAG_CENTER, FLAG_RESIZABLE, type ShellOptions } from "./types";
|
|
35
|
+
import { debugOf, type ShellDebug } from "./debug";
|
|
36
|
+
import { attachPlacementStore, type PlacementStore } from "./state";
|
|
35
37
|
|
|
36
38
|
/** 骨架起出来的东西。`shell` 是完整的内核对象,骨架只是帮它把周边接好 */
|
|
37
39
|
export type App = {
|
|
@@ -55,6 +57,29 @@ export type App = {
|
|
|
55
57
|
readonly token: string;
|
|
56
58
|
/** 给了 `service` 才有;否则为 null */
|
|
57
59
|
readonly ipc: IpcServer | null;
|
|
60
|
+
/**
|
|
61
|
+
* 排障接口(`hittest` / `metrics` / `dpi` / `selfBase` / `selfPath`)。
|
|
62
|
+
*
|
|
63
|
+
* 等价于 `debugOf(app.shell)`,只是省掉一次调用和一条子路径依赖。
|
|
64
|
+
* 排障面的稳定性承诺与 `bun-app-window/debug` 相同:**随时可能变化,生产代码
|
|
65
|
+
* 不应依赖**。骨架把它挂上来不改变这个定位,只是让它更好拿。
|
|
66
|
+
*/
|
|
67
|
+
readonly debug: ShellDebug;
|
|
68
|
+
/**
|
|
69
|
+
* 把窗口位置持久化挂到这个 App 上。
|
|
70
|
+
*
|
|
71
|
+
* 与直接调 `attachPlacementStore(shell, file, { events })` 的差别只有一件事:
|
|
72
|
+
* **事件泵由骨架提供**,不可能忘。那条 `{ events }` 参数漏掉的后果是
|
|
73
|
+
* `attachPlacementStore` 自建第二个泵,而 `shell.pollEvents()` 是消费式的——
|
|
74
|
+
* 两个泵各自拿到一部分事件,窗口位置会**随机漏掉**一些变化,且两端都不报错。
|
|
75
|
+
* 这是本库里最容易静默出错的接线点,所以收进骨架。
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* const store = app.attachPlacement(join(dataDir, "window.json"));
|
|
79
|
+
* // 关闭前:await store.flush(); store.dispose();
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
attachPlacement(file: string, opts?: { debounceMs?: number }): PlacementStore;
|
|
58
83
|
/**
|
|
59
84
|
* 停止窗口与本地服务。
|
|
60
85
|
*
|
|
@@ -108,6 +133,24 @@ export type AppOptions = {
|
|
|
108
133
|
appName?: string;
|
|
109
134
|
nativeLibPath?: string;
|
|
110
135
|
embedded?: () => Promise<Uint8Array | null>;
|
|
136
|
+
/**
|
|
137
|
+
* 页面脚本(已打包的单文件 ESM 源码)。给了就自动在 `{@link PAGE_JS_PATH}`
|
|
138
|
+
* 提供它,`content-type` 固定为 `text/javascript; charset=utf-8`。
|
|
139
|
+
*
|
|
140
|
+
* 传的是**字符串**,不是路径:构建期由 `bun-app-window/pack` 打好后内联进来,
|
|
141
|
+
* 运行期不碰磁盘。写成路径会在 `bun build --compile` 之后失效,
|
|
142
|
+
* 因为那时 `import.meta.dir` 指向 `B:\~BUN\root`。
|
|
143
|
+
*
|
|
144
|
+
* ```ts
|
|
145
|
+
* import pageCode from "./ui/page.bundle.txt" with { type: "text" };
|
|
146
|
+
* const app = await createApp({ html, page: pageCode as unknown as string });
|
|
147
|
+
* ```
|
|
148
|
+
*
|
|
149
|
+
* 不给时该路径交回 `routes` 与默认处理(即 404),行为与引入本项之前一致。
|
|
150
|
+
*
|
|
151
|
+
* 调用方的 `routes` 仍然优于此项:返回非 `undefined` 就能覆盖它。
|
|
152
|
+
*/
|
|
153
|
+
page?: string;
|
|
111
154
|
/**
|
|
112
155
|
* IPC 服务方法表。给了就自动挂 `/ws`(含 token 校验)。
|
|
113
156
|
*
|
|
@@ -165,6 +208,26 @@ export function buildAppUrl(port: number, token: string): string {
|
|
|
165
208
|
return `http://127.0.0.1:${port}/?t=${token}`;
|
|
166
209
|
}
|
|
167
210
|
|
|
211
|
+
/**
|
|
212
|
+
* `AppOptions.page` 的服务路径。
|
|
213
|
+
*
|
|
214
|
+
* 固定为 `/page.js` 而不是可配置:页面 HTML 里写的是
|
|
215
|
+
* `<script type="module" src="/page.js">`,两侧必须一致。留一个可配置项意味着
|
|
216
|
+
* 多一处「配置与 HTML 对不上」的失败方式,而它换不来任何能力——路径本身没有人关心。
|
|
217
|
+
*
|
|
218
|
+
* 导出是为了让调用方的 HTML 与 `routes` 都能引用同一个常量,而不是各写一份字符串。
|
|
219
|
+
*/
|
|
220
|
+
export const PAGE_JS_PATH = "/page.js";
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 页面脚本响应的 `content-type`。
|
|
224
|
+
*
|
|
225
|
+
* 必须是 `text/javascript`。写成 `application/javascript` 浏览器也接受,但写成
|
|
226
|
+
* `text/plain` 或漏掉时,`<script type="module">` 会**拒绝执行**且不报语法错——
|
|
227
|
+
* 表现是页面白屏、控制台只有一条 MIME 类型警告,很容易被当成「脚本逻辑有问题」。
|
|
228
|
+
*/
|
|
229
|
+
const PAGE_JS_CONTENT_TYPE = "text/javascript; charset=utf-8";
|
|
230
|
+
|
|
168
231
|
/**
|
|
169
232
|
* `/ws` 请求的处置结论。
|
|
170
233
|
*
|
|
@@ -254,6 +317,14 @@ export async function createApp(opts: AppOptions): Promise<App> {
|
|
|
254
317
|
return custom;
|
|
255
318
|
}
|
|
256
319
|
|
|
320
|
+
// 页面脚本。给了 `page` 才有这一条;不给时该路径落到底部的 404,
|
|
321
|
+
// 与引入本项之前的行为一致。
|
|
322
|
+
if (opts.page !== undefined && url.pathname === PAGE_JS_PATH) {
|
|
323
|
+
return new Response(opts.page, {
|
|
324
|
+
headers: { "content-type": PAGE_JS_CONTENT_TYPE },
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
257
328
|
if (url.pathname === "/") {
|
|
258
329
|
return new Response(opts.html, {
|
|
259
330
|
headers: { "content-type": "text/html; charset=utf-8" },
|
|
@@ -317,6 +388,15 @@ export async function createApp(opts: AppOptions): Promise<App> {
|
|
|
317
388
|
url,
|
|
318
389
|
token,
|
|
319
390
|
ipc,
|
|
391
|
+
debug: debugOf(shell),
|
|
392
|
+
attachPlacement(file: string, opts2?: { debounceMs?: number }): PlacementStore {
|
|
393
|
+
// 事件泵用骨架这一个:`shell.pollEvents()` 是消费式的,再起一个就分票。
|
|
394
|
+
// 这里不给调用方传 `events` 的机会,正是这个方法存在的全部理由。
|
|
395
|
+
return attachPlacementStore(shell, file, {
|
|
396
|
+
events,
|
|
397
|
+
...(opts2?.debounceMs === undefined ? {} : { debounceMs: opts2.debounceMs }),
|
|
398
|
+
});
|
|
399
|
+
},
|
|
320
400
|
async stop(): Promise<void> {
|
|
321
401
|
if (stopped) {
|
|
322
402
|
return;
|
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/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
|
/** 无边框:原生层不画标题栏与边框,由页面自绘 */
|
|
@@ -78,7 +90,24 @@ export const BROWSER_UI_AUTOFILL = 16;
|
|
|
78
90
|
export const BROWSER_UI_ALL =
|
|
79
91
|
BROWSER_UI_MENU | BROWSER_UI_KEYS | BROWSER_UI_STATUSBAR | BROWSER_UI_SWIPE | BROWSER_UI_AUTOFILL;
|
|
80
92
|
|
|
81
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* 缩放边框的 8 个命中边,取值与 Win32 HT* 常量一致。
|
|
95
|
+
*
|
|
96
|
+
* 这是 `Shell.beginResize()` 的**入参取值**。传 `RESIZE_EDGE.LEFT` 即可。
|
|
97
|
+
*
|
|
98
|
+
* 历史(0.6.0 修正):原生 `shell_begin_resize()` 收的是内部数组下标 0–7,
|
|
99
|
+
* 而本位定义是 HT* 常量 10–17,两者差一个**非线性**映射——
|
|
100
|
+
*
|
|
101
|
+
* ```c
|
|
102
|
+
* static const WPARAM codes[8] = {HTLEFT, HTRIGHT, HTTOP, HTBOTTOM,
|
|
103
|
+
* HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT};
|
|
104
|
+
* ```
|
|
105
|
+
*
|
|
106
|
+
* 注意 `HTTOPLEFT`(13) 在下标 4、`HTBOTTOM`(15) 在下标 3:既不是恒等也不是减 10。
|
|
107
|
+
* 0.5.x 里 `beginResize` 的注释写「取 RESIZE_EDGE 的值」而实现直传下标,
|
|
108
|
+
* 照注释写必然被 `edge > 7` 拒掉(`rc=-1`,表现是「点了没反应」)。0.6.0 起由
|
|
109
|
+
* `Shell.beginResize()` 在 TS 层查表转换,本常量成为真正的入参契约。
|
|
110
|
+
*/
|
|
82
111
|
export const RESIZE_EDGE = {
|
|
83
112
|
LEFT: 10,
|
|
84
113
|
RIGHT: 11,
|
|
@@ -92,6 +121,61 @@ export const RESIZE_EDGE = {
|
|
|
92
121
|
|
|
93
122
|
export type ResizeEdge = (typeof RESIZE_EDGE)[keyof typeof RESIZE_EDGE];
|
|
94
123
|
|
|
124
|
+
/**
|
|
125
|
+
* `RESIZE_EDGE` 的 HT* 值 → 原生 `codes[]` 的**数组下标**。
|
|
126
|
+
*
|
|
127
|
+
* 顺序必须与 `native/shell.cc` 里那张表逐项一致:
|
|
128
|
+
*
|
|
129
|
+
* ```c
|
|
130
|
+
* static const WPARAM codes[8] = {HTLEFT, HTRIGHT, HTTOP, HTBOTTOM,
|
|
131
|
+
* HTTOPLEFT, HTTOPRIGHT, HTBOTTOMLEFT, HTBOTTOMRIGHT};
|
|
132
|
+
* ```
|
|
133
|
+
*
|
|
134
|
+
* **它既不是恒等也不是 `value - 10`**:`HTTOPLEFT`=13 落在下标 4、`HTBOTTOM`=15
|
|
135
|
+
* 落在下标 3。`tests/types.test.ts` 有一条断言逐项钉住这张表。
|
|
136
|
+
*
|
|
137
|
+
* 消费者一般不需要它——`Shell.beginResize()` 已经替你查表了。放在公共面是因为
|
|
138
|
+
* 它属于「协议的另一半」,逃生机 `shell.symbols.shell_begin_resize()` 要直连原生
|
|
139
|
+
* 时需要它,而那种时候手写映射必然抄错。
|
|
140
|
+
*/
|
|
141
|
+
export const RESIZE_INDEX: Readonly<Record<number, number>> = {
|
|
142
|
+
[RESIZE_EDGE.LEFT]: 0,
|
|
143
|
+
[RESIZE_EDGE.RIGHT]: 1,
|
|
144
|
+
[RESIZE_EDGE.TOP]: 2,
|
|
145
|
+
[RESIZE_EDGE.BOTTOM]: 3,
|
|
146
|
+
[RESIZE_EDGE.TOPLEFT]: 4,
|
|
147
|
+
[RESIZE_EDGE.TOPRIGHT]: 5,
|
|
148
|
+
[RESIZE_EDGE.BOTTOMLEFT]: 6,
|
|
149
|
+
[RESIZE_EDGE.BOTTOMRIGHT]: 7,
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* HT* 值 → 下标;不认识的值返回 -1。
|
|
154
|
+
*
|
|
155
|
+
* 返回 -1 而不是抛错:原生 `shell_begin_resize()` 用 `edge < 0` 把它当场拒掉并回
|
|
156
|
+
* `rc=-1`,与「窗口已销毁」是同一个外部表现,调用方按 `rc` 处理即可。
|
|
157
|
+
*/
|
|
158
|
+
export function resizeIndex(edge: number): number {
|
|
159
|
+
return RESIZE_INDEX[edge] ?? -1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 缩放阶梯,与原生 `native/shell.cc` 的 `kZoomLadder` 逐档一致。
|
|
164
|
+
*
|
|
165
|
+
* 原生侧 `apply_zoom` 只把入参夹到首尾两档(0.25 ~ 3.0),不吸附中间档——
|
|
166
|
+
* 传任意值都能生效。这份表是给「按档位走」的界面用的:`−` / `+` 要按相邻档
|
|
167
|
+
* 跳,而档位表属于两边都要知道的常量。
|
|
168
|
+
*
|
|
169
|
+
* 过去消费者只能自己抄一份(抄了 14 个数,没有任何门禁能发现它与 C++ 漂移)。
|
|
170
|
+
*/
|
|
171
|
+
export const ZOOM_LADDER = [
|
|
172
|
+
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,
|
|
173
|
+
] as const;
|
|
174
|
+
|
|
175
|
+
/** 缩放倍率的上下限(`ZOOM_LADDER` 的首尾两项) */
|
|
176
|
+
export const ZOOM_MIN = ZOOM_LADDER[0];
|
|
177
|
+
export const ZOOM_MAX = ZOOM_LADDER[ZOOM_LADDER.length - 1];
|
|
178
|
+
|
|
95
179
|
/** window state 位掩码(与原生 shell_get_state 一致) */
|
|
96
180
|
export const STATE_MAXIMIZED = 1;
|
|
97
181
|
export const STATE_MINIMIZED = 2;
|
|
@@ -117,6 +201,15 @@ export type NativeLibInfo = {
|
|
|
117
201
|
* fallback-tmp — 受限环境下退回 Bun 默认释放
|
|
118
202
|
*/
|
|
119
203
|
source: "explicit" | "dev" | "package" | "embedded" | "fallback-tmp";
|
|
204
|
+
/**
|
|
205
|
+
* `bun-app-window` 自身的版本号(取自包内 `package.json`,不是 DLL 的 PE 版本)。
|
|
206
|
+
*
|
|
207
|
+
* 加它的理由:消费者要在界面上显示「连上的确实是这个版本」时,过去只能
|
|
208
|
+
* `import libPkg from "bun-app-window/package.json"` 再拼字符串——那是一条
|
|
209
|
+
* 额外的子路径依赖,且 `package.json` 在 `files` 白名单里所以要跟着发布面走。
|
|
210
|
+
* 现在顺手带出来,页面侧直接读 `shell.libInfo.version`。
|
|
211
|
+
*/
|
|
212
|
+
version: string;
|
|
120
213
|
sha256: string;
|
|
121
214
|
bytes: number;
|
|
122
215
|
reused: boolean;
|