Repository navigation
feat(console): installable desktop PWA with an offline app shell and a push-ready Service Worker - #29326
Conversation
…API and push handlers getServiceWorkerSource gains strategy 'app-shell' (build-versioned cache, network-only API, network-first HTML entry, cache-first hashed assets) and notifications (push and notificationclick, in-app routes only). The default 'offline-first' output is byte-identical. The Console's vite config emits the worker at the build root, named after the build. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
…and serve the e2e like the CLI The Console links its branded web app manifest as a blob: URL, which default-src 'self' refused, so the served Console was not installable. The Playwright service-worker spec serves the build with the CLI's base tag, so routes stay inside the worker's scope. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
…de the scope Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
…-detgab # Conflicts: # ui/packages/mobile/README.md
…e's mount Production builds register service-worker.js with the Console's own scope, and post the files the first load fetched so an offline start finds them. In the native shell no worker is registered and an earlier one is removed. The generated manifest's start_url, scope and icon are absolute URLs, since a Blob URL is no base to resolve ./ against. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
vite.config.ts imports the dependency-free generator from source, and the composite tsconfig.node.json refuses a file outside its include (TS6307). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
… inputs The Console's vite config reads ui/packages/mobile/src/serviceWorkerSource.ts from outside the package; without it in the inputs, turbo replays a stale verdict when the generator changes. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
turbo.json now hashes ui/packages/mobile/src/serviceWorkerSource.ts as a build and test input, and check:ci-filter-parity requires every such input to start the core jobs on a PR. Filter row only; no job, step or trigger change. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 35 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin cee6c24c77936486901feadeb276f02537c67cd2 && git checkout cee6c24c77936486901feadeb276f02537c67cd2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2f7977cd2f25d878f31942dae6e229af9ff316d3 55e3e5e501fa69124b14cfa9e536b78fd5845afd && git checkout -B drift-repro 2f7977cd2f25d878f31942dae6e229af9ff316d3 && git merge --no-ff 55e3e5e501fa69124b14cfa9e536b78fd5845afd
node scripts/docs-audit/affected-docs.mjs --json 2f7977cd2f25d878f31942dae6e229af9ff316d3 |
Consumer acceptance (mobile shell side), head
|
… the worker docs The new serviceWorkerSource test carried an eslint-disable for no-new-func that the mobile lint config never reports, and the README's app-shell snippet read process.env, which the doc snippet type check compiles without node types. Both turned Lint UI red. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QsR2jQhk3S1XeTLgXSkEMt
|
director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw Contract-review-claim: session_01QsR2jV5sBSzCYNEDgpYLJw · head 55e3e5e · 2026-11-02T12:55Z Generated by Claude Code |
|
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y Contract reviewServed-tier: Face: declaration, for the diff outside the governed hit. The PR body and the changeset declare ① Derived judgments
② Semver levelThe changeset entries are ③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
director#1 · zhuangjianguo · session_01QsR2jV5sBSzCYNEDgpYLJw Contract reviewServed-tier: Read-only review of the committed tree at the head; the check-runs on it (56 success, 7 skipped, 0 failure) are the gate verdicts. The ① Derived judgments
② Semver level
③ Boundary flags
④ Design judgment
No must-fix found. One should-fix under ③, outside the Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y 维护者速读
席位意见:建议合并。总监合同复审 PASS(6577669391),席位 席位核对 · ACCEPT · 2026-11-02T12:56ZHead-sha:
Generated by Claude Code |
|
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y Landed · 2026-11-02T13:43Z · PR #29326 (Tier H) merged by os-zhuang at 13:36:52Z as
The card closed through Generated by Claude Code |
Requested by Steedos · project thread
Fixes #29155
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
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 或触发器)。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 启动即失败。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),两条用例通过:离线刷新显示登录页的「无法连接服务器」面板,模拟部署后一次导航即换到新构建、旧缓存被删。.github/workflows/ci.yml,需要维护者批准并合并;席位不会自行翻 ready 或入队。What changes
ui/packages/mobile/src/serviceWorkerSource.tsgetServiceWorkerSourcegainsstrategy: 'offline-first' | 'app-shell',buildandnotifications. The default'offline-first'output is byte-identical tomain(checked for the default and a fully customised options object).'app-shell'needsbuild;buildunder'offline-first'is refused, since that worker would ignore it.ui/packages/mobile/src/serviceWorker.tscacheLoadedResources(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.tscacheLoadedResources,SERVICE_WORKER_CACHE_URLS_MESSAGE,ServiceWorkerStrategy.ui/apps/console/vite.config.tsemitServiceWorker()emitsservice-worker.jsat the build root with the build id and the precache list.ui/apps/console/src/main.tsxsyncServiceWorker()registers the worker under the Console's mount in production builds, or unregisters it in the native shell. The generated manifest'sstart_url,scopeand icon are absolute URLs.packages/cli/src/utils/console.tsmanifest-src 'self' blob:; the comment that saiddefault-srccovered the manifest is corrected.packages/cli/src/utils/console.content-security-policy.test.tsui/packages/mobile/src/__tests__/serviceWorkerSource.test.tsui/e2e/console-service-worker.spec.tsui/apps/console/tsconfig.node.jsonserviceWorkerSource.ts, whichvite.config.tsimports from source (TS6307 otherwise).turbo.jsonui/packages/mobile/src/serviceWorkerSource.tsin thebuild,testandtype-checkinputs, so a generator change never replays a stale cache..github/workflows/ci.ymlcore:paths filter for the same file, whichcheck:ci-filter-parityrequires for every turbo build and test input. No job, step or trigger changes.ui/packages/mobile/README.md,.changeset/29155-console-desktop-pwa.mdSurface 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), thetsconfig.node.jsoninclude (comment 6574807547), the threeturbo.jsoninput entries (comment 6575431847), and the oneci.ymlfilter row (comment 6575983994). Importing the generator through a dependency-free subpath export of@object-ui/mobileinstead was measured and refused: Test UI and Live E2E UI build no@object-uidist, and the root vitest config loads the Console'svite.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.jsonis unchanged.The
'app-shell'worker./_console/, never/. Every request outside the scope, and every request under/api/, is network-only: the worker returns beforerespondWith, so it never answers, reads or writes the cache for it, and no cookie is stored.objectui-console::SCOPE_PATH::BUILD. Activation deletes every cache with the sameobjectui-console::SCOPE_PATH::prefix except its own, and nothing else on the origin. The build id is the console build name (VITE_APP_VERSIONor 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.cacheLoadedResources), so the first visit's route chunks are there for an offline start.assets/files: cache-first. Other in-scope files (favicon, manifest): network-first with the cached copy offline.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.pushshows{ title, body?, route?, tag? }withroutein the notification's data.notificationclickresolvesrouteagainst the scope: it must pass the same in-app check asisInAppRoute(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'sdefault-src 'self'(see the security header note below).Security-header widening (
manifest-src 'self' blob:). Measured with CDPPage.getInstallabilityErrors/Page.getAppManifestin 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):main(nomanifest-src)Loading a manifest from 'blob:…' violates … "default-src 'self'". Note that 'manifest-src' was not explicitly setmanifest-parsing-or-network-error,no-manifestmanifest-src 'self' blob:nameObjectOS,start_urlandscopehttp://127.0.0.1:PORT/_console/, icon…/_console/favicon.svgA
blob:URL can only be minted by a script the page already runs, whichscript-srcgoverns; no other directive changes. A screenshot of this check is on the card thread.Tests
Measured on the head of this PR (
b5658de9,mainata19759acmerged 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 theci.ymlrow adds (workflow lints, filter parity, step-name checks) were run atb0ef42a0;check:ci-filter-parity,check:type-check-debt,check-sdui-manifestandpnpm --dir ui exec vitest run scripts/(169 files, 4903 tests) were re-run after the merge.@object-ui/mobilevitest: 115 passed (25 new inserviceWorkerSource.test.ts).@objectstack/clicontent-security-policy suite: 50 passed.turbo run type-checkfor@object-ui/mobileand@object-ui/consolewith their dependencies: 72 of 72 tasks green; Consoletsc+vite buildgreen.e4e73277, before the config rows): 66 passed, 2 skipped (both skips pre-existing), including the two new cases inconsole-service-worker.spec.ts.Ablations (each restored byte-identical afterwards):
apiPrefixbypass inbypasses()/api/)Acceptance notes
console-service-worker.spec.tscase 1. The offline document isfromServiceWorker(), the sign-in screen'sauth-server-unreachablepanel renders, a direct/api/v1/auth/get-sessionfetch rejects withTypeError, and no/api/URL is in the worker's cache.vite preview: without a base tag, the router's basename is/and the app moves to/login, outside the worker's scope.title,body,route,tag) is provisional; the delivery contract is Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041's. No push subscription, device-registry call or push vendor is added here.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
getServiceWorkerSource()with no options, and with a fully customised'offline-first'options object, emits the same bytes asmain.syncServiceWorker()returns after unregistering whengetNativeHost()is set; it never registers there.Human Minimum
packages/specholds no runtime logic. (not touched)pnpm lintpasses. (CI runs it)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (not touched)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code