Skip to content

native: Swift 警告清零并转为错误;Mac 后端安装向导;iOS 可访问性与推送通道 - #371

Merged
oratis merged 6 commits into
mainfrom
claude/opt-native
Sep 7, 2026
Merged

oratis merged 6 commits into
mainfrom
claude/opt-native

Conversation

@oratis

@oratis oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner

第 3 / 8 条,实现 T-11 与 UX-7、UX-12。基于 #370。

macOS

14 个警告 → 0,并开启 warnings-as-errors。 修掉 BackendController 里被 Sendable 闭包捕获后又改写的 observer、IslandContent/WebContent 两处已弃用的 WKProcessPool(改用共享 WKWebsiteDataStore 达到同样的会话共享效果)、以及一个从未重新赋值的 var。Swift 6 的严格并发检查下这些会变成错误,现在提前挡住回归。debug 与 release 两种配置均验证通过。

后端安装向导(UX-7)。 官网承诺一键下载,实际上下载 DMG 之后还要用户自己跑 npm install -g @oratis/lisa。现在检测 Node(经 /bin/zsh -lc 以便解析 nvm/brew 的 PATH)与 lisa CLI,提供一键安装并在面板内实时显示输出;Node 缺失时给出 Homebrew 命令(带复制按钮)和官网链接以及重新检测按钮;权限失败时直接给出修复命令。决策逻辑抽成纯函数并有测试。

iOS

可访问性(UX-12)。 之前全应用几乎没有 accessibility 标注:状态点只用颜色区分、roster 行被读成互不相关的碎片、图标按钮无标签、扫码区域没有说明。现在状态一律配文字、每行合并为单个可访问元素、图标按钮全部有标签、进度点读作"第 N 步 / 共 M 步",Send/Copy 等控件补足 44pt 触控区。

推送通道。 服务端一直支持 ntfy,客户端却只接 APNs——没有 Apple 推送密钥时界面显示"已注册"但永远收不到通知。现在 Settings 里可选传输方式,并按服务端实际返回如实说明投递状态。

PTY 实时输出。 会话详情改为订阅 /api/agents/pty/<id>/stream 的 SSE,替代原来的一次性拉取按钮,断线自动重连。

验证

swift build -c debug 与 -c release 均 0 警告 0 错误;iOS 模拟器测试 44 通过 / 0 失败(此前 29)。

🤖 Generated with Claude Code

oratis and others added 6 commits September 7, 2026 22:56
The Mac shell carried the same six warning sites across several releases
(14 warning lines per clean build). Fix each at the root and pin the count
at zero with -warnings-as-errors on the Lisa target, so they cannot creep
back — Swift 6 strict concurrency would have turned them into hard errors
anyway.

- BackendController.restart: the one-shot NotificationCenter observer was
  captured by a Sendable closure and then mutated. Replace it with a
  waiter list on the @mainactor controller that post() drains right after
  the status broadcast; the termination handler hops back via Task
  { @mainactor } instead of a bare asyncAfter.
- WebContent / IslandContent: `config.processPool = WKProcessPool()` has
  been a no-op since macOS 12 and is deprecated. The real seam for the
  chat window and the island pill sharing cookies / localStorage is the
  website data store — set `websiteDataStore = .default()` explicitly on
  both, with the intent documented.
- HotkeyManager: `hotKeyID` was never mutated; `let`, and drop the
  `_ = hotKeyID` workaround.
- Package.swift: `-warnings-as-errors` via swiftSettings.unsafeFlags. This
  is a root-only package (swift build, build.sh, build-mac-apps.sh), never
  a dependency, so unsafeFlags is fine and covers every build path.

Verified: `swift build -c debug`, `-c release`, and the universal
`-c release --arch arm64 --arch x86_64` that build.sh runs — all 0
warnings / 0 errors from a clean .build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit e564fc58b4f48bb31a7eb279e08ba50e818b37cd)
The Mac download is advertised as one click, but Lisa.app is only the
window — the backend is a separate npm package. Until now the app assumed
`npm install -g @oratis/lisa` had already been run in a terminal: when it
hadn't, start() spawned a shell that failed silently and the user watched
"Starting…" for 20 s before a hint appeared. That's the review's UX-7.

This adds a wizard that closes the gap end to end:

