Repository navigation
Commit b772078
feat(console): installable desktop PWA with an offline app shell and a push-ready Service Worker (#29326)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} -->
_Requested by **Steedos** · [project
thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbPHS2hBi91fybR5PLv1Jvm2)_
Fixes #29155
Clause-②: yes (widening)
Metadata-protocol: none
## 维护者速读
- **改了什么**:Console 生产构建现在在根目录产出 `service-worker.js`,并在 Console
自己的挂载路径(`/_console/`)下注册。它缓存应用外壳(HTML 入口 + 入口脚本),离线冷启动显示 Console
而不是浏览器错误页;`/api/` 请求一律直连网络,不读不写缓存。Worker 带 `push` / `notificationclick`
处理器(点击只打开 `/_console/` 内的路由),但不订阅推送。CLI 文档的 CSP 增加 `manifest-src 'self'
blob:`,让带品牌的安装清单能加载,Console 因此可安装。另有三处构建配置:`tsconfig.node.json` 加一行
include、`turbo.json` 三个任务各加一个输入、**`.github/workflows/ci.yml` 的 `core:`
路径过滤加一行 `ui/packages/mobile/src/serviceWorkerSource.ts`**(只加过滤行,不改任何
job、step 或触发器)。
- **为什么改**:桌面用户在浏览器里用 Console(产品决定不做原生桌面端)。此前没有任何代码注册 Service
Worker,装成应用后离线打开是错误页,也没有 worker 承接 Web Push;实测 CLI 的 CSP 还拒绝了 Console
注入的 blob 清单,线上 Console 根本不可安装。Console 的 `vite.config.ts` 从源码读取 mobile 包的
worker 生成器,所以 turbo 必须把这个文件算进 build/test/type-check
的缓存输入;`check:ci-filter-parity` 要求每个这样的 turbo 输入在 ci.yml 的 `core:`
过滤里有一行,否则只改这个文件的 PR 不会触发 Build Core / Test Core。改用包的子路径导出(不碰
ci.yml)已实测不可行:Test UI 和 Live E2E 两个 CI 作业不构建 mobile 的 dist,vitest 启动即失败。
- **风险与回滚**:新构建上线后的下一次导航即运行新构建(HTML 走网络优先,worker 用 `skipWaiting`
接管并删除旧构建缓存),旧外壳不会钉住用户。在原生壳(`getNativeHost()`)里不注册 worker,并注销之前注册过的。CSP
只多了 manifest 一条,blob URL 只能由已通过 `script-src` 的脚本创建。ci.yml 那一行只让更多 PR
触发核心作业,不会少跑。回滚即 revert 本 PR。
- **先看**:`ui/packages/mobile/src/serviceWorkerSource.ts` 的
`appShellSource()`(`bypasses()` 与 fetch 分支),然后
`ui/apps/console/src/main.tsx` 的 `syncServiceWorker()`,再看
`packages/cli/src/utils/console.ts` 的 `manifest-src` 一行和 ci.yml 的新增过滤行。
- **两分钟验证**:`pnpm --dir ui exec playwright test
e2e/console-service-worker.spec.ts --project=chromium`(需先以
`VITE_BASE_PATH=/console/` 构建
Console),两条用例通过:离线刷新显示登录页的「无法连接服务器」面板,模拟部署后一次导航即换到新构建、旧缓存被删。
- **需要你做的**:本 PR 触及受管文件 `.github/workflows/ci.yml`,需要维护者批准并合并;席位不会自行翻
ready 或入队。
## What changes
| File | Change |
|---|---|
| `ui/packages/mobile/src/serviceWorkerSource.ts` |
`getServiceWorkerSource` gains `strategy: 'offline-first' \|
'app-shell'`, `build` and `notifications`. The default `'offline-first'`
output is byte-identical to `main` (checked for the default and a fully
customised options object). `'app-shell'` needs `build`; `build` under
`'offline-first'` is refused, since that worker would ignore it. |
| `ui/packages/mobile/src/serviceWorker.ts` |
`cacheLoadedResources(worker)`: posts the page's resource-timing URLs to
an `'app-shell'` worker, which caches only same-origin, in-scope,
non-API files. |
| `ui/packages/mobile/src/index.ts` | Exports `cacheLoadedResources`,
`SERVICE_WORKER_CACHE_URLS_MESSAGE`, `ServiceWorkerStrategy`. |
| `ui/apps/console/vite.config.ts` | `emitServiceWorker()` emits
`service-worker.js` at the build root with the build id and the precache
list. |
| `ui/apps/console/src/main.tsx` | `syncServiceWorker()` registers the
worker under the Console's mount in production builds, or unregisters it
in the native shell. The generated manifest's `start_url`, `scope` and
icon are absolute URLs. |
| `packages/cli/src/utils/console.ts` | The Console document's policy
adds `manifest-src 'self' blob:`; the comment that said `default-src`
covered the manifest is corrected. |
| `packages/cli/src/utils/console.content-security-policy.test.ts` |
Pins the directive. |
| `ui/packages/mobile/src/__tests__/serviceWorkerSource.test.ts` | 25
cases running the generated worker against a fake worker global. |
| `ui/e2e/console-service-worker.spec.ts` | Offline reload and update
pickup on the production build. |
| `ui/apps/console/tsconfig.node.json` | Lists `serviceWorkerSource.ts`,
which `vite.config.ts` imports from source (TS6307 otherwise). |
| `turbo.json` | Hashes `ui/packages/mobile/src/serviceWorkerSource.ts`
in the `build`, `test` and `type-check` inputs, so a generator change
never replays a stale cache. |
| `.github/workflows/ci.yml` | One row in the `core:` paths filter for
the same file, which `check:ci-filter-parity` requires for every turbo
build and test input. No job, step or trigger changes. |
| `ui/packages/mobile/README.md`,
`.changeset/29155-console-desktop-pwa.md` | Docs and changeset. |
**Surface amendments (seat rulings on the card).** The claimed surface
was the mobile worker files, `main.tsx`, `vite.config.ts`, the manifest,
a Playwright spec and a changeset. Four amendments widened it: the CLI
policy and its pin (`packages/cli/src/utils/console.ts`,
`console.content-security-policy.test.ts`, comment 6573999866), the
`tsconfig.node.json` include (comment 6574807547), the three
`turbo.json` input entries (comment 6575431847), and the one `ci.yml`
filter row (comment 6575983994). Importing the generator through a
dependency-free subpath export of `@object-ui/mobile` instead was
measured and refused: Test UI and Live E2E UI build no `@object-ui`
dist, and the root vitest config loads the Console's `vite.config.ts`,
so every shard would fail at startup with "Failed to initialize projects
… Cannot find module …/@object-ui/mobile/dist/serviceWorkerSource.js".
`ui/apps/console/public/manifest.json` is unchanged.
**The `'app-shell'` worker.**
- **Scope.** Served from the Console's base directory, so its scope is
`/_console/`, never `/`. Every request outside the scope, and every
request under `/api/`, is network-only: the worker returns before
`respondWith`, so it never answers, reads or writes the cache for it,
and no cookie is stored.
- **Cache versioning by build.** The cache is
`objectui-console::SCOPE_PATH::BUILD`. Activation deletes every cache
with the same `objectui-console::SCOPE_PATH::` prefix except its own,
and nothing else on the origin. The build id is the console build name
(`VITE_APP_VERSION` or the git sha) plus a 12-hex digest of every
emitted file name and the HTML entry; hashed names change with their
bytes, so any changed chunk gives a byte-different worker.
- **Precache choice: Vite's emitted entry assets, plus cache-on-fetch.**
The precache is the entry chunk, its static-import closure and their CSS
(101 files on this build), plus the scope root (the HTML entry). Lazy
chunks are cached on fetch, and after registration the page posts the
files it loaded before the worker controlled it
(`cacheLoadedResources`), so the first visit's route chunks are there
for an offline start.
- **Strategies.** Navigations: network-first, the precached entry
offline. Hashed `assets/` files: cache-first. Other in-scope files
(favicon, manifest): network-first with the cached copy offline.
- **Update flow: `skipWaiting` + `clients.claim`, no reload prompt.**
The HTML entry is network-first, so the next navigation after a deploy
loads the new document and its new hashed chunks even before the new
worker activates. The new worker installs (precaching the new build),
activates at once and deletes the old build's cache. No shell can pin a
user.
- **Notifications.** `push` shows `{ title, body?, route?, tag? }` with
`route` in the notification's data. `notificationclick` resolves `route`
against the scope: it must pass the same in-app check as `isInAppRoute`
(one leading `/`, no `//`, no backslash, no control character), and the
URL-normalised result must still start with the scope path. A refused
route only closes the notification. An open, controlled Console window
is navigated and focused; otherwise a window is opened.
## Why
Desktop users run the Console in a browser. Nothing registered a Service
Worker, so an installed Console showed a browser error page when started
offline, and Web Push had no worker to reach. The served Console was
also not installable: measured before this change, Chromium refused the
Console's branded `blob:` manifest under the CLI's `default-src 'self'`
(see the security header note below).
**Security-header widening (`manifest-src 'self' blob:`).** Measured
with CDP `Page.getInstallabilityErrors` / `Page.getAppManifest` in full
Chromium 1243 (new headless, persistent profile) on a default-base build
served the way the CLI serves it (an injected base tag, `base
href="/_console/"`, nonce-stamped scripts, the policy's exact directive
list):
| Policy | Console message | Installability errors | Manifest |
|---|---|---|---|
| `main` (no `manifest-src`) | `Loading a manifest from 'blob:…'
violates … "default-src 'self'". Note that 'manifest-src' was not
explicitly set` | `manifest-parsing-or-network-error`, `no-manifest` |
none |
| with `manifest-src 'self' blob:` | none | none | `name` ObjectOS,
`start_url` and `scope` `http://127.0.0.1:PORT/_console/`, icon
`…/_console/favicon.svg` |
A `blob:` URL can only be minted by a script the page already runs,
which `script-src` governs; no other directive changes. A screenshot of
this check is on the card thread.
## Tests
Measured on the head of this PR (`b5658de9`, `main` at `a19759ac` merged
in):
- `node scripts/pm/dispatch-gates.mjs --ran FILE`: **98 derived, 98 run,
0 NOT-MEASURED, 0 UNRUN, every exit code 0.** The 33 families the
`ci.yml` row adds (workflow lints, filter parity, step-name checks) were
run at `b0ef42a0`; `check:ci-filter-parity`, `check:type-check-debt`,
`check-sdui-manifest` and `pnpm --dir ui exec vitest run scripts/` (169
files, 4903 tests) were re-run after the merge.
- `@object-ui/mobile` vitest: 115 passed (25 new in
`serviceWorkerSource.test.ts`).
- `@objectstack/cli` content-security-policy suite: 50 passed.
- `turbo run type-check` for `@object-ui/mobile` and
`@object-ui/console` with their dependencies: 72 of 72 tasks green;
Console `tsc` + `vite build` green.
- Playwright, full chromium project on the production Console build (at
`e4e73277`, before the config rows): 66 passed, 2 skipped (both skips
pre-existing), including the two new cases in
`console-service-worker.spec.ts`.
**Ablations** (each restored byte-identical afterwards):
| Removed | Result |
|---|---|
| the `apiPrefix` bypass in `bypasses()` | 2 tests red (including the
root-mount case, where the scope bypass cannot cover `/api/`) |
| the scope-prefix check on the click route | 2 tests red |
| activation keeps only caches with this scope's prefix (made to delete
every cache) | 1 test red |
## Acceptance notes
- **Offline reload after one load shows the Console shell, and data
calls fail visibly as offline:** `console-service-worker.spec.ts` case
1. The offline document is `fromServiceWorker()`, the sign-in screen's
`auth-server-unreachable` panel renders, a direct
`/api/v1/auth/get-session` fetch rejects with `TypeError`, and no
`/api/` URL is in the worker's cache.
- **A new build is picked up within one navigation:** case 2. A server
switches to a "next" build (marked document, worker with a new build
id); one navigation shows the marked document, and the caches end as
exactly one, the new build's.
- **Unit tests cover cache versioning, the API bypass, and the handlers,
including an escaping route:** 25 cases, ablations in Tests above.
- **Chrome installability check passes on the served Console:** the
table above.
- The e2e spec serves the build itself, the way the CLI does (with a
base tag), not through `vite preview`: without a base tag, the router's
basename is `/` and the app moves to `/login`, outside the worker's
scope.
- The push payload shape (`title`, `body`, `route`, `tag`) is
provisional; the delivery contract is #29041's. No push subscription,
device-registry call or push vendor is added here.
- A `getNativeHost()` bridge that lands after boot leaves that page
load's worker registered; the next load, which sees the bridge at boot,
unregisters it.
## Verification
- **Offline start and update pickup:** the two Playwright cases above,
on a production build served with the CLI's base tag.
- **Installability:** CDP before/after in the security-header table
above; the report and a screenshot of the served Console are on the card
thread.
- **Default worker unchanged:** `getServiceWorkerSource()` with no
options, and with a fully customised `'offline-first'` options object,
emits the same bytes as `main`.
- **Native shell:** `syncServiceWorker()` returns after unregistering
when `getNativeHost()` is set; it never registers there.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(not touched)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes. (CI runs it)
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (not touched)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 1b6709b commit b772078
14 files changed
Lines changed: 1089 additions & 29 deletions
File tree
- .changeset
- .github/workflows
- packages/cli/src/utils
- ui
- apps/console
- src
- e2e
- packages/mobile
- src
- __tests__
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
369 | 369 | | |
370 | 370 | | |
371 | 371 | | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
372 | 376 | | |
373 | 377 | | |
374 | 378 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
157 | 157 | | |
158 | 158 | | |
159 | 159 | | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
160 | 163 | | |
161 | 164 | | |
162 | 165 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
831 | 831 | | |
832 | 832 | | |
833 | 833 | | |
834 | | - | |
| 834 | + | |
835 | 835 | | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
| 841 | + | |
836 | 842 | | |
837 | 843 | | |
838 | 844 | | |
| |||
868 | 874 | | |
869 | 875 | | |
870 | 876 | | |
| 877 | + | |
871 | 878 | | |
872 | 879 | | |
873 | 880 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
| 25 | + | |
25 | 26 | | |
26 | 27 | | |
27 | 28 | | |
| |||
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
43 | | - | |
| 44 | + | |
| 45 | + | |
44 | 46 | | |
45 | 47 | | |
46 | 48 | | |
| |||
69 | 71 | | |
70 | 72 | | |
71 | 73 | | |
72 | | - | |
| 74 | + | |
| 75 | + | |
73 | 76 | | |
74 | 77 | | |
75 | 78 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | | - | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
13 | 19 | | |
14 | 20 | | |
15 | 21 | | |
16 | 22 | | |
17 | 23 | | |
18 | 24 | | |
| 25 | + | |
19 | 26 | | |
20 | 27 | | |
21 | 28 | | |
| |||
70 | 77 | | |
71 | 78 | | |
72 | 79 | | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
73 | 90 | | |
74 | 91 | | |
75 | 92 | | |
| |||
149 | 166 | | |
150 | 167 | | |
151 | 168 | | |
152 | | - | |
153 | | - | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
154 | 173 | | |
155 | | - | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
156 | 182 | | |
157 | 183 | | |
158 | 184 | | |
| |||
162 | 188 | | |
163 | 189 | | |
164 | 190 | | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
165 | 195 | | |
166 | 196 | | |
167 | 197 | | |
| |||
176 | 206 | | |
177 | 207 | | |
178 | 208 | | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
70 | 70 | | |
71 | 71 | | |
72 | 72 | | |
73 | | - | |
| 73 | + | |
| 74 | + | |
74 | 75 | | |
75 | 76 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
| 12 | + | |
12 | 13 | | |
13 | 14 | | |
14 | 15 | | |
| |||
27 | 28 | | |
28 | 29 | | |
29 | 30 | | |
| 31 | + | |
30 | 32 | | |
31 | 33 | | |
32 | 34 | | |
| |||
744 | 746 | | |
745 | 747 | | |
746 | 748 | | |
| 749 | + | |
| 750 | + | |
| 751 | + | |
| 752 | + | |
| 753 | + | |
| 754 | + | |
| 755 | + | |
| 756 | + | |
| 757 | + | |
| 758 | + | |
| 759 | + | |
| 760 | + | |
| 761 | + | |
| 762 | + | |
| 763 | + | |
| 764 | + | |
| 765 | + | |
| 766 | + | |
| 767 | + | |
| 768 | + | |
| 769 | + | |
| 770 | + | |
| 771 | + | |
| 772 | + | |
| 773 | + | |
| 774 | + | |
| 775 | + | |
| 776 | + | |
| 777 | + | |
| 778 | + | |
| 779 | + | |
| 780 | + | |
| 781 | + | |
| 782 | + | |
| 783 | + | |
| 784 | + | |
| 785 | + | |
| 786 | + | |
| 787 | + | |
| 788 | + | |
| 789 | + | |
| 790 | + | |
| 791 | + | |
| 792 | + | |
| 793 | + | |
| 794 | + | |
| 795 | + | |
| 796 | + | |
| 797 | + | |
| 798 | + | |
| 799 | + | |
| 800 | + | |
| 801 | + | |
| 802 | + | |
| 803 | + | |
| 804 | + | |
| 805 | + | |
| 806 | + | |
| 807 | + | |
| 808 | + | |
| 809 | + | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
747 | 813 | | |
748 | 814 | | |
749 | 815 | | |
| |||
810 | 876 | | |
811 | 877 | | |
812 | 878 | | |
| 879 | + | |
| 880 | + | |
813 | 881 | | |
814 | 882 | | |
815 | 883 | | |
| |||
0 commit comments