mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-21 14:21:48 +00:00
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 <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
parent
f822124af5
commit
535dfd9a11
86 changed files with 2772 additions and 524 deletions
1
.github/workflows/.size-baseline
vendored
1
.github/workflows/.size-baseline
vendored
|
|
@ -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
|
||||
|
|
|
|||
48
.github/workflows/mobile-shell.yml
vendored
Normal file
48
.github/workflows/mobile-shell.yml
vendored
Normal file
|
|
@ -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'
|
||||
2
.github/workflows/pnpm-worktree-smoke.yml
vendored
2
.github/workflows/pnpm-worktree-smoke.yml
vendored
|
|
@ -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/**'
|
||||
|
|
|
|||
58
docs/design/mobile-android-shell.md
Normal file
58
docs/design/mobile-android-shell.md
Normal file
|
|
@ -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.
|
||||
54
docs/design/mobile-android-shell.zh-CN.md
Normal file
54
docs/design/mobile-android-shell.zh-CN.md
Normal file
|
|
@ -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、原生通知及运行时权限;文件选择、麦克风、下载、新窗口及渲染进程恢复;字体缩放和无障碍验收。
|
||||
63
docs/design/web-shell-pwa-installability.md
Normal file
63
docs/design/web-shell-pwa-installability.md
Normal file
|
|
@ -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.
|
||||
63
docs/design/web-shell-pwa-installability.zh-CN.md
Normal file
63
docs/design/web-shell-pwa-installability.zh-CN.md
Normal file
|
|
@ -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 验证报告记录命令、结果和准确版本。真机安装、最低版本渲染和移动键盘行为需要单独设备证据,此处不作已通过声明。
|
||||
|
|
@ -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 <pattern>` 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 `<script src>` subresource, and the static shell carries no secrets. The SPA deep-link fallback is registered after all API routes instead. `--no-web` opts out. |
|
||||
| Web Shell static assets and MCP App sandbox | `/`, `/assets*`, `/manifest.webmanifest`, `/sw.js`, `/mcp-app-sandbox`, and exact `/session/:id` document navigations, mounted ahead of `bearerAuth`. | A browser cannot attach `Authorization` to a navigation or a `<script src>` subresource, and the static shell carries no secrets. The SPA deep-link fallback is registered after all API routes instead. `--no-web` opts out. |
|
||||
| Channel webhook routes | `POST /channels/:channelName/webhooks/:source`, registered ahead of `bearerAuth`. | Authenticates with its own `x-qwen-webhook-secret`; rotating the daemon bearer does not rotate webhook secrets. |
|
||||
| `bearerAuth(token)` | SHA-256 plus `timingSafeEqual` constant-time bearer comparison. | Open passthrough when no token is configured (loopback dev default). `Bearer` scheme is case-insensitive. |
|
||||
| Rate-limit middleware | Optional per-tier token bucket for prompt, mutation, and read routes. | Registered after `bearerAuth` and before JSON parsing, so only authenticated requests are counted; returns 429 before parsing when a bucket is exhausted. Webhook routes use their own shared-secret gate instead. |
|
||||
|
|
@ -46,19 +46,19 @@ The **bootstrap app** that answers requests during the cold window (`createBoots
|
|||
|
||||
**Subsystems**:
|
||||
|
||||
| Path | Role |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serve/fs/` | `WorkspaceFileSystem` factory plus `policy.ts` (size/trust/binary checks), `paths.ts` (canonicalize, resolveWithin, symlink rejection), `audit.ts`, and typed `FsError` values. |
|
||||
| `serve/routes/workspace-file-read.ts`, `workspace-file-write.ts` | HTTP handlers for `GET /file`, `GET /file/bytes`, `POST /file/write`, and `POST /file/edit`. |
|
||||
| `serve/workspace-memory.ts` | `GET/POST /workspace/memory` (QWEN.md CRUD). |
|
||||
| `serve/workspace-agents.ts` | `GET/POST/DELETE /workspace/agents` (subagent CRUD). |
|
||||
| `serve/daemon-status-provider.ts` | Env snapshot plus daemon-host preflight cells: Node version, CLI entry, workspace stat, ripgrep, git, npm. |
|
||||
| `serve/permission-audit.ts` | `PermissionAuditRing` (512-entry FIFO) and `createPermissionAuditPublisher`. |
|
||||
| `serve/auth/device-flow.ts`, `qwen-device-flow-provider.ts` | Device-flow OAuth routes. See [`12-auth-security.md`](./12-auth-security.md). |
|
||||
| `serve/daemon-logger.ts` | `DaemonLogger` structured file logs. See [`19-observability.md`](./19-observability.md). |
|
||||
| `serve/debug-mode.ts` | Shared `isServeDebugMode()` predicate controlling verbose error context in HTTP responses. |
|
||||
| `serve/acp-http/` | ACP Streamable HTTP transport (RFD #721), mounted at `/acp`. Seven files implement JSON-RPC POST, SSE GET, DELETE teardown, and shared bridge usage in parallel with the REST surface. |
|
||||
| `serve/web-shell-static.ts`, `serve/web-shell-resolver.ts` | Locate and mount the built Web Shell assets (the daemon's browser UI) at `/`, `/assets`, and `/session/:id`, plus the SPA deep-link fallback registered after all API routes. Mounted **before** `bearerAuth` in every launch mode because a browser cannot attach `Authorization` to a navigation or subresource. API calls follow the normal authority policy: configured tokens gate normal API routes except loopback `/health` unless `--require-auth` is set, while channel webhook ingress always uses its own shared secret and the token-less trusted-loopback primary listener has full operator access. Degrades to API-only when the assets are absent; `--no-web` opts out. |
|
||||
| Path | Role |
|
||||
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `serve/fs/` | `WorkspaceFileSystem` factory plus `policy.ts` (size/trust/binary checks), `paths.ts` (canonicalize, resolveWithin, symlink rejection), `audit.ts`, and typed `FsError` values. |
|
||||
| `serve/routes/workspace-file-read.ts`, `workspace-file-write.ts` | HTTP handlers for `GET /file`, `GET /file/bytes`, `POST /file/write`, and `POST /file/edit`. |
|
||||
| `serve/workspace-memory.ts` | `GET/POST /workspace/memory` (QWEN.md CRUD). |
|
||||
| `serve/workspace-agents.ts` | `GET/POST/DELETE /workspace/agents` (subagent CRUD). |
|
||||
| `serve/daemon-status-provider.ts` | Env snapshot plus daemon-host preflight cells: Node version, CLI entry, workspace stat, ripgrep, git, npm. |
|
||||
| `serve/permission-audit.ts` | `PermissionAuditRing` (512-entry FIFO) and `createPermissionAuditPublisher`. |
|
||||
| `serve/auth/device-flow.ts`, `qwen-device-flow-provider.ts` | Device-flow OAuth routes. See [`12-auth-security.md`](./12-auth-security.md). |
|
||||
| `serve/daemon-logger.ts` | `DaemonLogger` structured file logs. See [`19-observability.md`](./19-observability.md). |
|
||||
| `serve/debug-mode.ts` | Shared `isServeDebugMode()` predicate controlling verbose error context in HTTP responses. |
|
||||
| `serve/acp-http/` | ACP Streamable HTTP transport (RFD #721), mounted at `/acp`. Seven files implement JSON-RPC POST, SSE GET, DELETE teardown, and shared bridge usage in parallel with the REST surface. |
|
||||
| `serve/web-shell-static.ts`, `serve/web-shell-resolver.ts` | Locate and mount the built Web Shell assets (the daemon's browser UI) at `/`, `/assets`, `/manifest.webmanifest`, `/sw.js`, and `/session/:id`, plus the SPA deep-link fallback registered after all API routes. Mounted **before** `bearerAuth` in every launch mode because a browser cannot attach `Authorization` to a navigation or subresource. API calls follow the normal authority policy: configured tokens gate normal API routes except loopback `/health` unless `--require-auth` is set, while channel webhook ingress always uses its own shared secret and the token-less trusted-loopback primary listener has full operator access. Degrades to API-only when the assets are absent; `--no-web` opts out. |
|
||||
|
||||
**ACP bridge package imports**:
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,8 @@ plus the normalized `Host` authority — forwarded headers are never consulted
|
|||
and bearer-authenticates the match before deleting `Origin`, so the built-in Web
|
||||
Shell's same-origin HTTP mutations need no `--allow-origin`. Its pre-auth
|
||||
predicate (`web-shell-preauth.ts`) exempts the shell entry points (`/`, `//`,
|
||||
`/assets*`, `/mcp-app-sandbox`, exact `/session/:id` document navigations) from
|
||||
`/assets*`, `/manifest.webmanifest`, `/sw.js`, `/mcp-app-sandbox`, exact
|
||||
`/session/:id` document navigations) from
|
||||
the credential check because browser module-script fetches carry `Origin`
|
||||
without `Authorization`. WebSocket upgrades and TLS-front-proxy `https` origins
|
||||
are not covered: the upgrade gate keeps its own CSWSH policy (loopback origin,
|
||||
|
|
@ -163,7 +164,7 @@ Control is enabled (the LAN origin is added/removed with the listener):
|
|||
- Non-matching `Origin` values receive the same deterministic
|
||||
`403 { error: 'Request denied by CORS policy' }` as deny mode.
|
||||
- `--allow-origin '*'` requires a bearer token; on loopback binds boot refuses when none is configured, while on non-loopback binds the generated ephemeral token satisfies the guard (the refusal is loopback-only).
|
||||
- Without a token, HTTP(S) `--allow-origin` values are limited to loopback hosts on loopback binds; on non-loopback binds the generated token satisfies the same guard. A non-loopback browser origin authenticates with the bearer on every API route because it could otherwise exercise the full operator API, including code execution as the daemon user; the pre-auth exceptions are the Web Shell document/asset routes and the MCP App sandbox (`/`, `/assets*`, `/mcp-app-sandbox`, exact `/session/:id` navigations), plus the channel webhook ingress, which branches ahead of `bearerAuth` and authenticates with its own `x-qwen-webhook-secret` instead of the bearer, and — on loopback binds only — `/health` (bearer-gated elsewhere).
|
||||
- Without a token, HTTP(S) `--allow-origin` values are limited to loopback hosts on loopback binds; on non-loopback binds the generated token satisfies the same guard. A non-loopback browser origin authenticates with the bearer on every API route because it could otherwise exercise the full operator API, including code execution as the daemon user; the pre-auth exceptions are the Web Shell document/asset routes and the MCP App sandbox (`/`, `/assets*`, `/manifest.webmanifest`, `/sw.js`, `/mcp-app-sandbox`, exact `/session/:id` navigations), plus the channel webhook ingress, which branches ahead of `bearerAuth` and authenticates with its own `x-qwen-webhook-secret` instead of the bearer, and — on loopback binds only — `/health` (bearer-gated elsewhere).
|
||||
- Explicit browser-extension origins retain their tokenless local-automation path. Startup logs that any tokenless allowed browser origin receives full operator authority.
|
||||
- `parseAllowOriginPatterns()` validates pattern syntax at boot.
|
||||
- The `allow_origin` capability tag is advertised only when this mode is
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ This page collects every setting that affects the `qwen serve` daemon and its ad
|
|||
| `--rate-limit-window-ms <n>` | integer `>= 1000` | `60000` | Rate limit window length; requires rate limiting to be enabled. |
|
||||
| no flag | - | - | `QWEN_SERVE_NO_MCP_POOL=1` fully disables the pool. |
|
||||
|
||||
The Web Shell also exposes `/manifest.webmanifest` and `/sw.js` when `--web`
|
||||
is enabled. They are public bootstrap resources; authenticated API routes keep
|
||||
their configured bearer or trusted-loopback policy.
|
||||
|
||||
## Environment variables
|
||||
|
||||
### Read by `runQwenServe` / Express middleware
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ curl -s -H "Authorization: Bearer $TOKEN" http://<bind>:4170/capabilities | jq
|
|||
|
||||
## 8. Is there a browser UI?
|
||||
|
||||
**Yes — the Web Shell.** `resolveWebShellDir()` finds the built assets (bundled next to the CLI bundle in a release, `packages/web-shell/dist` in a checkout) and `mountWebShellAssets()` serves them at `/`, `/assets`, and `/session/:id` document navigations (browser deep links — a plain `curl /session/<id>` gets the API's 401/404, not the shell). When the assets are missing the daemon degrades to API-only instead of crashing; `--no-web` opts out explicitly.
|
||||
**Yes — the Web Shell.** `resolveWebShellDir()` finds the built assets (bundled next to the CLI bundle in a release, `packages/web-shell/dist` in a checkout) and `mountWebShellAssets()` serves them at `/`, `/assets`, `/manifest.webmanifest`, `/sw.js`, and `/session/:id` document navigations (browser deep links — a plain `curl /session/<id>` gets the API's 401/404, not the shell). When the assets are missing the daemon degrades to API-only instead of crashing; `--no-web` opts out explicitly.
|
||||
|
||||
The static shell is mounted **before** `bearerAuth` in every launch mode — a browser cannot attach an `Authorization` header to an address-bar navigation or a `<script src>` subresource, so gating it would just break the UI. API calls use trusted-loopback authority on the token-less primary listener or attach the configured bearer in authenticated modes. On a non-loopback bind with a token the shell's same-origin **HTTP** requests need no allowlist: an `Origin` equal to the direct socket scheme plus the normalized `Host` authority is bearer-authenticated and then stripped ahead of the CORS wall, so its POSTs go through. Three cases still need an explicit `--allow-origin <origin>` — the WebSocket upgrades behind the terminal and voice features; browsers reaching the daemon through a TLS-terminating proxy, whose `https` origin never matches the plain socket; and any plain-HTTP intermediary that rewrites the `Host` header (nginx's default `proxy_set_header Host $proxy_host`, k8s Ingress), whose forwarded `Host` then no longer matches the browser's `Origin`. Forwarded headers are never trusted to establish same-origin, so the fix is the allowlist entry or a proxy that forwards `Host` verbatim; port translation alone (`docker -p 8080:4170`) needs nothing **on a non-loopback bind**, because only the forwarded `Host` is compared — on the default **loopback** bind the Host gate rejects the translated port with `403 Invalid Host header` for every request including the shell document, and `--allow-origin` cannot override it, so forward the same port (`ssh -L 4170:localhost:4170`) or bind non-loopback.
|
||||
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ Channel webhook ingress (`POST /channels/:channelName/webhooks/:source`) is sepa
|
|||
|
||||
When the flag is on, the global `bearerAuth` middleware gates **every normal API route** — including `/health` and `/capabilities`. Channel webhook ingress remains independently shared-secret-authenticated, and Web Shell document and asset routes remain pre-auth. An **unauthenticated** client therefore cannot pre-flight `caps.features` to discover that auth is required: the discovery surface for that case is the **401 response body** itself (uniform across bearer-gated routes per the [Authentication](#authentication) section). The `require_auth` capability tag is a **post-authentication confirmation** — once a client successfully authenticates and reads `/capabilities`, the tag's presence confirms the daemon was started with `--require-auth` (useful for audit / compliance UIs and for SDK clients to surface "this deployment is hardened" in a settings panel). Strict mutation routes accept trusted-loopback primary-listener requests, bearer-authenticated requests, or paired Local Control requests. Non-trusted token-less embeds still receive `401 { code: "token_required", error: "…" }`; with `--require-auth`, global bearer middleware rejects first with the legacy `Unauthorized` body.
|
||||
|
||||
**`--allow-origin <pattern>` (T2.4 [#4514](https://github.com/QwenLM/qwen-code/issues/4514)).** Browser clients hitting the daemon cross-origin are blocked by default — a request carrying an `Origin` header returns `403 {"error":"Request denied by CORS policy"}` because CLI/SDK clients never send `Origin` and the daemon treats its presence as a sign the request came from a browser context the operator has not opted into. One exception precedes the wall on a non-loopback bind with a token: a same-origin request (`Origin` equal to the direct socket scheme plus the normalized `Host` authority) is bearer-authenticated and its `Origin` stripped **on the primary listener** — with a valid bearer the route's own status follows, with a missing or invalid bearer a `401`, **except the pre-auth Web Shell document, `/assets/*` and `/mcp-app-sandbox` routes, which are served without a credential as in every other mode** — and only cross-origin or non-matching `Origin` values keep the `403` envelope. Pass `--allow-origin <pattern>` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either:
|
||||
**`--allow-origin <pattern>` (T2.4 [#4514](https://github.com/QwenLM/qwen-code/issues/4514)).** Browser clients hitting the daemon cross-origin are blocked by default — a request carrying an `Origin` header returns `403 {"error":"Request denied by CORS policy"}` because CLI/SDK clients never send `Origin` and the daemon treats its presence as a sign the request came from a browser context the operator has not opted into. One exception precedes the wall on a non-loopback bind with a token: a same-origin request (`Origin` equal to the direct socket scheme plus the normalized `Host` authority) is bearer-authenticated and its `Origin` stripped **on the primary listener** — with a valid bearer the route's own status follows, with a missing or invalid bearer a `401`, **except the pre-auth Web Shell document, `/assets/*`, `/manifest.webmanifest`, `/sw.js`, and `/mcp-app-sandbox` routes, which are served without a credential as in every other mode** — and only cross-origin or non-matching `Origin` values keep the `403` envelope. Pass `--allow-origin <pattern>` (repeatable) at boot to install an allowlist instead of the wall. Each pattern is either:
|
||||
|
||||
- The literal `*` — admit any origin. **Risky**: boot refuses when `*` is configured but no bearer token resolves. The guard reads the _resolved_ token — `--token`, `QWEN_SERVER_TOKEN`, `--open-with-auth`'s generated loopback token, or the ephemeral bearer a non-loopback bind generates when neither configured source is present — so this refusal is loopback-only. The boot breadcrumb emits a stderr warning when `*` is in the list. **Recommendation**: pair with `--require-auth` on loopback binds so `/health` is also gated by the bearer — it's registered before the bearer middleware on loopback by default (so k8s/Compose probes can reach it without a token), and a `*` allowlist makes it reachable from any cross-origin browser. `--require-auth` still leaves the Web Shell static assets (`/`, `/assets/*`, and `/session/:id` document navigations) pre-auth on loopback by design — they are mounted before the bearer middleware — so under a `*` allowlist they remain readable from any cross-origin browser; `--no-web` removes that surface. On non-loopback binds the bearer is already mandatory at boot and `/health` is registered behind it. Normal API routes are bearer-gated, channel webhook ingress retains its own shared-secret gate, and Web Shell static assets (`/`, `/assets/*`, and `/session/:id` document navigations) remain pre-auth unless `--no-web` removes them.
|
||||
- The literal `*` — admit any origin. **Risky**: boot refuses when `*` is configured but no bearer token resolves. The guard reads the _resolved_ token — `--token`, `QWEN_SERVER_TOKEN`, `--open-with-auth`'s generated loopback token, or the ephemeral bearer a non-loopback bind generates when neither configured source is present — so this refusal is loopback-only. The boot breadcrumb emits a stderr warning when `*` is in the list. **Recommendation**: pair with `--require-auth` on loopback binds so `/health` is also gated by the bearer — it's registered before the bearer middleware on loopback by default (so k8s/Compose probes can reach it without a token), and a `*` allowlist makes it reachable from any cross-origin browser. `--require-auth` still leaves the Web Shell static assets (`/`, `/assets/*`, `/manifest.webmanifest`, `/sw.js`, and `/session/:id` document navigations) pre-auth on loopback by design — they are mounted before the bearer middleware — so under a `*` allowlist they remain readable from any cross-origin browser; `--no-web` removes that surface. On non-loopback binds the bearer is already mandatory at boot and `/health` is registered behind it. Normal API routes are bearer-gated, channel webhook ingress retains its own shared-secret gate, and Web Shell static assets (`/`, `/assets/*`, `/manifest.webmanifest`, `/sw.js`, and `/session/:id` document navigations) remain pre-auth unless `--no-web` removes them.
|
||||
- A canonical URL origin — `<scheme>://<host>[:<port>]`. **No trailing slash, no path, no userinfo, no query.** Boot refuses with `InvalidAllowOriginPatternError` if the entry fails the round-trip `new URL(pattern).origin === pattern`; the error message names the bad pattern and the canonical form. Strict-by-intent: silent normalization (e.g. trimming a trailing `/`) would let typos slip through and accept ambiguous input. Without a resolved token — which after generation means a loopback bind — HTTP(S) entries are limited to loopback hosts; a non-loopback browser origin requires a token because it can otherwise drive the full operator API, including code execution as the daemon user. Explicit browser-extension origins keep their existing tokenless local-automation path. Startup logs the authority granted to any tokenless allowed browser origin.
|
||||
|
||||
Matched origins receive the standard CORS response headers on every request:
|
||||
|
|
|
|||
|
|
@ -466,6 +466,49 @@ Notes:
|
|||
- **An IPv6 wildcard bind is dialled back on the loopback this host actually assigns** — `--hostname ::` (or `[::]`) binds an IPv6 socket. (An empty `--hostname` never reaches the listener: boot refuses it as operator error before binding, so a compose file with an unset `HOSTNAME` fails loudly instead of silently wildcarding.) That socket is dual-stack (Node pins `IPV6_V6ONLY=0` on it, so the `net.ipv6.bindv6only` sysctl does not change this), and both loopbacks usually reach it — but a host with no IPv4 at all has only `::1`, while a host that binds `::` yet carries no `::1` on its loopback (for example `net.ipv6.conf.lo.disable_ipv6=1`) has only `127.0.0.1`. Workers are sent to `[::1]` when this host assigns it and to `127.0.0.1` otherwise. A serving certificate for an IPv6 wildcard bind should carry both loopbacks in its SANs (`mkcert localhost 127.0.0.1 ::1` covers it); the boot trust diagnostic inspects the exact URL workers will dial and names the gap. `--hostname 0.0.0.0` is unchanged and still needs `127.0.0.1`.
|
||||
- **Rotating `--tls-cert` in place needs a daemon restart** — the daemon serves the bytes it read at boot, so until it restarts, respawned workers can load newer contents than the daemon presents and their handshakes fail.
|
||||
|
||||
## Install the Web Shell as an app
|
||||
|
||||
On a supported browser, open the daemon's Web Shell and use the browser's
|
||||
**Install app** or **Add to Home screen** action. The installed app opens the
|
||||
same server in a standalone window with the Qwen Code name and icon. On iOS,
|
||||
use the browser's Share menu and **Add to Home Screen**. Availability depends
|
||||
on the browser and device.
|
||||
|
||||
The daemon serves the PWA metadata itself: `GET /manifest.webmanifest` and
|
||||
`GET /sw.js` are pre-auth routes (the browser fetches both before any
|
||||
Authorization header exists), and `sw.js` is served with
|
||||
`Service-Worker-Allowed: /` so its scope covers the whole origin. The worker
|
||||
caches only content-hashed Vite build assets, cache-first. Unhashed public
|
||||
icons and the manifest are revalidated over HTTP. API fetches, event streams
|
||||
and requests carrying an authorization header stay on the network.
|
||||
|
||||
Use a trusted HTTPS connection for a phone or other remote device; HTTP on
|
||||
`localhost` or a loopback address is supported for local development. Plain
|
||||
HTTP on a LAN IP does not enable the service worker. See the TLS instructions
|
||||
above; installation does not bypass certificate or authentication checks.
|
||||
|
||||
Installation always launches the server root. A token, session link or
|
||||
workspace in the current address is never included in the app's launch URL.
|
||||
You may need to enter the token again in the installed app: it uses the
|
||||
existing Web Shell authentication flow and does not add persistent credential
|
||||
storage.
|
||||
|
||||
The server must remain running and reachable. After the service worker has
|
||||
activated and taken control of a page, opening the root or a session link when
|
||||
the connection fails shows a retry page. No conversations, files, API
|
||||
responses or credentials are saved in a service-worker cache. Existing HTTP
|
||||
errors still come from the server. This first step does not provide offline
|
||||
work, Web Push or notifications while the app is closed. Updated workers
|
||||
activate immediately with `skipWaiting()` and `clients.claim()`, replacing
|
||||
the previous worker for open tabs without forcing a page reload. Activation
|
||||
removes older Qwen shell caches and preserves unrelated caches.
|
||||
|
||||
The installation metadata uses the built-in Qwen Code identity; runtime custom
|
||||
branding still applies inside the Web Shell. Embedded library consumers do not
|
||||
register the worker. To remove the installation, use your browser or operating
|
||||
system's app management; to remove its service worker and stored site data,
|
||||
use the browser's site settings.
|
||||
|
||||
## CLI flags
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|
|
@ -502,7 +545,7 @@ Notes:
|
|||
| `--initialize-timeout-ms <n>` | `10000` | ACP child request timeout, including the `initialize` handshake (ms). Must be a positive integer up to `2147483647`. Values above the JS timer ceiling (`2^31-1`) are rejected at boot because Node silently compresses them to 1 ms. Cold-container deployments that need extra headroom for child startup can raise this; the same value governs `newSession`, workspace-status polls, and other ACP ext-method deadlines. |
|
||||
| `--session-restore-timeout-ms <n>` | `60000` | ACP session load/resume deadline in milliseconds. Must be a positive integer up to `2147483647`; `0` is invalid. If omitted, the default is 60 seconds, raised to an explicitly supplied `--initialize-timeout-ms` when that value is larger; a shorter initialize timeout never lowers the restore budget. The SDK and Web Shell add 10 and 15 seconds of client headroom. A timeout returns retryable `504 session_restore_timeout`; it does not imply that the daemon itself exited. |
|
||||
| `--allow-origin <pat>` | — | T2.4 ([#4514](https://github.com/QwenLM/qwen-code/issues/4514)). Cross-origin allowlist for browser clients. Repeatable. Each value is `*` (any origin — on loopback binds boot refuses when no bearer token is configured; on non-loopback binds a generated ephemeral token satisfies these guards, so the refusals are loopback-only; `--require-auth` on loopback is recommended so `/health` is also bearer-gated, since it is pre-auth on loopback by default; the Web Shell static assets stay pre-auth in every mode, so pass `--no-web` to remove them) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo / query). Without a token, HTTP(S) origins must be loopback on loopback binds; on non-loopback binds the generated token satisfies the same guard. Remote HTTP(S) origins authenticate with the bearer on every API route; the pre-auth exceptions are only the Web Shell document/asset routes, the MCP App sandbox, the channel webhook ingress, which authenticates with its own `x-qwen-webhook-secret`, and `/health` on loopback binds (bearer-gated elsewhere). Browser extensions retain tokenless access locally. Subdomain wildcards (`https://*.example.com`) are intentionally unsupported — list each subdomain explicitly, or use `*` with a configured token (and `--require-auth` for full hardening). Matched origins receive CORS response headers (`Access-Control-Allow-Origin`, `Vary: Origin`, methods, headers, max-age, and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as today's wall. `Origin: null` (sandboxed iframes, file:// docs) is always rejected, even under `*`. Pre-flight via `caps.features.allow_origin`. Loopback self-origin hits are unaffected. |
|
||||
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, and `GET /session/<id>` document navigations). These entry points are registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, and the shell carries no secrets. API requests follow the daemon's normal authority policy: configured tokens gate normal API routes except loopback `/health` unless `--require-auth` is set, channel webhook ingress always uses its own shared secret, and the token-less trusted-loopback primary listener has full operator access. The SPA deep-link fallback for all other paths sits behind the bearer middleware. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
|
||||
| `--web` / `--no-web` | `true` | Serve the built Web Shell SPA at the daemon root (`GET /`, `/assets/*`, `/manifest.webmanifest`, `/sw.js`, and `GET /session/<id>` document navigations). These entry points are registered **before** the bearer-auth gate — a browser can't attach a token to a `<script>` subresource or an address-bar navigation, and the shell carries no secrets. API requests follow the daemon's normal authority policy: configured tokens gate normal API routes except loopback `/health` unless `--require-auth` is set, channel webhook ingress always uses its own shared secret, and the token-less trusted-loopback primary listener has full operator access. The SPA deep-link fallback for all other paths sits behind the bearer middleware. On non-loopback binds a one-line stderr warning notes the UI is reachable without auth. Use `--no-web` for an API-only daemon. No effect when the build omits the Web Shell assets (the daemon logs a breadcrumb and runs API-only). |
|
||||
| `--open` | `false` | After the listener is up, open the Web Shell in your default browser at the daemon URL (with `#token=` appended as a URL fragment when a token resolves — configured or generated — since a fragment is never sent to the server, keeping the token out of access logs and Referer headers). No-op with `--no-web`, or in headless / CI / SSH environments where no browser is available. |
|
||||
| `--open-with-auth` | `false` | Open the Web Shell with bearer authentication on loopback. Requires an enabled Web Shell and built assets. Reuses a configured token or generates a process-lifetime 256-bit bearer and delivers it in the Web Shell fragment. Browser-ineligible environments print the secret-bearing manual URL. Other clients need the same explicitly configured shared token instead. |
|
||||
|
||||
|
|
@ -714,7 +757,7 @@ provider decision with their normal tool policy and isolation boundary.
|
|||
- **`--hostname 0.0.0.0` requires a token** — one is generated when neither token source is supplied; explicitly empty sources still fail closed.
|
||||
- **`LOOPBACK_BINDS` includes IPv6** — `::1` and `[::1]` count as loopback for the no-token rule.
|
||||
- **Host header allowlist** — on **loopback** binds the daemon checks `Host:` matches `localhost:port` / `127.0.0.1:port` / `[::1]:port` / `host.docker.internal:port` or the exact bound loopback address and port (case-insensitive per RFC 7230 §5.4) to defend against DNS rebinding. When listening on port 80 or 443, the corresponding port-less forms are also accepted because browsers omit scheme-default ports. This supports the complete IPv4 loopback range (`127.0.0.0/8`) without admitting unrelated Hosts. **Non-loopback binds (`--hostname 0.0.0.0`) intentionally bypass the Host allowlist** — the operator has chosen the surface area, so the bearer-token gate is the sole authentication layer for normal API routes; reverse proxies / SNI / client cert pinning are the operator's responsibility, not the daemon's. If you need Host-based isolation on a non-loopback bind, terminate TLS + check Host at a front proxy.
|
||||
- **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin <pattern>`** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — on a **loopback** bind boot refuses if no bearer token is configured, while a non-loopback bind boots because token generation supplies one; `--require-auth` on loopback is recommended for full hardening since `/health` remains pre-auth on loopback by default — note that the Web Shell static assets (`/`, `/assets/*`, `/session/:id` document navigations) are mounted before the bearer in every mode and stay pre-auth even under `--require-auth`, so use `--no-web` when the residual browser surface matters) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo). On a **loopback** bind without a token, HTTP(S) entries must use a loopback host; a non-loopback bind always carries a token — supplied or generated — precisely because remotely hosted browser origins can execute code through the operator API as the daemon user. Explicit browser-extension origins retain their tokenless local-automation path. Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the Web Shell UI) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. The built-in remote Web Shell's same-origin **HTTP** requests also work without `--allow-origin`: on a non-loopback primary listener an `Origin` equal to the direct socket scheme plus the normalized `Host` authority is bearer-authenticated and then stripped ahead of the CORS wall, while its existing public static routes stay unauthenticated. Two exclusions still need `--allow-origin` — forwarded headers never establish same-origin trust, so a TLS-terminating reverse proxy's `https` browser origin is not same-origin (and neither is a plain-HTTP intermediary that rewrites the `Host` header, such as nginx's default `proxy_set_header Host $proxy_host` or k8s Ingress; the remedy there is `--allow-origin <origin>` for the origin your browser sees, or a proxy that forwards `Host` verbatim — port translation alone needs nothing, because only the forwarded `Host` is compared, with just the scheme-default `:80`/`:443` stripped), and the WebSocket upgrade routes (terminal, voice) keep a separate CSWSH gate that admits only loopback origins, allowlisted origins, and the Local Control listener's own origin.
|
||||
- **CORS denies any browser Origin by default** — returns `403` JSON. Pass **`--allow-origin <pattern>`** (repeatable, T2.4 #4514) to opt specific browser origins through. Each value is either the literal `*` (any origin — on a **loopback** bind boot refuses if no bearer token is configured, while a non-loopback bind boots because token generation supplies one; `--require-auth` on loopback is recommended for full hardening since `/health` remains pre-auth on loopback by default — note that the Web Shell static assets (`/`, `/assets/*`, `/manifest.webmanifest`, `/sw.js`, `/session/:id` document navigations) are mounted before the bearer in every mode and stay pre-auth even under `--require-auth`, so use `--no-web` when the residual browser surface matters) or a canonical URL origin (`<scheme>://<host>[:<port>]`, no trailing slash / path / userinfo). On a **loopback** bind without a token, HTTP(S) entries must use a loopback host; a non-loopback bind always carries a token — supplied or generated — precisely because remotely hosted browser origins can execute code through the operator API as the daemon user. Explicit browser-extension origins retain their tokenless local-automation path. Matched origins receive proper CORS response headers (`Access-Control-Allow-Origin: <echoed>`, `Vary: Origin`, plus standard methods / headers / max-age and exposed `Retry-After`); unmatched origins still get a 403 with the same envelope as the default wall. `caps.features.allow_origin` is advertised conditionally so SDK / webui clients can pre-flight whether the daemon honors cross-origin hits before issuing them. Example: `qwen serve --allow-origin http://localhost:3000 --allow-origin http://localhost:5173`. Loopback self-origin hits (e.g. the Web Shell UI) are unaffected — a separate Origin-strip shim handles them regardless of `--allow-origin`. The built-in remote Web Shell's same-origin **HTTP** requests also work without `--allow-origin`: on a non-loopback primary listener an `Origin` equal to the direct socket scheme plus the normalized `Host` authority is bearer-authenticated and then stripped ahead of the CORS wall, while its existing public static routes stay unauthenticated. Two exclusions still need `--allow-origin` — forwarded headers never establish same-origin trust, so a TLS-terminating reverse proxy's `https` browser origin is not same-origin (and neither is a plain-HTTP intermediary that rewrites the `Host` header, such as nginx's default `proxy_set_header Host $proxy_host` or k8s Ingress; the remedy there is `--allow-origin <origin>` for the origin your browser sees, or a proxy that forwards `Host` verbatim — port translation alone needs nothing, because only the forwarded `Host` is compared, with just the scheme-default `:80`/`:443` stripped), and the WebSocket upgrade routes (terminal, voice) keep a separate CSWSH gate that admits only loopback origins, allowlisted origins, and the Local Control listener's own origin.
|
||||
- **Chrome extension browser automation is separate from framing.** `qwen serve --allow-origin chrome-extension://<id>` lets the extension frame the Web Shell and connect to the daemon. Console/network/screenshot/click tools require an external CDP MCP adapter command: `QWEN_CDP_MCP_COMMAND=/path/to/cdp-mcp-adapter qwen serve --allow-origin chrome-extension://<id>`. The main CLI package does not bundle a browser automation adapter; clients can check `caps.features.includes('browser_automation_mcp')` before presenting those tools as available.
|
||||
- **A spawned `qwen --acp` child receives its owning runtime's effective environment.** The daemon freezes a process-env base, applies that workspace's settings/env-file overlay to a runtime-local snapshot, and never writes the overlay back to `process.env`; same-named keys in another runtime do not cross over. `QWEN_SERVER_TOKEN` is scrubbed before spawn because the agent does not need the daemon bearer. Loader-affecting variables (`NODE_OPTIONS`, `npm_config_node_options` and npm's config-file redirects, `NODE_PATH`, `OPENSSL_CONF`, `NODE_REPL_EXTERNAL_MODULE`, `npm_config_node_gyp`, `npm_config_init_module`, `LD_PRELOAD`, `LD_AUDIT`, `DYLD_INSERT_LIBRARIES`, `BASH_ENV`, `ZDOTDIR`, exported bash function definitions `BASH_FUNC_*`) are likewise never passed to session subprocesses — the daemon scrubs them from its own `process.env` and from the frozen base env that session-hosting children spawn with (the base env keeps them only under the `DEV=true` harness, whose `.ts` entries still need the tsx loader), and `.env` / `settings.json` `env` sources reject them (see [settings](./configuration/settings.md)); this applies to every session the daemon hosts. Base credentials such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `QWEN_*`, and `DASHSCOPE_API_KEY` otherwise pass through unless the runtime overlay changes them. **This is intentional, not a sandbox.** The agent runs as the same UID with shell-tool access, so anything in `~/.bashrc`, `~/.aws/credentials`, or `~/.npmrc` is reachable by prompt injection regardless. Environment isolation between runtimes is not an operating-system security boundary; do not run `qwen serve` under an identity that has credentials you would not trust the agent with.
|
||||
- **Agent text reads are child-local and follow the regular CLI permission rules, not the workspace filesystem boundary.** Direct `read_file` can reach host text paths outside every registered workspace: external paths default to confirmation, and allow rules or approval modes may approve them automatically. Approved reads use the configurable CLI output limits rather than the workspace filesystem's returned-output, full-snapshot, and large-text scan caps. This applies to every shared text-read consumer, so the pre-reads performed by write, edit, notebook, sed, and artifact operations lose those caps together with the workspace filesystem's read audit, symlink rejection, and read-side TOCTOU protections — see [the read design](../design/daemon-local-text-reads.md) for the exact list. Because a confirmation payload is built by reading the file, an out-of-workspace diff is fanned out to **every** attached SSE subscriber before anyone approves it — in the interactive CLI that content is seen only by the person at the terminal. Treat authenticated daemon clients as the same security principal. HTTP filesystem routes remain workspace-scoped and agent discovery-tool behavior is unchanged.
|
||||
|
|
|
|||
3
package-lock.json
generated
3
package-lock.json
generated
|
|
@ -24,7 +24,8 @@
|
|||
"integrations/external-context",
|
||||
"integrations/external-context-mem0",
|
||||
"!packages/desktop-shell",
|
||||
"!packages/live-host"
|
||||
"!packages/live-host",
|
||||
"!packages/mobile-shell"
|
||||
],
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@
|
|||
"integrations/external-context",
|
||||
"integrations/external-context-mem0",
|
||||
"!packages/desktop-shell",
|
||||
"!packages/live-host"
|
||||
"!packages/live-host",
|
||||
"!packages/mobile-shell"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -105,16 +105,22 @@ describe('remote same-origin authentication', () => {
|
|||
installRemoteSelfOriginMiddleware(result, '0.0.0.0', 'secret');
|
||||
result.use(denyBrowserOriginCors);
|
||||
result.get('/assets/app.js', (_req, res) => res.sendStatus(200));
|
||||
result.get('/manifest.webmanifest', (_req, res) => res.sendStatus(200));
|
||||
result.get('/sw.js', (_req, res) => res.sendStatus(200));
|
||||
// Registered ahead of bearerAuth so the unauthenticated same-origin POST
|
||||
// row discriminates the pre-auth predicate's GET/HEAD method gate: with
|
||||
// the gate deleted the Origin is stripped and this route answers 200.
|
||||
result.post('/assets/app.js', (_req, res) => res.sendStatus(200));
|
||||
result.post('/sw.js', (_req, res) => res.sendStatus(200));
|
||||
result.use(bearerAuth('secret'));
|
||||
result.get('/capabilities', (_req, res) => res.sendStatus(200));
|
||||
for (const [method, path, status] of [
|
||||
['get', '/assets/app.js', 200],
|
||||
['get', '/manifest.webmanifest', 200],
|
||||
['get', '/sw.js', 200],
|
||||
['get', '/capabilities', 401],
|
||||
['post', '/assets/app.js', 401],
|
||||
['post', '/sw.js', 401],
|
||||
] as const) {
|
||||
const response = await request(result)
|
||||
[method](path)
|
||||
|
|
|
|||
49
packages/cli/src/serve/web-shell-preauth.test.ts
Normal file
49
packages/cli/src/serve/web-shell-preauth.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Request } from 'express';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isPreAuthWebShellRequest } from './web-shell-preauth.js';
|
||||
|
||||
describe('PWA pre-auth classification', () => {
|
||||
it.each([
|
||||
'/manifest.webmanifest',
|
||||
'/manifest.webmanifest/',
|
||||
'/MANIFEST.WEBMANIFEST',
|
||||
'/sw.js',
|
||||
'/SW.JS/',
|
||||
'/assets/icon-192.png',
|
||||
])('allows GET and HEAD for %s', (path) => {
|
||||
for (const method of ['GET', 'HEAD']) {
|
||||
expect(
|
||||
isPreAuthWebShellRequest({ method, path, headers: {} } as Request),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/sw.js',
|
||||
'/manifest.webmanifest',
|
||||
'/sw.js/extra',
|
||||
'/sw.js%2fextra',
|
||||
'/capabilities',
|
||||
'/session/abc/events',
|
||||
])('does not exempt writes to %s', (path) => {
|
||||
expect(
|
||||
isPreAuthWebShellRequest({
|
||||
method: 'POST',
|
||||
path,
|
||||
headers: {},
|
||||
} as Request),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'/sw.js/extra',
|
||||
'/sw.js%2fextra',
|
||||
'/manifest.webmanifest/extra',
|
||||
'/capabilities',
|
||||
'/session/abc/events',
|
||||
])('does not broaden public paths to %s', (path) => {
|
||||
expect(
|
||||
isPreAuthWebShellRequest({ method: 'GET', path, headers: {} } as Request),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,6 +5,9 @@
|
|||
*/
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { WEB_SHELL_PWA_ASSETS } from '@qwen-code/sdk/daemon';
|
||||
|
||||
export { WEB_SHELL_PWA_ASSETS } from '@qwen-code/sdk/daemon';
|
||||
|
||||
/**
|
||||
* Dependency-light home of the pre-auth Web Shell request discriminators.
|
||||
|
|
@ -56,7 +59,7 @@ const SESSION_DEEP_LINK_PATH = /^\/session\/[^/]+\/?$/u;
|
|||
* data: pre-auth answers serve only the public shell HTML or the MCP App
|
||||
* sandbox proxy, identical to `GET /` (or the startup-failure envelope).
|
||||
* Keep in sync with the routes registered in `mountWebShellAssets` and
|
||||
* `mountMcpAppSandbox`.
|
||||
* `mountMcpAppSandbox`, including the public manifest and service worker.
|
||||
*/
|
||||
export function isPreAuthWebShellRequest(req: Request): boolean {
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') return false;
|
||||
|
|
@ -70,7 +73,12 @@ export function isPreAuthWebShellRequest(req: Request): boolean {
|
|||
reqPath === '//' ||
|
||||
reqPath === '/assets' ||
|
||||
reqPath.startsWith('/assets/') ||
|
||||
reqPath === '/mcp-app-sandbox'
|
||||
reqPath === '/mcp-app-sandbox' ||
|
||||
// Manifest and worker requests cannot attach Authorization. Express
|
||||
// matches these routes case-insensitively with an optional trailing slash.
|
||||
WEB_SHELL_PWA_ASSETS.some(
|
||||
({ route }) => reqPath === route || reqPath === `${route}/`,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
return SESSION_DEEP_LINK_PATH.test(reqPath) && isDocumentNavigation(req);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,23 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
mountWebShellAssets,
|
||||
buildWebShellCsp,
|
||||
buildWebShellPermissionsPolicy,
|
||||
remoteDaemonConnectOrigins,
|
||||
requestedDaemonParam,
|
||||
} from './web-shell-static.js';
|
||||
|
||||
const stderr = vi.hoisted(() => ({ writeStderrLine: vi.fn() }));
|
||||
vi.mock('../utils/stdioHelpers.js', () => stderr);
|
||||
|
||||
describe('Web Shell sandbox framing', () => {
|
||||
it('allows live previews and PDF blobs while retaining shell isolation', () => {
|
||||
const csp = buildWebShellCsp();
|
||||
|
|
@ -188,3 +197,141 @@ describe('Web Shell sandbox framing', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public PWA HTTP routes', () => {
|
||||
let directory: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), 'qwen-pwa-'));
|
||||
await mkdir(path.join(directory, 'assets'));
|
||||
await writeFile(
|
||||
path.join(directory, 'manifest.webmanifest'),
|
||||
'{"name":"Qwen Code"}',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, 'sw.js'),
|
||||
'self.addEventListener("fetch", () => {});',
|
||||
);
|
||||
await writeFile(
|
||||
path.join(directory, 'assets', 'index-abc12345.js'),
|
||||
'export {};',
|
||||
);
|
||||
await writeFile(path.join(directory, 'assets', 'icon-192.png'), 'icon');
|
||||
await writeFile(path.join(directory, 'assets', 'icon.svg'), '<svg/>');
|
||||
await writeFile(path.join(directory, 'assets', 'future-config.json'), '{}');
|
||||
app = express();
|
||||
mountWebShellAssets(app, directory);
|
||||
app.use((_req, res) => {
|
||||
res.status(401).send('Unauthorized');
|
||||
});
|
||||
app.use(
|
||||
(
|
||||
_err: Error,
|
||||
_req: express.Request,
|
||||
res: express.Response,
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
res.status(500).send('Error');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
stderr.writeStderrLine.mockReset();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.each(['/manifest.webmanifest', '/MANIFEST.WEBMANIFEST/'])(
|
||||
'serves %s without a token and permits revalidation',
|
||||
async (url) => {
|
||||
const response = await request(app).get(url).expect(200);
|
||||
expect(response.headers['content-type']).toContain(
|
||||
'application/manifest+json',
|
||||
);
|
||||
expect(JSON.parse(response.text)).toEqual({ name: 'Qwen Code' });
|
||||
expect(response.headers['cache-control']).toBe('no-cache');
|
||||
expect(response.headers['x-content-type-options']).toBe('nosniff');
|
||||
await request(app).head(url).expect(200);
|
||||
await request(app)
|
||||
.get(url)
|
||||
.set('If-None-Match', response.headers['etag'])
|
||||
.expect(304);
|
||||
},
|
||||
);
|
||||
|
||||
it('serves a root worker with the correct type and scope', async () => {
|
||||
const response = await request(app).get('/sw.js').expect(200);
|
||||
expect(response.headers['content-type']).toContain(
|
||||
'application/javascript',
|
||||
);
|
||||
expect(response.headers['service-worker-allowed']).toBe('/');
|
||||
expect(response.headers['cache-control']).toBe('no-cache');
|
||||
expect(response.text).toContain('addEventListener');
|
||||
});
|
||||
|
||||
it('revalidates unhashed icons but keeps hashed build assets immutable', async () => {
|
||||
expect(
|
||||
(await request(app).get('/assets/icon.svg').expect(200)).headers[
|
||||
'cache-control'
|
||||
],
|
||||
).toBe('no-cache');
|
||||
expect(
|
||||
(await request(app).get('/assets/icon-192.png').expect(200)).headers[
|
||||
'cache-control'
|
||||
],
|
||||
).toBe('no-cache');
|
||||
expect(
|
||||
(await request(app).get('/assets/index-abc12345.js').expect(200)).headers[
|
||||
'cache-control'
|
||||
],
|
||||
).toContain('immutable');
|
||||
expect(
|
||||
(await request(app).get('/assets/future-config.json').expect(200))
|
||||
.headers['cache-control'],
|
||||
).toBe('no-cache');
|
||||
});
|
||||
|
||||
it.each(['/sw.js', '/manifest.webmanifest'])(
|
||||
'returns 404, not HTML or a catch-all 500, when %s is absent',
|
||||
async (url) => {
|
||||
await rm(path.join(directory, url.slice(1)));
|
||||
const response = await request(app).get(url).expect(404);
|
||||
expect(response.headers['content-type']).toContain('text/plain');
|
||||
expect(response.text).toBe('Not found');
|
||||
},
|
||||
);
|
||||
|
||||
it('logs a route-specific error when a PWA file cannot be read', async () => {
|
||||
vi.spyOn(express.response, 'sendFile').mockImplementation(function (
|
||||
this: express.Response,
|
||||
...args: unknown[]
|
||||
) {
|
||||
const callback = args.at(-1) as (
|
||||
error: Error & { status: number },
|
||||
) => void;
|
||||
callback(
|
||||
Object.assign(new Error('EACCES: permission denied'), { status: 403 }),
|
||||
);
|
||||
return this;
|
||||
});
|
||||
|
||||
await request(app)
|
||||
.get('/sw.js')
|
||||
.expect(500, 'Failed to load Web Shell asset');
|
||||
expect(stderr.writeStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/sw.js'),
|
||||
);
|
||||
expect(stderr.writeStderrLine).toHaveBeenCalledWith(
|
||||
expect.stringContaining('EACCES: permission denied'),
|
||||
);
|
||||
});
|
||||
|
||||
it('retains authentication for API requests and writes', async () => {
|
||||
await request(app).get('/capabilities').expect(401);
|
||||
await request(app).post('/sw.js').expect(401);
|
||||
await request(app).post('/manifest.webmanifest').expect(401);
|
||||
await request(app).get('/sw.js/extra').expect(401);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import express from 'express';
|
|||
import type { Application, NextFunction, Request, Response } from 'express';
|
||||
import { writeStderrLine } from '../utils/stdioHelpers.js';
|
||||
import { isServeDebugMode } from './debug-mode.js';
|
||||
import { isDocumentNavigation } from './web-shell-preauth.js';
|
||||
import {
|
||||
isDocumentNavigation,
|
||||
WEB_SHELL_PWA_ASSETS,
|
||||
} from './web-shell-preauth.js';
|
||||
export { resolveWebShellDir } from './web-shell-resolver.js';
|
||||
|
||||
/**
|
||||
|
|
@ -185,10 +188,9 @@ function createSendIndex(
|
|||
{ cacheControl: false, dotfiles: 'allow' },
|
||||
(err) => {
|
||||
if (!err) return;
|
||||
// Only 5xx path in the serve app that would otherwise emit nothing —
|
||||
// log it so an operator can see why the shell stopped loading
|
||||
// (EACCES/ESTALE on a network mount, a perms change, a partial
|
||||
// deploy).
|
||||
// Log filesystem failures so an operator can see why the shell stopped
|
||||
// loading (EACCES/ESTALE on a network mount, a permissions change, or
|
||||
// a partial deploy).
|
||||
writeStderrLine(
|
||||
`qwen serve: Web Shell index send failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
|
|
@ -216,6 +218,8 @@ function createSendIndex(
|
|||
* - `GET /` — the HTML shell, always (so `curl /` shows the UI too).
|
||||
* - `GET /session/:id` document navigations — the HTML shell, so a browser
|
||||
* refresh can load before the front-end adds its bearer header.
|
||||
* - `GET /manifest.webmanifest` and `GET /sw.js` — public PWA metadata and
|
||||
* the origin-scoped worker, revalidated on every request.
|
||||
*
|
||||
* `GET /mcp-app-sandbox` is a separate pre-auth route mounted by
|
||||
* `mountMcpAppSandbox` (the iframe proxy, not the shell HTML).
|
||||
|
|
@ -235,8 +239,18 @@ export function mountWebShellAssets(
|
|||
'/assets',
|
||||
express.static(path.join(webShellDir, 'assets'), {
|
||||
index: false,
|
||||
immutable: true,
|
||||
maxAge: '1y',
|
||||
maxAge: 0,
|
||||
setHeaders(res, filePath) {
|
||||
const fileName = path.basename(filePath);
|
||||
// Vite content hashes are the only safe basis for immutable caching.
|
||||
// Future unhashed assets therefore revalidate by default instead of
|
||||
// silently inheriting a one-year lifetime.
|
||||
const contentAddressed = /-[a-zA-Z0-9_-]{8,}\.[^.]+$/u.test(fileName);
|
||||
res.setHeader(
|
||||
'Cache-Control',
|
||||
contentAddressed ? 'public, max-age=31536000, immutable' : 'no-cache',
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
// A request still under /assets here is a missing chunk (e.g. a stale hashed
|
||||
|
|
@ -260,6 +274,43 @@ export function mountWebShellAssets(
|
|||
if (!isDocumentNavigation(req)) return next();
|
||||
sendIndex(req, res);
|
||||
});
|
||||
// Process-global public PWA files carry no daemon credentials or workspace data.
|
||||
for (const {
|
||||
route,
|
||||
contentType,
|
||||
serviceWorkerAllowed,
|
||||
} of WEB_SHELL_PWA_ASSETS) {
|
||||
app.get(route, (_req: Request, res: Response) => {
|
||||
res
|
||||
.set('Content-Type', contentType)
|
||||
.set('Cache-Control', 'no-cache')
|
||||
.set('X-Content-Type-Options', 'nosniff');
|
||||
if (serviceWorkerAllowed) res.set('Service-Worker-Allowed', '/');
|
||||
res.sendFile(
|
||||
path.join(webShellDir, route.slice(1)),
|
||||
{ cacheControl: false, dotfiles: 'allow' },
|
||||
(err) => {
|
||||
if (!err) return;
|
||||
if (res.headersSent) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const status = 'status' in err && err.status === 404 ? 404 : 500;
|
||||
if (status === 500) {
|
||||
writeStderrLine(
|
||||
`qwen serve: Web Shell asset send failed (${route}): ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
res
|
||||
.status(status)
|
||||
.type('text/plain')
|
||||
.send(
|
||||
status === 404 ? 'Not found' : 'Failed to load Web Shell asset',
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
3
packages/mobile-shell/.gitignore
vendored
Normal file
3
packages/mobile-shell/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
58
packages/mobile-shell/README.md
Normal file
58
packages/mobile-shell/README.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Qwen Code Mobile Shell (Android)
|
||||
|
||||
A development-only WebView spike around the daemon-served Web Shell. It has one saved development profile and native bootstrap/error messages. Production profile management, Keystore storage, per-device revocation, background SSE and native notifications are future work. See the [design and reviewer test plan](../../docs/design/mobile-android-shell.md).
|
||||
|
||||
## Build
|
||||
|
||||
Use JDK 17 and Android SDK platform/build tools 34. Configure `ANDROID_HOME` or an untracked `local.properties` containing `sdk.dir=/path/to/android-sdk`. From this directory:
|
||||
|
||||
```bash
|
||||
./gradlew :app:testDebugUnitTest :app:assembleDebug
|
||||
```
|
||||
|
||||
On Windows use `gradlew.bat`. The committed wrapper pins Gradle 8.2.1 and verifies its distribution checksum. The debug APK is `app/build/outputs/apk/debug/app-debug.apk`. This native package is excluded from npm and pnpm workspaces.
|
||||
|
||||
## Development Profile
|
||||
|
||||
A debug installation can be provisioned through Android Studio Device Explorer in the application's private `shared_prefs/qwen_profiles.xml`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<map>
|
||||
<string name="daemon_url">https://daemon.example.com</string>
|
||||
<string name="daemon_token">YOUR_DEVELOPMENT_TOKEN</string>
|
||||
</map>
|
||||
```
|
||||
|
||||
Stop the application before changing preferences and relaunch afterward. Use an HTTP(S) origin with an optional trailing slash; paths, query strings, fragments and user information are rejected. There is no profile editor or multi-profile switching in this spike.
|
||||
|
||||
The token is passed to the H5 in an encoded URL fragment, absent from the navigation request. The H5 uses it for authenticated API calls. A static `--token` or `QWEN_SERVER_TOKEN` avoids daemon-restart invalidation during development. This plaintext development storage is not a production credential solution; backup and device transfer exclude it.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
Android API 26+ and Android System WebView 111+. The provider is checked before constructing a WebView, so unsupported or missing providers receive a native update message.
|
||||
|
||||
Use HTTPS for remote daemons. Cleartext is disabled except for explicit loopback entries; LAN HTTP hosts need explicit network-security configuration. Secure web APIs require HTTPS or a trustworthy loopback origin.
|
||||
|
||||
An emulator's localhost is the emulator itself. For a host daemon on port 4170,
|
||||
run `adb reverse tcp:4170 tcp:4170` and use `http://127.0.0.1:4170` in the
|
||||
profile. **Never use a token-less daemon through this tunnel on a physical
|
||||
device:** every app that can reach the device loopback port can then use the
|
||||
host daemon as you. Start it with bearer authentication, for example
|
||||
`QWEN_SERVER_TOKEN=$(openssl rand -hex 32) qwen serve --require-auth`, and save
|
||||
the same value as `daemon_token` in the development profile. The app trusts
|
||||
system certificate authorities; a host-only or
|
||||
user-installed development CA is not automatically trusted by this WebView.
|
||||
Use a certificate chain trusted by the device. Certificate errors are not bypassed.
|
||||
The first Gradle build needs network access; offline builds require a populated
|
||||
Gradle and SDK cache.
|
||||
|
||||
The H5 loads directly from the daemon, without a local copy. Direct same-origin HTTP API calls need no extra CORS
|
||||
configuration. Reverse proxies and remote terminal/voice WebSocket connections
|
||||
retain the daemon's [origin requirements](../../docs/users/qwen-serve.md#security-threat-model). Same-origin navigation compares scheme, host and effective port. Supported external main-frame links open in other apps; file/content and mixed-content access are disabled. Connection failures show a native Retry screen.
|
||||
|
||||
## Limitations
|
||||
|
||||
This is not a released mobile client. File selection, microphone permission bridging, downloads, new-window handling and renderer-process recovery still need native integrations. System font-scale integration and full pinch-zoom/accessibility acceptance remain follow-ups. No foreground service runs. Phase 2 also requires profile keys and switching, capability checks per connection, credential migration, maintainer-provided per-device revocation, Keystore storage, background SSE and notification permissions.
|
||||
|
||||
JVM tests and APK compilation are separate from emulator/physical-device acceptance. Consult the PR verification report for actual completed checks; source presence does not establish device validation.
|
||||
39
packages/mobile-shell/app/build.gradle.kts
Normal file
39
packages/mobile-shell/app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.qwen.mobileshell"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.qwen.mobileshell"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.23.3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
||||
targetCompatibility = JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.activity:activity-ktx:1.8.2")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
implementation("androidx.webkit:webkit:1.10.0")
|
||||
}
|
||||
31
packages/mobile-shell/app/src/main/AndroidManifest.xml
Normal file
31
packages/mobile-shell/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Required for the WebView to reach the daemon (loopback or LAN or public) -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@android:drawable/sym_def_app_icon"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AppCompat.NoActionBar"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|keyboardHidden|screenSize|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
package com.qwen.mobileshell
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.webkit.JsResult
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.webkit.WebViewCompat
|
||||
|
||||
/** Development shell; production profiles and per-device credentials are Phase 2. */
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var webView: WebView? = null
|
||||
private var activeJsConfirm: AlertDialog? = null
|
||||
private var activeJsResult: JsResult? = null
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Check before constructing WebView: a missing provider needs a native screen.
|
||||
val major = WebViewCompat.getCurrentWebViewPackage(this)?.versionName
|
||||
?.substringBefore('.')?.toIntOrNull()
|
||||
if (major == null || major < 111) {
|
||||
showMessage(
|
||||
"Android System WebView update required",
|
||||
"Qwen Code requires WebView 111 or later. Update your device's WebView provider and relaunch the app.",
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("qwen_profiles", MODE_PRIVATE)
|
||||
val origin = prefs.getString("daemon_url", null)?.takeIf(OriginPolicy::isDaemonRoot)
|
||||
if (origin == null) {
|
||||
showMessage("No valid daemon profile", "This development build requires a saved HTTP(S) daemon origin. Profile setup will be added in Phase 2.")
|
||||
return
|
||||
}
|
||||
val token = prefs.getString("daemon_token", null)
|
||||
val loadUrl = Uri.parse(origin).buildUpon().path("/")
|
||||
.encodedFragment(token?.let { "token=${Uri.encode(it)}" }).build().toString()
|
||||
|
||||
webView = WebView(this).apply {
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
domStorageEnabled = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
allowContentAccess = false
|
||||
allowFileAccess = false
|
||||
setSupportZoom(true)
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
}
|
||||
webChromeClient = object : WebChromeClient() {
|
||||
override fun onJsConfirm(
|
||||
view: WebView,
|
||||
url: String,
|
||||
message: String,
|
||||
result: JsResult,
|
||||
): Boolean {
|
||||
// WebView does not provide a confirmation UI unless the host
|
||||
// handles this callback. Keep window.confirm() semantics for
|
||||
// destructive Web Shell actions instead of silently returning false.
|
||||
activeJsResult?.cancel()
|
||||
activeJsConfirm?.dismiss()
|
||||
activeJsResult = result
|
||||
activeJsConfirm = AlertDialog.Builder(this@MainActivity)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
activeJsResult?.confirm()
|
||||
activeJsResult = null
|
||||
activeJsConfirm = null
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
activeJsResult?.cancel()
|
||||
activeJsResult = null
|
||||
activeJsConfirm = null
|
||||
}
|
||||
.setOnCancelListener {
|
||||
activeJsResult?.cancel()
|
||||
activeJsResult = null
|
||||
activeJsConfirm = null
|
||||
}
|
||||
.create()
|
||||
.also { it.show() }
|
||||
return true
|
||||
}
|
||||
}
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
|
||||
if (OriginPolicy.isSameOrigin(origin, request.url.toString())) return false
|
||||
if (request.isForMainFrame && OriginPolicy.isExternalLink(request.url.toString())) {
|
||||
try {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, request.url))
|
||||
} catch (_: ActivityNotFoundException) {
|
||||
// Some devices have no handler for a valid external link.
|
||||
} catch (_: SecurityException) {
|
||||
// Device policy may prevent opening another app.
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
|
||||
if (request.isForMainFrame) showConnectionError(loadUrl)
|
||||
}
|
||||
|
||||
override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) {
|
||||
if (request.isForMainFrame) showConnectionError(loadUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
setContentView(webView!!)
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
val view = webView
|
||||
if (view != null && view.canGoBack()) {
|
||||
if (view.parent == null) setContentView(view)
|
||||
view.goBack()
|
||||
} else {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
})
|
||||
webView!!.loadUrl(loadUrl)
|
||||
}
|
||||
|
||||
private fun showConnectionError(loadUrl: String) {
|
||||
showMessage("Cannot reach Qwen Code", "Check the daemon address, connection and certificate, then retry.") {
|
||||
webView?.let {
|
||||
setContentView(it)
|
||||
it.loadUrl(loadUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMessage(title: String, message: String, retry: (() -> Unit)? = null) {
|
||||
setContentView(LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val padding = (24 * resources.displayMetrics.density).toInt()
|
||||
setPadding(padding, padding, padding, padding)
|
||||
addView(TextView(context).apply { text = title; textSize = 22f })
|
||||
addView(TextView(context).apply { text = message; textSize = 16f })
|
||||
if (retry != null) addView(Button(context).apply {
|
||||
text = getString(R.string.retry)
|
||||
setOnClickListener { retry() }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
activeJsResult?.cancel()
|
||||
activeJsResult = null
|
||||
activeJsConfirm?.setOnCancelListener(null)
|
||||
activeJsConfirm?.dismiss()
|
||||
activeJsConfirm = null
|
||||
webView?.destroy()
|
||||
webView = null
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package com.qwen.mobileshell
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
internal object OriginPolicy {
|
||||
private fun parseHttp(value: String): URI? = try {
|
||||
URI(value).takeIf {
|
||||
(it.scheme.equals("https", true) || it.scheme.equals("http", true)) &&
|
||||
!it.host.isNullOrBlank() && it.rawUserInfo == null &&
|
||||
(it.port == -1 || it.port in 1..65535)
|
||||
}
|
||||
} catch (_: URISyntaxException) {
|
||||
null
|
||||
}
|
||||
|
||||
fun isDaemonRoot(value: String): Boolean = parseHttp(value)?.let {
|
||||
(it.rawPath.isNullOrEmpty() || it.rawPath == "/") &&
|
||||
it.rawQuery == null && it.rawFragment == null
|
||||
} ?: false
|
||||
|
||||
fun isSameOrigin(origin: String, target: String): Boolean {
|
||||
val left = parseHttp(origin) ?: return false
|
||||
val right = parseHttp(target) ?: return false
|
||||
fun port(uri: URI) = if (uri.port != -1) uri.port else if (uri.scheme.equals("https", true)) 443 else 80
|
||||
return left.scheme.equals(right.scheme, true) && left.host.equals(right.host, true) && port(left) == port(right)
|
||||
}
|
||||
|
||||
fun isExternalLink(value: String): Boolean = parseHttp(value) != null || try {
|
||||
URI(value).scheme.equals("mailto", true)
|
||||
} catch (_: URISyntaxException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Qwen Code</string>
|
||||
<string name="retry">Retry</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<exclude domain="sharedpref" path="qwen_profiles.xml"/>
|
||||
</full-backup-content>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<exclude domain="sharedpref" path="qwen_profiles.xml"/>
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="sharedpref" path="qwen_profiles.xml"/>
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Network security configuration for the Qwen Code Android shell.
|
||||
|
||||
The daemon can be reached over three topologies:
|
||||
1. loopback (localhost / 127.0.0.1) — explicitly allowed below.
|
||||
2. LAN (http://192.168.x.x, http://10.x.x.x, etc.) — explicit cleartext.
|
||||
3. Public TLS endpoint (https://) — default; no special config needed.
|
||||
|
||||
Per maintainer guidance (@wenshao, issue #11704): "Android also blocks cleartext
|
||||
traffic by default from API 28, so a LAN profile needs an explicit network
|
||||
security config entry."
|
||||
|
||||
We block cleartext globally and only allow it for explicitly named domains or
|
||||
IP addresses configured for development LAN access. For now localhost is
|
||||
allowed (used during development) and explicit LAN hosts must be added by the
|
||||
operator if they run a plain-HTTP LAN daemon.
|
||||
|
||||
Public HTTPS profiles require no entry here.
|
||||
-->
|
||||
<network-security-config>
|
||||
<!-- Block cleartext globally (Android 9+ default, enforced on all APIs here). -->
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors>
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</base-config>
|
||||
|
||||
<!-- Allow cleartext for loopback — used in development / local daemon. -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">localhost</domain>
|
||||
<domain includeSubdomains="false">127.0.0.1</domain>
|
||||
<domain includeSubdomains="false">::1</domain>
|
||||
</domain-config>
|
||||
|
||||
<!--
|
||||
LAN profiles: to connect to a plain-HTTP daemon on the local network,
|
||||
add the daemon's hostname or IP here, e.g.:
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="false">192.168.1.100</domain>
|
||||
</domain-config>
|
||||
Or configure the daemon with a TLS certificate and key to use HTTPS.
|
||||
-->
|
||||
</network-security-config>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.qwen.mobileshell
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class OriginPolicyTest {
|
||||
@Test fun comparesParsedOrigins() {
|
||||
val origin = "https://daemon.example"
|
||||
assertTrue(OriginPolicy.isSameOrigin(origin, "https://DAEMON.example:443/session/1"))
|
||||
assertTrue(OriginPolicy.isSameOrigin("http://[::1]:8080", "http://[::1]:8080/?x=1"))
|
||||
for (url in listOf("https://daemon.example.evil/", "https://daemon.example@evil/", "https://user@daemon.example/", "https://daemon.example:444/", "http://daemon.example/", "javascript:alert(1)", "https://daemon.example\\@evil/")) {
|
||||
assertFalse(url, OriginPolicy.isSameOrigin(origin, url))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun validatesSavedDaemonRoots() {
|
||||
for (url in listOf("https://daemon.example", "http://localhost:3000/", "http://[::1]:8080/")) assertTrue(url, OriginPolicy.isDaemonRoot(url))
|
||||
for (url in listOf("", "relative", "file:///tmp/a", "https://user:token@daemon.example", "https://daemon.example/path", "https://daemon.example?token=secret", "https://daemon.example#token=secret", "https://daemon.example:65536")) assertFalse(url, OriginPolicy.isDaemonRoot(url))
|
||||
}
|
||||
|
||||
@Test fun onlyOpensKnownExternalSchemes() {
|
||||
assertTrue(OriginPolicy.isExternalLink("https://example.com/docs"))
|
||||
assertTrue(OriginPolicy.isExternalLink("mailto:help@example.com"))
|
||||
for (url in listOf("intent://example/", "javascript:alert(1)", "file:///etc/passwd", "content://provider/1", "data:text/html,hello")) assertFalse(url, OriginPolicy.isExternalLink(url))
|
||||
}
|
||||
}
|
||||
4
packages/mobile-shell/build.gradle.kts
Normal file
4
packages/mobile-shell/build.gradle.kts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
|
||||
}
|
||||
1
packages/mobile-shell/gradle.properties
Normal file
1
packages/mobile-shell/gradle.properties
Normal file
|
|
@ -0,0 +1 @@
|
|||
android.useAndroidX=true
|
||||
BIN
packages/mobile-shell/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
packages/mobile-shell/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
1
packages/mobile-shell/gradle/wrapper/gradle-wrapper.jar.sha256
vendored
Normal file
1
packages/mobile-shell/gradle/wrapper/gradle-wrapper.jar.sha256
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
81a82aaea5abcc8ff68b3dfcb58b3c3c429378efd98e7433460610fecd7ae45f
|
||||
8
packages/mobile-shell/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
8
packages/mobile-shell/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionSha256Sum=03ec176d388f2aa99defcadc3ac6adf8dd2bce5145a129659537c0874dea5ad1
|
||||
251
packages/mobile-shell/gradlew
vendored
Executable file
251
packages/mobile-shell/gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
|||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
packages/mobile-shell/gradlew.bat
vendored
Normal file
94
packages/mobile-shell/gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
6
packages/mobile-shell/package.json
Normal file
6
packages/mobile-shell/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/mobile-shell",
|
||||
"version": "0.23.3",
|
||||
"private": true,
|
||||
"description": "Android WebView shell for Qwen Code"
|
||||
}
|
||||
16
packages/mobile-shell/settings.gradle.kts
Normal file
16
packages/mobile-shell/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
rootProject.name = "QwenMobileShell"
|
||||
include(":app")
|
||||
|
|
@ -50,6 +50,10 @@ export type { RestSseTransport } from './RestSseTransport.js';
|
|||
// The `NegotiateTransportOptions` *type* stays available from this barrel
|
||||
// for backward compatibility (type-only, no bundle cost).
|
||||
export type { NegotiateTransportOptions } from './negotiateTransport.js';
|
||||
export {
|
||||
WEB_SHELL_PWA_ASSETS,
|
||||
WEB_SHELL_SERVICE_WORKER_ROUTE,
|
||||
} from './pwa-assets.js';
|
||||
export type { JsonRpcNotification } from './AcpEventDenormalizer.js';
|
||||
export type { TransportFactory } from './AutoReconnectTransport.js';
|
||||
export {
|
||||
|
|
|
|||
22
packages/sdk-typescript/src/daemon/pwa-assets.ts
Normal file
22
packages/sdk-typescript/src/daemon/pwa-assets.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* Public Web Shell PWA files mounted before bearer authentication.
|
||||
*
|
||||
* The daemon and standalone browser client both consume this table so their
|
||||
* route, content-type, and service-worker-scope contracts cannot drift apart.
|
||||
*/
|
||||
export const WEB_SHELL_PWA_ASSETS = [
|
||||
{
|
||||
route: '/manifest.webmanifest',
|
||||
contentType: 'application/manifest+json',
|
||||
serviceWorkerAllowed: false,
|
||||
},
|
||||
{
|
||||
route: '/sw.js',
|
||||
contentType: 'application/javascript',
|
||||
serviceWorkerAllowed: true,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const WEB_SHELL_SERVICE_WORKER_ROUTE = WEB_SHELL_PWA_ASSETS.find(
|
||||
({ serviceWorkerAllowed }) => serviceWorkerAllowed,
|
||||
)!.route;
|
||||
|
|
@ -56,6 +56,22 @@ daemon 自身。页面的防嵌入策略可能要求使用外部打开。远程
|
|||
组件包会自动注入自身的 CSS(包括 Tailwind 编译产物),接入方不需要配置
|
||||
Tailwind 或额外引入全局 CSS。
|
||||
|
||||
### Browser Support Matrix
|
||||
|
||||
- Chrome / Edge 111+
|
||||
- Firefox 128+
|
||||
- Safari / iOS 16.4+
|
||||
- Android System WebView 111+
|
||||
|
||||
最低版本覆盖 Tailwind v4 的生成 CSS;JavaScript 构建目标单独设置为 ES2021。
|
||||
独立页面在不支持的浏览器显示升级提示,嵌入式组件由宿主保证该支持约定。
|
||||
此矩阵不是所有最低版本真机均已验证的声明。
|
||||
|
||||
独立生产页面在 HTTPS 或可信 loopback origin 下注册 service worker。
|
||||
仅带内容哈希的构建资源使用 worker 缓存;manifest、公开图标、API、令牌和
|
||||
事件流不缓存。HTML 连接失败时显示 503 重试页,不支持离线会话。
|
||||
安装入口由浏览器决定,不保证自动弹出安装提示。
|
||||
|
||||
## 浏览器任务通知
|
||||
|
||||
通过 `qwen serve` 打开的独立 Web Shell 可在 **Settings → UI → 浏览器任务通知**
|
||||
|
|
|
|||
|
|
@ -1188,13 +1188,15 @@
|
|||
height: 14px;
|
||||
}
|
||||
|
||||
@container (max-width: 699px) {
|
||||
.queuedPromptStateLabel {
|
||||
display: none;
|
||||
}
|
||||
@supports (container-type: inline-size) {
|
||||
@container (max-width: 699px) {
|
||||
.queuedPromptStateLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.queuedPromptState:not(.queuedPromptStateLoading) {
|
||||
display: none;
|
||||
.queuedPromptState:not(.queuedPromptStateLoading) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import postcss, { type Rule } from 'postcss';
|
|||
const DIST_DIR = resolve(__dirname, '../dist');
|
||||
const DIST_PATH = resolve(DIST_DIR, 'index.js');
|
||||
const TRANSCRIPT_DIST_PATH = resolve(DIST_DIR, 'transcript.js');
|
||||
const SERVICE_WORKER_DIST_PATH = resolve(DIST_DIR, 'sw.js');
|
||||
|
||||
function readBundle(): string {
|
||||
return readFileSync(DIST_PATH, 'utf8');
|
||||
|
|
@ -45,6 +46,12 @@ describe('build artifact — package boundary', () => {
|
|||
expect(bundle).not.toContain('@qwen-code/webui');
|
||||
});
|
||||
|
||||
it('keeps standalone service-worker registration out of embedded bundles', () => {
|
||||
expect(readPackageJavascript()).not.toContain(
|
||||
'service worker registration failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('owns the DaemonSessionProvider source code', () => {
|
||||
const bundle = readPackageJavascript();
|
||||
expect(bundle).toContain(
|
||||
|
|
@ -307,6 +314,20 @@ describe('build artifact — package boundary', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('build artifact — standalone PWA', () => {
|
||||
it('emits a classic root service worker tied to the package version', () => {
|
||||
const worker = readFileSync(SERVICE_WORKER_DIST_PATH, 'utf8');
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(resolve(__dirname, '../package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
expect(worker).not.toMatch(/^\s*(?:import|export)\b/m);
|
||||
expect(worker).not.toContain('import.meta');
|
||||
expect(worker).toContain('qwen-code-shell-v1-');
|
||||
expect(worker).toContain(JSON.stringify(packageJson.version));
|
||||
});
|
||||
});
|
||||
|
||||
describe('build artifact — transcript entry (#11031)', () => {
|
||||
// `@qwen-code/web-shell/transcript` exists so the self-contained
|
||||
// `/export html` document renderer can bundle the read-only transcript
|
||||
|
|
|
|||
|
|
@ -61,11 +61,16 @@
|
|||
}
|
||||
|
||||
.container:focus-within .content,
|
||||
.container:has([aria-expanded='true']) .content,
|
||||
.container[data-at-panel-open] .content {
|
||||
border-color: var(--chat-editor-accent-color);
|
||||
}
|
||||
|
||||
@supports (selector(:has(*))) {
|
||||
.container:has([aria-expanded='true']) .content {
|
||||
border-color: var(--chat-editor-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.container[data-image-drag-active] .content {
|
||||
border-color: var(--primary);
|
||||
background: color-mix(
|
||||
|
|
@ -894,6 +899,15 @@
|
|||
white-space: normal;
|
||||
}
|
||||
|
||||
@supports (height: 100dvh) {
|
||||
.tagTooltip {
|
||||
max-height: min(
|
||||
calc(100dvh - 16px),
|
||||
var(--radix-tooltip-content-available-height, calc(100dvh - 16px))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.tagLabel,
|
||||
.tagValue {
|
||||
color: var(--chat-editor-text-primary);
|
||||
|
|
@ -1798,9 +1812,11 @@
|
|||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@container (min-width: 521px) {
|
||||
.contextUsagePercentage {
|
||||
display: inline;
|
||||
@supports (container-type: inline-size) {
|
||||
@container (min-width: 521px) {
|
||||
.contextUsagePercentage {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -519,6 +519,7 @@
|
|||
width: min(320px, calc(100vw - 32px));
|
||||
max-width: min(320px, calc(100vw - 32px)) !important;
|
||||
max-height: min(280px, calc(100vh - 32px));
|
||||
max-height: min(280px, calc(100dvh - 32px));
|
||||
padding: 8px 10px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border, #2a2a2a);
|
||||
|
|
|
|||
|
|
@ -105,10 +105,12 @@
|
|||
* `container-type`; until then keep this block to what actually resolves,
|
||||
* rather than leaving half a responsive rule that silently does nothing.
|
||||
*/
|
||||
@container (max-width: 620px) {
|
||||
.activity,
|
||||
.separator,
|
||||
.elapsed {
|
||||
display: none;
|
||||
@supports (container-type: inline-size) {
|
||||
@container (max-width: 620px) {
|
||||
.activity,
|
||||
.separator,
|
||||
.elapsed {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,6 +226,7 @@
|
|||
.sessionTimelineViewport {
|
||||
width: calc(100% + 32px);
|
||||
max-height: min(640px, max(220px, calc(100vh - 220px)));
|
||||
max-height: min(640px, max(220px, calc(100dvh - 220px)));
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-color: transparent transparent !important;
|
||||
|
|
@ -331,67 +332,69 @@
|
|||
transform: scaleX(2.8);
|
||||
}
|
||||
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:hover)
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:focus-visible)
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(+ .sessionTimelineItem .sessionTimelineButton:hover)
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem .sessionTimelineButton:focus-visible
|
||||
)
|
||||
.sessionTimelineTick {
|
||||
transform: scaleX(2.2);
|
||||
}
|
||||
@supports (selector(:has(*))) {
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:hover)
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:focus-visible)
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(+ .sessionTimelineItem .sessionTimelineButton:hover)
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem .sessionTimelineButton:focus-visible
|
||||
)
|
||||
.sessionTimelineTick {
|
||||
transform: scaleX(2.2);
|
||||
}
|
||||
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:hover)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:focus-visible)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem + .sessionTimelineItem .sessionTimelineButton:hover
|
||||
)
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:hover)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:focus-visible)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem + .sessionTimelineItem .sessionTimelineButton:hover
|
||||
)
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineButton:focus-visible
|
||||
)
|
||||
.sessionTimelineTick {
|
||||
transform: scaleX(1.6);
|
||||
}
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineButton:focus-visible
|
||||
)
|
||||
.sessionTimelineTick {
|
||||
transform: scaleX(1.6);
|
||||
}
|
||||
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:hover)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:focus-visible)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:hover)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineButton:hover
|
||||
)
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(.sessionTimelineButton:focus-visible)
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineButton:hover
|
||||
)
|
||||
.sessionTimelineTick,
|
||||
.sessionTimelineItem:has(
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineButton:focus-visible
|
||||
)
|
||||
.sessionTimelineTick {
|
||||
transform: scaleX(1.2);
|
||||
+ .sessionTimelineItem
|
||||
+ .sessionTimelineItem
|
||||
.sessionTimelineButton:focus-visible
|
||||
)
|
||||
.sessionTimelineTick {
|
||||
transform: scaleX(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
.sessionTimelineButtonInRange .sessionTimelineTick {
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 600px) {
|
||||
.agentGrid[data-column-count='2'],
|
||||
.agentGrid[data-column-count='3'],
|
||||
.agentGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@supports (container-type: inline-size) {
|
||||
@container panel-body (min-width: 600px) {
|
||||
.agentGrid[data-column-count='2'],
|
||||
.agentGrid[data-column-count='3'],
|
||||
.agentGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 900px) {
|
||||
.agentGrid[data-column-count='3'],
|
||||
.agentGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 900px) {
|
||||
.agentGrid[data-column-count='3'],
|
||||
.agentGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.agentGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.agentGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1112,6 +1112,7 @@ button.treeRow:hover {
|
|||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: calc(100vh - 120px);
|
||||
max-height: calc(100dvh - 120px);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -263,9 +263,11 @@
|
|||
box-shadow: 0 1px 3px color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.sessionScopeOption:has(.sessionScopeRadio:focus-visible) {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
@supports (selector(:has(*))) {
|
||||
.sessionScopeOption:has(.sessionScopeRadio:focus-visible) {
|
||||
outline: 2px solid var(--ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.sessionScopeRadio {
|
||||
|
|
|
|||
|
|
@ -340,49 +340,51 @@
|
|||
height: 14px;
|
||||
}
|
||||
|
||||
@container panel-body (max-width: 760px) {
|
||||
.platformGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (max-width: 620px) {
|
||||
.pageBody {
|
||||
padding-inline: 20px;
|
||||
@supports (container-type: inline-size) {
|
||||
@container panel-body (max-width: 760px) {
|
||||
.platformGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
@container panel-body (max-width: 620px) {
|
||||
.pageBody {
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.toolbarActions {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspacePicker {
|
||||
min-width: 0;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.toolbarActions {
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workspacePickerTrigger {
|
||||
width: 100%;
|
||||
}
|
||||
.workspacePicker {
|
||||
min-width: 0;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.channelHeader {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.workspacePickerTrigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardActionGroup {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.channelHeader {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.platformGrid {
|
||||
grid-template-columns: 1fr;
|
||||
.cardActionGroup {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.platformGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,189 +44,22 @@
|
|||
--dialog-overlay: rgba(10, 10, 11, 0.32);
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--web-shell-dialog-backdrop-z-index, 1000);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: var(--dialog-overlay);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.backdrop,
|
||||
.backdrop * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(100%, 560px);
|
||||
.viewportPanel {
|
||||
max-height: min(80vh, calc(100vh - 48px));
|
||||
overflow: hidden;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 90%, transparent);
|
||||
border-radius: 10px;
|
||||
background: var(--background);
|
||||
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
/* The panel receives focus programmatically for keyboard management; suppress
|
||||
the container outline since selection is shown via a roving row highlight. */
|
||||
.panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sizeSm {
|
||||
width: min(100%, 420px);
|
||||
}
|
||||
|
||||
.sizeMd {
|
||||
width: min(100%, 560px);
|
||||
}
|
||||
|
||||
.sizeLg {
|
||||
width: min(100%, 720px);
|
||||
}
|
||||
|
||||
.sizeXl {
|
||||
width: min(100%, 900px);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 46px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.titleWrap {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.close {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus-visible {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.close::before,
|
||||
.close::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 13px;
|
||||
height: 1.5px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.close::before {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.close::after {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
/* Shared header icon-button styling (fullscreen toggle); mirrors .close's hover
|
||||
treatment without the ::before/::after "X". */
|
||||
.iconButton {
|
||||
display: inline-flex;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--muted-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton:hover,
|
||||
.iconButton:focus-visible {
|
||||
background: var(--muted);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 1 auto;
|
||||
max-height: calc(min(80vh, calc(100vh - 48px)) - 46px);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
/* Fullscreen: expand to (near) the whole viewport, overriding the size width
|
||||
and the 80vh caps on the panel and its scrolling body. */
|
||||
.panelFullscreen {
|
||||
width: calc(100vw - 32px);
|
||||
max-width: none;
|
||||
.viewportPanel[data-fullscreen] {
|
||||
height: calc(100vh - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.panelFullscreen .body {
|
||||
max-height: calc(100vh - 32px - 46px);
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.backdrop {
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
@supports (height: 100dvh) {
|
||||
.viewportPanel {
|
||||
max-height: min(80dvh, calc(100dvh - 48px));
|
||||
}
|
||||
|
||||
.panel {
|
||||
max-height: min(80vh, calc(100vh - 32px));
|
||||
}
|
||||
|
||||
/* Keep fullscreen dominant on mobile: `.panel` above has equal specificity
|
||||
and comes later in source order than the base `.panelFullscreen`, so
|
||||
without this it clamps fullscreen back to 80vh on narrow screens. */
|
||||
.panelFullscreen {
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.body {
|
||||
max-height: calc(min(80vh, calc(100vh - 32px)) - 46px);
|
||||
.viewportPanel[data-fullscreen] {
|
||||
height: calc(100dvh - 32px);
|
||||
max-height: calc(100dvh - 32px);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
|
@ -243,16 +245,48 @@ describe('DialogShell', () => {
|
|||
});
|
||||
|
||||
const toggle = document.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Fullscreen"]',
|
||||
'button[aria-label="Fullscreen"]',
|
||||
)!;
|
||||
const panel = document.querySelector<HTMLElement>(
|
||||
'[data-web-shell-dialog]',
|
||||
)!;
|
||||
expect(toggle.querySelector('.lucide-expand')).not.toBeNull();
|
||||
expect(panel.hasAttribute('data-fullscreen')).toBe(false);
|
||||
|
||||
act(() => toggle.click());
|
||||
|
||||
expect(
|
||||
document
|
||||
.querySelector('[aria-label="Exit fullscreen"]')
|
||||
.querySelector('button[aria-label="Exit fullscreen"]')
|
||||
?.querySelector('.lucide-shrink'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-web-shell-dialog]'),
|
||||
).some((candidate) => candidate.hasAttribute('data-fullscreen')),
|
||||
).toBe(true);
|
||||
|
||||
act(() =>
|
||||
document
|
||||
.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Exit fullscreen"]',
|
||||
)!
|
||||
.click(),
|
||||
);
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-web-shell-dialog]'),
|
||||
).every((candidate) => !candidate.hasAttribute('data-fullscreen')),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the fullscreen panel height on the dynamic viewport', () => {
|
||||
const css = readFileSync(
|
||||
resolve(__dirname, 'DialogShell.module.css'),
|
||||
'utf8',
|
||||
);
|
||||
expect(css).toMatch(
|
||||
/@supports \(height: 100dvh\)[\s\S]*\.viewportPanel\[data-fullscreen\][\s\S]*height: calc\(100dvh - 32px\);[\s\S]*max-height: calc\(100dvh - 32px\);/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -188,16 +188,17 @@ export function DialogShell({
|
|||
onMouseUp: handleBackdropMouseUp,
|
||||
onClick: handleBackdropClick,
|
||||
}}
|
||||
className={`${themeClass} ${
|
||||
className={`${styles.viewportPanel} ${themeClass} ${
|
||||
theme === WebShellThemeId.Dark ? 'dark' : ''
|
||||
} flex max-h-[min(80vh,calc(100vh-48px))] flex-col gap-0 overflow-hidden p-0 font-mono text-sm ${
|
||||
} flex flex-col gap-0 overflow-hidden p-0 font-mono text-sm ${
|
||||
fullscreen
|
||||
? 'h-[calc(100vh-32px)] max-h-[calc(100vh-32px)] max-w-[calc(100vw-32px)] sm:max-w-[calc(100vw-32px)]'
|
||||
? 'max-w-[calc(100vw-32px)] sm:max-w-[calc(100vw-32px)]'
|
||||
: sizeClass[size]
|
||||
}`}
|
||||
aria-label={title}
|
||||
data-keyboard-scope
|
||||
data-web-shell-dialog
|
||||
data-fullscreen={fullscreen ? 'true' : undefined}
|
||||
data-web-shell-dialog-title={title}
|
||||
onPointerDownOutside={(event) => event.preventDefault()}
|
||||
onEscapeKeyDown={(event) => {
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 600px) {
|
||||
.extensionGrid[data-column-count='2'],
|
||||
.extensionGrid[data-column-count='3'],
|
||||
.extensionGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@supports (container-type: inline-size) {
|
||||
@container panel-body (min-width: 600px) {
|
||||
.extensionGrid[data-column-count='2'],
|
||||
.extensionGrid[data-column-count='3'],
|
||||
.extensionGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 900px) {
|
||||
.extensionGrid[data-column-count='3'],
|
||||
.extensionGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 900px) {
|
||||
.extensionGrid[data-column-count='3'],
|
||||
.extensionGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.extensionGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.extensionGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,23 +9,25 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 600px) {
|
||||
.serverGrid[data-column-count='2'],
|
||||
.serverGrid[data-column-count='3'],
|
||||
.serverGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@supports (container-type: inline-size) {
|
||||
@container panel-body (min-width: 600px) {
|
||||
.serverGrid[data-column-count='2'],
|
||||
.serverGrid[data-column-count='3'],
|
||||
.serverGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 900px) {
|
||||
.serverGrid[data-column-count='3'],
|
||||
.serverGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 900px) {
|
||||
.serverGrid[data-column-count='3'],
|
||||
.serverGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.serverGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.serverGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -435,8 +435,10 @@
|
|||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@container (max-width: 420px) {
|
||||
.actions > [data-shortcut]::after {
|
||||
display: none;
|
||||
@supports (container-type: inline-size) {
|
||||
@container (max-width: 420px) {
|
||||
.actions > [data-shortcut]::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,7 @@
|
|||
.translationPopover {
|
||||
width: min(480px, calc(100vw - 32px));
|
||||
max-height: min(520px, calc(100vh - 48px));
|
||||
max-height: min(520px, calc(100dvh - 48px));
|
||||
}
|
||||
|
||||
.translationTitle {
|
||||
|
|
|
|||
|
|
@ -586,33 +586,35 @@ tr:hover .frozenCell.selectedCell {
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
@container table-toolbar (max-width: 720px) {
|
||||
.selectionMetricSecondary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container table-toolbar (max-width: 500px) {
|
||||
.compactToolbarLabel,
|
||||
.selectionMetric:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compactToolbarControl,
|
||||
.densityTrigger {
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container table-scroller (max-width: 320px) {
|
||||
.detailPanel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detailItem {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
@supports (container-type: inline-size) {
|
||||
@container table-toolbar (max-width: 720px) {
|
||||
.selectionMetricSecondary {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container table-toolbar (max-width: 500px) {
|
||||
.compactToolbarLabel,
|
||||
.selectionMetric:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.compactToolbarControl,
|
||||
.densityTrigger {
|
||||
width: 28px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding-inline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@container table-scroller (max-width: 320px) {
|
||||
.detailPanel {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.detailItem {
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -381,9 +381,11 @@
|
|||
text-align: left;
|
||||
}
|
||||
|
||||
.node:has(.nodeSummary:focus-visible) {
|
||||
outline: 2px solid var(--node-ring);
|
||||
outline-offset: 2px;
|
||||
@supports (selector(:has(*))) {
|
||||
.node:has(.nodeSummary:focus-visible) {
|
||||
outline: 2px solid var(--node-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.nodeSummary:focus-visible {
|
||||
|
|
@ -735,21 +737,23 @@ button.nestedExecution:not(:disabled):focus-visible {
|
|||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@container (max-width: 760px) {
|
||||
.overview {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
@supports (container-type: inline-size) {
|
||||
@container (max-width: 760px) {
|
||||
.overview {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.progressCard {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.progressCard {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.overviewStat {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.overviewStat {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.overviewStat:nth-child(2) {
|
||||
border-left: 0;
|
||||
.overviewStat:nth-child(2) {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -838,42 +838,44 @@
|
|||
|
||||
/* ---------- narrow container ---------- */
|
||||
|
||||
@container (max-width: 680px) {
|
||||
.historyBar,
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@supports (container-type: inline-size) {
|
||||
@container (max-width: 680px) {
|
||||
.historyBar,
|
||||
.toolbar {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.historyActions,
|
||||
.toolbarActions {
|
||||
width: 100%;
|
||||
}
|
||||
.historyActions,
|
||||
.toolbarActions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.historyTools {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
.historyTools {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.historyTools > span {
|
||||
display: none;
|
||||
}
|
||||
.historyTools > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyRunSelect {
|
||||
grid-template-columns: minmax(128px, 1fr) 60px 44px;
|
||||
}
|
||||
.historyRunSelect {
|
||||
grid-template-columns: minmax(128px, 1fr) 60px 44px;
|
||||
}
|
||||
|
||||
.historyRunSelect > :nth-child(4),
|
||||
.historyRunSelect > :nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
.historyRunSelect > :nth-child(4),
|
||||
.historyRunSelect > :nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.workbench {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.inspector {
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 0;
|
||||
.inspector {
|
||||
border-top: 1px solid var(--border);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import postcss from 'postcss';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// jsdom does not compute the CSS cascade, so pin the stylesheet's source
|
||||
|
|
@ -15,23 +16,45 @@ const sidebarCss = readFileSync(
|
|||
'utf8',
|
||||
).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
|
||||
// Top-level @media blocks close with `}` at column 0, so a lazy match up to
|
||||
// the first such brace captures exactly one block.
|
||||
function mediaBlock(query: string): string {
|
||||
const match = sidebarCss.match(
|
||||
new RegExp(`@media ${query} \\{([\\s\\S]*?)\\n\\}`),
|
||||
);
|
||||
return match?.[1] ?? '';
|
||||
/**
|
||||
* The :has() rules live inside @supports blocks with nested @media, so the
|
||||
* stylesheet is parsed instead of sliced with regex. `media(query)` returns
|
||||
* the text of the top-level @media blocks matching the query (nested blocks
|
||||
* belong to capability-guarded rule groups, not to the row-action layout
|
||||
* contract this test pins); `withoutAll(query)` is the stylesheet with every
|
||||
* @media block matching the query removed, nested ones included.
|
||||
*/
|
||||
function parseOnce() {
|
||||
return postcss.parse(sidebarCss);
|
||||
}
|
||||
|
||||
const hoverMedia = mediaBlock('\\(hover: hover\\)');
|
||||
const nonHoverCss = sidebarCss.replace(
|
||||
/@media \(hover: hover\) \{[\s\S]*?\n\}/,
|
||||
'',
|
||||
);
|
||||
function mediaBlocks(query: RegExp): string[] {
|
||||
const blocks: string[] = [];
|
||||
parseOnce().walkAtRules('media', (rule) => {
|
||||
if (!query.test(rule.params)) return;
|
||||
if (rule.parent?.type !== 'root') return;
|
||||
blocks.push(rule.toString());
|
||||
});
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function withoutMedia(query: RegExp): string {
|
||||
const root = parseOnce();
|
||||
root.walkAtRules('media', (rule) => {
|
||||
if (query.test(rule.params)) rule.remove();
|
||||
});
|
||||
return root.toString();
|
||||
}
|
||||
|
||||
function mediaText(query: RegExp): string {
|
||||
return mediaBlocks(query).join('\n');
|
||||
}
|
||||
|
||||
const hoverMedia = mediaText(/hover: hover/);
|
||||
const nonHoverCss = withoutMedia(/hover: hover/);
|
||||
// The repo's touch query, matching TOUCH_COMPOSER_QUERY in
|
||||
// client/hooks/useIsTouchComposer.ts.
|
||||
const touchMedia = mediaBlock('\\(hover: none\\) and \\(pointer: coarse\\)');
|
||||
const touchMedia = mediaText(/hover: none/);
|
||||
|
||||
describe('WebShellSidebar session row actions stylesheet', () => {
|
||||
it('keeps the hidden actions overlay inert until revealed', () => {
|
||||
|
|
|
|||
|
|
@ -1173,10 +1173,17 @@
|
|||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.sessionMetaSlot:not(
|
||||
:has(.sessionLoading, .sessionAttention, .sessionGitIcon, .sessionSourceIcon)
|
||||
) {
|
||||
padding-right: 10px;
|
||||
@supports (selector(:has(*))) {
|
||||
.sessionMetaSlot:not(
|
||||
:has(
|
||||
.sessionLoading,
|
||||
.sessionAttention,
|
||||
.sessionGitIcon,
|
||||
.sessionSourceIcon
|
||||
)
|
||||
) {
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.sessionAttention {
|
||||
|
|
@ -1236,29 +1243,40 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sessionActions:focus-within,
|
||||
.sessionActions:has([data-state='open']) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
@supports (selector(:has(*))) {
|
||||
.sessionActions:focus-within,
|
||||
.sessionActions:has([data-state='open']) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within),
|
||||
.sessionMetaSlot:has([data-state='open']) {
|
||||
min-width: var(--session-actions-width, 78px);
|
||||
}
|
||||
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionGitIcon,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionGitIcon,
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionLoading,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionLoading,
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionAttention,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionAttention,
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionSourceIcon,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionSourceIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sessionRow:hover:not(.runningSession)
|
||||
.sessionMetaSlot:has(.sessionActions)
|
||||
.sessionSourceIcon,
|
||||
.sessionMetaSlot:has(.sessionActions):hover .sessionSourceIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within),
|
||||
.sessionMetaSlot:has([data-state='open']) {
|
||||
min-width: var(--session-actions-width, 78px);
|
||||
}
|
||||
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionGitIcon,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionGitIcon,
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionLoading,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionLoading,
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionAttention,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionAttention,
|
||||
.sessionMetaSlot:has(.sessionActions:focus-within) .sessionSourceIcon,
|
||||
.sessionMetaSlot:has([data-state='open']) .sessionSourceIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sessionRow:hover .sessionMetaSlot {
|
||||
min-width: var(--session-actions-width, 78px);
|
||||
}
|
||||
|
|
@ -1267,11 +1285,7 @@
|
|||
.sessionRow:hover:not(.runningSession) .sessionAttention,
|
||||
.sessionMetaSlot:hover .sessionGitIcon,
|
||||
.sessionMetaSlot:hover .sessionLoading,
|
||||
.sessionMetaSlot:hover .sessionAttention,
|
||||
.sessionRow:hover:not(.runningSession)
|
||||
.sessionMetaSlot:has(.sessionActions)
|
||||
.sessionSourceIcon,
|
||||
.sessionMetaSlot:has(.sessionActions):hover .sessionSourceIcon {
|
||||
.sessionMetaSlot:hover .sessionAttention {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
|
@ -1416,6 +1430,7 @@
|
|||
z-index: 1100;
|
||||
width: 240px;
|
||||
max-height: min(320px, calc(100vh - 16px));
|
||||
max-height: min(320px, calc(100dvh - 16px));
|
||||
padding: 6px;
|
||||
border: 1px solid var(--sidebar-border);
|
||||
border-radius: 8px;
|
||||
|
|
|
|||
|
|
@ -4,23 +4,25 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 600px) {
|
||||
.skillGrid[data-column-count='2'],
|
||||
.skillGrid[data-column-count='3'],
|
||||
.skillGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@supports (container-type: inline-size) {
|
||||
@container panel-body (min-width: 600px) {
|
||||
.skillGrid[data-column-count='2'],
|
||||
.skillGrid[data-column-count='3'],
|
||||
.skillGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 900px) {
|
||||
.skillGrid[data-column-count='3'],
|
||||
.skillGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 900px) {
|
||||
.skillGrid[data-column-count='3'],
|
||||
.skillGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.skillGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@container panel-body (min-width: 1200px) {
|
||||
.skillGrid[data-column-count='4'] {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,10 @@ import { readIndexHtml } from './test/indexHtmlTestUtils';
|
|||
* a simulated order: a MutationObserver built at install time is constructed
|
||||
* against a #root that has not been parsed yet.
|
||||
*/
|
||||
function bootDocument(): JSDOM {
|
||||
function bootDocument(
|
||||
userAgent?: string,
|
||||
unsupportedFeature?: [string, string?],
|
||||
): JSDOM {
|
||||
// Drop only the module script's src: jsdom would try to fetch /main.tsx,
|
||||
// and the app never mounting is precisely the state under test.
|
||||
const html = readIndexHtml().replace(' src="/main.tsx"', '');
|
||||
|
|
@ -23,6 +26,20 @@ function bootDocument(): JSDOM {
|
|||
return new JSDOM(html, {
|
||||
runScripts: 'dangerously',
|
||||
url: 'http://localhost:5173/',
|
||||
beforeParse(window) {
|
||||
Object.defineProperty(window, 'CSS', {
|
||||
value: {
|
||||
supports: (property: string, value?: string) =>
|
||||
!unsupportedFeature ||
|
||||
property !== unsupportedFeature[0] ||
|
||||
value !== unsupportedFeature[1],
|
||||
},
|
||||
});
|
||||
if (userAgent)
|
||||
Object.defineProperty(window.navigator, 'userAgent', {
|
||||
value: userAgent,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +62,75 @@ function fallback(dom: JSDOM): Element | null {
|
|||
}
|
||||
|
||||
describe('boot watchdog in a really parsed document', () => {
|
||||
it.each([
|
||||
'Mozilla/5.0 Chrome/110.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 Firefox/127.0',
|
||||
'Mozilla/5.0 Version/16.3 Mobile/15E148 Safari/604.1',
|
||||
])(
|
||||
'shows one update screen inside the real root for %s',
|
||||
async (userAgent) => {
|
||||
const dom = bootDocument(userAgent);
|
||||
await new Promise((resolve) => dom.window.queueMicrotask(resolve));
|
||||
expect(
|
||||
root(dom).querySelector('[data-boot-fallback]')?.textContent,
|
||||
).toContain('Browser update required');
|
||||
expect(
|
||||
root(dom).querySelector('[data-boot-fallback]')?.textContent,
|
||||
).toContain('111+');
|
||||
expect(
|
||||
dom.window.document.querySelectorAll('[data-boot-fallback]'),
|
||||
).toHaveLength(1);
|
||||
dom.window.close();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'Mozilla/5.0 Chrome/111.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 Firefox/128.0',
|
||||
'Mozilla/5.0 Version/16.4 Mobile/15E148 Safari/604.1',
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 7 Build/UP1A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/113.0.0.0 Mobile Safari/537.36',
|
||||
])('permits the declared browser floor for %s', async (userAgent) => {
|
||||
const dom = bootDocument(userAgent);
|
||||
await new Promise((resolve) => dom.window.queueMicrotask(resolve));
|
||||
expect(
|
||||
dom.window.document.documentElement.hasAttribute(
|
||||
'data-web-shell-unsupported-browser',
|
||||
),
|
||||
).toBe(false);
|
||||
expect(fallback(dom)).toBeNull();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['color', 'oklch(0.5 0.1 90)'],
|
||||
['color', 'color-mix(in srgb, red, blue)'],
|
||||
['height', '100dvh'],
|
||||
['container-type', 'inline-size'],
|
||||
['selector(:has(*))', undefined],
|
||||
] as const)(
|
||||
'rejects a browser missing CSS.supports(%s, %s)',
|
||||
async (property, value) => {
|
||||
const feature: [string, string?] = [property, value];
|
||||
const dom = bootDocument(
|
||||
'Mozilla/5.0 Chrome/111.0.0.0 Safari/537.36',
|
||||
feature,
|
||||
);
|
||||
await new Promise((resolve) => dom.window.queueMicrotask(resolve));
|
||||
expect(fallback(dom)?.textContent).toContain('Browser update required');
|
||||
dom.window.close();
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps the update reason when a resource fails on an unsupported browser', () => {
|
||||
const dom = bootDocument('Mozilla/5.0 Chrome/110.0.0.0 Safari/537.36');
|
||||
failResource(dom, 'http://localhost:5173/main.tsx', 'network failed');
|
||||
expect(fallback(dom)?.textContent).toContain('Please update your browser');
|
||||
expect(fallback(dom)?.textContent).not.toContain(
|
||||
'A required resource failed to load',
|
||||
);
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
it('renders the fallback when a module fails to load', () => {
|
||||
const dom = bootDocument();
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,54 @@
|
|||
-->
|
||||
<meta name="theme-color" content="#0d0d0d" />
|
||||
<title>Qwen Code Web chat</title>
|
||||
<!--
|
||||
PWA manifest: makes the Web Shell installable on Android / iOS.
|
||||
start_url is intentionally relative ("/") so it resolves against
|
||||
whatever daemon origin serves this page — never hard-coded.
|
||||
The manifest itself is served by the daemon's static handler at /manifest.webmanifest.
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<!-- The CSS floor follows Tailwind v4, independently of the ES2021 JS target.
|
||||
Keep this guard ES5 so unsupported engines can still show the fallback. -->
|
||||
<!-- prettier-ignore -->
|
||||
<script>
|
||||
!(function () {
|
||||
try {
|
||||
var ua = navigator.userAgent;
|
||||
var chromium = ua.match(/Chrom(?:e|ium)\/(\d+)/);
|
||||
var firefox = ua.match(/Firefox\/(\d+)/);
|
||||
// Android WebView also carries `Version/4.0 ... Safari/`, but its
|
||||
// actual engine version is the Chromium token handled above.
|
||||
var safari =
|
||||
!chromium && ua.match(/Version\/(\d+)(?:\.(\d+))?.*Safari\//);
|
||||
var unsupported =
|
||||
(chromium && parseInt(chromium[1], 10) < 111) ||
|
||||
(firefox && parseInt(firefox[1], 10) < 128) ||
|
||||
(safari &&
|
||||
(parseInt(safari[1], 10) < 16 ||
|
||||
(parseInt(safari[1], 10) === 16 &&
|
||||
parseInt(safari[2] || '0', 10) < 4)));
|
||||
if (window.CSS && typeof window.CSS.supports === 'function') {
|
||||
unsupported =
|
||||
unsupported ||
|
||||
!window.CSS.supports('color', 'oklch(0.5 0.1 90)') ||
|
||||
!window.CSS.supports('color', 'color-mix(in srgb, red, blue)') ||
|
||||
!window.CSS.supports('height', '100dvh') ||
|
||||
!window.CSS.supports('container-type', 'inline-size') ||
|
||||
!window.CSS.supports('selector(:has(*))');
|
||||
}
|
||||
if (unsupported) {
|
||||
document.documentElement.setAttribute(
|
||||
'data-web-shell-unsupported-browser',
|
||||
'Please update your browser. Qwen Code requires Chrome/Edge or Android System WebView 111+, Firefox 128+, or Safari/iOS 16.4+. / 请更新浏览器:Qwen Code 需要 Chrome/Edge 或 Android System WebView 111+、Firefox 128+ 或 Safari/iOS 16.4+。'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Unknown engines can try to load; the boot watchdog reports failures.
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Apply the stored theme class + theme-color before first paint so the
|
||||
<html> background matches the app theme on mobile. Without this, the
|
||||
|
|
@ -34,7 +82,7 @@
|
|||
</script>
|
||||
<!--
|
||||
Favicon is inlined as a data: URI rather than a /favicon.svg file because
|
||||
the daemon's static server only exposes /assets/* and / (see
|
||||
the daemon's public static routes explicitly allow shell/PWA paths (see
|
||||
packages/cli/src/serve/web-shell-static.ts) — a file at the dist root
|
||||
would never be reachable. The data: URI is allowed by the shell CSP
|
||||
(img-src 'self' data: blob:). The encodeURIComponent(svg) encoding
|
||||
|
|
@ -132,6 +180,7 @@
|
|||
a settled boot. The fallback box, once shown, is sticky, so a late
|
||||
timer never overwrites a more specific earlier reason.
|
||||
-->
|
||||
<!-- prettier-ignore -->
|
||||
<script>
|
||||
!(function () {
|
||||
var GRACE_MS = 15000;
|
||||
|
|
@ -178,6 +227,10 @@
|
|||
function show(reason) {
|
||||
var r = rootEl();
|
||||
if (!r || appMounted() || fallbackPresent()) return;
|
||||
var unsupported = document.documentElement.getAttribute(
|
||||
'data-web-shell-unsupported-browser'
|
||||
);
|
||||
if (unsupported) reason = unsupported;
|
||||
var dark = document.documentElement.classList.contains('theme-dark');
|
||||
var box = document.createElement('div');
|
||||
box.setAttribute('data-boot-fallback', '');
|
||||
|
|
@ -188,10 +241,13 @@
|
|||
(dark ? '#e5e5e5' : '#1a1a1a');
|
||||
var h = document.createElement('h1');
|
||||
h.style.cssText = 'font-size:18px;margin:0';
|
||||
h.textContent = 'Web Shell 加载失败 / failed to load';
|
||||
h.textContent = document.documentElement.hasAttribute(
|
||||
'data-web-shell-unsupported-browser'
|
||||
)
|
||||
? 'Browser update required / 请更新浏览器'
|
||||
: 'Web Shell 加载失败 / failed to load';
|
||||
var p = document.createElement('p');
|
||||
p.style.cssText =
|
||||
'margin:0;font-size:13px;opacity:.75;max-width:560px';
|
||||
p.style.cssText = 'margin:0;font-size:13px;opacity:.75;max-width:560px';
|
||||
p.textContent = reason;
|
||||
box.appendChild(h);
|
||||
box.appendChild(p);
|
||||
|
|
@ -252,16 +308,14 @@
|
|||
show(
|
||||
'A required resource failed to load: ' +
|
||||
msg +
|
||||
'. This usually happens right after a dev-server or daemon restart — reload the page.',
|
||||
'. This usually happens right after a dev-server or daemon restart — reload the page.'
|
||||
);
|
||||
}
|
||||
}
|
||||
function onRejection(e) {
|
||||
var reason = e && e.reason;
|
||||
pushError(
|
||||
String(
|
||||
(reason && reason.message) || reason || 'unhandled rejection',
|
||||
),
|
||||
String((reason && reason.message) || reason || 'unhandled rejection')
|
||||
);
|
||||
}
|
||||
function installObserver() {
|
||||
|
|
@ -281,6 +335,13 @@
|
|||
}
|
||||
return;
|
||||
}
|
||||
var unsupported = document.documentElement.getAttribute(
|
||||
'data-web-shell-unsupported-browser'
|
||||
);
|
||||
if (unsupported) {
|
||||
show(unsupported);
|
||||
return;
|
||||
}
|
||||
// A mount that beat this install would never produce a record.
|
||||
if (appMounted()) {
|
||||
uninstall();
|
||||
|
|
@ -300,7 +361,7 @@
|
|||
show(
|
||||
'The app did not start within ' +
|
||||
GRACE_MS / 1000 +
|
||||
's. The daemon may be restarting or its access token may have rotated — reload the page.',
|
||||
's. The daemon may be restarting or its access token may have rotated — reload the page.'
|
||||
);
|
||||
}
|
||||
// The grace period is over either way: mounted, fallback shown, or
|
||||
|
|
|
|||
|
|
@ -56,9 +56,28 @@ describe('web shell boot', () => {
|
|||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
document.documentElement.removeAttribute(
|
||||
'data-web-shell-unsupported-browser',
|
||||
);
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('keeps the native HTML update message on unsupported browsers', async () => {
|
||||
document.documentElement.setAttribute(
|
||||
'data-web-shell-unsupported-browser',
|
||||
'Update required',
|
||||
);
|
||||
document.body.innerHTML =
|
||||
'<div id="root"><div data-boot-fallback>Update required</div></div>';
|
||||
await import('./main');
|
||||
expect(testState.containers).toHaveLength(0);
|
||||
expect(testState.resolveToken).toBeUndefined();
|
||||
expect(testState.removeToken).toHaveBeenCalledOnce();
|
||||
expect(document.querySelector('[data-boot-fallback]')?.textContent).toBe(
|
||||
'Update required',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps only the fragment token when the daemon target is invalid', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Load resets before any component can import CSS modules.
|
||||
import './styles/globals.css';
|
||||
import React from 'react';
|
||||
import { scheduleServiceWorkerRegistration } from './pwa-registration.js';
|
||||
import { StandaloneContext } from './config/standalone';
|
||||
import { isKnownDaemonTarget } from './config/daemon';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
|
@ -423,11 +424,6 @@ async function main() {
|
|||
const storedToken = INVALID_DAEMON_TARGET
|
||||
? undefined
|
||||
: getDaemonToken(baseUrl);
|
||||
const daemonToken =
|
||||
storedToken ??
|
||||
(!INVALID_DAEMON_TARGET && baseUrl === window.location.origin
|
||||
? await waitForDaemonTokenMessage()
|
||||
: undefined);
|
||||
if (INVALID_DAEMON_TARGET) {
|
||||
// Keep a fragment token for recovery, but never leave a server-visible
|
||||
// query token in the address bar or history.
|
||||
|
|
@ -439,6 +435,18 @@ async function main() {
|
|||
} else {
|
||||
removeDaemonTokenFromUrl();
|
||||
}
|
||||
// The native bootstrap may declare the WebView unsupported. Scrub a URL
|
||||
// token first, but leave its update message in place instead of mounting
|
||||
// React or waiting for the daemon-token handshake.
|
||||
if (
|
||||
document.documentElement.hasAttribute('data-web-shell-unsupported-browser')
|
||||
)
|
||||
return;
|
||||
const daemonToken =
|
||||
storedToken ??
|
||||
(!INVALID_DAEMON_TARGET && baseUrl === window.location.origin
|
||||
? await waitForDaemonTokenMessage()
|
||||
: undefined);
|
||||
|
||||
const container = document.getElementById('root');
|
||||
// Boot can outlast the watchdog's grace period (a slow daemon, a token
|
||||
|
|
@ -469,3 +477,7 @@ async function main() {
|
|||
}
|
||||
|
||||
void main();
|
||||
|
||||
// Deferred to `load` so registration does not compete with the initial module
|
||||
// graph. The helper also guards secure-context and service-worker support.
|
||||
scheduleServiceWorkerRegistration({ production: import.meta.env.PROD });
|
||||
|
|
|
|||
BIN
packages/web-shell/client/public/assets/icon-192.png
Normal file
BIN
packages/web-shell/client/public/assets/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
packages/web-shell/client/public/assets/icon-512.png
Normal file
BIN
packages/web-shell/client/public/assets/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
6
packages/web-shell/client/public/assets/icon.svg
Normal file
6
packages/web-shell/client/public/assets/icon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 141.38 140">
|
||||
<path
|
||||
fill="#6D44E8"
|
||||
d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 933 B |
38
packages/web-shell/client/public/manifest.webmanifest
Normal file
38
packages/web-shell/client/public/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
|
||||
"name": "Qwen Code",
|
||||
"short_name": "Qwen Code",
|
||||
"description": "Qwen Code — AI coding assistant web shell",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0d0d0d",
|
||||
"theme_color": "#0d0d0d",
|
||||
"lang": "en",
|
||||
"dir": "ltr",
|
||||
"categories": ["developer tools", "productivity"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/assets/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/assets/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"screenshots": [],
|
||||
"related_applications": [],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
65
packages/web-shell/client/pwa-assets.test.ts
Normal file
65
packages/web-shell/client/pwa-assets.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { WEB_SHELL_PWA_ASSETS } from '@qwen-code/sdk/daemon';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const PUBLIC_DIR = resolve(__dirname, 'public');
|
||||
|
||||
describe('committed PWA assets', () => {
|
||||
it('keeps the install manifest identity, scope, and icons valid', () => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(resolve(PUBLIC_DIR, 'manifest.webmanifest'), 'utf8'),
|
||||
) as {
|
||||
name: string;
|
||||
short_name: string;
|
||||
start_url: string;
|
||||
scope: string;
|
||||
display: string;
|
||||
icons: Array<{
|
||||
src: string;
|
||||
sizes: string;
|
||||
type: string;
|
||||
purpose: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'Qwen Code',
|
||||
short_name: 'Qwen Code',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
});
|
||||
expect(manifest.scope).toBe(manifest.start_url);
|
||||
expect(manifest.icons).toEqual([
|
||||
{
|
||||
src: '/assets/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/assets/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: '/assets/icon.svg',
|
||||
sizes: 'any',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
]);
|
||||
for (const { src } of manifest.icons) {
|
||||
expect(existsSync(resolve(PUBLIC_DIR, src.slice(1)))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the same public route table as the daemon', () => {
|
||||
expect(WEB_SHELL_PWA_ASSETS.map(({ route }) => route)).toEqual([
|
||||
'/manifest.webmanifest',
|
||||
'/sw.js',
|
||||
]);
|
||||
});
|
||||
});
|
||||
51
packages/web-shell/client/pwa-registration.test.ts
Normal file
51
packages/web-shell/client/pwa-registration.test.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { WEB_SHELL_SERVICE_WORKER_ROUTE } from '@qwen-code/sdk/daemon';
|
||||
import { scheduleServiceWorkerRegistration } from './pwa-registration.js';
|
||||
|
||||
function registrationHarness({
|
||||
production = true,
|
||||
unsupported = false,
|
||||
}: {
|
||||
production?: boolean;
|
||||
unsupported?: boolean;
|
||||
} = {}) {
|
||||
let loadListener: (() => void) | undefined;
|
||||
const register = vi.fn().mockResolvedValue(undefined);
|
||||
const addLoadListener = vi.fn((listener: () => void) => {
|
||||
loadListener = listener;
|
||||
});
|
||||
|
||||
scheduleServiceWorkerRegistration({
|
||||
production,
|
||||
root: { hasAttribute: () => unsupported },
|
||||
serviceWorker: { register },
|
||||
addLoadListener,
|
||||
});
|
||||
|
||||
return { addLoadListener, register, runLoad: () => loadListener?.() };
|
||||
}
|
||||
|
||||
describe('standalone PWA registration', () => {
|
||||
it('registers the shared worker route after load in production', () => {
|
||||
const harness = registrationHarness();
|
||||
expect(harness.register).not.toHaveBeenCalled();
|
||||
harness.runLoad();
|
||||
expect(harness.register).toHaveBeenCalledWith(
|
||||
WEB_SHELL_SERVICE_WORKER_ROUTE,
|
||||
{ scope: '/' },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not register in a browser rejected by the feature gate', () => {
|
||||
const harness = registrationHarness({ unsupported: true });
|
||||
expect(harness.addLoadListener).not.toHaveBeenCalled();
|
||||
harness.runLoad();
|
||||
expect(harness.register).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not register outside production', () => {
|
||||
const harness = registrationHarness({ production: false });
|
||||
expect(harness.addLoadListener).not.toHaveBeenCalled();
|
||||
expect(harness.register).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
36
packages/web-shell/client/pwa-registration.ts
Normal file
36
packages/web-shell/client/pwa-registration.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { WEB_SHELL_SERVICE_WORKER_ROUTE } from '@qwen-code/sdk/daemon';
|
||||
|
||||
interface ServiceWorkerRegistrationOptions {
|
||||
production: boolean;
|
||||
root?: Pick<HTMLElement, 'hasAttribute'>;
|
||||
serviceWorker?: Pick<ServiceWorkerContainer, 'register'>;
|
||||
addLoadListener?: (listener: () => void) => void;
|
||||
warn?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
/** Schedule standalone PWA registration after the initial document load. */
|
||||
export function scheduleServiceWorkerRegistration({
|
||||
production,
|
||||
root = document.documentElement,
|
||||
serviceWorker = typeof navigator !== 'undefined' &&
|
||||
'serviceWorker' in navigator
|
||||
? navigator.serviceWorker
|
||||
: undefined,
|
||||
addLoadListener = (listener) => window.addEventListener('load', listener),
|
||||
warn = (...args) => console.warn(...args),
|
||||
}: ServiceWorkerRegistrationOptions): void {
|
||||
if (
|
||||
!production ||
|
||||
root.hasAttribute('data-web-shell-unsupported-browser') ||
|
||||
!serviceWorker
|
||||
)
|
||||
return;
|
||||
|
||||
addLoadListener(() => {
|
||||
void serviceWorker
|
||||
.register(WEB_SHELL_SERVICE_WORKER_ROUTE, { scope: '/' })
|
||||
.catch((err: unknown) => {
|
||||
warn('qwen-code: service worker registration failed:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
187
packages/web-shell/client/service-worker.test.ts
Normal file
187
packages/web-shell/client/service-worker.test.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { runInNewContext } from 'node:vm';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const source = readFileSync(new URL('./sw.js', import.meta.url), 'utf8');
|
||||
|
||||
function basicResponse(body: string, init?: ResponseInit) {
|
||||
const response = new Response(body, init);
|
||||
Object.defineProperty(response, 'type', { value: 'basic' });
|
||||
return response;
|
||||
}
|
||||
|
||||
function worker() {
|
||||
const fetch = vi.fn();
|
||||
const store = new Map<string, Response>();
|
||||
const listeners = new Map<string, (event: unknown) => void>();
|
||||
const cache = {
|
||||
match: vi.fn(async (request: Request) => store.get(request.url)?.clone()),
|
||||
put: vi.fn(async (request: Request, response: Response) => {
|
||||
store.set(request.url, response);
|
||||
}),
|
||||
};
|
||||
const caches = {
|
||||
open: vi.fn(async () => cache),
|
||||
keys: vi.fn(async (): Promise<string[]> => []),
|
||||
delete: vi.fn(async () => true),
|
||||
};
|
||||
const self = {
|
||||
location: { origin: 'https://qwen.example' },
|
||||
addEventListener: (type: string, listener: (event: unknown) => void) =>
|
||||
listeners.set(type, listener),
|
||||
skipWaiting: vi.fn(async () => undefined),
|
||||
clients: { claim: vi.fn(async () => undefined) },
|
||||
};
|
||||
runInNewContext(source, { self, caches, fetch, URL, Response });
|
||||
function fetchEvent(path: string, overrides: Record<string, unknown> = {}) {
|
||||
const respondWith = vi.fn();
|
||||
const pending: Array<Promise<unknown>> = [];
|
||||
const request = {
|
||||
url: new URL(path, self.location.origin).href,
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
headers: new Headers(),
|
||||
...overrides,
|
||||
};
|
||||
listeners.get('fetch')!({
|
||||
request,
|
||||
respondWith,
|
||||
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
|
||||
});
|
||||
return { respondWith, request, pending };
|
||||
}
|
||||
async function lifecycle(type: string) {
|
||||
const pending: Array<Promise<unknown>> = [];
|
||||
listeners.get(type)!({
|
||||
waitUntil: (promise: Promise<unknown>) => pending.push(promise),
|
||||
});
|
||||
await Promise.all(pending);
|
||||
}
|
||||
return { fetch, cache, caches, self, fetchEvent, lifecycle };
|
||||
}
|
||||
|
||||
describe('service worker shell assets', () => {
|
||||
it('keeps an independent cached body after the browser consumes the response', async () => {
|
||||
const w = worker();
|
||||
w.fetch.mockResolvedValueOnce(basicResponse('asset-v1'));
|
||||
const first = w.fetchEvent('/assets/index-abc12345.js');
|
||||
const response: Response = await first.respondWith.mock.calls[0][0];
|
||||
expect(await response.text()).toBe('asset-v1');
|
||||
await Promise.all(first.pending);
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const hit = w.fetchEvent('/assets/index-abc12345.js');
|
||||
const cached: Response = await hit.respondWith.mock.calls[0][0];
|
||||
expect(await cached.text()).toBe('asset-v1');
|
||||
}
|
||||
expect(w.fetch).toHaveBeenCalledOnce();
|
||||
expect(w.caches.open).toHaveBeenCalledWith('qwen-code-shell-v1-dev');
|
||||
});
|
||||
|
||||
it('still loads assets when CacheStorage is unavailable', async () => {
|
||||
const w = worker();
|
||||
w.caches.open.mockRejectedValueOnce(new Error('Storage disabled'));
|
||||
w.fetch.mockResolvedValueOnce(basicResponse('online'));
|
||||
const event = w.fetchEvent('/assets/index-abc12345.js');
|
||||
expect(await (await event.respondWith.mock.calls[0][0]).text()).toBe(
|
||||
'online',
|
||||
);
|
||||
});
|
||||
|
||||
it('settles the cache lifetime promise when storage is full', async () => {
|
||||
const w = worker();
|
||||
w.cache.put.mockRejectedValueOnce(new Error('Quota exceeded'));
|
||||
w.fetch.mockResolvedValueOnce(basicResponse('online'));
|
||||
const event = w.fetchEvent('/assets/index-abc12345.js');
|
||||
expect(await (await event.respondWith.mock.calls[0][0]).text()).toBe(
|
||||
'online',
|
||||
);
|
||||
await expect(Promise.all(event.pending)).resolves.toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('does not cache failed responses', async () => {
|
||||
const w = worker();
|
||||
w.fetch.mockResolvedValueOnce(basicResponse('Not found', { status: 404 }));
|
||||
const event = w.fetchEvent('/assets/missing-abc12345.js');
|
||||
expect((await event.respondWith.mock.calls[0][0]).status).toBe(404);
|
||||
expect(w.cache.put).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker bypass', () => {
|
||||
it.each([
|
||||
['/manifest.webmanifest', {}],
|
||||
['/assets/icon-192.png', {}],
|
||||
['/assets/icon.svg', {}],
|
||||
['/session/abc/events', {}],
|
||||
['/capabilities', {}],
|
||||
['/health', {}],
|
||||
['/permission/request', { method: 'POST' }],
|
||||
['/assets/main.js', { method: 'POST' }],
|
||||
['/', { method: 'POST' }],
|
||||
[
|
||||
'/assets/index-abc12345.js',
|
||||
{ headers: new Headers({ authorization: 'Bearer x' }) },
|
||||
],
|
||||
[
|
||||
'/assets/index-abc12345.js',
|
||||
{ headers: new Headers({ accept: 'text/event-stream' }) },
|
||||
],
|
||||
['https://other.example/assets/index.js', {}],
|
||||
])('leaves %s to the browser network stack (%j)', (path, overrides) => {
|
||||
const w = worker();
|
||||
expect(w.fetchEvent(path, overrides).respondWith).not.toHaveBeenCalled();
|
||||
expect(w.fetch).not.toHaveBeenCalled();
|
||||
expect(w.caches.open).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker navigation', () => {
|
||||
it('passes successful navigations through without caching HTML', async () => {
|
||||
const w = worker();
|
||||
const online = new Response('document html');
|
||||
w.fetch.mockResolvedValueOnce(online);
|
||||
const event = w.fetchEvent('/', { mode: 'navigate' });
|
||||
expect(await event.respondWith.mock.calls[0][0]).toBe(online);
|
||||
expect(w.caches.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns a real 503 retry page when the daemon is offline', async () => {
|
||||
const w = worker();
|
||||
w.fetch.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
const event = w.fetchEvent('/session/123', { mode: 'navigate' });
|
||||
const response: Response = await event.respondWith.mock.calls[0][0];
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.headers.get('content-type')).toContain('text/html');
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
const html = await response.text();
|
||||
expect(html).toContain('Try again');
|
||||
expect(html).toContain('重试');
|
||||
expect(w.caches.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves subframe navigations to the browser network stack', () => {
|
||||
const w = worker();
|
||||
const event = w.fetchEvent('/frame.html', {
|
||||
mode: 'navigate',
|
||||
destination: 'iframe',
|
||||
});
|
||||
expect(event.respondWith).not.toHaveBeenCalled();
|
||||
expect(w.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('service worker lifecycle', () => {
|
||||
it('activates immediately and removes only older shell caches', async () => {
|
||||
const w = worker();
|
||||
w.caches.keys.mockResolvedValueOnce([
|
||||
'qwen-code-shell-v1-dev',
|
||||
'qwen-code-shell-v1-0.1.0',
|
||||
'unrelated-cache',
|
||||
]);
|
||||
await w.lifecycle('install');
|
||||
await w.lifecycle('activate');
|
||||
expect(w.self.skipWaiting).toHaveBeenCalledOnce();
|
||||
expect(w.self.clients.claim).toHaveBeenCalledOnce();
|
||||
expect(w.caches.delete.mock.calls).toEqual([['qwen-code-shell-v1-0.1.0']]);
|
||||
});
|
||||
});
|
||||
102
packages/web-shell/client/sw.js
Normal file
102
packages/web-shell/client/sw.js
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*
|
||||
* Cache-first for same-origin build assets. Manifest, daemon API requests,
|
||||
* authenticated requests and event streams remain network-only. HTML is
|
||||
* never cached: an unavailable daemon gets a retry page, not a stale session.
|
||||
*/
|
||||
|
||||
/* global __WEB_SHELL_VERSION__, self, caches, URL, fetch, Response */
|
||||
|
||||
var VERSION =
|
||||
typeof __WEB_SHELL_VERSION__ !== 'undefined' ? __WEB_SHELL_VERSION__ : 'dev';
|
||||
var SHELL_CACHE = 'qwen-code-shell-v1-' + VERSION;
|
||||
|
||||
self.addEventListener('install', function (event) {
|
||||
event.waitUntil(self.skipWaiting());
|
||||
});
|
||||
|
||||
self.addEventListener('activate', function (event) {
|
||||
event.waitUntil(
|
||||
caches.keys().then(function (keys) {
|
||||
return Promise.all(
|
||||
keys
|
||||
.filter(function (key) {
|
||||
return key.startsWith('qwen-code-shell-') && key !== SHELL_CACHE;
|
||||
})
|
||||
.map(function (key) {
|
||||
return caches.delete(key);
|
||||
}),
|
||||
).then(function () {
|
||||
return self.clients.claim();
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
async function readAsset(request, event) {
|
||||
var cache;
|
||||
try {
|
||||
cache = await caches.open(SHELL_CACHE);
|
||||
var cached = await cache.match(request);
|
||||
if (cached) return cached;
|
||||
} catch {
|
||||
// A disabled or full browser cache must not prevent loading the UI.
|
||||
}
|
||||
var response = await fetch(request);
|
||||
if (cache && response.status === 200 && response.type === 'basic') {
|
||||
event.waitUntil(cache.put(request, response.clone()).catch(function () {}));
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', function (event) {
|
||||
var request = event.request;
|
||||
var url = new URL(request.url);
|
||||
if (
|
||||
request.method !== 'GET' ||
|
||||
request.headers.has('Authorization') ||
|
||||
(request.headers.get('Accept') || '').includes('text/event-stream') ||
|
||||
url.origin !== self.location.origin
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only content-addressed build chunks can safely outlive a deployment.
|
||||
// Any future unhashed file under /assets stays on the network by default.
|
||||
if (
|
||||
url.pathname.startsWith('/assets/') &&
|
||||
/-[a-zA-Z0-9_-]{8,}\.[^/]+$/.test(url.pathname)
|
||||
) {
|
||||
event.respondWith(readAsset(request, event));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.mode === 'navigate' &&
|
||||
(request.destination === 'document' || !request.destination)
|
||||
) {
|
||||
event.respondWith(
|
||||
fetch(request).catch(function () {
|
||||
return new Response(
|
||||
'<!doctype html><html lang="en"><meta charset="utf-8">' +
|
||||
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
||||
'<title>Qwen Code unavailable</title><body>' +
|
||||
'<h1>Cannot reach Qwen Code / <span lang="zh-CN">无法连接 Qwen Code</span></h1>' +
|
||||
'<p>Reconnect to the daemon, then reload this page. / ' +
|
||||
'<span lang="zh-CN">请重新连接守护进程,然后重新加载此页面。</span></p>' +
|
||||
'<a href="">Try again / <span lang="zh-CN">重试</span></a></body></html>',
|
||||
{
|
||||
status: 503,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -142,6 +142,19 @@ export default defineConfig(({ command }) => ({
|
|||
target: WEB_SHELL_BUILD_TARGET,
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'client/index.html'),
|
||||
// This import-free worker must remain at the origin root so it can
|
||||
// control all Web Shell navigation.
|
||||
sw: resolve(__dirname, 'client/sw.js'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: (chunk) =>
|
||||
chunk.name === 'sw' ? '[name].js' : 'assets/[name]-[hash].js',
|
||||
format: 'es',
|
||||
},
|
||||
},
|
||||
},
|
||||
define: {
|
||||
__WEB_SHELL_VERSION__: JSON.stringify(pkg.version),
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ packages:
|
|||
- 'integrations/external-context-mem0'
|
||||
- '!packages/desktop-shell'
|
||||
- '!packages/live-host'
|
||||
- '!packages/mobile-shell'
|
||||
|
||||
nodeLinker: 'hoisted'
|
||||
# The root postinstall runs patch-package, which rewrites files inside
|
||||
|
|
|
|||
|
|
@ -5,13 +5,18 @@
|
|||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const desktopPrefixes = ['packages/desktop-shell', 'packages/live-host'];
|
||||
const nativePrefixes = [
|
||||
'packages/desktop-shell',
|
||||
'packages/live-host',
|
||||
'packages/mobile-shell',
|
||||
];
|
||||
const forbiddenRootPackages = [
|
||||
'electron',
|
||||
'electron-builder',
|
||||
|
|
@ -22,10 +27,10 @@ const forbiddenRootPackages = [
|
|||
|
||||
let hasError = false;
|
||||
|
||||
console.log('Checking desktop workspace isolation...');
|
||||
console.log('Checking native workspace isolation...');
|
||||
|
||||
function isDesktopLocation(location) {
|
||||
return desktopPrefixes.some(
|
||||
function isNativeLocation(location) {
|
||||
return nativePrefixes.some(
|
||||
(prefix) => location === prefix || location.startsWith(`${prefix}/`),
|
||||
);
|
||||
}
|
||||
|
|
@ -56,28 +61,28 @@ try {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
const desktopWorkspaces = workspaces
|
||||
const nativeWorkspaces = workspaces
|
||||
.map((workspace) => workspace.location)
|
||||
.filter(isDesktopLocation);
|
||||
.filter(isNativeLocation);
|
||||
|
||||
if (desktopWorkspaces.length > 0) {
|
||||
if (nativeWorkspaces.length > 0) {
|
||||
reportError(
|
||||
'Desktop packages should not be part of the root npm workspace set.',
|
||||
desktopWorkspaces,
|
||||
'Native packages should not be part of the root npm workspace set.',
|
||||
nativeWorkspaces,
|
||||
);
|
||||
}
|
||||
|
||||
const lockfile = JSON.parse(
|
||||
readFileSync(join(root, 'package-lock.json'), 'utf8'),
|
||||
);
|
||||
const desktopLockfileEntries = Object.keys(lockfile.packages ?? {}).filter(
|
||||
isDesktopLocation,
|
||||
const nativeLockfileEntries = Object.keys(lockfile.packages ?? {}).filter(
|
||||
isNativeLocation,
|
||||
);
|
||||
|
||||
if (desktopLockfileEntries.length > 0) {
|
||||
if (nativeLockfileEntries.length > 0) {
|
||||
reportError(
|
||||
'Root package-lock.json should not contain desktop package entries.',
|
||||
desktopLockfileEntries,
|
||||
'Root package-lock.json should not contain native package entries.',
|
||||
nativeLockfileEntries,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -92,8 +97,32 @@ if (installedForbiddenPackages.length > 0) {
|
|||
);
|
||||
}
|
||||
|
||||
const wrapperJar = join(
|
||||
root,
|
||||
'packages',
|
||||
'mobile-shell',
|
||||
'gradle',
|
||||
'wrapper',
|
||||
'gradle-wrapper.jar',
|
||||
);
|
||||
const wrapperChecksum = `${wrapperJar}.sha256`;
|
||||
if (!existsSync(wrapperJar) || !existsSync(wrapperChecksum)) {
|
||||
reportError('Android Gradle wrapper integrity files are missing.');
|
||||
} else {
|
||||
const expected = readFileSync(wrapperChecksum, 'utf8').trim().toLowerCase();
|
||||
const actual = createHash('sha256')
|
||||
.update(readFileSync(wrapperJar))
|
||||
.digest('hex');
|
||||
if (!/^[a-f0-9]{64}$/u.test(expected) || actual !== expected) {
|
||||
reportError('Android Gradle wrapper checksum does not match.', [
|
||||
`expected ${expected}`,
|
||||
`actual ${actual}`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasError) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Desktop workspace isolation check passed.');
|
||||
console.log('Native workspace isolation check passed.');
|
||||
|
|
|
|||
|
|
@ -592,6 +592,14 @@ export function copyBundleAssets({ root = defaultRoot } = {}) {
|
|||
mkdirSync(destWebShellDir, { recursive: true });
|
||||
copyFileSync(webShellIndexHtml, join(destWebShellDir, 'index.html'));
|
||||
copyRecursiveSync(webShellAssetsDir, join(destWebShellDir, 'assets'));
|
||||
for (const file of ['manifest.webmanifest', 'sw.js']) {
|
||||
const source = join(webShellDistDir, file);
|
||||
if (existsSync(source)) {
|
||||
copyFileSync(source, join(destWebShellDir, file));
|
||||
} else {
|
||||
console.warn(`Warning: Web Shell PWA asset not found: ${source}`);
|
||||
}
|
||||
}
|
||||
console.log('Copied Web Shell UI to dist/web-shell/');
|
||||
} else {
|
||||
console.warn(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,8 @@ function verifyBundleArtifacts(rootDir, distDir) {
|
|||
// --cli-only dev bundles; this is the release gate.
|
||||
path.join(distDir, 'web-shell', 'index.html'),
|
||||
path.join(distDir, 'web-shell', 'assets'),
|
||||
path.join(distDir, 'web-shell', 'manifest.webmanifest'),
|
||||
path.join(distDir, 'web-shell', 'sw.js'),
|
||||
path.join(distDir, 'export-transcript-document.js'),
|
||||
path.join(distDir, 'export-transcript-document.css'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -41,6 +41,75 @@ describe('package asset scripts', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('copies the manifest and service worker into the published shell', () => {
|
||||
const rootDir = createFixtureRoot();
|
||||
stubConsole();
|
||||
writeFile(rootDir, 'packages/web-shell/dist/index.html', '<!doctype html>');
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/web-shell/dist/assets/index-abc.js',
|
||||
'export {};',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/web-shell/dist/manifest.webmanifest',
|
||||
'{"name":"Qwen Code"}',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/web-shell/dist/sw.js',
|
||||
'self.addEventListener("fetch", () => {});',
|
||||
);
|
||||
copyBundleAssets({ root: rootDir });
|
||||
for (const file of ['manifest.webmanifest', 'sw.js']) {
|
||||
expect(
|
||||
readFileSync(path.join(rootDir, 'dist/web-shell', file), 'utf8'),
|
||||
).toBe(
|
||||
readFileSync(
|
||||
path.join(rootDir, 'packages/web-shell/dist', file),
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('warns and skips a missing service worker from a stale shell build', () => {
|
||||
const rootDir = createFixtureRoot();
|
||||
stubConsole();
|
||||
writeFile(rootDir, 'packages/web-shell/dist/index.html', '<!doctype html>');
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/web-shell/dist/assets/index-abc.js',
|
||||
'export {};',
|
||||
);
|
||||
writeFile(
|
||||
rootDir,
|
||||
'packages/web-shell/dist/manifest.webmanifest',
|
||||
'{"name":"Qwen Code"}',
|
||||
);
|
||||
|
||||
expect(() => copyBundleAssets({ root: rootDir })).not.toThrow();
|
||||
|
||||
expect(
|
||||
readFileSync(
|
||||
path.join(rootDir, 'dist/web-shell/manifest.webmanifest'),
|
||||
'utf8',
|
||||
),
|
||||
).toBe('{"name":"Qwen Code"}');
|
||||
expect(existsSync(path.join(rootDir, 'dist/web-shell/sw.js'))).toBe(false);
|
||||
expect(
|
||||
console.warn.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.some(
|
||||
(message) =>
|
||||
message.includes('PWA asset not found') &&
|
||||
message.includes(
|
||||
path.join(rootDir, 'packages/web-shell/dist/sw.js'),
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('emits an executable dist/cli.js — shebang plus the exec bit, once', () => {
|
||||
// shellContextEnv blanks a QWEN_CODE_CLI a POSIX shell cannot exec (no
|
||||
// shebang, or no exec bit), and `"${QWEN_CODE_CLI:-qwen}"` then silently
|
||||
|
|
@ -661,6 +730,36 @@ describe('package asset scripts', () => {
|
|||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['manifest.webmanifest', 'sw.js'])(
|
||||
'rejects a published shell missing %s',
|
||||
(file) => {
|
||||
const rootDir = createFixtureRoot();
|
||||
createBundleArtifacts(rootDir);
|
||||
rmSync(path.join(rootDir, 'dist', 'web-shell', file));
|
||||
stubConsole();
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
// verifyBundleArtifacts reports with console.error + process.exit(1), not a
|
||||
// throw, so the exit has to become one to keep the rest of the suite alive.
|
||||
const exit = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit(1)');
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
preparePackage({ rootDir, requireNativeAudioCapture: false }),
|
||||
).toThrow('process.exit(1)');
|
||||
expect(exit).toHaveBeenCalledWith(1);
|
||||
expect(
|
||||
console.error.mock.calls
|
||||
.map(([message]) => String(message))
|
||||
.some(
|
||||
(message) =>
|
||||
message.includes('Required package artifact not found') &&
|
||||
message.includes(file),
|
||||
),
|
||||
).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('copies Computer Use platform references to both CLI and core distributions', () => {
|
||||
const rootDir = createFixtureRoot();
|
||||
const coreDir = path.join(rootDir, 'packages', 'core');
|
||||
|
|
@ -1476,6 +1575,12 @@ describe('package asset scripts', () => {
|
|||
// Web Shell release gate (prepare-package.js verifyBundleArtifacts): the
|
||||
// published package must ship the UI, so the fixture provides it too.
|
||||
writeFile(rootDir, 'dist/web-shell/index.html', '<!doctype html>');
|
||||
writeFile(rootDir, 'dist/web-shell/manifest.webmanifest', '{}');
|
||||
writeFile(
|
||||
rootDir,
|
||||
'dist/web-shell/sw.js',
|
||||
'self.addEventListener("fetch", () => {});',
|
||||
);
|
||||
mkdirSync(path.join(rootDir, 'dist', 'web-shell', 'assets'), {
|
||||
recursive: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,7 @@ describe('package scripts', () => {
|
|||
'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/**',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue