From 535dfd9a11dc113be8e98a4fe4e23f13cb8ec8fe Mon Sep 17 00:00:00 2001 From: Jabrail <78273416+jabrailkhalil@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:23:58 +0000 Subject: [PATCH] feat(web-shell): add PWA installability and Android development shell (#11722) * feat(web-shell): add installable PWA support * fix(build): skip incomplete Web Shell assets when bundling * feat(web-shell): declare browser support matrix and compat fallbacks * feat(web-shell): make the Web Shell installable as a PWA Serves /manifest.webmanifest and /sw.js pre-auth, registers the worker deferred to load, and replaces the earlier service-worker.js draft with sw.js as a Vite entry (PNG icons included). * feat(mobile-shell): add Android WebView shell skeleton WebView shell around the daemon-served Web Shell (no second UI), profile tuples of URL/token/display name with #token= fragment delivery, native WebView version check at the Chrome 107 floor, and a foreground-service skeleton for SSE/notifications. * chore(web-shell): drop superseded service worker draft files * test(web-shell): cover service worker routing and lifecycle * chore(web-shell): align @playwright/test with main lockfile * chore: register mobile-shell importer in pnpm lockfile * test(web-shell): parse sidebar stylesheet with postcss for @supports nesting * chore: register mobile-shell in pnpm rewrite set * fix(web-shell): address PWA and Android acceptance blockers * docs(mobile-shell): make remaining native acceptance limits explicit * fix(web-shell): address fourth PWA review round * fix(mobile-shell): restore web view on back --------- Co-authored-by: Shaojin Wen --- .github/workflows/.size-baseline | 1 + .github/workflows/mobile-shell.yml | 48 ++++ .github/workflows/pnpm-worktree-smoke.yml | 2 + docs/design/mobile-android-shell.md | 58 ++++ docs/design/mobile-android-shell.zh-CN.md | 54 ++++ docs/design/web-shell-pwa-installability.md | 63 +++++ .../web-shell-pwa-installability.zh-CN.md | 63 +++++ docs/developers/daemon/02-serve-runtime.md | 30 +-- docs/developers/daemon/12-auth-security.md | 5 +- docs/developers/daemon/17-configuration.md | 4 + .../daemon/20-quickstart-operations.md | 2 +- docs/developers/qwen-serve-protocol.md | 4 +- docs/users/qwen-serve.md | 47 +++- package-lock.json | 3 +- package.json | 3 +- .../cli/src/serve/server/self-origin.test.ts | 6 + .../cli/src/serve/web-shell-preauth.test.ts | 49 ++++ packages/cli/src/serve/web-shell-preauth.ts | 12 +- .../cli/src/serve/web-shell-static.test.ts | 149 ++++++++++- packages/cli/src/serve/web-shell-static.ts | 65 ++++- packages/mobile-shell/.gitignore | 3 + packages/mobile-shell/README.md | 58 ++++ packages/mobile-shell/app/build.gradle.kts | 39 +++ .../app/src/main/AndroidManifest.xml | 31 +++ .../java/com/qwen/mobileshell/MainActivity.kt | 174 ++++++++++++ .../java/com/qwen/mobileshell/OriginPolicy.kt | 34 +++ .../app/src/main/res/values/strings.xml | 5 + .../app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + .../main/res/xml/network_security_config.xml | 44 +++ .../com/qwen/mobileshell/OriginPolicyTest.kt | 27 ++ packages/mobile-shell/build.gradle.kts | 4 + packages/mobile-shell/gradle.properties | 1 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes .../gradle/wrapper/gradle-wrapper.jar.sha256 | 1 + .../gradle/wrapper/gradle-wrapper.properties | 8 + packages/mobile-shell/gradlew | 251 ++++++++++++++++++ packages/mobile-shell/gradlew.bat | 94 +++++++ packages/mobile-shell/package.json | 6 + packages/mobile-shell/settings.gradle.kts | 16 ++ packages/sdk-typescript/src/daemon/index.ts | 4 + .../sdk-typescript/src/daemon/pwa-assets.ts | 22 ++ packages/web-shell/README.md | 16 ++ packages/web-shell/client/App.module.css | 14 +- .../web-shell/client/build-artifact.test.ts | 21 ++ .../client/components/ChatEditor.module.css | 24 +- .../client/components/Editor.module.css | 1 + .../components/GoalStatusStrip.module.css | 12 +- .../client/components/MessageList.module.css | 109 ++++---- .../agents/AgentsManagerPage.module.css | 30 ++- .../artifacts/ArtifactPanel.module.css | 1 + .../channels/ChannelEditorDialog.module.css | 8 +- .../channels/ChannelsManagerPage.module.css | 72 ++--- .../components/dialogs/DialogShell.module.css | 185 +------------ .../components/dialogs/DialogShell.test.tsx | 38 ++- .../client/components/dialogs/DialogShell.tsx | 7 +- .../ExtensionsManagerPage.module.css | 30 ++- .../components/mcp/McpManagerPage.module.css | 30 ++- .../messages/AskUserQuestion.module.css | 8 +- .../messages/AssistantMessage.module.css | 1 + .../messages/EnhancedMarkdownTable.module.css | 58 ++-- .../messages/PlanExecutionView.module.css | 34 +-- .../messages/WorkflowExecutionView.module.css | 60 +++-- .../sidebar/WebShellSidebar.css.test.ts | 49 +++- .../sidebar/WebShellSidebar.module.css | 73 +++-- .../skills/SkillsManagerPage.module.css | 30 ++- .../web-shell/client/index-html-boot.test.ts | 88 +++++- packages/web-shell/client/index.html | 79 +++++- packages/web-shell/client/main-boot.test.tsx | 19 ++ packages/web-shell/client/main.tsx | 22 +- .../client/public/assets/icon-192.png | Bin 0 -> 22445 bytes .../client/public/assets/icon-512.png | Bin 0 -> 75952 bytes .../web-shell/client/public/assets/icon.svg | 6 + .../client/public/manifest.webmanifest | 38 +++ packages/web-shell/client/pwa-assets.test.ts | 65 +++++ .../web-shell/client/pwa-registration.test.ts | 51 ++++ packages/web-shell/client/pwa-registration.ts | 36 +++ .../web-shell/client/service-worker.test.ts | 187 +++++++++++++ packages/web-shell/client/sw.js | 102 +++++++ packages/web-shell/vite.config.ts | 13 + pnpm-workspace.yaml | 1 + scripts/check-desktop-isolation.js | 59 ++-- scripts/copy_bundle_assets.js | 8 + scripts/prepare-package.js | 2 + scripts/tests/package-assets.test.js | 105 ++++++++ scripts/tests/package-scripts.test.js | 1 + 86 files changed, 2772 insertions(+), 524 deletions(-) create mode 100644 .github/workflows/mobile-shell.yml create mode 100644 docs/design/mobile-android-shell.md create mode 100644 docs/design/mobile-android-shell.zh-CN.md create mode 100644 docs/design/web-shell-pwa-installability.md create mode 100644 docs/design/web-shell-pwa-installability.zh-CN.md create mode 100644 packages/cli/src/serve/web-shell-preauth.test.ts create mode 100644 packages/mobile-shell/.gitignore create mode 100644 packages/mobile-shell/README.md create mode 100644 packages/mobile-shell/app/build.gradle.kts create mode 100644 packages/mobile-shell/app/src/main/AndroidManifest.xml create mode 100644 packages/mobile-shell/app/src/main/java/com/qwen/mobileshell/MainActivity.kt create mode 100644 packages/mobile-shell/app/src/main/java/com/qwen/mobileshell/OriginPolicy.kt create mode 100644 packages/mobile-shell/app/src/main/res/values/strings.xml create mode 100644 packages/mobile-shell/app/src/main/res/xml/backup_rules.xml create mode 100644 packages/mobile-shell/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 packages/mobile-shell/app/src/main/res/xml/network_security_config.xml create mode 100644 packages/mobile-shell/app/src/test/java/com/qwen/mobileshell/OriginPolicyTest.kt create mode 100644 packages/mobile-shell/build.gradle.kts create mode 100644 packages/mobile-shell/gradle.properties create mode 100644 packages/mobile-shell/gradle/wrapper/gradle-wrapper.jar create mode 100644 packages/mobile-shell/gradle/wrapper/gradle-wrapper.jar.sha256 create mode 100644 packages/mobile-shell/gradle/wrapper/gradle-wrapper.properties create mode 100755 packages/mobile-shell/gradlew create mode 100644 packages/mobile-shell/gradlew.bat create mode 100644 packages/mobile-shell/package.json create mode 100644 packages/mobile-shell/settings.gradle.kts create mode 100644 packages/sdk-typescript/src/daemon/pwa-assets.ts create mode 100644 packages/web-shell/client/public/assets/icon-192.png create mode 100644 packages/web-shell/client/public/assets/icon-512.png create mode 100644 packages/web-shell/client/public/assets/icon.svg create mode 100644 packages/web-shell/client/public/manifest.webmanifest create mode 100644 packages/web-shell/client/pwa-assets.test.ts create mode 100644 packages/web-shell/client/pwa-registration.test.ts create mode 100644 packages/web-shell/client/pwa-registration.ts create mode 100644 packages/web-shell/client/service-worker.test.ts create mode 100644 packages/web-shell/client/sw.js diff --git a/.github/workflows/.size-baseline b/.github/workflows/.size-baseline index 5869bf417a..a90e896c36 100644 --- a/.github/workflows/.size-baseline +++ b/.github/workflows/.size-baseline @@ -30,6 +30,7 @@ 11394 finalize-release.yml 16647 live-host-release.yml 5950 live-host.yml +1343 mobile-shell.yml 7642 main-ci-failure-issue.yml 1686 npm-cache.yml 2709 pnpm-lock-freshness.yml diff --git a/.github/workflows/mobile-shell.yml b/.github/workflows/mobile-shell.yml new file mode 100644 index 0000000000..38b8a78904 --- /dev/null +++ b/.github/workflows/mobile-shell.yml @@ -0,0 +1,48 @@ +name: 'Android Mobile Shell' + +on: + pull_request: + paths: + - '.github/workflows/mobile-shell.yml' + - 'packages/mobile-shell/**' + - 'scripts/check-desktop-isolation.js' + push: + branches: + - 'main' + paths: + - '.github/workflows/mobile-shell.yml' + - 'packages/mobile-shell/**' + - 'scripts/check-desktop-isolation.js' + workflow_dispatch: + +permissions: + contents: 'read' + +jobs: + validate: + name: 'Build, unit tests, and lint' + runs-on: 'ubuntu-latest' + timeout-minutes: 20 + defaults: + run: + working-directory: 'packages/mobile-shell' + steps: + - name: 'Checkout' + uses: 'actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10' # v6.0.3 + + - name: 'Set up Java 17' + uses: 'actions/setup-java@cf277c60eb25467037889841efdb72551f06f6c3' # v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: 'Verify Gradle wrapper' + shell: 'bash' + run: | + expected="$(tr -d '[:space:]' < gradle/wrapper/gradle-wrapper.jar.sha256)" + actual="$(sha256sum gradle/wrapper/gradle-wrapper.jar | cut -d ' ' -f1)" + test "$actual" = "$expected" + + - name: 'Build, run unit tests, and lint' + shell: 'bash' + run: './gradlew --no-daemon assembleDebug testDebugUnitTest lintDebug' diff --git a/.github/workflows/pnpm-worktree-smoke.yml b/.github/workflows/pnpm-worktree-smoke.yml index 6323ac1360..d654a58598 100644 --- a/.github/workflows/pnpm-worktree-smoke.yml +++ b/.github/workflows/pnpm-worktree-smoke.yml @@ -13,6 +13,7 @@ on: - 'packages/*/package.json' - '!packages/desktop-shell/package.json' - '!packages/live-host/package.json' + - '!packages/mobile-shell/package.json' - 'packages/channels/*/package.json' - 'integrations/*/package.json' - 'patches/**' @@ -36,6 +37,7 @@ on: - 'packages/*/package.json' - '!packages/desktop-shell/package.json' - '!packages/live-host/package.json' + - '!packages/mobile-shell/package.json' - 'packages/channels/*/package.json' - 'integrations/*/package.json' - 'patches/**' diff --git a/docs/design/mobile-android-shell.md b/docs/design/mobile-android-shell.md new file mode 100644 index 0000000000..a6e86316a7 --- /dev/null +++ b/docs/design/mobile-android-shell.md @@ -0,0 +1,58 @@ +# Android Mobile Shell (Technical Spike) + +[English](mobile-android-shell.md) | [简体中文](mobile-android-shell.zh-CN.md) + +Status: development-only spike under review, following [issue #11704](https://github.com/QwenLM/qwen-code/issues/11704). It is not a production mobile client. + +## Problem and Goals + +Use the existing daemon-served Web Shell in an Android WebView, with no second native session UI and no locally bundled H5. Establish a buildable native bootstrap and a precise origin boundary before adding production credentials or background connectivity. + +## Implemented Scope + +- One development profile in `qwen_profiles` SharedPreferences: `daemon_url` and optional `daemon_token`. There is no profile editor, stable profile key or multi-profile switching yet. +- Validate that the saved URL is an HTTP(S) origin with no user information, non-root path, query or fragment. Pass an encoded token via `#token=`. The fragment is absent from the navigation request; the Web Shell subsequently sends the token in authenticated API requests. +- Compare parsed scheme, host and effective port for in-WebView navigation. Similar domain prefixes, user information and different ports are not same-origin. Only HTTP(S) and mailto external main-frame links may launch another app; missing handlers and device restrictions do not crash the activity. +- Check the WebView provider before constructing a WebView. Missing or pre-111 providers get a native update message. Missing/invalid profiles and connection failures also use native bootstrap messages; a connection failure offers Retry. +- Keep browser back history and zoom support. Disable file/content access and mixed content. Globally reject cleartext, with explicit loopback exceptions in the network security configuration. +- Exclude development credentials from cloud backup and device transfer. `allowBackup=false` is retained as well. +- Exclude this Gradle package from both npm and pnpm workspaces. Build using JDK 17, Android SDK 34 and the pinned Gradle 8.2.1 wrapper with its distribution checksum. + +## Design Decisions + +The WebView navigates to the daemon origin, so direct same-origin HTTP API behavior applies without `--allow-origin`. Reverse proxies and remote terminal/voice WebSocket upgrades still follow the origin requirements documented in [qwen serve](../users/qwen-serve.md). Chrome/WebView 111 is the Web Shell's CSS support floor, derived from Tailwind v4, independently of the ES2021 JavaScript target. + +A static daemon token (`--token` or `QWEN_SERVER_TOKEN`) avoids restart invalidation during development. SharedPreferences storage is a temporary development mechanism, not Keystore-backed production security. HTTPS is the intended remote connection path. Additional HTTP LAN hosts require explicit network-security entries. + +`adb reverse` makes a host loopback daemon reachable from the device loopback +port. It must never forward a token-less daemon to a physical device: any app +on that device can reach the port and act as the daemon user. Physical-device +testing requires `--require-auth` with a bearer token saved as `daemon_token`; +for example, `QWEN_SERVER_TOKEN=$(openssl rand -hex 32) qwen serve --require-auth`. + +No foreground service starts in this spike. A service with no SSE connection would merely consume resources and show a misleading persistent notification, so its placeholder and permissions are deferred. + +## Constraints and Production Prerequisites + +Per-device revocable daemon credentials are a maintainer-owned prerequisite. This spike must not be presented as resolving that requirement. Android Keystore storage alone cannot make a shared static bearer revocable per device. + +Phase 2 must implement N profiles with client-minted stable keys and display names; switching profiles navigates to a new origin. Native cached workspace state must be keyed by profile and workspace ID, and capability checks must be performed per profile and connection. Migration must remove the development plaintext token after moving it into the approved credential store. + +File chooser, microphone permission bridging, download handling, new-window handling, renderer-process recovery, lifecycle-aware SSE and notifications are not implemented here. System font-scale integration and complete pinch-zoom/accessibility acceptance also remain follow-ups. Browser H5 availability does not imply those native integrations already work. + +## Reviewer Test Plan + +1. Build the debug APK and run the JVM origin-policy tests using the committed wrapper. +2. On an emulator/device with WebView 111+, provision a development profile and confirm the daemon-served UI loads. Check that the navigation URL sent to the daemon has no token fragment. +3. Verify same-origin links stay in WebView; suffix domains, userinfo tricks and changed ports do not. External links without a matching app must not crash. +4. Launch without a profile or usable WebView provider; expect a readable native message, without relying on web rendering. +5. Stop the daemon, navigate, then restore it and press Retry. Confirm main-frame errors offer recovery and subresource errors do not replace the entire UI. +6. Confirm no foreground service or notification runs when opening or closing the activity. + +## Validation Evidence + +JVM tests cover origin comparison, saved-root validation and external scheme restrictions. Build results and exact revisions belong in the PR verification report. Emulator/device interaction and provider-update behavior require device evidence and are not claimed as completed by this design. + +## Follow-ups + +Production profile UI and credential migration; maintainer-provided per-device revocation; Keystore-backed storage; capability negotiation; lifecycle-aware SSE and native notifications with runtime permission handling; file selection, microphone, downloads, new windows and renderer recovery; font scaling and accessibility acceptance. diff --git a/docs/design/mobile-android-shell.zh-CN.md b/docs/design/mobile-android-shell.zh-CN.md new file mode 100644 index 0000000000..828d1df3e1 --- /dev/null +++ b/docs/design/mobile-android-shell.zh-CN.md @@ -0,0 +1,54 @@ +# Android 移动端 Shell(技术验证) + +[English](mobile-android-shell.md) | [简体中文](mobile-android-shell.zh-CN.md) + +状态:遵循 [issue #11704](https://github.com/QwenLM/qwen-code/issues/11704) 的开发用途技术验证,正在评审。它不是生产级移动客户端。 + +## 问题与目标 + +在 Android WebView 中使用 daemon 已提供的 Web Shell,不创建第二套原生会话 UI,也不本地打包 H5。在添加生产凭据和后台连接前,建立可构建的原生启动流程和准确的 origin 边界。 + +## 已实现范围 + +- `qwen_profiles` SharedPreferences 中的一个开发配置:`daemon_url` 和可选 `daemon_token`。尚无配置编辑器、稳定配置键或多配置切换。 +- 保存的 URL 必须是 HTTP(S) origin,不能包含用户信息、非根路径、查询或 fragment。编码后的令牌通过 `#token=` 传入;fragment 不包含在导航请求中,Web Shell 随后会在已认证 API 请求中发送令牌。 +- WebView 内导航比较解析后的 scheme、host 和有效端口。相似域名前缀、用户信息和不同端口不属于同源。仅 HTTP(S) 和 mailto 的外部主框架链接可打开其他应用;缺少处理程序或设备策略限制不使 Activity 崩溃。 +- 在创建 WebView 前检查 provider。缺失或低于 111 的 provider 显示原生升级提示。缺少/无效配置和连接失败也显示原生启动提示;连接失败提供 Retry。 +- 保留浏览器返回历史和缩放,禁用 file/content 访问及 mixed content。网络安全配置全局拒绝明文,仅明确允许 loopback。 +- 云备份和设备迁移均排除开发凭据,同时保留 `allowBackup=false`。 +- 从 npm 和 pnpm workspaces 中排除这个 Gradle 包。使用 JDK 17、Android SDK 34,以及固定版本且含分发校验和的 Gradle 8.2.1 wrapper 构建。 + +## 设计决策 + +WebView 直接导航到 daemon origin,因此沿用同源 API 行为,直接同源 HTTP API 无需 `--allow-origin`。反向代理和远程 terminal/voice WebSocket 连接仍须遵守 [qwen serve](../users/qwen-serve.md) 的 origin 要求。Chrome/WebView 111 是基于 Tailwind v4 的 CSS 最低版本,与 ES2021 JavaScript target 独立。 + +开发时静态 daemon 令牌(`--token` 或 `QWEN_SERVER_TOKEN`)避免重启后失效。SharedPreferences 只是临时开发方案,并不具备 Keystore 支持的生产凭据保护。远程连接应使用 HTTPS;额外的 HTTP 局域网主机需要显式网络安全配置。 + +`adb reverse` 会让宿主机的回环 daemon 可从设备回环端口访问。绝不能把无 token 的 daemon 转发到真机:设备上的任何 app 都可以连接该端口,并以 daemon 用户身份执行操作。真机测试必须使用 `--require-auth`,并把 bearer token 保存为 `daemon_token`;例如:`QWEN_SERVER_TOKEN=$(openssl rand -hex 32) qwen serve --require-auth`。 + +本技术验证不启动前台服务。没有 SSE 连接的服务只会消耗资源并显示误导性的持续通知,因此占位服务及其权限推迟到后续阶段。 + +## 约束与生产前提 + +Daemon 的按设备可撤销凭据属于维护者负责的前提,不能将本技术验证描述为已经解决。仅使用 Android Keystore 并不能使共享静态 bearer 按设备撤销。 + +第二阶段需要 N 个配置,包含客户端生成的稳定键和显示名;切换时导航到新的 origin。原生工作区缓存必须按配置和工作区 ID 隔离,capability 检查必须按配置和连接执行。迁移至批准的凭据存储后,必须删除开发阶段明文令牌。 + +文件选择器、麦克风权限桥接、下载、新窗口处理、渲染进程恢复、生命周期感知 SSE 和通知尚未实现。系统字体缩放集成及完整的双指缩放和无障碍验收也属于后续工作。H5 能在浏览器运行,不意味着这些原生集成已经可用。 + +## 评审测试计划 + +1. 使用提交的 wrapper 构建 debug APK 并运行 JVM origin-policy 测试。 +2. 在 WebView 111+ 模拟器/真机上配置开发 origin,确认加载 daemon 提供的 UI,并确认发往 daemon 的导航 URL 不含令牌 fragment。 +3. 同源链接保留在 WebView;域名后缀、userinfo 和不同端口不视为同源。没有对应应用的外链不能造成崩溃。 +4. 无配置或可用 WebView provider 时启动,应看到不依赖网页渲染的原生提示。 +5. 停止 daemon 后导航,恢复服务并点击 Retry。主框架错误提供恢复入口;子资源错误不替换整个 UI。 +6. 打开或关闭 Activity 时均不运行前台服务或通知。 + +## 验证证据 + +JVM 测试覆盖 origin 比较、配置根 URL 验证和外链 scheme 限制。构建结果及准确版本记录在 PR 验证报告中。模拟器/真机交互和 provider 升级行为需要设备证据,本文不声明已经完成。 + +## 后续工作 + +生产配置 UI 和凭据迁移;维护者提供按设备撤销;Keystore 存储;capability 协商;生命周期感知 SSE、原生通知及运行时权限;文件选择、麦克风、下载、新窗口及渲染进程恢复;字体缩放和无障碍验收。 diff --git a/docs/design/web-shell-pwa-installability.md b/docs/design/web-shell-pwa-installability.md new file mode 100644 index 0000000000..db9e651967 --- /dev/null +++ b/docs/design/web-shell-pwa-installability.md @@ -0,0 +1,63 @@ +# Web Shell PWA Installability and Browser Compatibility + +[English](web-shell-pwa-installability.md) | [简体中文](web-shell-pwa-installability.zh-CN.md) + +Status: implementation under review. Follows the optional PWA direction in [issue #11704](https://github.com/QwenLM/qwen-code/issues/11704). + +## Problem + +The daemon-served Web Shell needs install metadata and an explicit browser support contract. An unsupported engine must show an update message instead of a blank page. Publishing the CLI must preserve the PWA files emitted by the Web Shell build. + +## Goals + +- Add same-origin install metadata and a service worker to the standalone shell. +- Declare a floor that covers both JavaScript and the generated CSS. +- Keep daemon state, tokens, event streams and HTML out of worker caches. + +## Out of Scope + +Offline sessions, Web Push delivery, notifications after closing the app, and polyfills for engines below the declared floor. The embedded component library does not register a service worker or install the standalone browser guard. + +## Design + +### Build and public routes + +Vite emits the standalone UI to `packages/web-shell/dist`, including `index.html`, `manifest.webmanifest`, `sw.js` and `assets/`. The worker is a second entry with no imports or exports; although Rollup uses ES output, this entry remains usable as a classic worker. The bundle copier preserves both PWA files under `dist/web-shell`; the package release gate rejects missing files. + +The daemon serves GET/HEAD requests for the manifest and worker before bearer authentication. These process-global assets carry no workspace data or credentials. The cold-daemon predicate mirrors Express's case-insensitive paths and optional trailing slash. Manifest responses use `application/manifest+json`; worker responses use `application/javascript` and `Service-Worker-Allowed: /`. Both use `no-cache` and `nosniff`; missing files return 404 rather than shell HTML. + +### Worker lifetime and caching + +Only the production standalone entry registers `/sw.js` after page load. Registration failure leaves ordinary online use available. HTTPS or a trustworthy loopback origin is required; arbitrary plain-HTTP LAN origins do not support registration. + +The worker activates immediately using `skipWaiting` and `clients.claim`. It removes older `qwen-code-shell-*` caches while preserving unrelated caches. The current namespace contains the package version; content-hashed chunk filenames distinguish builds made with the same version. + +Only same-origin build assets are cache-first. Unhashed `icon.svg` and `icon-*.png` files bypass the worker cache and receive HTTP revalidation headers. The manifest also stays on the browser network path, so same-version deployments can update it. Non-GET, cross-origin, authorized and SSE requests are untouched. Ordinary API fetches are not intercepted. HTML navigation uses the network and returns a static 503 retry page on connection failure; it never restores cached session HTML. Cache reads or writes failing must not prevent an available network asset from loading. + +### Browser contract and layout + +The support floor is Chrome/Edge/Android System WebView 111+, Firefox 128+, and Safari/iOS 16.4+. This follows [Tailwind v4's CSS requirements](https://tailwindcss.com/docs/compatibility), not Vite's default target. The explicit JavaScript build target remains ES2021, including compatibility with xterm's generated syntax; a syntax target alone does not establish CSS support. + +An ES5 inline guard checks known browser versions and, when available, required CSS capabilities before the module graph starts. It marks unsupported engines; the existing boot watchdog renders one themed update panel inside `#root`. Main boot preserves that panel. Unknown user agents may attempt to load, with the existing failure watchdog as a fallback. + +The modified viewport rules retain `vh` fallbacks and add `dvh` where the actual rendered element needs it. Dialog sizing applies to the current Radix dialog content, including fullscreen. Tooltip viewport custom properties use an `@supports` guard because an unsupported unit inside a custom property invalidates the whole computed declaration. Existing `:has` and container guards are progressive CSS behavior, not polyfills or a promise to support older engines. + +## Constraints and Risks + +- A manifest does not guarantee an automatic install prompt: browser policy, engagement, secure context and platform UI still apply. +- Service worker activation affects open tabs immediately. Hashed assets retain their content identity; API state and HTML are never cached. +- Dynamic viewport units cover toolbar resizing, but do not establish that every mobile keyboard behaves identically. +- The browser matrix is a support contract, not a claim that every minimum engine or physical device was tested. + +## Reviewer Test Plan + +1. Start a token-protected daemon from the packaged CLI. Open the shell and retrieve the manifest, worker and icons without a bearer. API requests without a bearer must remain unauthorized. +2. Redeploy changed metadata at the same package version. Reload and check that the manifest and all public icons update; hashed chunks may remain cached. +3. Stop the daemon and navigate. Expect a readable retry page, without restored sessions or cached API responses. Reconnect and retry. +4. Simulate an unsupported browser version and a module-load failure independently. Expect one appropriate message inside the themed root, with no second message or blank page. +5. On supported mobile browsers, inspect normal/fullscreen dialogs and composer tooltips while changing viewport height. +6. Verify installation through the browser's available install/Add to Home Screen UI. Record the actual browser and device; an automated fixed viewport alone does not prove device installability. + +## Validation Evidence + +Targeted automated tests cover HTTP routes, packaging, worker behavior and parsed-document boot. The PR verification report records commands, results and exact revision. Physical-device installation, minimum-engine rendering and mobile keyboard behavior require separate device evidence; they are not claimed here. diff --git a/docs/design/web-shell-pwa-installability.zh-CN.md b/docs/design/web-shell-pwa-installability.zh-CN.md new file mode 100644 index 0000000000..9ddef6f9cd --- /dev/null +++ b/docs/design/web-shell-pwa-installability.zh-CN.md @@ -0,0 +1,63 @@ +# Web Shell PWA 安装能力与浏览器兼容性 + +[English](web-shell-pwa-installability.md) | [简体中文](web-shell-pwa-installability.zh-CN.md) + +状态:实现正在评审。遵循 [issue #11704](https://github.com/QwenLM/qwen-code/issues/11704) 中可选 PWA 方案的方向。 + +## 问题 + +由 daemon 提供的 Web Shell 需要安装元数据和明确的浏览器支持约定。不受支持的引擎应显示升级提示,而不是白屏。发布 CLI 时必须保留 Web Shell 构建生成的 PWA 文件。 + +## 目标 + +- 为独立 Shell 增加同源安装元数据和 service worker。 +- 声明同时覆盖 JavaScript 和生成 CSS 的最低版本。 +- 不在 worker 缓存中存储 daemon 状态、令牌、事件流或 HTML。 + +## 范围外 + +离线会话、Web Push 投递、关闭应用后的通知,以及最低版本以下的引擎 polyfill。嵌入式组件库不注册 service worker,也不安装独立页面的浏览器检查。 + +## 设计 + +### 构建和公开路由 + +Vite 将独立 UI 输出到 `packages/web-shell/dist`,包含 `index.html`、`manifest.webmanifest`、`sw.js` 和 `assets/`。Worker 是不含 import/export 的第二入口;虽然 Rollup 使用 ES 输出,该入口仍可作为经典 worker 加载。Bundle 复制步骤将两个 PWA 文件保留到 `dist/web-shell`;发布检查拒绝缺少这些文件的产物。 + +Daemon 在 bearer 认证之前响应 manifest 和 worker 的 GET/HEAD 请求。这些资源属于进程全局,不携带工作区数据或凭据。冷启动请求判定与 Express 的大小写不敏感路径和可选尾部斜杠一致。Manifest 使用 `application/manifest+json`;worker 使用 `application/javascript` 和 `Service-Worker-Allowed: /`。两者均设置 `no-cache` 和 `nosniff`;文件缺失返回 404,而非 Shell HTML。 + +### Worker 生命周期与缓存 + +仅生产版独立入口在页面加载后注册 `/sw.js`。注册失败不影响普通在线使用。注册需要 HTTPS 或可信的 loopback origin,普通 HTTP 局域网 origin 不支持注册。 + +Worker 通过 `skipWaiting` 和 `clients.claim` 立即激活,删除旧的 `qwen-code-shell-*` 缓存并保留无关缓存。当前缓存名包含包版本;带内容哈希的 chunk 文件名区分同一版本下的不同构建。 + +只有同源构建资源使用 cache-first。不带哈希的 `icon.svg` 和 `icon-*.png` 绕过 worker 缓存,HTTP 响应要求重新验证。Manifest 同样走浏览器网络路径,因此同版本部署也能更新它。非 GET、跨源、带认证和 SSE 请求均不拦截;普通 API fetch 也不拦截。HTML 导航走网络,连接失败时返回静态 503 重试页面,不恢复缓存的会话 HTML。缓存读写失败不能阻止可用网络资源加载。 + +### 浏览器约定与布局 + +最低版本为 Chrome/Edge/Android System WebView 111+、Firefox 128+、Safari/iOS 16.4+。此约定源于 [Tailwind v4 的 CSS 要求](https://tailwindcss.com/docs/compatibility),而非 Vite 默认 target。显式 JavaScript 构建目标仍为 ES2021,兼容 xterm 的生成语法;语法目标本身无法证明 CSS 兼容性。 + +ES5 内联检查在模块图启动前检查已知浏览器版本,并在可用时检查必要 CSS 能力。检查标记不支持的引擎,由现有启动 watchdog 在 `#root` 内显示一个跟随主题的升级面板;主入口保留该面板。未知 UA 可以尝试启动,失败时由现有 watchdog 显示提示。 + +修改的 viewport 规则保留 `vh` 回退,并在实际渲染元素需要时使用 `dvh`。对话框尺寸作用于当前 Radix dialog content,覆盖全屏状态。Tooltip 中带 viewport 单位的自定义属性放入 `@supports`,因为不支持的单位会使整条计算后的声明失效。现有 `:has` 和 container 条件只是渐进 CSS 行为,不是 polyfill,也不承诺支持旧引擎。 + +## 约束与风险 + +- Manifest 不保证自动安装提示;浏览器策略、使用条件、安全上下文和平台 UI 都有影响。 +- Worker 立即接管已打开标签页。带哈希的资源保持内容身份,API 状态和 HTML 不缓存。 +- 动态 viewport 单位处理工具栏缩放,但不证明所有移动键盘行为一致。 +- 浏览器矩阵是支持约定,不代表已测试所有最低版本或真机。 + +## 评审测试计划 + +1. 用打包后的 CLI 启动带令牌保护的 daemon。无 bearer 打开 Shell 并获取 manifest、worker 和图标;无 bearer 的 API 请求必须仍被拒绝。 +2. 使用相同包版本部署修改后的元数据。重新加载后 manifest 和所有公开图标应更新,带哈希的 chunk 可以继续缓存。 +3. 停止 daemon 后导航,应看到可读的重试页面,不能恢复会话或缓存 API 响应;恢复连接后重试。 +4. 分别模拟不支持的浏览器版本和模块加载失败;主题根节点中应只有一个对应提示,不出现第二个提示或白屏。 +5. 在受支持的移动浏览器改变 viewport 高度,检查普通/全屏对话框和输入框 tooltip。 +6. 通过浏览器提供的安装或“添加到主屏幕”入口验证,记录实际浏览器和设备;固定 viewport 自动测试不能证明真机可安装性。 + +## 验证证据 + +定向自动测试覆盖 HTTP 路由、打包、worker 行为和完整 HTML 解析后的启动。PR 验证报告记录命令、结果和准确版本。真机安装、最低版本渲染和移动键盘行为需要单独设备证据,此处不作已通过声明。 diff --git a/docs/developers/daemon/02-serve-runtime.md b/docs/developers/daemon/02-serve-runtime.md index 75465165c4..24a1f9a53c 100644 --- a/docs/developers/daemon/02-serve-runtime.md +++ b/docs/developers/daemon/02-serve-runtime.md @@ -31,10 +31,10 @@ | Access-log middleware | Records method, path, status, durationMs, sessionId, and clientId to `DaemonLogger` when a request finishes. | Registered ahead of every gate, so `401` / `403` / `429` short-circuits are logged too. Exempt **by exact path**: `GET /health` and `POST */heartbeat`, so those liveness probes are never logged — including when a gate below rejects them (`HEAD /health` and `GET /health/` are logged like any request). Successful `GET */events` streams are dropped as well. Burst-limited to 60 lines refilling at 2/s; overflow coalesces into an `access logs suppressed` warning. Pre-authentication gate rejects (Host allowlist, the CORS wall, the remote same-origin credential check) draw from a separate 30/1-s budget, so a credential-less flood of those rejects cannot starve the operator's own lines behind that warning; `bearerAuth` 401s (a no-Origin flood) are not marked and still charge the operator budget — unchanged from before the reorder. | | Inbound trace-id capture | Captures the caller's `traceparent` trace id before any gate can short-circuit. | Lets the access log join a `401` / `429` / `400` / `404` line to the caller's trace in telemetry-off deployments, where it is the only such link. | | `hostAllowlist(bind, getPort)` | On loopback, validate `Host` belongs to `localhost`, `127.0.0.1`, `[::1]`, `host.docker.internal`, or the exact bound loopback address, plus the actual port; port-less forms are accepted on ports 80 and 443. | Defense against DNS rebinding; it therefore also covers the pre-auth `/health` routes below it. Comparison is case-insensitive and cached per port. A deliberate no-op on non-loopback binds, where the bearer is the authentication layer. The Local Control LAN listener always enforces its advertised-authority Host check, whatever the primary bind is. | -| `installRemoteSelfOriginMiddleware` | On a non-loopback primary listener that has a token, bearer-authenticates a request whose `Origin` equals the direct socket scheme plus the normalized `Host`, then deletes that `Origin`. | This is what lets the built-in Web Shell's same-origin HTTP mutations through without `--allow-origin`. No-op on loopback binds and when no token is configured. Pre-auth Web Shell routes (`/`, `//`, `/assets*`, `/mcp-app-sandbox`, exact `/session/:id` document navigations) skip the credential check. Forwarded headers are never consulted. | +| `installRemoteSelfOriginMiddleware` | On a non-loopback primary listener that has a token, bearer-authenticates a request whose `Origin` equals the direct socket scheme plus the normalized `Host`, then deletes that `Origin`. | This is what lets the built-in Web Shell's same-origin HTTP mutations through without `--allow-origin`. No-op on loopback binds and when no token is configured. Pre-auth Web Shell routes (`/`, `//`, `/assets*`, `/manifest.webmanifest`, `/sw.js`, `/mcp-app-sandbox`, exact `/session/:id` document navigations) skip the credential check. Forwarded headers are never consulted. | | `allowOriginCors` | Always installed on the runtime app over a `MutableOriginAllowlist`: `--allow-origin ` entries seed it, Local Control adds the LAN origin while enabled; unmatched origins get the 403 deny envelope. | See [`12-auth-security.md`](./12-auth-security.md). Its rejects are logged by the access log above, except on the health/heartbeat exemptions. | | Pre-auth `/health` | Liveness route registered ahead of `bearerAuth` on an ordinary loopback bind. | Dropped under `--require-auth`, and never registered pre-auth on a non-loopback bind; those cases register `/health` after `bearerAuth` instead. A Local Control listener authenticates its own `/health` even in the pre-auth position. | -| Web Shell static assets and MCP App sandbox | `/`, `/assets*`, `/mcp-app-sandbox`, and exact `/session/:id` document navigations, mounted ahead of `bearerAuth`. | A browser cannot attach `Authorization` to a navigation or a ` + +