- Detection runs on a zsh LOGIN shell plus an explicit PATH prelude
  (~/.npm-global, Volta, Homebrew, nvm.sh, fnm). A GUI app launched from
  Finder reads ~/.zprofile but not ~/.zshrc, which is exactly where nvm and
  fnm live — so a Node that "works in Terminal" was invisible to us. Detect,
  install and `lisa serve` now all resolve tools the same way.
- The decision is a pure state machine in a new AppKit-free LisaSetup
  target — ready / nodeMissing / nodeTooOld / cliMissing — with 20 unit
  tests in a new test target (Tests/LisaSetupTests). An installed `lisa`
  (or a serve-command.txt override) wins over an odd-looking Node, because
  `lisa` resolving is the thing that actually has to be true.
- nodeMissing shows both doors: `brew install node` with a Copy button and
  a nodejs.org link, plus Re-check. cliMissing shows one button that runs
  the install with live, ANSI-stripped npm output and a spinner, then
  starts `lisa serve --web` exactly as before.
- A failed install is classified instead of dumped. EACCES/EPERM gets the
  standard no-sudo fix (npm prefix → ~/.npm-global, PATH into ~/.zprofile)
  shown verbatim with "Apply fix and retry"; network gets a retry;
  EBADENGINE routes back to the Node step.
- BackendController.start() now gates on `command -v lisa` and reports a
  LISA_MISSING marker, so a missing CLI opens the wizard immediately
  rather than timing out a 25-attempt poll. start(completion:) lets the
  wizard follow the outcome; the waiter list carries the note too.

The terminal path stays first class: the offline splash keeps its
`npm install -g` / `lisa serve --web` block, and the wizard's own footer
repeats it. Three ways in — Lisa ▸ Set Up Backend…, "Set Up…" in the
menu-bar popover, and "Set up backend…" on the offline splash.

Verified: swift build -c debug and -c release clean (warnings are errors),
swift test 20/20.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 058d71ae40a329eb0639d382ecb6c32d33a6601a)
Review §D of REVIEW_IOS_APP_v1.0 (still open per the v0.24.0 UX review, UX-12):
status was carried by colour alone, several rows read as disjoint fragments, and
the icon-only controls were both unlabeled and under the 44pt floor.

The root fix is a shared vocabulary rather than per-view strings:
GlanceColors.phrase(_:) turns an agent state into a word, and it lives in
Shared/ because the widget and Live Activity targets can't see the app's copy —
so "waiting on you" reads identically in the roster, the Lock Screen banner, the
Dynamic Island and the home Widget. statusPhrase(_:) is its roster-side twin and
mirrors stateColor's precedence (a pending permission outranks the raw state) so
the pip's colour and its spoken label can't disagree; a test pins that pair.

StatusDot now takes an optional label: passing one makes the pip speak, omitting
one hides it from VoiceOver. That second half matters — where the row already
says the state, an unlabeled dot was about to be read as "circle".

- One element per row: RosterRow (already), NeedsYouCard's header, the LISA
  session rows, dispatch entries, StatCell ("Needs you: 2"), onboarding choice
  cards (with .isSelected instead of a stray checkmark image).
- Labels + hints on the icon-only controls: Send, Stop, Open, Unlock, Copy,
  quick chips, the agents card, the proactive Toggle (which had no name at all).
- 44pt targets via minHeight, never a fixed height, so Dynamic Type still grows
  the label: OnboardingSecondaryButton, "Not now", the scanner's manual escape
  hatch, SetupBanner, NeedsYouCard's Open, LockView's Unlock.
- The camera viewfinder is an empty rectangle to VoiceOver; it now announces
  "Camera is live" with a value that tracks the scan note and a hint naming the
  manual path.
- Text beside colour where colour was load-bearing: mail importance reads
  "‼ Urgent" / "! Important", dispatch rows say "exited" rather than only
  greying the pip, a stale widget snapshot says so instead of just dimming, and
  the allowance bar exposes "$x of $y left" as its value.

Verified: ./build.sh test 'platform=iOS Simulator,name=iPhone 17 Pro' — 31
tests pass (29 before, +2 new), 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 394b2909b21d5f0ef7eb575a0b7924b73457efff)
src/web/push.ts has supported ntfy and APNs since day one, but the app buried a
topic field under an "Enable push notifications" button that reported "Push
registered (APNs)" regardless — so on a Mac with no Apple key (the default:
apnsConfigFromEnv() returns null and delivery logs a stub) the user was told
push worked and then nothing ever arrived. That's UX-12 / review §B.

