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 CHANGED
@@ -4,6 +4,172 @@ 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
+ ## [0.6.1] - 2026-10-22
10
+
11
+ ### Added
12
+
13
+ - **The caption buttons now draw Win 11's actual glyphs at Win 11's actual size, and the component has all five states instead of two.** The previous defaults were two independent misses that compounded.
14
+
15
+ **The glyphs were wrong for the platform, not just "less pretty".** `TITLEBAR_GLYPHS.unicode` renders `─ □ ❐ ✕` — geometric symbols from whatever text font is loaded. Their ink fills about half the em box: measured on `baw-mvp` at @192dpi, `\u25A1` came out **10×9 physical** and `\u25B1` **16×2**, against Win 11's **20×20** and **20×2** (measured on the Settings app, same machine). The fix is not a bigger `font-size` — `10px` was already correct — it is the font. `TITLEBAR_GLYPHS.fluent` holds `E921` / `E922` / `E923` / `E8BB`, which `Segoe Fluent Icons` (Win 11) and `Segoe MDL2 Assets` (Win 10) draw at **the same code points**, so one table covers both systems.
16
+
17
+ `fluent` was previously opt-in because a missing font renders private-use code points as tofu. `detectIconSet()` now checks `document.fonts` for either face and only falls back to `unicode` when neither is present; with no `FontFaceSource` to ask, it assumes `fluent`, since the component targets Windows. The result on `baw-mvp`: **20×20 / 20×20 / 20×2 physical**, identical to the system's own buttons, with horizontal centring at 0.0 px.
18
+
19
+ **Two states became five.** Hover and pressed backplates move from `0.0605` / `0.0419` to **`0.102` / `0.200`** of the foreground colour — the old values were faint enough that hovering looked like nothing happened. `setActive()` dims the glyph to `0.447` when the window loses focus, and `setEnabled(action, enabled)` drops it to `0.361` and stops dispatching. All four numbers, and the close button's red, come from Windows' own implementation as cross-checked against `egoist/mygo`'s `internal/windows/titlebar.go` (`colors()`).
20
+
21
+ **Focus dimming is expressed as a custom property, not a specificity fight.** `.ws-titlebar__btn` sets `color: var(--ws-tb-btn-fg)`, and `[data-active="false"]` on the root re-points that variable. Writing `color: var(--ws-tb-inactive)` straight onto the button would have lost to the `:hover` rule below it, and the fix for that would have been `:not()` chains or `!important` — the kind of thing that works until the next state arrives.
22
+
23
+ `setEnabled` guards in **two** places on purpose: CSS `pointer-events: none` stops the mouse, but a keyboard user tabbing to the button and pressing Enter still reaches the click handler. The `aria-disabled` attribute is the single source both read.
24
+
25
+ Verification: 38 assertions in `tests/titlebar.test.ts` (up from 25), including `detectIconSet` in all four combinations, the five-state CSS values asserted **inside their rule blocks**, and the disabled button not firing. Reverse-verified by restoring `--ws-tb-hover: 0.0605` — exactly one assertion went red, the rest stayed green.
26
+
27
+ **Still manual:** whether the focus-dimmed glyph actually renders dimmer on screen. The check needs the window to lose foreground, and in this session `SetForegroundWindow` never took it away from the host chat window — both readings came back at full brightness, i.e. the window never left the active state, so the probe measured nothing. The logic is covered by unit tests and the CSS is a plain attribute selector, but the end-to-end path (native `focused` bit → `windowState` push → `setActive` → repaint) has not been observed.
28
+
29
+ - **Caption buttons on a frameless window now answer `HTMAXBUTTON`, so Windows 11 Snap Layouts works.** The only documented trigger is `WM_NCHITTEST` returning `HTMAXBUTTON` for the maximize/restore button (*Support snap layouts for desktop apps on Windows 11*). Previously `hit_test_client` returned only the eight resize codes and `HTCLIENT`, so hovering maximize never opened the layout picker — the last piece of "looks like a Win 11 app" that CSS cannot buy.
30
+
31
+ Three constants became a single source of truth in `types.ts` — `CAPTION_BAR_HEIGHT` (32), `CAPTION_BUTTON_WIDTH` (46), `CAPTION_BUTTONS` (`close` / `maximize` / `minimize`, right to left). Two copies follow it: `kCaptionBarHeight` / `kCaptionButtonWidth` plus the `caption_button_at()` order in `native/shell.cc` (C++ cannot import TS), and the `32px` / `46px` in `titlebar.css`.
32
+
33
+ **Order matters more than the numbers.** If the hit-test order and the DOM order disagree, clicking "close" maximizes the window — and the buttons look identical, so nothing about the symptom points at a constant.
34
+
35
+ **`HTMAXBUTTON` cannot ship alone.** Once `WM_NCHITTEST` answers that code, the point is non-client: a press delivers `WM_NCLBUTTONDOWN` instead of `WM_LBUTTONDOWN`, so the page receives no pointer or click event at all. Changing only the hit test turns the button into a black hole that eats clicks — the symptom reads as "clicking maximize does nothing", which is exactly backwards from the intent. `shell_wnd_proc` therefore handles `WM_NCLBUTTONDOWN` for the three button codes and performs the action itself; every other code still falls through to `DefWindowProc` so caption dragging and edge resizing keep working.
36
+
37
+ The caption check is deliberately placed **before** the resize-border check and is **not** gated on `g_resizable` / `IsZoomed`. The old guard opened with `!g_frameless || !g_resizable || IsZoomed(hwnd) → HTCLIENT`; reusing it would make the maximize button unhittable precisely when the window is maximized — which is when the user needs it to restore.
38
+
39
+ Measured with a one-off FFI probe against `dist/libshell.dll` and `user32.dll` (7/7):
40
+
41
+ ```text
42
+ A. scan from the right edge (physical px, 200% scale)
43
+ x=1799 → HTCLOSE
44
+ x=1708 → HTMAXBUTTON
45
+ x=1616 → HTMINBUTTON
46
+ x=1524 → HTTOP ← inside the resize border
47
+ button width 91/92, expected 92 (= CSS 46px × scale)
48
+
49
+ B. WM_NCLBUTTONDOWN
50
+ HTMAXBUTTON → maximized state=1
51
+ again → restored state=0
52
+ HTMINBUTTON → minimized state=2, win=314x50 (sentinel — really minimized)
53
+ ```
54
+
55
+ **Still manual**: whether the Snap flyout actually *appears* on hover. It is a DWM-owned popup, so `PrintWindow` cannot capture it and no readout exposes it. Automating the hover was attempted and blocked — the Windows MCP refused the cursor move (`USER_CONTROL`, `state: user`), and `SetCursorPos` landed 216 px away, i.e. the pointer was in use. What is verified is the precondition the documentation names; what is not verified is the flyout itself.
56
+
57
+ One measurement worth keeping: the outer 13 physical pixels of the window are the resize border, so the title bar's top ~6.5 logical pixels answer `HTTOP` rather than the button code. Caption buttons win where they overlap (they are checked first), but a probe that samples `y=1` will read `HTTOP` and should not be read as "the button is missing".
58
+
59
+ ### Fixed
60
+
61
+ - **The caption buttons sat 18 CSS px left of where they belong, and the title bar was squeezed to a third of its height — because a consumer's `header { … }` rule leaked into the component.** The component mounts on `<header class="ws-titlebar">`, and `baw-mvp`'s page has an element-selector rule for its own page header: `header { padding: 12px 18px 10px; gap: 12px }`. `.ws-titlebar` never declared `padding`, so the 18px right padding pushed all three buttons left by 18 CSS px (measured: 36 physical px @192dpi), and under `box-sizing: border-box` the 12/10 vertical padding squeezed the 32px bar down to 9px of content, shrinking the buttons to 18 physical px tall.
62
+
63
+ **It reads as "the glyph is not centred", and that diagnosis is wrong.** Measuring the hover backplate — `background` covers the whole button, so its edges *are* the button's edges — put the backplate at 92 physical px wide with the glyph's centre and the backplate's centre both at `d=137.5`: the glyph was centred all along. The whole group was displaced, then squashed.
64
+
65
+ **Specificity is not the mechanism.** An element selector (`0,0,1`) does lose to a class selector (`0,1,0`) — but only where the component *declares* the property. An undeclared property does not compete at all, so it is handed to the consumer. The mounting tag is part of the public contract, and `<header>` / `<button>` / `<div>` are exactly the tags consumers write global rules for.
66
+
67
+ The fix declares `margin` / `padding` / `gap` on `.ws-titlebar` and `margin` on `.ws-titlebar__btn`. The consumer sides changes too: `header { … }` becomes `.app-header { … }`, and `button { … }` becomes `button:not(.ws-titlebar__btn)` — the latter also stops `button:active { transform: translateY(1px) }` from reaching the caption buttons, which had been sinking the glyph 1px on press where Win 11 only swaps the backplate.
68
+
69
+ Three assertions guard it in `tests/titlebar.test.ts`, and they must be made **inside the rule block** (`cssBlock(css, ".ws-titlebar")` before `toContain("padding: 0")`). Writing `expect(css).toContain("padding: 0")` would be satisfied by the identically-valued declaration already in `.ws-titlebar__btn` — the guard would silently become a tautology, and this stylesheet happens to contain exactly that coincidence.
70
+
71
+ Verified by reverse: dropping `padding: 0` from `.ws-titlebar` turned **exactly one** assertion red and left the other 24 green; restoring it gave 25/25. On a real window the centring offsets went from `-35.5 / -36.0 / -36.0` to `0.5 / 0.0 / 0.0` physical px, and the close button now ends flush with the client area's right edge.
72
+
73
+ - **`getBounds()` returned the Win32 minimised sentinel rectangle, and `attachPlacementStore` wrote it straight to disk.** `format_bounds()` only took `WINDOWPLACEMENT.rcNormalPosition` when `IsZoomed()` was true. A minimised window is not zoomed, so it fell through to `GetWindowRect()` — which for a minimised window reports `-32000,-32000` plus an icon-sized box (measured: `314x50`). `IsIconic()` was never consulted.
74
+
75
+ Those values look entirely valid: integer coordinates, positive dimensions. The existing shape checks (finite numbers, size > 0) cannot distinguish them from a real rectangle. So `window.json` became `{"x":-32000,"y":-32000,"width":314,"height":50,"dpi":192}`, and the next launch restored a 314×50 window — observed on `baw-mvp` as a 666×471 window with the page wrapped and scrolling.
76
+
77
+ Fixed at two layers:
78
+
79
+ - **Native.** The condition is now "minimised **or** maximised" instead of "maximised", and `IsIconic()` is explicit. The `maximized` bit comes from `WPF_RESTORETOMAXIMIZED` while minimised, because `IsZoomed()` is always false there — without it, "maximise → minimise → close" silently dropped the maximised state.
80
+ - **`state.ts`.** `parseBounds()` and `parsePlacement()` reject any coordinate at or below `-30000`. The first stops new bad values being recorded; the second invalidates files already written by earlier versions, so upgrading repairs the existing bad state rather than only preventing new ones. The threshold is a range, not an exact `-32000`, because the sentinel carries a few pixels of offset depending on DPI and theme. It cannot collide with real coordinates: an 8K display is 7680 wide, and even a long multi-monitor row stays far below 30000.
81
+
82
+ Three tests added, and verified by removing the guard — exactly those three go red, including the `-31996` offset variant. The companion cases that must keep passing are pinned too: a legitimate negative `x` (secondary monitor to the left) still parses.
83
+
84
+ Verified on a real window with a one-off FFI probe against `dist/libshell.dll` (bypassing every TS wrapper):
85
+
86
+ ```text
87
+ normal → 147,147,1826,1271,0,192
88
+ minimised → 147,147,1826,1271,0,192 ← no sentinel, restore rect
89
+ maximised → 147,147,1826,1271,1,192
90
+ maximised → minimise → 147,147,1826,1271,1,192 ← maxed bit preserved
91
+ ```
92
+
93
+ - **The caption buttons are now drawn and hit-tested by the shell on its own layered child window, so a consumer can stop faking them with DOM.** `FLAG_NATIVE_CAPTIONS` (512) creates a `WS_EX_LAYERED | WS_CHILD` window named `webview_shell_caption`, anchored to the client area's top-right corner at exactly `3 × CAPTION_BUTTON_WIDTH` by `CAPTION_BAR_HEIGHT` in logical pixels (`276×64` physical at @192dpi). It answers `WM_NCHITTEST` itself with `HTCLOSE` / `HTMAXBUTTON` / `HTMINBUTTON`; the title strip and the client area still report `Chrome_RenderWidgetHostHWND`, so the page keeps receiving mouse input. `mountTitleBar({ captions: "native" })` skips creating the three buttons and only reserves their footprint.
94
+
95
+ It supersedes `FLAG_TITLEBAR_RESERVE` for consumers that draw their own title bar. `baw-mvp` now runs `flags = 573`; the DLL grows to `1,025,536 B` with its 34 exports unchanged, and `gdi32` joins the subsystem library list (ten now).
96
+
97
+ Three details it depends on, each pinned by an assertion in `tests/nav-guard.test.ts`:
98
+
99
+ - **`UpdateLayeredWindow` must be given an explicit `SIZE`.** Passing `nullptr` returns `true`, sets no error, and leaves the surface `0×0` — the window then exists, is visible, sits at the top of the child chain, has a correct rectangle, and is hit-transparent anyway. The window under it receives every click. See `docs/pitfalls.md` 2.21 for the A/B and for why the documented "the size does not change" does not cover "never was set".
100
+ - **The rest fill is `alpha = 1`, not 0.** A fully transparent layered window is hit-transparent: measured 0/3 hits at `alpha = 0`, 3/3 for every value from 1 to 255. Rest state is invisible at 1/255 either way, so the value exists only to keep the window hittable.
101
+ - **`WM_NCMOUSEMOVE` breaks to `DefWindowProcW` instead of returning 0.** That is where the Snap hover detection happens; short-circuiting it costs the layout picker while leaving the window looking completely normal.
102
+
103
+ Clicks go through `WM_SYSCOMMAND` from the shell process, and only fire when released over the same button. Once `WM_NCHITTEST` answers `HTMAXBUTTON` the point is non-client — the press arrives as `WM_NCLBUTTONDOWN`, not `WM_LBUTTONDOWN` — so the page never sees a `click` and the shell has to perform the action itself. This is also why the three buttons are children of the shell rather than elements of the page: a DOM button cannot return a non-client hit code.
104
+
105
+ Measured on `baw-mvp` at @192dpi maximised: `WindowFromPoint` lands on `webview_shell_caption` 3/3, the three hit codes are exact, glyph boxes are `20×20 / 20×20 / 20×2` physical (`10×10 / 10×10 / 10×1` logical) with `0.0` centring offsets, and the hover backplate moves `32.0 → 55.1` (`+23.1`, against `+22.7` expected). Gate: `72 files`, `603 pass / 0 fail`, `VERIFY-PACK PASSED (7 checks)`.
106
+
107
+ **Snap Layouts was confirmed by a real mouse, and that is the interesting part.** Four automated probes reported zero new top-level windows while hovering and were read as "a child window cannot trigger Snap" — which was wrong. The flyout is a DWM-owned popup, so window enumeration never sees it and the probe could not have reported anything else. The rule this leaves behind (`docs/pitfalls.md` 2.21, fourth section): a guard that reports `0` must first prove it is not blind, by reporting non-zero on a sample known to be true.
108
+
109
+ ## [0.6.0] - 2026-10-21
110
+
111
+ **`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.**
112
+
113
+ ### Added
114
+
115
+ - **`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.
116
+
117
+ 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.
118
+
119
+ - **`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.
120
+
121
+ - **`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).
122
+
123
+ - **`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.
124
+
125
+ - **`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.
126
+
127
+ - **`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.
128
+
129
+ - **`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.
130
+
131
+ - **`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`.
132
+
133
+ 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.
134
+
135
+ - **`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.
136
+
137
+ ### Changed
138
+
139
+ - **BREAKING: `Shell.beginResize(edge)` takes `RESIZE_EDGE` values (10–17).** Migrating from 0.5.x:
140
+
141
+ | You were passing | Before | Now |
142
+ | --- | --- | --- |
143
+ | `RESIZE_EDGE.LEFT` (10) and friends | `rc=-1`, nothing moved | works |
144
+ | raw indices 0–7 | worked | `rc=-1` |
145
+
146
+ 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.
147
+
148
+ - `tests/gate.test.ts` shrank with the stage: `parsePs1Summary` and the `.ps1`-failure short-circuit assertions are gone, and `planStages` now asserts three stages.
149
+ - **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.
150
+ - `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.
151
+ - 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.
152
+
153
+ ### Fixed
154
+
155
+ - **`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.
156
+
157
+ 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.
158
+
159
+ ### Removed
160
+
161
+ **The last two PowerShell scripts are deleted rather than migrated, and the gate's `.ps1` stage went with them.**
162
+
163
+ - **`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.
164
+
165
+ - `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.
166
+ - `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.
167
+ - 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.
168
+
169
+ - **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`.
170
+
171
+ - **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.
172
+
7
173
  ## [0.5.1] - 2026-10-21
8
174
 
9
175
  **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 +630,9 @@ First public release.
464
630
  - Not yet verified in managed environments that enforce AppLocker, WDAC, or third-party EDR behaviour blocking.
465
631
  - No commercially trusted code signing: only a self-signed certificate has been exercised end to end.
466
632
 
467
- [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.1...HEAD
633
+ [Unreleased]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.1...HEAD
634
+ [0.6.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.6.0...v0.6.1
635
+ [0.6.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.1...v0.6.0
468
636
  [0.5.1]: https://github.com/TiancongLx/bun-app-window/compare/v0.5.0...v0.5.1
469
637
  [0.5.0]: https://github.com/TiancongLx/bun-app-window/compare/v0.4.1...v0.5.0
470
638
  [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
  [![npm version](https://img.shields.io/npm/v/bun-app-window?color=cb3837&logo=npm)](https://www.npmjs.com/package/bun-app-window)
4
4
  [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.3-f472b6?logo=bun&logoColor=white)](https://bun.sh)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D7.0.2-3178C6?logo=typescript&logoColor=white)](#requirements--design-boundaries)
5
6
  [![Platform](https://img.shields.io/badge/Platform-Windows%20x64-0078D6?logo=windows&logoColor=white)](#requirements--design-boundaries)
6
7
  [![WebView2](https://img.shields.io/badge/WebView2-System%20Runtime-008080)](#requirements--design-boundaries)
7
8
  [![License](https://img.shields.io/badge/License-MIT-2ea44f)](./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
  [![npm version](https://img.shields.io/npm/v/bun-app-window?color=cb3837&logo=npm)](https://www.npmjs.com/package/bun-app-window)
4
4
  [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.3-f472b6?logo=bun&logoColor=white)](https://bun.sh)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D7.0.2-3178C6?logo=typescript&logoColor=white)](#系统要求与设计边界)
5
6
  [![Platform](https://img.shields.io/badge/Platform-Windows%20x64-0078D6?logo=windows&logoColor=white)](#系统要求与设计边界)
6
7
  [![WebView2](https://img.shields.io/badge/WebView2-System%20Runtime-008080)](#系统要求与设计边界)
7
8
  [![License](https://img.shields.io/badge/License-MIT-2ea44f)](./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
  [![npm version](https://img.shields.io/npm/v/bun-app-window?color=cb3837&logo=npm)](https://www.npmjs.com/package/bun-app-window)
4
4
  [![Bun](https://img.shields.io/badge/Bun-%3E%3D1.4.3-f472b6?logo=bun&logoColor=white)](https://bun.sh)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-%3E%3D7.0.2-3178C6?logo=typescript&logoColor=white)](#系統需求與設計邊界)
5
6
  [![Platform](https://img.shields.io/badge/Platform-Windows%20x64-0078D6?logo=windows&logoColor=white)](#系統需求與設計邊界)
6
7
  [![WebView2](https://img.shields.io/badge/WebView2-System%20Runtime-008080)](#系統需求與設計邊界)
7
8
  [![License](https://img.shields.io/badge/License-MIT-2ea44f)](./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/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.5.1",
4
+ "version": "0.6.1",
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;