feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs

App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
This commit is contained in:
Yaojia Wang
2026-07-30 15:57:41 +02:00
parent 9114630c3a
commit 284cfd193a
70 changed files with 10271 additions and 358 deletions

View File

@@ -0,0 +1,315 @@
import Foundation
import SessionCore
import SwiftTerm
import TestSupport
import Testing
import UIKit
import WireProtocol
@testable import WebTerm
/// T-iOS-33 · ios-completion §4 RED 118
///
/// SwiftTerm 1.13.0 API`findNext`/`findPrevious`/
/// `clearSearch``SearchOptions` xterm.js search addon + web
/// `public/search.ts`Enter=next / Shift+Enter=prev / Esc= clear
///
/// **** PTY byte-shuttle
@MainActor
@Suite("TerminalSearch (T-iOS-33)")
struct TerminalSearchTests {
// MARK: - Fakes
///
@MainActor
private final class FakeSearcher: TerminalSearching {
enum Call: Equatable {
case next(String)
case previous(String)
case clear
}
private(set) var calls: [Call] = []
var hitResult = true
func searchNext(term: String) -> Bool {
calls = calls + [.next(term)]
return hitResult
}
func searchPrevious(term: String) -> Bool {
calls = calls + [.previous(term)]
return hitResult
}
func searchClear() {
calls = calls + [.clear]
}
}
private func model(_ searcher: FakeSearcher) -> TerminalSearchModel {
let model = TerminalSearchModel()
model.attach(searcher)
return model
}
// MARK: - A. 13
@Test("1 · 空串不可提交,且零引擎调用")
func emptyQueryIsNotSubmittable() {
#expect(!TerminalSearchQuery.isSubmittable(""))
let searcher = FakeSearcher()
let model = model(searcher)
model.query = ""
model.find(.next)
#expect(searcher.calls.isEmpty)
#expect(model.outcome == .idle)
}
@Test("2 · 单个空格可提交(镜像 web空格是合法搜索词绝不 trim 掉)")
func singleSpaceIsSubmittable() {
#expect(TerminalSearchQuery.isSubmittable(" "))
let searcher = FakeSearcher()
let model = model(searcher)
model.query = " "
model.find(.next)
#expect(searcher.calls == [.next(" ")])
}
@Test("3 · 查询词逐字符原样下传(不 trim、不改大小写")
func queryIsPassedThroughVerbatim() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = " Foo "
model.find(.next)
#expect(searcher.calls == [.next(" Foo ")])
}
// MARK: - B. 49
@Test("4 · find(.next) → 恰一次 searchNext零 searchPrevious")
func findNextCallsNextOnly() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "claude"
model.find(.next)
#expect(searcher.calls == [.next("claude")])
}
@Test("5 · find(.previous) → 恰一次 searchPrevious零 searchNext")
func findPreviousCallsPreviousOnly() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "claude"
model.find(.previous)
#expect(searcher.calls == [.previous("claude")])
}
@Test("6 · 引擎命中 → outcome == .found")
func hitProducesFoundOutcome() {
let searcher = FakeSearcher()
searcher.hitResult = true
let model = model(searcher)
model.query = "claude"
model.find(.next)
#expect(model.outcome == .found)
}
@Test("7 · 引擎未命中 → outcome == .notFound且有非空文案")
func missProducesNotFoundOutcomeWithCopy() {
let searcher = FakeSearcher()
searcher.hitResult = false
let model = model(searcher)
model.query = "zzz"
model.find(.next)
#expect(model.outcome == .notFound)
let status = model.statusText
#expect(status != nil)
#expect(!(status ?? "").isEmpty)
}
@Test("8 · 连续三次 find(.next) → 三次引擎调用(游标由 SwiftTerm 推进)")
func repeatedFindHitsEngineEachTime() {
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "x"
model.find(.next)
model.find(.next)
model.find(.next)
#expect(searcher.calls == [.next("x"), .next("x"), .next("x")])
}
@Test("9 · 未 attach 引擎 → 不崩outcome 保持 .idle")
func noSearcherAttachedIsSafe() {
let model = TerminalSearchModel()
model.query = "claude"
model.find(.next)
#expect(model.outcome == .idle)
#expect(model.statusText == nil)
}
// MARK: - C. /1014
@Test("10 · 初始 isPresented == false 且 outcome == .idle")
func initialStateIsClosedAndIdle() {
let model = TerminalSearchModel()
#expect(!model.isPresented)
#expect(model.outcome == .idle)
}
@Test("11 · present()/dismiss() 切换 isPresented")
func presentAndDismissTogglePresentation() {
let searcher = FakeSearcher()
let model = model(searcher)
model.present()
#expect(model.isPresented)
model.dismiss()
#expect(!model.isPresented)
}
@Test("12 · dismiss() → 恰一次 searchClear + 清空 query + outcome 复位")
func dismissClearsHighlightAndQuery() {
let searcher = FakeSearcher()
searcher.hitResult = false
let model = model(searcher)
model.present()
model.query = "zzz"
model.find(.next)
#expect(model.outcome == .notFound)
model.dismiss()
#expect(searcher.calls == [.next("zzz"), .clear])
#expect(model.query.isEmpty)
#expect(model.outcome == .idle)
}
@Test("13 · present() 不调用任何引擎方法(开面板 ≠ 搜索)")
func presentDoesNotTouchEngine() {
let searcher = FakeSearcher()
let model = model(searcher)
model.present()
#expect(searcher.calls.isEmpty)
}
@Test("14 · 改 query → outcome 复位 .idle旧「无匹配」不挂新词")
func editingQueryResetsOutcome() {
let searcher = FakeSearcher()
searcher.hitResult = false
let model = model(searcher)
model.query = "zzz"
model.find(.next)
#expect(model.outcome == .notFound)
model.query = "zz"
#expect(model.outcome == .idle)
#expect(model.statusText == nil)
}
// MARK: - D. 1516
@Test("15 · 整个搜索流程后零 PTY 字节byte-shuttle 不变式)")
func searchNeverWritesToThePty() async throws {
// Arrange SessionEngine over FakeTransport + TerminalViewModel
let transport = FakeTransport()
let clock = FakeClock()
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
terminalViewModel.start()
// Act
let searcher = FakeSearcher()
let model = model(searcher)
model.present()
model.query = "claude"
model.find(.next)
model.find(.previous)
model.dismiss()
// Assert PTY
#expect(terminalViewModel.forwardedSendCount == 0)
#expect(terminalViewModel.droppedReadOnlyInputCount == 0)
}
@Test("16 · 终端已退出read-only时搜索照常可用")
func searchWorksOnAnExitedTerminal() async throws {
// Arrange VM .exited
let transport = FakeTransport()
let clock = FakeClock()
let baseURL = try #require(URL(string: "http://192.168.1.5:3000"))
let endpoint = try #require(HostEndpoint(baseURL: baseURL))
let engine = SessionEngine(
transport: transport, clock: clock, endpoint: endpoint,
eventsSource: { _ in [] }
)
let terminalViewModel = TerminalViewModel(engine: engine, events: engine.events)
terminalViewModel.start()
await engine.open(sessionId: nil, cwd: nil)
await transport.emit(frame: #"{"type":"exit","code":0,"reason":"done"}"#)
await terminalViewModel.waitUntilProcessed(eventCount: 3)
#expect(terminalViewModel.isReadOnly)
// Act
let searcher = FakeSearcher()
let model = model(searcher)
model.query = "claude"
model.find(.next)
// Assert退
#expect(searcher.calls == [.next("claude")])
#expect(model.outcome == .found)
}
// MARK: - E. SwiftTerm 1718Accept
/// `KeyCommandTerminalView` `TerminalSearching`
/// SwiftTerm
@Test("17 · 真 SwiftTerm view命中返回 true 并高亮选区;未命中返回 false")
func realTerminalViewSearchHitsAndHighlights() {
let terminal = KeyCommandTerminalView(
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
)
terminal.feed(text: "claude code cockpit\r\n")
let searcher: any TerminalSearching = terminal
#expect(searcher.searchNext(term: "cockpit"))
#expect(terminal.hasActiveSelection)
#expect(!searcher.searchNext(term: "nonexistent-needle"))
}
@Test("18 · searchClear() 清掉高亮")
func clearRemovesHighlight() {
let terminal = KeyCommandTerminalView(
frame: CGRect(x: 0, y: 0, width: 480, height: 480)
)
terminal.feed(text: "claude code cockpit\r\n")
let searcher: any TerminalSearching = terminal
#expect(searcher.searchNext(term: "cockpit"))
#expect(terminal.hasActiveSelection)
searcher.searchClear()
#expect(!terminal.hasActiveSelection)
}
}