# v0.23 桌面版（Tether Desktop）验收清单与证据

日期：2026-09-25  
环境：macOS arm64 (Darwin 27), Apple Silicon, .NET SDK 10.0.400  
关联 issue：[#379](https://github.com/eanzhao-os/tether/issues/379) · milestone [v0.23 - Native desktop shell](https://github.com/eanzhao-os/tether/milestone/25) · 设计：[2026-09-25-desktop-shell-design.md](../../specs/2026-09-25-desktop-shell-design.md)

---

## 1. macOS 手工验收清单

> **说明**：当前终端会话未被授予屏幕录制权限（`screencapture` 报错 `could not create image from display`），因此所有依赖肉眼视觉渲染与物理窗口交互的项目均实事求是标记为**待手工验收**，绝不伪造或虚构截图。所有已验证项目均严格基于自动化测试以及调度器（Orchestrator）在真机上的直接运行观测记录。

| 验收项 | 检验方法 | 状态 | 证据 / 观测记录 |
|---|---|---|---|
| **秒开启动页 → 主界面** | 启动进程后，首帧立即展示带 `TETHER` 字标与 spinner 的秒开启动页（`tether-app://boot/`），后台 `AppBoot` 就绪后同一窗口平滑导航至 Web UI | 待手工验收（视觉呈现） | `tests/Tether.Desktop.Tests` 启动页模板及生命周期单测通过；真机观测：启动 3–5 秒完成，标准输出仅打印 `desktop: http://127.0.0.1:<port>/`，无 token 泄漏；无录屏权限无法截图，视觉待手工验收 |
| **隐藏标题栏与侧栏 vibrancy** | 窗口全内容撑满无原生标题栏，侧栏呈现 `NSVisualEffectView` 磨砂模糊材质（亮色 / 暗色 / 跟随系统三态） | 待手工验收（视觉呈现） | `tests/Tether.Desktop.PhotinoX.Tests` MacWindowChromeTests 全部通过；打包 `.app` 运行 stderr 记录：`Observed contentView view hierarchy: [NSVisualEffectView, NSKVONotifying_WKWebView]` 与 `Native macOS chrome successfully configured`；视觉呈现待手工验收 |
| **侧栏收起后 `shell.leading` 座** | 点击折叠侧边栏后，红绿灯右侧留出 `shell.leading` 避让占位，展开/折叠控件不错位 | 待手工验收 | 由 vendored Web 客户端在 `html[data-platform='darwin']` 下内建 CSS 驱动；无录屏权限，待手工验收 |
| **全屏进出** | 进入全屏时切换 `html[data-fullscreen='true']`，自动收起红绿灯避让留白；退出全屏时恢复 | 待手工验收（视觉呈现） | `tests/Tether.Desktop.Web.Tests` 全屏状态查询与全屏事件推送单测通过；物理窗口进出全屏视觉待手工验收 |
| **顶部 52px 拖拽带** | 窗口顶部 52px 区域按住左键可拖拽移动窗口，双击可缩放（zoom）；按钮、链接、输入框、弹窗等交互元素不被拖拽拦截 | 待手工验收（物理交互） | `tests/Tether.Desktop.Web.Tests` 几何选择器过滤与 `window.drag` / `window.zoom` 通道单测通过；物理窗口拖拽待手工验收 |
| **Cmd+C/V/Q/W 原生主菜单** | WKWebView 获得焦点时，标准快捷键 Cmd+C（复制）、Cmd+V（粘贴）、Cmd+A（全选）、Cmd+Z（撤销）、Cmd+W（关闭窗口）、Cmd+Q（退出）正常响应 | 待手工验收（按键交互） | `tests/Tether.Desktop.PhotinoX.Tests` MacMainMenuTests 通过；打包 `.app` 运行 stderr 记录：`[MacMainMenu] Standard macOS main menu installed successfully.`；物理按键待手工验收 |
| **原生目录对话框落地工作区** | 新会话 hero 卡片与侧栏「添加工作区」点击后弹出 macOS 原生目录选择器，选择后成功建立并切换工作区 | 待手工验收（UI 交互） | `tests/Tether.Desktop.PhotinoX.Tests` DirectoryPickerCoordinatorTests（并发请求合并共用同一个挂起原生对话框）、`tests/Tether.Desktop.Web.Tests` 通道单测通过；GUI 弹窗落地待手工验收 |
| **外链外开** | 页面内超链接（`target=_blank` 或任意非 Host origin 的 http/https 导航）在系统默认浏览器中打开，主窗口不被劫持 | 待手工验收（系统调用） | `tests/Tether.Desktop.PhotinoX.Tests` NavigationGateTests（8/8，非 Host origin 严格拦截并标记为 `BlockAndOpenExternal`）；系统默认浏览器唤起待手工验收 |
| **Finder reveal** | 执行 `session/openWorkspacePath` 的 reveal 操作时，能唤起系统 Finder 并定位到指定工作区路径 | 待手工验收 | 沿用 #338 `NativeDesktop` 既有 macOS JXA 实现；系统 Finder 定位待手工验收 |
| **启动失败态可重试** | 故意配置错误的 profile 或损坏的 patch，启动页原地展示失败诊断卡与日志路径，且「重试」、「打开日志目录」、「退出」三按钮均可用 | 待手工验收（卡片渲染） | #373 worker 验证通过（坏补丁启动进程存活并输出诊断日志）；`Tether.Desktop.Tests` 覆盖 DesktopSplashPage 失败卡 HTML 生成与启动页消息处理；卡片渲染待手工验收 |
| **关闭确认** | 在有 Agent 任务（turn）正在运行时尝试关闭窗口或按 Cmd+Q，弹出原生对话框提示确认；取消则继续运行，确认则优雅退出 | 待手工验收（交互弹窗） | `tests/Tether.Desktop.Tests` DesktopShutdownCoordinatorTests（9/9，覆盖活动任务检测、确认拦截、取消令牌与强制退出等）；原生弹窗待手工验收 |
| **第二实例激活** | 启动第一个实例后，在同一 `--home` 下启动第二个实例，首个实例窗口被唤醒激活，第二个实例在 1 秒内退出码 0 | **已验证** | 真机观测：同 `--home` 第二实例在 <1s 内退出码 0 并唤醒首实例；超长 home 路径（>103 字节）自动安全回退至 `$TMPDIR/tether-desktop/<16-hex>.sock`；SingleInstanceTests 单测全绿 |
| **与 `tether web` 同 home 并行** | 桌面壳与 `tether web` 同时在同一个 `~/.tether` 下运行，端口各自独立（桌面壳使用 `--port 0`），同一会话写操作由 per-session 内核锁正确排他 | **已验证（[#458](https://github.com/eanzhao-os/tether/issues/458) 修复后，两个 `tether web`）** | 修复前真机观测：先启动 `tether web` 再在同一 `--home` 下启动桌面壳（或两个 `tether web`），第二个进程日志报错 `storage-main (storage-json): PluginApplyException … LocalPathLeaseException: exclusive local file lease is already held: <home>/storage/.writer`，`workspace-registry` 与 `session-projection-cache` 随之报 `UNKNOWN_ROUTE`，降级运行（`Tether.Storage.Json` 的 home 级独占租约，非桌面壳缺陷）。修复（JSON 存储改为每次写入短暂持有 `.writer` 跨进程写区间，工作区注册表在 `workspace-registry.lock` 内跨进程串行并读时可见）后，在 scratch home 上并行启动两个 `tether web`（`--port 0`）：两进程 stdout/stderr 均无 `LocalPathLeaseException`、`UNKNOWN_ROUTE`、`PluginApplyException`；空闲时 `<home>/storage/.writer` 未被持有；A 新建的工作区出现在 B 的 `workspace/follow` 基线里、反之亦然，在对方已登记的路径上再次创建返回 `created: false`，`<home>/storage/domain%3Aworkspace-registry.json` 同时含两者；SIGTERM 后两进程退出码均为 0。同一脚本对未修复的 `origin/main`（`82ebca76`）构建运行，第一步即复现上述错误。会话写排他仍由各会话的 `session.lock` 裁决（本次未改动）。桌面壳挂载同一 web 组合（`desktop.patch.yaml` 只追加 desktop-shell / desktop-bridge 行），存储与注册表行相同，未单独复跑桌面壳 × web 组合 |
| **fresh home 首启** | 在全新的空目录（`--home <empty-temp-dir>`）下首次启动，无需额外参数，自动以 web 栈启动成功并呈现界面 | **已验证（boot 与 web 行就绪；界面呈现待手工确认）** | commit 052973f0 确保全新 home 自动物化出 `bundles: ["web"]` 的 `profiles/local/profile.yaml`；真机 scratch home 启动确认 Web 宿主正常就绪且 origin 返回 401；因无录屏权限，Web UI 界面渲染视觉待手工确认 |

---

## 2. 无显示门禁验证（Headless Gates）

所有无显示自动化门禁均在 macOS arm64 本机环境执行并通过：

### 2.1 单元测试套件

| 测试套件 / 过滤条件 | 执行命令 | 通过 / 失败 / 总计 | 结果 |
|---|---|---|---|
| `Tether.Desktop.Tests` | `dotnet test tests/Tether.Desktop.Tests` | 29 / 0 / 29 | **全部通过** |
| `Tether.Desktop.PhotinoX.Tests` | `dotnet test tests/Tether.Desktop.PhotinoX.Tests` | 64 / 0 / 64 | **全部通过** |
| `Tether.Desktop.Web.Tests` | `dotnet test tests/Tether.Desktop.Web.Tests` | 19 / 0 / 19 | **全部通过** |
| `Tether.Bundles.Tests` (Desktop) | `dotnet test tests/Tether.Bundles.Tests --filter Desktop` | 4 / 0 / 4 | **全部通过** |
| `Tether.PluginExtensions.Tests` (Notify) | `dotnet test tests/Tether.PluginExtensions.Tests --filter Notify` | 25 / 0 / 25 | **全部通过** |

### 2.2 仓库与架构镜像门禁

```bash
$ node scripts/check-test-mirror.mjs
测试镜像检查通过：161 个包，17 豁免、38 个间接覆盖登记。
```

`Tether.Desktop` 作为纯 Service Definition 程序集（仅接口与领域记录，无执行逻辑）在 `scripts/test-mirror-registry.json` 中登记豁免；`Tether.Desktop.PhotinoX`、`Tether.Desktop.Web` 与 `apps/Tether.Desktop` 均有完整测试套件覆盖。

### 2.3 Web 源码门禁与 upstream 零修改

1. **upstream 镜像比对**：
   ```bash
   $ git diff --stat $(git merge-base origin/main HEAD)..HEAD -- web/upstream
   # 输出为空：本分支对 web/upstream 零就地修改
   ```
2. **Web 源码门禁测试**：
   ```bash
   $ node --test web/scripts/source-gate.test.mjs web/scripts/build-state.test.mjs web/scripts/retirement-gate.test.mjs
   ✔ prebuilt acceptance binds inputs, environment and all delivered bytes (36.5ms)
   ✔ exact immutable embedding snapshots survive a later destructive build (28.0ms)
   ✔ no executable legacy client, resource or carrier remains (133.2ms)
   ✔ retirement gate rejects reintroduced path and carrier registrations (4.0ms)
   ✔ console client uses actual upstream platform and one API (0.4ms)
   ✔ source gate rejects extra edits inside an allowlisted file (278.1ms)
   ✔ source gate reverse-apply stays byte-exact under a forced Windows core.autocrlf=true config (266.3ms)
   ✔ source gate preserves exact bytes with inherited core.autocrlf=false (359.7ms)
   ✔ source gate preserves exact bytes with inherited core.autocrlf=true (318.0ms)
   ℹ tests 9, pass 9, fail 0
   ```

---

## 3. 打包与跨平台情况（Windows / Linux）

依据 commit `c2e8570b`（#378）本地打包脚本实现与构建结果：

- **macOS (`osx-arm64`)**：
  - 打包命令：`./scripts/package-desktop.sh --version 0.9.0-alpha`
  - 产物规格：`Tether.app` 约 275 MB（self-contained 发布），`Tether-0.9.0-alpha-osx-arm64.dmg` 约 114 MB（UDZO 格式压缩）。
  - 运行时校验：解包独立运行 `Tether.app/Contents/MacOS/tether-desktop`（未设置 `DOTNET_ROOT`），4 秒内就绪，正常加载主菜单与 macOS chrome 材质，未带 launch cookie 的 curl 请求返回 HTTP 401，SIGTERM 关停耗时约 2 秒。
- **Windows (`win-x64`)**：
  - 构建状态：`dotnet publish -r win-x64 --self-contained` **构建通过**。
  - 运行时状态：**未验证**（需依赖 Windows 10/11 及 Microsoft Edge WebView2 Evergreen Runtime；支持 Velopack `vpk pack` 输出 Setup.exe）。
- **Linux (`linux-x64`)**：
  - 构建状态：`dotnet publish -r linux-x64 --self-contained` **构建通过**。
  - 运行时状态：**未验证**（需系统安装 WebKitGTK 4.1 如 `libwebkit2gtk-4.1-0`；支持 Velopack `vpk pack` 输出 AppImage）。

---

## 4. 启动后 401（SameSite=Strict + 跨站发起）

### 4.1 问题现象与根因

在 macOS 桌面版（`Tether.app`）上，窗口从启动页（`tether-app://boot/`）引导完成后进入主界面时，页面直接显示 `"dsh web authentication required; reopen the URL printed by dsh web."`（HTTP 401 文本），无法进入主界面。

**根因分析**：
1. 桌面壳在系统 WKWebView 里首先加载自定义 scheme 的启动页 `tether-app://boot/`。
2. 后台 `AppBoot` 完成后，桌面壳调用 `host.Shell.NavigateAsync(...)` 导航至带 token 的回环地址 `http://127.0.0.1:<port>/?token=<launch token>`。
3. 服务端 `VendoredWebFrontendPlugin` / `BrowserSessionAuth` 校验 token 后，签发 `SameSite=Strict; HttpOnly` 的 `dsh-auth-*` cookie，并按上游规范返回 `303 See Other` 重定向到 `./`。
4. **WebKit 同站判定规则**：WebKit 按「发起这次导航的文档所在站点」判定 SameSite，`tether-app://boot/` 的 host 是 `boot`，和 `127.0.0.1` 不同站，所以 303 之后的 `GET /` 不带 Strict cookie；请求头 `Sec-Fetch-Site` 是 `none`，不是判定依据。
5. 按照 RFC 6265bis 规范，浏览器在跨站发起的请求及重定向链中扣留（不发送）`SameSite=Strict` Cookie，导致后续对 `GET /` 的请求未携带任何 Cookie，服务端因此返回 401。
6. 实测 reload 后仍不带 cookie，所以刷新救不回来。

### 4.2 独立探针实验证据

为严格验证上述机制与可行解法，使用最小 WKWebView 原生程序与 HTTP 服务（源码归档于本目录下的 `wkwebview-samesite-probe/`）进行了多组实验（非持久化存储、无窗口运行）：

- **实验探针复现命令**：
  ```bash
  cd public/superpowers/reports/evidence/2026-09-25-desktop/wkwebview-samesite-probe
  swiftc -O -o probe probe.swift
  python3 server.py Strict
  # 打印监听端口后，可验证支持的所有模式：
  ./probe <port> direct         # 直接加载 token 地址
  ./probe <port> splash         # 启动页 -> token 地址（原流程）
  ./probe <port> splash-reload  # 启动页 -> token 地址 -> 401 后 reload
  ./probe <port> blank          # 启动页 -> about:blank -> token 地址
  ./probe <port> hostsplash     # 启动页地址换成 tether-app://127.0.0.1/ -> token 地址
  ./probe <port> handoff        # 启动页 -> 回环交接页 -> 脚本发起同源 token 导航
  ```

- **实验结果矩阵（实测各运行 3 次）**：

| 测试链路 / 模式 | 导航路径 | Cookie 携带情况 | 最终结果 | 结论 |
|---|---|---|---|---|
| **direct**（无启动页） | 直接加载 `/?token=abc` | 303 后 `/` 带上 Cookie | `AUTH-OK` (200) | 无跨站来源时 Strict Cookie 正常工作（3/3 通过） |
| **splash**（原流程） | `tether-app://boot/` → `/?token=abc` → 303 `./` | 303 后的 `/` 请求未带 Cookie | `UNAUTHORIZED` (401) | **稳定复现原 bug（3/3 失败）** |
| **splash-reload** | `tether-app://boot/` → `/?token=abc` → 401 后 reload | reload 仍未带 Cookie | `UNAUTHORIZED` (401) | 刷新无法恢复（3/3 失败） |
| **blank** | `tether-app://boot/` → `about:blank` → `/?token=abc` → 303 `./` | 303 后的 `/` 带上 Cookie | `AUTH-OK` (200) | `about:blank` 中转绕过（3/3 通过） |
| **hostsplash** | `tether-app://127.0.0.1/` → `/?token=abc` → 303 `./` | 303 后的 `/` 带上 Cookie | `AUTH-OK` (200) | 启动页同 host 绕过（3/3 通过） |
| **handoff**（回环交接页） | `tether-app://boot/` → `/handoff#abc` → 页面脚本 `location.replace('./?token=' + hash)` → 303 `./` | 脚本发起的请求与重定向链均为 `same-origin`，带上 Cookie | `AUTH-OK` (200) | **稳定解决（3/3 通过）** |

**同站判定结论**：
`blank` 与 `hostsplash` 实验说明 WebKit 的同站判定只比 host、不看 scheme（schemeless same-site）。把启动页的 host 改成 `127.0.0.1` 虽然也能过，但依赖这个 WebKit 行为，将来 WebKit 若改成 schemeful same-site 就会失效；`about:blank` 中转也只是巧合地可用。交接页让整条导航链真正同源，不依赖这些特异行为，因而作为正式方案采用。

### 4.3 修复方案与真实 App 验证

1. **同站回环交接页**：
   - 桌面桥接插件 `DesktopBridgePlugin` 在同一生命周期内注册 Exact 路由 `/__tether/desktop-handoff` 并提供 `IDesktopLaunchHandoff` 服务。
   - 桌面入口在导航时将带 query token 的 URL 转换为交接页 URL（token 仅置于 `#hash` fragment，不发送给网络层服务端）。
   - 交接页在同源回环上下文下运行，从 fragment 提取并正则校验 token 后，执行绝对路径 `location.replace('/?token=' + token)`。
   - 服务端认证逻辑（与上游一致的 `SameSite=Strict` cookie）零修改。
   - 入口 URL 判定若遇到 handoff 异常（如参数非法）则原样抛出，不静默降级（遵循 AGENTS.md 错误可见原则）。
2. **自动化测试覆盖**：
   - `tests/Tether.Desktop.Web.Tests/DesktopLaunchHandoffTests.cs`：覆盖路由注册、GET/HEAD/POST、严格 CSP 响应头与从 HTML 正文动态提取 script/style sha256 比对、URL 转换（合法、缺失、非法字符、重复 token）；
   - `tests/Tether.Desktop.Tests/DesktopBootRunnerTests.cs`：覆盖入口 URL 判定分支与异常原样抛出；
   - 门禁全绿：`Tether.Desktop.Web.Tests` 31/31（含 `DesktopLaunchHandoffTests`）、`Tether.Desktop.Tests` 33/33（含 `DesktopBootRunnerTests`）、`Tether.Desktop.PhotinoX.Tests` 64/64、`Tether.Bundles.Tests --filter Desktop` 4/4；`check-test-mirror` 与 `docs:check` 通过；`web/upstream`、`web/patches`、`src/Tether.Web.Client` 零改动。
3. **真实 App 验证结果（2026-09-26）**：
   - 2026-09-26，用本修复的打包产物（`scripts/package-desktop.sh` 生成的未签名 `Tether.app`），临时 home（在 `$TMPDIR` 的 realpath 下 `mktemp -d`）。注意 `/tmp` 是指向 `/private/tmp` 的符号链接，Tether 本地存储会拒绝（`LocalPathSecurityException: local path is a symlink or reparse point: /tmp`，session-store 行启动失败），所以临时 home 必须用不含符号链接的路径。结果：启动约 2 秒开始监听；t+3s / t+8s / t+15s 各有 7 条 `WebKit.Networking` ↔ `tether-desktop` 的 `ESTABLISHED` 连接；t+5m14s（已超过 Kestrel 默认 130s keep-alive 空闲超时）仍有 2 条长连接，说明主界面脚本在运行。
   - 对照：修复前的构建（`82ebca76`）在用户 home 上运行到 4m15s 时，WebKit 与 App 端口之间 0 条连接，窗口显示 401 文本。
   - 随后在用户真实 home（`~/.tether`）上用本构建打开：约 3 秒开始监听，t+14s 有 6 条 `ESTABLISHED` 连接。
