bun-app-window 0.2.0 → 0.3.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 CHANGED
@@ -6,11 +6,115 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-10-21
10
+
11
+ ### Fixed
12
+
13
+ - **A page link could navigate the app away, permanently.** In a desktop shell there is no address bar, no back button, and no host API that can navigate the page back — so one mis-click on an ordinary `<a href="https://example.com">` replaced the application with that website, and the only recovery was to close and relaunch. The native layer could not stop it either: `NavigationStarting` was never subscribed, `NewWindowRequested` was never subscribed, and `g_origin` was never assigned.
14
+
15
+ This is worth describing precisely, because the failure was not a wrong decision — it was **241 lines of correct-looking code that had never been compiled**. `NavigationGuard`, `NewWindowGuard`, `open_external()`, `install_nav_guard()` and `uninstall_nav_guard()` were all present and heavily commented, but: `install_nav_guard()` had **no call site anywhere in the tree**; `SHELL_FLAG_ALLOW_EXTERNAL_NAV = 128` was declared in the C++ enum with **no parsing site**, and the TypeScript side had no corresponding constant at all, so a caller could not even express the intent; and `g_origin` carried a comment saying it was "computed by `shell_start_ex` from `g_url`" while never being assigned — which alone would have made `install_nav_guard()`'s first guard clause return early forever. Type checking, 532 test cases and the full gate were green throughout. The first `bun run build:native` produced six compile errors and settled it: `NavEventBase` existed to hold the refcount and `QueryInterface` plumbing, and **neither guard inherited it**.
16
+
17
+ Two independent guards now install before `webview_navigate` and uninstall before `webview_destroy` (after destroy, `core_webview()` returns `nullptr` and `remove_` is unreachable). Verified end-to-end on a real window: a cross-origin navigation is cancelled and handed to the system browser — which then actually loaded the URL — while a same-origin navigation in the same run went through untouched.
18
+
19
+ ### Changed
20
+
21
+ - **`FLAG_ALLOW_EXTERNAL_NAV` (new, `128`) opts out of navigation interception** for applications that genuinely are browsers. This is the release's one breaking-in-effect change: without the flag, a cross-origin top-level navigation now opens the system browser instead of replacing the app. Nothing else about the flag's surrounding policy moved — `window.open` / `target=_blank` is still cancelled and opened externally **even with this flag**, because that is a capability boundary rather than a safety policy: the shell owns exactly one top-level window (the native side is a process-level singleton), so a second window would have no host API able to close, read or message it.
22
+ - Only `http:` / `https:` ever reach `ShellExecuteW`. `file:`, `javascript:`, `ms-*:` and custom schemes are cancelled silently and never touch a protocol handler, and `mailto:` / `tel:` are deliberately **not** on the list either — a dead `mailto:` link is preferable to handing a page an arbitrary protocol dispatcher.
23
+ - **An origin-less startup URL is no longer a free pass.** The old predicate returned "same origin" whenever `g_origin` was empty, which meant `file://`, `about:blank` and custom-scheme apps — the most common shape for a desktop shell — were the least protected. That branch now degrades to a *same-document* test: a navigation stays inside the window only if it matches the startup URL after stripping the `#fragment`. In-app hash routing keeps working; anything else is external.
24
+
25
+ ### Added
26
+
27
+ - `tests/nav-guard.test.ts` — eleven assertions, written against the **call-site and assignment-site** shapes rather than identifier presence, because `toContain("install_nav_guard")` passes on the definition alone and the definition was never the problem. It also pins the two `FLAG_*` tables together in both directions: every type constant must exist in the C++ enum **and** every C++ flag constant must exist in the type table. The forward assertion alone would not have caught this release's failure — the C++ side was written first and the TypeScript side was simply missing.
28
+ - `docs/pitfalls.md` 2.11 (declared-but-not-wired) and 2.12 (a probe's own criterion can lie), the latter recording two false criteria written during this work: a placeholder expression dressed as an assertion, and a "did the system browser load the page" check that cannot distinguish the browser from an in-webview navigation because both hit the same endpoint.
29
+
30
+ ### Note
31
+
32
+ - The published tarball gains one exported constant (`FLAG_ALLOW_EXTERNAL_NAV`) and one `dist/libshell.dll` rebuild. Native exports are unchanged at 33 symbols.
33
+ - The behaviour change is observable without a flag: an app that was silently relying on external links navigating its own window will now see them open in the browser instead. That is the intent, and it is why this is a minor rather than a patch.
34
+
35
+ ## [0.2.2] - 2026-10-21
36
+
37
+ ### Fixed
38
+
39
+ - **The PNG encoder produced IDAT chunks that were not zlib.** `Bun.deflateSync` is byte-identical to `zlib.deflateRawSync`, so the output was **raw deflate**, while the specification requires zlib framing (`0x78` header plus an adler32 trailer). What made this expensive to find is that **three decoders gave two different answers**: `System.Drawing` opened the files, and our own decoder plus `Bun.inflateSync` decompressed them — because they share the same raw-deflate assumption as the encoder. Only the strict decoder rejected them: `Bun.Image` (libspng) returned `ERR_IMAGE_DECODE_FAILED`, and so did the upstream validator behind `read_file`. Worse, `Bun.Image.metadata()` still succeeded, because it reads only the header and never touches the IDAT payload — so the failure looked like "correct size, correct format, just won't open". `native/png.ts` now imports `deflateSync` from `node:zlib` (Bun's built-in node compat module, not an external node runtime), and `tests/png.test.ts` asserts both that the first IDAT byte is `0x78` and that the payload decompresses under **strict** zlib inflate — never `Bun.inflateSync`, which would make the assertion vacuous. Recorded as `docs/pitfalls.md` 3.11.
40
+ - **`Bun.Image.resize(w)` limits the width, not the longest edge.** The documented semantics are `resize(800)` → "width 800, keep aspect ratio". `native/model-image.ts` was calling `resize(maxEdge, undefined, { withoutEnlargement: true })`, which is correct for landscape images and silently wrong for portrait ones: `300×600` with `maxEdge: 100` produced `100×200`, four times the intended pixel count, with the token estimate and byte size inflated to match. The fix passes both dimensions with `fit: "inside"`. Caught by asserting on a **portrait** image; a landscape-only test would have stayed green. Recorded as `docs/pitfalls.md` 3.15.
41
+ - Six references in `AGENTS.md` and `docs/pitfalls.md` pointed at files that no longer exist: `tools/capture-window.ps1` (three occurrences), `probe-memory.ps1`, `src/safe-port.ts` (the module is actually `src/shell/port.ts`), and `src/gui/page-script.test.ts` (the whole `src/gui/` directory was removed when the UI became a static `src/ui/index.html`). A migration leaves legitimate historical references behind, so the fix is not to delete them but to make the decision explicit — see the guard under *Added*.
42
+
43
+ ### Added
44
+
45
+ - `native/resample.ts` — area-average (box) downscaling over BGRA, and the reason `--max-edge` no longer goes through `Bun.Image` at all. Measured on a real 3098×1850 window scaled to 1300×776, median of five runs:
46
+
47
+ | route | ms | bytes |
48
+ | --- | --- | --- |
49
+ | **our `downscaleBgra` + our encoder** | **45.9** | **102,036** |
50
+ | `Bun.Image` resize + png | 95.9 | 222,621 |
51
+
52
+ **2.1× faster and 2.2× smaller — both directions, not a trade-off.** It is faster because `Bun.Image` takes a PNG as input, so it must encode and then decode again before it can resample; we hold the raw `CreateDIBSection` pointer and never pay that. It is smaller because its default filter is `lanczos3` — a **sharpening** filter that rings around text edges, and the resulting high-frequency detail is exactly what DEFLATE compresses worst. Area averaging is inherently smooth, so the correct algorithm saves both costs at once. A prefix-sum variant was tried and came out **slower** (51.3 vs 39.1 ms for the resample alone): the extra full-width pass and the larger intermediate buffer cost more in cache locality than the inner-loop subtraction saves. Recorded as `docs/pitfalls.md` 3.16.
53
+ - `native/resample.ts` weights the average by alpha (`Σ(b·a)/Σa`) whenever any pixel is transparent, because a plain average mixes the RGB of fully transparent pixels — usually `0` — into the result and grows a black fringe around rounded corners and shadows. `scripts/make-icon.ts` produces exactly such rounded corners. Fully opaque input takes a fast path where the arithmetic is exact. `tests/resample.test.ts` pins this with a 2×2 → 1×1 construction (one opaque black pixel, three opaque whites): the right answer is white at alpha 191, the wrong one is grey 191.
54
+ - `cropBgra` in `native/gdi.ts` — cropping is a per-row `memcpy` over the raw pointer, so it costs no FFI and no resample. It is deliberately applied **before** encoding: once the pixels are handed to `Bun.Image` they are an opaque object and can no longer be cut. `--crop x,y,w,h` exposes it, and the rectangle is clamped to the image rather than producing a malformed one.
55
+ - `native/model-image.ts` — narrowed to the three things the in-repo code cannot do: **enlarging** (box averaging cannot interpolate), **palette** quantisation, and **jpeg / webp** encoding. Everything else on the PNG path now stays inside `native/`.
56
+ - A **two-way** guard on the `AGENTS.md` directory tree. The existing assertion ("everything declared must exist on disk") cannot catch the opposite failure, and `native/model-image.ts` sat outside the tree for a whole round while the gate was green — an unlisted file is invisible to an agent reading the tree as its map. Writing the reverse assertion also exposed a blind spot in the forward one: the tree is aligned by longest name, so `probe-child-windows.ts` is followed by a **single** space, and the parser's `\s{2,}` separator meant that line had never been parsed — and therefore never checked — since the guard was written.
57
+ - A guard for script paths referenced in the docs, backed by an explicit *deleted* allow-list (same orientation as `verify-pack.ts`'s `ALLOW`: adding an entry must be a deliberate decision). A second assertion fails if a listed-deleted file reappears, so the list cannot rot unnoticed.
58
+
59
+ ### Changed
60
+
61
+ - `tools/capture-window.ts` defaults are now set by measurement rather than intuition: `--format png`, `--level 6`, `--max-edge 0`, plus a new `--level <1-9>` and `--crop <x,y,w,h>`. Level 6 is the knee — 31% smaller than level 1 for about 3 ms, and 4× faster than level 9 for 13% more bytes. End-to-end on a real window (including Bun startup and `PrintWindow`):
62
+
63
+ | command | bytes | end-to-end ms |
64
+ | --- | --- | --- |
65
+ | `--level 1` | 704,353 | 148 |
66
+ | default (level 6) | 439,347 | 170 |
67
+ | `--level 9` | 411,366 | 330 |
68
+ | `--crop 700,300,800,600` | **50,836** | 170 |
69
+
70
+ Level 9 buys 6% for double the time; cropping buys **88%** and also drops the token estimate from 1024 to 291. Recorded as `docs/pitfalls.md` 3.14.
71
+ - The choice between the in-repo encoder and `Bun.Image` is now two conditions rather than one: **downscale *and* png** together keep the whole path inside `native/`; anything else (enlarging, palette, jpeg, webp) still goes to `Bun.Image`. Previously a crop was enough to trigger the `Bun.Image` detour, which re-encoded through it for no benefit — 50.0 ms / 84 KiB against 6.0 ms / 62 KiB for the same crop. **Cropping is a pixel operation and must not change the encoding route.**
72
+
73
+ ### Note
74
+
75
+ - The published tarball is unchanged again: `native/` and `tools/` are repository-only. What a repository user will notice is that `bun run demo` screenshots are now roughly half the size at the same readable resolution.
76
+ - A screenshot is now cheap enough to be the primary feedback channel for agent work: a 3098×1850 window at full size is 212 KB, and the same view scaled to the vision model's 1300 normalisation ceiling is 102 KB — with text still legible, as the byte count alone cannot prove. That is the prerequisite for driving a GUI from an agent loop rather than for looking at a window once.
77
+
78
+ ## [0.2.1] - 2026-10-21
79
+
80
+ ### Changed
81
+
82
+ - **The PowerShell tool layer is gone: 14 scripts down to 2.** Everything that could move did. The remaining `scripts/test-sign.ps1` and `scripts/verify-sign-crypto.ps1` need CNG key generation and `SignedCms`, where a hand-written `bun:ffi` structure that is off by one field is a process-level segfault rather than an exception — a cost that buys nothing for two scripts run once per release. The decision and its evidence are in ADR 0014.
83
+ - `native/build-pack.ps1` → `native/build-pack.ts`, run as `bun run build:exe`. Three external tools disappeared with it: `llvm-objdump -p` (Subsystem), `rg -a -o` (embedded asset names) and `Get-AuthenticodeSignature` (signature presence) are now pure byte reads in `native/pe.ts`. Removing them is not about dependency count — the parsing is a pure function, so all three became `bun test` assertions (`tests/pe.test.ts`) instead of output a human has to eyeball.
84
+ - `scripts/make-icon.ps1` → `scripts/make-icon.ts`, and it no longer needs `System.Drawing`. The rounded rectangle is an SDF, the gradient is a projection, and anti-aliasing is 4×4 supersampling — `renderIcon(size)` is a pure function returning pixels, so `tests/make-icon.test.ts` asserts the corner alpha is 0, the centre alpha is 255, and that 16px omits the stroke and the three dots (below that size they smear into grey). The output ICO is 15,605 bytes against the previous 17,429; `System.Drawing.Icon` reads it back at 32×32 and the sampled centre pixel is the brand colour.
85
+ - The other five moved too: `scripts/trust-cert.ts`, `scripts/extract-icon.ts`, `scripts/probe-memory.ts`, `scripts/test-av.ts`, `tools/capture-window.ts`, `tools/probe-child-windows.ts`, `tools/probe-gui-boundary.ts`. `scripts/test-av-runtime.ps1` was **deleted rather than migrated** — its premise was a Session 0 agent observing a Session 1 GUI process through `schtasks /IT`, which stopped being true when OTT moved to Session 1 (`ADR 0005`). Its two remaining abilities moved into `probe-memory.ts` (module enumeration, WebView2 child processes) and `test-av.ts` (Defender scan plus event log).
86
+
87
+ ### Added
88
+
89
+ - `native/png.ts` — a PNG writer, and the single reason three of those scripts could move. `System.Drawing.Bitmap.Save(ImageFormat.Png)` was the only genuine blocker; what it actually did was four chunks and a zlib call, and `Bun.deflateSync` already supplies the zlib. `tests/png.test.ts` round-trips every image it encodes back through a minimal decoder and compares pixels byte for byte, because asserting the signature `89 50 4E 47` would stay green even if the channel order were wrong.
90
+ - `native/win32.ts` and `native/gdi.ts` — the Win32 surface the diagnostic scripts need, grouped by the DLL each symbol actually lives in rather than by meaning. `EnumWindows` and `EnumChildWindows` are replaced by `GetTopWindow` + `GetWindow` iteration, so no C→JS callback is involved at all.
91
+ - `native/pe.ts` — read-only PE parsing: Subsystem, certificate table, and embedded asset names.
92
+ - 324 new test cases across `tests/png.test.ts`, `tests/make-icon.test.ts`, `tests/win32.test.ts`, `tests/pe.test.ts`, `tests/build-pack.test.ts`, `tests/tooling.test.ts`. Total is now 456 across 25 files.
93
+
94
+ ### Fixed
95
+
96
+ - **`u64_fast` returns `number` or `bigint` — and which one is not stable.** The same declaration returned `number 1694568820` from one `CreateCompatibleDC` call and `bigint 18446744073676065759n` from the next. Code that checked `typeof h === "number"` to detect a null handle therefore treated a valid DC as `0`, and `extract-icon` silently produced a fully transparent PNG. Every handle return now goes through `toHandle()`; the pitfall is recorded as `docs/pitfalls.md` 3.7.
97
+ - **`DrawIconEx` has nine parameters, and the first version declared eight.** The missing `hbrFlickerFreeDraw` shifted `diFlags` to zero, so the call returned `true` and drew nothing. `bun:ffi` moves arguments as declared and does not check arity — a wrong parameter count is as silent as a wrong type, not only as fatal as a wrong pointer. Recorded as `docs/pitfalls.md` 3.9.
98
+ - **Two symbols were declared in the wrong DLL.** `DestroyIcon` and `DrawIconEx` both live in `user32.dll`, not `shell32.dll` or `gdi32.dll`, and `dlopen` refuses the whole descriptor when any symbol is missing — so one wrong DLL disables every symbol in that block. Recorded as `docs/pitfalls.md` 3.10.
99
+ - `native/build-pack.ts` computed the repository root with `resolve(dirname(import.meta.dir), "..")`, one level too far, so `bun run build:exe` resolved the entry as `_Repositories/src/host.ts`. The gate was fully green at the time: every `main()` test passed `root` as an argument, and no test asked what the entry point computes for itself. The fix exports `REPO_ROOT` and pins it with three assertions, including the reverse one — the parent directory must **not** contain a `package.json`.
100
+ - The pitfalls guard compared section numbers as floats: `Number("3.10")` is `3.1`, which sorts before `3.9`. Nothing noticed until this release introduced the first two-digit minor number. Now encoded as `major * 1000 + minor`.
101
+ - `scripts/test-av.ts` treated a missing `wevtutil` as a thrown `ENOENT` from `Bun.spawn` rather than as a reportable state; the spawn wrapper now returns a negative exit code instead of propagating.
102
+
103
+ ### Note
104
+
105
+ - Nothing in the published tarball changes: `files` still ships `src/shell`, `src/ui/titlebar.ts`, `dist/libshell.dll` and the docs. This release is entirely about the tooling that builds and verifies it. `bun run build:exe` is the one behaviour a repository user will notice — it no longer needs pwsh.
106
+
9
107
  ## [0.2.0] - 2026-10-20
10
108
 
11
109
  ### Changed
12
110
 
13
111
  - The gate entry point moved from `scripts/gate.ps1` to `scripts/gate.ts`, run as `bun run scripts/gate.ts` (`--clean`, `--skip-pack`, `--keep-clone`). PowerShell-style `-Clean` / `-SkipPack` are accepted too. The reason is structural rather than aesthetic: `$LASTEXITCODE` is a global that only reflects the last native command, and every PowerShell function leaks its child-process output into the return-value pipeline — the exact mechanism behind the "reported GATE PASSED while the type check had failed" bug recorded in `docs/pitfalls.md` 5.3. `Bun.spawn`'s `exited` is bound per call, and `gate.ts` is itself covered by `bun check` (which caught a dead import on the first run). See ADR 0012.
112
+ - Four more PowerShell scripts moved to TypeScript: `install-hooks`, `native/build`, `run`, and `bump-version`. All now accept both `--kebab-case` and `-PascalCase` flags, are covered by `bun check`, and are behind the entry-point guard. Only the 14 scripts that genuinely need Win32 APIs (`Add-Type` P/Invoke, `System.Drawing`, the certificate store, `Get-CimInstance`) remain as `.ps1`. Total: 18 files / 1361 lines down to 14 files, with the four ordinary ones gone.
113
+ - `bun run build:native` now runs `bun run native/build.ts`. The clang++ argument list became a pure function (`clangArgs`) asserted by `tests/build.test.ts`: a missing `-l` or a misplaced `-I` used to surface only as a C++ link error pointing at the compiler rather than at the script.
114
+ - `scripts/install-hooks.ts` writes the hook as raw UTF-8 bytes with no EOL translation — the failure mode the `.ps1` version had (`Set-Content` rewrites to CRLF, which makes `sh` unable to execute the hook) is structurally impossible here, and `tests/install-hooks.test.ts` asserts the template contains no CRLF.
115
+ - `run.ts` drops the hardcoded `C:\Users\root\scoop\shims\bun.exe` and uses `process.execPath` — the running interpreter is by definition the bun to use.
116
+ - `scripts/bump-version.ts` replaces only the version literal and asserts the file's original EOL survived; the `.ps1` version used `-replace` (regex engine) plus `Set-Content` (CRLF rewrite), so a one-token edit could rewrite every byte of the file.
117
+ - Verified equivalent output: `build.ts` produces a byte-identical-size `libshell.dll` (940,032 bytes, 33 exported symbols) and `.build/` and `dist/` copies hash equal.
14
118
  - A shebang was verified rather than assumed: `#!/usr/bin/env bun` does nothing in PowerShell (Windows does not parse shebangs — `./gate.ts` is opened as a document), but it **does** work under the `sh` that Git ships, which is the environment `pre-push` runs in. The hook therefore calls `bun run scripts/gate.ts` explicitly, which is correct on every platform.
15
119
  - The published tarball now carries only the shipping surface: 19 files (1.1 MB unpacked) instead of 101 (2.40 MB). The native C++ layer (`native/`) and the vendored upstream headers (`vendor/`) are no longer packed — they are only needed to rebuild `libshell.dll`, which requires cloning the repository. `bun run build:native` and `build:exe` are therefore repository-only entry points and do nothing useful inside `node_modules`.
16
120
 
@@ -38,7 +142,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
38
142
  - `bun-app-window/native` is documented in all three READMEs (`import.meta.resolve` gives the DLL path; use `bun-app-window/embedded` for `bun build --compile` instead).
39
143
  - ADR 0010 records the publish-surface decision, the conditional-export fix, and why `sideEffects: false` must not be added (it would let a bundler shake away the DLL embedding import in `embedded.ts`).
40
144
  - ADR 0011 records the `fetch` criteria change: why the criterion must equal the files `build.ps1` actually validates, and why the network-touching steps became opt-in switches. The operational version lives in `docs/pitfalls.md` 5.5.
41
- - ADR 0012 records the move of the gate to TypeScript: the three PowerShell mechanisms it removes (`$LASTEXITCODE` as a global, function output leaking into the return-value pipeline, no static checker), the measured behaviour of shebangs on Windows, and the migration boundary — the 6 scripts that are pure process orchestration migrate, the 14 that need Win32 APIs (`Add-Type` P/Invoke, `System.Drawing`, the certificate store, CIM) stay where they are.
145
+ - ADR 0012 records the move of the gate to TypeScript: the three PowerShell mechanisms it removes (`$LASTEXITCODE` as a global, function output leaking into the return-value pipeline, no static checker), the measured behaviour of shebangs on Windows, and the migration boundary — scripts that are pure process orchestration migrate, those that need Win32 APIs (`Add-Type` P/Invoke, `System.Drawing`, the certificate store, CIM) stay where they are. The migration is now complete on the orchestration side: only those 14 Win32 scripts remain as `.ps1`.
42
146
 
43
147
  ## [0.1.0] - 2026-10-20
44
148
 
@@ -117,6 +221,9 @@ First public release.
117
221
  - Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
118
222
  - No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
119
223
 
120
- [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.0...HEAD
224
+ [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.3.0...HEAD
225
+ [0.3.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.2...v0.3.0
226
+ [0.2.2]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.1...v0.2.2
227
+ [0.2.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.2.0...v0.2.1
121
228
  [0.2.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.1.0...v0.2.0
122
229
  [0.1.0]: https://github.com/TiancongLx/bun-app-window/releases/tag/v0.1.0
package/README.md CHANGED
@@ -113,6 +113,26 @@ Combine via bitwise OR (`|`) and pass to `opts.flags`:
113
113
  | `FLAG_DEFER_SHOW` | Creates the window hidden initially until `show()` is explicitly called |
114
114
  | `FLAG_ACCEPT_DROP` | Enables native file and folder drag-and-drop from Windows Explorer |
115
115
  | `FLAG_START_MAXIMIZED` | Opens the window directly in a maximized state without an intermediate normal-sized frame flash |
116
+ | `FLAG_ALLOW_EXTERNAL_NAV` | Opts out of external-navigation interception — for apps that genuinely are browsers |
117
+
118
+ ### External navigation is blocked by default
119
+
120
+ Without `FLAG_ALLOW_EXTERNAL_NAV`, a cross-origin link can no longer navigate the app away.
121
+ The shell cancels the navigation and hands the URL to the system browser instead:
122
+
123
+ - **Same origin** (or, for `file://` / other origin-less startup URLs, the same document
124
+ ignoring the `#fragment`) stays inside the window. In-app routing keeps working.
125
+ - **Cross origin** is cancelled and opened externally — but only for `http:` / `https:`.
126
+ `file:`, `javascript:`, `ms-*:` and custom schemes are cancelled silently and never
127
+ reach a protocol handler. `mailto:` / `tel:` are deliberately not allowed.
128
+ - **`window.open` / `target=_blank`** is always cancelled and opened externally, even with
129
+ `FLAG_ALLOW_EXTERNAL_NAV`. That is a capability boundary, not a policy: the shell owns a
130
+ single top-level window (native side is a process-level singleton), so a second window
131
+ would have no host API to manage it.
132
+
133
+ A desktop shell has no address bar and no back button, and no host API can navigate the page
134
+ back — so a single mis-click would otherwise replace the app with that website. That is why
135
+ the default picks the safe side.
116
136
 
117
137
  ## Subpath Exports
118
138
 
@@ -407,7 +427,7 @@ The underlying commands remain available directly:
407
427
 
408
428
  ```bash
409
429
  bun check # Run TypeScript type checker
410
- bun test # Run automated test suite (tests/, 182 test cases)
430
+ bun test # Run automated test suite (tests/)
411
431
  bun run demo # Launch interactive self-test demo (requires desktop session)
412
432
  ```
413
433
 
package/README.zh-CN.md CHANGED
@@ -113,6 +113,24 @@ shell.setTitle("Doing work…");
113
113
  | `FLAG_DEFER_SHOW` | 创建窗口后保持隐藏状态,等待显式调用 `show()` |
114
114
  | `FLAG_ACCEPT_DROP` | 允许窗口接收外部文件或文件夹拖放事件 |
115
115
  | `FLAG_START_MAXIMIZED` | 窗口首次显示即处于最大化状态,避免从普通尺寸跳变到最大化的中间闪烁帧 |
116
+ | `FLAG_ALLOW_EXTERNAL_NAV` | 放弃外部导航拦截——用于本身就是浏览器的应用 |
117
+
118
+ ### 外部导航默认被拦截
119
+
120
+ 不给 `FLAG_ALLOW_EXTERNAL_NAV` 时,一个跨源链接再也无法把应用导航走。壳会取消这次导航,
121
+ 把地址交给系统默认浏览器:
122
+
123
+ - **同源**(启动地址是 `file://` 等无 origin 的形态时,改为判断「同文档」,即剥掉
124
+ `#fragment` 后与启动地址逐字相同)留在窗口内导航,应用自己的路由不受影响。
125
+ - **跨源**会取消导航并交给系统浏览器——但只有 `http:` / `https:`。`file:`、
126
+ `javascript:`、`ms-*:` 以及自定义 scheme 一律静默取消,绝不触达协议处理器;
127
+ `mailto:` / `tel:` 也**不在**放行之列。
128
+ - **`window.open` / `target=_blank`** 始终被拦下并外开,即使给了
129
+ `FLAG_ALLOW_EXTERNAL_NAV` 也一样。那是能力边界而非策略:壳只持有一个顶层窗口
130
+ (原生层是进程级单例),多开出来的窗口没有任何宿主 API 能管它。
131
+
132
+ 桌面壳没有地址栏、没有后退按钮,也没有任何宿主 API 能把页面导航回来——所以一次误点
133
+ 外链就等于应用被换成了那个网站。这就是默认值选安全一侧的原因。
116
134
 
117
135
  ## 子路径模块
118
136
 
@@ -405,7 +423,7 @@ bun run scripts/gate.ts --clean # 同上,但在 HEAD 的干净副本里
405
423
 
406
424
  ```bash
407
425
  bun check # 执行 TypeScript 类型检查
408
- bun test # 运行自动化测试套件(位于 tests/,共 182 个用例)
426
+ bun test # 运行自动化测试套件(位于 tests/)
409
427
  bun run demo # 启动交互式自检示例程序(需在桌面会话下运行)
410
428
  ```
411
429
 
package/README.zh-TW.md CHANGED
@@ -113,6 +113,24 @@ shell.setTitle("Doing work…");
113
113
  | `FLAG_DEFER_SHOW` | 建立視窗後保持隱藏狀態,等待明確呼叫 `show()` |
114
114
  | `FLAG_ACCEPT_DROP` | 允許視窗接收外部檔案或資料夾拖放事件 |
115
115
  | `FLAG_START_MAXIMIZED` | 視窗首次顯示即處於最大化狀態,避免從一般尺寸跳變至最大化的中間閃爍畫格 |
116
+ | `FLAG_ALLOW_EXTERNAL_NAV` | 放棄外部導覽攔截——用於本身就是瀏覽器的應用程式 |
117
+
118
+ ### 外部導覽預設被攔截
119
+
120
+ 未提供 `FLAG_ALLOW_EXTERNAL_NAV` 時,一個跨來源連結再也無法把應用程式導覽走。殼層會取消該次導覽,
121
+ 把位址交給系統預設瀏覽器:
122
+
123
+ - **同來源**(啟動位址是 `file://` 等無 origin 的形態時,改為判斷「同文件」,即剝除
124
+ `#fragment` 後與啟動位址逐字相同)留在視窗內導覽,應用程式自身的路由不受影響。
125
+ - **跨來源**會取消導覽並交給系統瀏覽器——但僅限 `http:` / `https:`。`file:`、
126
+ `javascript:`、`ms-*:` 以及自訂 scheme 一律靜默取消,絕不觸及協定處理常式;
127
+ `mailto:` / `tel:` 也**不在**放行清單內。
128
+ - **`window.open` / `target=_blank`** 一律被攔下並外開,即使提供了
129
+ `FLAG_ALLOW_EXTERNAL_NAV` 亦然。那是能力邊界而非策略:殼層僅持有一個頂層視窗
130
+ (原生層是處理序級單例),多開出來的視窗沒有任何宿主 API 能管理它。
131
+
132
+ 桌面殼層沒有網址列、沒有上一頁按鈕,也沒有任何宿主 API 能把頁面導覽回來——所以一次誤點
133
+ 外部連結就等於應用程式被換成了那個網站。這就是預設值選擇安全一側的原因。
116
134
 
117
135
  ## 子路徑模組
118
136
 
@@ -405,7 +423,7 @@ bun run scripts/gate.ts --clean # 同上,但在 HEAD 的乾淨副本內
405
423
 
406
424
  ```bash
407
425
  bun check # 執行 TypeScript 型別檢查
408
- bun test # 執行自動化測試套件(位於 tests/,共 182 個測試案例)
426
+ bun test # 執行自動化測試套件(位於 tests/)
409
427
  bun run demo # 啟動互動式自我檢測範例程式(需於桌面工作階段下執行)
410
428
  ```
411
429
 
package/dist/libshell.dll CHANGED
Binary file
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.2.0",
4
+ "version": "0.3.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",
@@ -51,8 +51,8 @@
51
51
  "LICENSE"
52
52
  ],
53
53
  "scripts": {
54
- "build:native": "pwsh -NoProfile -ExecutionPolicy Bypass -File native/build.ps1",
55
- "build:exe": "pwsh -NoProfile -ExecutionPolicy Bypass -File native/build-pack.ps1",
54
+ "build:native": "bun run native/build.ts",
55
+ "build:exe": "bun run native/build-pack.ts",
56
56
  "demo": "bun run ./src/host.ts",
57
57
  "pack": "bun pm pack"
58
58
  },
@@ -205,7 +205,7 @@ export async function resolveNativeLib(opts: ResolveOptions = {}): Promise<Nativ
205
205
 
206
206
  throw new Error(
207
207
  "libshell.dll not found. Build the native layer first: " +
208
- "`pwsh -File native/build.ps1`. " +
208
+ "`bun run native/build.ts`. " +
209
209
  `Searched under ${PKG_ROOT}`,
210
210
  );
211
211
  }
@@ -42,6 +42,24 @@ export const FLAG_ACCEPT_DROP = 32;
42
42
  * 与 `FLAG_CENTER` 同时给时以本项为准(最大化会覆盖位置)。
43
43
  */
44
44
  export const FLAG_START_MAXIMIZED = 64;
45
+ /**
46
+ * 允许页面把顶层框架导航到任意 http/https 地址,即**放弃外部导航拦截**。
47
+ *
48
+ * 默认(不给本位)时,壳会拦下跨源导航并改交系统默认浏览器:桌面应用没有地址栏、
49
+ * 没有后退按钮,也没有宿主 API 能把页面拉回来,一次误点外链就等于应用被换成了那个
50
+ * 网站。这是默认值应该选安全一侧的典型场景。
51
+ *
52
+ * 什么时候需要本位:应用本身就是浏览器(多标签、内置地址栏、能自己导航回来)。
53
+ *
54
+ * 边界(两条,别指望它能关掉更多):
55
+ *
56
+ * - **只影响顶层框架导航**。`window.open` / `target=_blank` 仍然被拦下并外开,
57
+ * 因为壳只支持一个顶层窗口(原生层是进程级单例),开出来的新窗口没有任何宿主
58
+ * API 能管它。这是能力边界,不是安全策略。
59
+ * - **不放宽 scheme 白名单**。交给系统浏览器的只有 http/https;`file:`、
60
+ * `javascript:`、`ms-*:` 以及自定义 scheme 一律静默取消,与本位无关。
61
+ */
62
+ export const FLAG_ALLOW_EXTERNAL_NAV = 128;
45
63
 
46
64
  /**
47
65
  * 关闭 WebView2 的浏览器行为(`ShellOptions.browserUi` 使用)。