Settings → Notifications is now a transport picker:

- ntfy: topic + an optional server field (blank = ntfy.sh, matching the server's
  own default), registered through /api/push/register with `server` — a field
  the client never sent, so a self-hosted ntfy was unreachable from the phone.
- APNs: unchanged registration, but the status copy no longer claims delivery.

The state line under the picker is derived from GET /api/push/list — what the
Mac says it will publish to — via a pure PushSettings.stateLine, so it can say
"your Mac is sending to a different topic" instead of a falsely reassuring
"registered", and the APNs line names LISA_APNS_* and admits the app cannot
check it from here (/api/push/* reports no APNs capability; adding one would be
a server change, outside this stream's ownership).

"Send a test notification" is ntfy-only, and posts straight from the phone to
the topic rather than through the Mac. Publishing to ntfy is an unauthenticated
POST, so this proves the topic and the user's ntfy subscription end to end even
while the Mac is asleep — and there is no APNs equivalent to offer: no server
test endpoint exists and a device can't make Apple push to itself. The button
reports the real HTTP outcome, including "a self-hosted server may need auth".

Preferences stopped being write-only: the toggles are seeded from the registered
subscription, show "Unsaved — these apply once you tap Save preferences" when
they diverge, and save through /api/push/prefs on the existing id rather than
re-registering (which would churn a registered APNs token). PushPrefs gains
`brief` — the server has always had it and the app silently shipped its default —
and a tolerant decoder so a Mac that predates a preference doesn't discard the
whole subscription. The topic/server/transport now persist in UserDefaults; they
lived in @State and vanished when you left the tab.

Verified: ./build.sh test 'platform=iOS Simulator,name=iPhone 17 Pro' — 39
tests pass (31 before, +8 new), 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 235f498ca960c24cfd2c5d13a0e88c3714bb2d2e)
Watching a build from the phone meant tapping "Load output" over and over: the
detail view called /api/agents/pty/<id>/output once per tap, even though the
server has had an attach stream all along (a `snapshot` frame with the current
tail, a `chunk` per burst, `end` when the agent finishes).

PTYLiveOutput follows that stream for a running session. The key invariant is
that a snapshot REPLACES the buffer rather than appending: after a dropped
connection the server re-sends the whole tail, so appending would duplicate
everything the reconnect was meant to recover. Reconnect is the roster's backoff
(1,2,4…30s) with a "Reconnect now" escape, and it stops for good on `end`, on a
403 (this Mac's control policy refuses remote attach — retrying would just
hammer it) and on a 404.

The buffer is bounded at 60k characters, trimmed on a line boundary when one is
close, so a chatty agent can't grow it without limit — the phone only ever shows
the tail. That trimming is pure and pinned by tests, as is the status phrase, so
"live" / "retrying in 4s" / "finished" can't drift into a permanent optimistic
"live" the way a bare spinner would.

Long lines scroll horizontally instead of wrapping: the text opts out of
wrapping inside a two-axis ScrollView, and the view follows the tail on each
append the way a terminal does. Backgrounding suspends SSE on iOS, so returning
to the foreground reattaches unless the agent already ended.

A finished session keeps the one-shot pull — there is nothing left to stream,
and an idle SSE socket for a done agent is pure cost.

Verified: ./build.sh test 'platform=iOS Simulator,name=iPhone 17 Pro' — 44
tests pass (39 before, +5 new), 0 warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 51b5f7b108863073586f4aaa8a3b00ba696a0ed0)
Delivers on the iOS half of the commitment made reviewing #365: the ledger
learned to record an observed exit and DispatchView now carries it
(status / exitCode / exitSignal / exitedAt), but the roster still rendered
"alive" vs "exited" — so a crash, an OOM kill and a clean finish were one
grey dot and one word.

- DispatchKind (running | ok | failed | unknown) maps the server's `status`,
  with an explicit fallback for a pre-status server: no `status` and
  `alive: false` is **unknown**, never `ok`. The agent is spawned detached,
  so a dead pid alone never means success.
- The row spells the state out in text and gives each state its own dot
  colour; colour is never the only carrier.
- The detail row names the exit code or the signal ("failed (exit 127)",
  "killed by SIGKILL") — "failed" alone does not say whether the agent
  errored out or was killed.
- Each state gets a distinct VoiceOver label, since the dot's colour carries
  nothing for VoiceOver. A test pins all four as distinct and non-empty.

Also: HTTP 403 said "Server returned HTTP 403." Hosted Lisa denies the
machine-level routes (push, dispatch, mail, consent, devices — the deny-list
in src/web/capabilities.ts, which #365 extended to /api/push), and push
registration is the route an iPhone hits first, so the generic message left
"registered, but no notifications ever arrive" unexplained. It now names the
hosted/local split and what to do about it.

Verified: `./build.sh test` — 49 tests, 0 failures (44 existing + 5 new).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis changed the base branch from claude/opt-docs to main September 7, 2026 15:03
@oratis

oratis commented Sep 7, 2026

Copy link
Copy Markdown
Owner Author

评审 — 构建承诺实测通过;补齐 iOS 退出状态(兑现 #365 的承诺)

已 rebase 到 main,base 改为 main,无冲突。

warnings-as-errors 实测

本机有 Xcode 26.6 / Swift 6.3.3,所以这条承诺是可以真跑的,不用只看描述:

检查 结果
swift build(debug,带 -warnings-as-errors) Build complete — 0 警告
swift build -c release Build complete
swift test 20 tests, 0 failures

也把 main 的 mac-client 单独导出来做了基线计数:干净构建 12 行警告 = 6 个不同警告,分别是 WebContent.swift:54 与 IslandContent.swift:39 各两条 WKProcessPool 弃用、HotkeyManager.swift:67 的 var 从未赋值、BackendController.swift:101 的 'observer' mutated after capture by sendable closure。与 PR 描述修掉的那一组完全对应(描述记 14 个,应是计数口径不同,不影响结论)。最后一条尤其值得修:Swift 6 严格并发下它会直接变成错误。

unsafeFlags 用在这里是安全的——该 package 只作为根 package 构建(swift build / build.sh / scripts/build-mac-apps.sh),从不被当作依赖消费,这一点注释里也写明了。

安装向导的命令执行面 — 查过,没问题

新增的 ShellRunner 会把脚本文本交给 /bin/zsh -lc,这类代码值得逐个看清注入面:

  • BackendSetup.shellQuote 实现正确(' → '\'' 再整体单引号包裹),logPath 走的是它。
  • startScript(command:...) 里 command 故意不引用。追到源头是 ~/.lisa/serve-command.txt——用户自己在本机写的完整命令行,等价于自己敲命令,是有意的逃生舱,不是注入面。
  • 决策逻辑抽进无 AppKit 依赖的 LisaSetup 并有 20 个单测,这个拆分是对的。

已补:iOS 的 dispatch 退出状态

评审 #365 时我把"UI 消费留给各自表面 PR"写成了明确承诺,这里兑现 iOS 那一半。

#365 让账本记录了观察到的退出、DispatchView 也带上了 status / exitCode / exitSignal,但 roster 仍然只渲染 alive / exited——崩溃、被 OOM 杀死、干净完成,三者是同一个灰点同一个词。本 PR 把标签从"alive/exited"改善了可读性,但没解决这个区分问题。

补上:

  • DispatchKind(running / ok / failed / unknown)映射服务端 status,并为旧版服务端留了显式回退:没有 status 且 alive: false 一律是 unknown,绝不是 ok。子进程是 detached 的,光看死掉的 pid 永远不能推出成功。
  • 每个状态有自己的文字与点颜色——颜色从不是唯一载体。
  • 详情页点名退出码或信号(failed (exit 127) / killed by SIGKILL):只说 "failed" 分不清是自己错误退出还是被杀。
  • 四个状态各有独立的 VoiceOver 文案(点的颜色对 VoiceOver 毫无信息),并有测试钉死"四个都不同且非空"。

顺带修了一处 #365 刚刚让它变得真实可达的问题:HTTP 403 只显示 "Server returned HTTP 403."。托管版 Lisa 拒绝所有机器级路由(push / dispatch / mail / consent / devices——capabilities.ts 的拒绝名单,#365 刚把 /api/push 加进去),而推送注册正是 iPhone 第一个会撞上的路由。原来的文案让"显示已注册、但通知永远收不到"这件事完全无从解释;现在会说明托管版与本机版的分工以及该怎么办。

./build.sh test:49 tests, 0 failures(44 既有 + 5 新增)。

其余核对

  • PushSettings 的纯逻辑拆得干净(ntfyPublishURL 容忍裸主机名/完整 URL/空值),stateLine 明确指出"服务端没有 Apple key 时不能声称投递可用"——这正是本 PR 要解决的那个"界面说已注册、实际收不到"的诚实性问题。
  • 可访问性改动是实的:状态一律配文字、roster 行合并为单个可访问元素、图标按钮补标签、进度点读作"第 N 步 / 共 M 步"。

验证

macOS:swift build / -c release / swift test(20)全通过,0 警告。
iOS:./build.sh(模拟器)与 ./build.sh test(49)全通过。
Node:npm run typecheck 通过;npm test 1710 个,1709 通过,0 失败,1 跳过。

🤖 Generated with Claude Code

@oratis
oratis merged commit 69fa1de into main Sep 7, 2026
2 checks passed
oratis added a commit that referenced this pull request Sep 7, 2026
`lisa upgrade` printed npm's first line and stopped. EACCES on a global
install is the single most common way this command fails — it is the whole
reason `sudo npm install -g` is folklore — and #371 had already taught the
Mac wizard the no-sudo prefix fix. The same user got a dead end from the CLI
and a working fix from the app.

classifyUpgradeFailure() mirrors classifyInstallFailure() in
packaging/mac-client/Sources/LisaSetup/BackendSetup.swift, on the same
needles, so the two surfaces cannot drift into contradicting each other about
one shared `npm install -g`. The permissions branch prints that wizard's
commands verbatim.

Classification reads the whole error message, not its first line: runCmd
rejects with `npm exited 1: <stderr>` and the needle is almost never on line
one — the reason the old first-line-only output was uninformative.

Tests cover each class, the multi-line real-world shape, that no branch
hands out a bare `sudo npm install` (that is what creates the root-owned
folder in the first place), and that MIN_NODE_MAJOR tracks
package.json `engines.node`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oratis added a commit that referenced this pull request Sep 7, 2026
The last of the three surfaces, and the second half of the commitment made
reviewing #365 (iOS landed in #371).

The dashboard rendered `d.alive ? 'Running' : 'Done'`, so a crash, an OOM
kill, `exit 127` and a clean finish were one green "Done" — the card actively
asserted a success the server never observed. The ledger has recorded a
classified exit since #365 and DispatchView carries it; nothing consumed it
here.

dispatchState() maps the server's `status` to a label and a colour class:
running, Done, `Failed · exit 127`, `Killed · SIGKILL`, or
`Exited · status unknown`. A pre-status backend degrades via `alive`, and an
exited dispatch there is **unknown, never Done** — a dead pid is not evidence
of success when the child is detached. An unrecognised status lands in the
same place rather than falling through to green.

Tested by slicing dispatchState out of the exact bytes the browser is served
and running it in a vm, the same technique this PR's i18n tests use — so the
test cannot pass against a version of the function the page doesn't ship.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
oratis added a commit that referenced this pull request Sep 8, 2026
embed-runtime.sh 把 dist + 生产依赖 + 校验过的官方 Node 打进 bundle(--omit=optional 去掉唯一原生模块,保持纯 JS);启动优先级 serve-command.txt → 内置后端 → PATH;后端瞬崩时从 backend.log 读出真实原因并按启动路径分类。离线横幅从压住标题栏的顶部红条改为主题化的底部 toast(info/error 音调 + 可复制命令 chip)。

评审补:
- toast 移植到 #375 之后的 main.js/main.css;README 段落移到 GUIDE。
- BackendController 与 #371 安装向导集成:换回主 actor waiter(原写法触发 -warnings-as-errors 的 sendable 捕获错误)、恢复 start(completion:) 让向导拿到分类后的失败原因。
- build.sh 从仓库根调用会失败(cd 后重新解析 $0),顺带修好 app 图标 fallback。
- 内置 Node 的哈希钉进版本库(sums 文件与 tarball 同源,无法自证)。

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant