Files
hapi/ios/Hapi/Models/AppModel.swift
T
weishu 6066f20c20 feat(ios): zh-CN localization + catalog dedupe (A-M5)
Part 1 — Simplified Chinese localization of the iOS app layer:
- ios/Hapi/Resources/Localizable.xcstrings: hand-authored String Catalog
  (395 keys, zh-Hans; en implicit as source). Terminology mined from
  web/src/lib/locales/zh-CN.ts (会话/新建会话/权限模式/允许/拒绝/工作树/
  机器/语音输入/用量/草稿夹 …).
- Mechanical edits only: merged multi-part string concatenations into
  single LocalizedStringKey literals, converted ternary/plain-String user
  copy to String(localized:), switched helper params (detailRow,
  DashboardCard, optionPicker, notice) to LocalizedStringKey.
- LocalizedNoticeMapper (app layer): display-point translation of the 27
  known HapiKit-emitted strings (ChatInteractor notices, dictation errors,
  window-sync warnings, files fallbacks, worktree-name validation) with
  verbatim passthrough for server-originated text; package stays
  language-free.
- Deliberately untranslated (web parity / non-copy): event rows
  (EventPresentation — web renders presentation.ts verbatim), catalog
  option labels (Default/Auto/Sonnet/permission modes — web shows them
  verbatim), tool names, code-like titles (grep(pattern:), MCP:, Skill,
  Task), unit suffixes (B/KB/MB, m/h/d/w), decorative separators.
- Language row wired: AppLanguage gains .system (follow system, new
  default); explicit picks write the AppleLanguages override, Follow
  system removes it; footer notes a relaunch applies it (no supported
  in-place SwiftUI locale swap).
- project.pbxproj: zh-Hans added to knownRegions.

Part 2 — #39 catalog dedupe (HapiProtocol):
- NewSessionCatalogs.claudeModels/claudeEfforts now derive from
  ClaudeModels/ClaudeEfforts (single source shared with ModelCatalog);
  codexReasoningEfforts stays own data (web CODEX_REASONING_EFFORT_OPTIONS
  minus max); effortLabel delegates to ModelCatalog.capitalizedFirst.
- New CatalogTests lock test for the derived option lists.

Gate: bash ios/scripts/linux-test.sh — 459 tests green.
2026-08-18 16:04:03 +08:00

262 lines
9.3 KiB
Swift

import Foundation
import HapiClient
import HapiProtocol
import Observation
import SwiftUI
/// A pairing that awaits user confirmation — from a deep link, a scanned QR,
/// or manual entry. Drives the confirm sheet.
struct PendingPairing: Identifiable, Hashable {
enum Source: Hashable {
case deepLink
case qrScan
case manual
}
let id = UUID()
/// Hub URL as carried by the link/entry (normalized during `pair`).
let hubUrl: String
let accessToken: String
let source: Source
}
/// Root application state: which hubs are paired, which one is active, and
/// the live ``HubSession`` for it. Owns the `HubRegistry` + credential store
/// and funnels every state transition (pair, switch, sign out, deep link,
/// scene phase, terminal auth failure) through one place.
@Observable @MainActor
final class AppModel {
enum AuthState: Equatable {
case unpaired
case paired(activeHub: String)
}
/// Derived from registry + credentials; every mutation goes through the
/// methods below so it can never drift.
private(set) var state: AuthState = .unpaired
/// Live session for the active hub (`nil` while unpaired).
private(set) var session: HubSession?
/// Paired hub origins, in pairing order (mirrors the registry, but
/// observable so menus update).
private(set) var hubs: [String] = []
/// Pairing waiting for confirmation (sheet presentation).
var pendingPairing: PendingPairing?
/// Presents the add-hub flow from the home screen. Lives here (not view
/// state) so a successful pair or an incoming deep link can close it.
var showAddHub = false
/// Origin of a hub that terminally rejected its credentials — shown as a
/// "pair again" banner until dismissed or re-paired.
var authFailureNotice: String?
/// Transient informational message (alert), e.g. deep link for an
/// already-paired hub.
var infoNotice: String?
// `let` storage is inert under @Observable (no annotation needed).
let registry: HubRegistry
private let credentialStore: any CredentialStoring
private let performer: any HTTPPerforming
private let pairingService: HubPairingService
@ObservationIgnored private var isForeground = false
init(
registry: HubRegistry = HubRegistry(),
credentialStore: any CredentialStoring = KeychainCredentialStore(),
performer: any HTTPPerforming = URLSessionHTTPPerformer.shared
) {
self.registry = registry
self.credentialStore = credentialStore
self.performer = performer
self.pairingService = HubPairingService(
registry: registry,
credentialStore: credentialStore,
performer: performer
)
restore()
}
// MARK: - Cold-start restore
/// Activates the stored selection — or the first registered hub that
/// still has credentials (a registry entry whose Keychain record is gone
/// is skipped rather than presented as paired-but-dead).
private func restore() {
hubs = registry.hubs
let candidates = [registry.activeHub].compactMap { $0 } + hubs
for hub in candidates where hasCredentials(for: hub) {
registry.setActiveHub(hub)
activate(hub: hub)
return
}
state = .unpaired
}
// MARK: - Pairing
/// Full pairing sequence (normalize → `/health` → `/api/auth` → persist,
/// see ``HubPairingService``); on success the hub becomes active with a
/// live session. Throws ``PairingFailure`` for the confirm sheet's error
/// states.
@discardableResult
func pair(hubUrl: String, accessToken: String) async throws -> PairedHub {
let paired = try await pairingService.pair(rawHubUrl: hubUrl, accessToken: accessToken)
authFailureNotice = nil
// Close whichever pairing surface was up; the state flip below swaps
// the root to the home screen.
pendingPairing = nil
showAddHub = false
activate(hub: paired.hubUrl)
return paired
}
/// Selects another paired hub, rebuilding the session for it.
func switchHub(to hub: String) {
guard registry.setActiveHub(hub) else { return }
if case .paired(let current) = state, current == hub, session != nil {
return
}
activate(hub: hub)
}
/// Unpairs a hub: credentials deleted, registry entry dropped. When it
/// was the active hub, the next registered hub takes over (or the app
/// falls back to the pairing flow).
func signOut(hub: String) {
let wasActive = state == .paired(activeHub: hub)
if wasActive {
session?.shutdown()
session = nil
}
let nextActive = pairingService.unpair(hubUrl: hub)
hubs = registry.hubs
guard wasActive else { return }
if let nextActive {
activate(hub: nextActive)
} else {
state = .unpaired
}
}
// MARK: - Deep links (hapicompanion://bind)
/// Routes a parsed bind link: already-paired hubs just switch (with a
/// notice), everything else goes through the confirm sheet — which covers
/// both the initial pairing and the add-another-hub case.
/// Returns `false` when the link's hub URL is unusable.
@discardableResult
func handleBindLink(_ link: BindLink) -> Bool {
guard let normalized = HubURLNormalization.normalize(link.hubUrl) else {
return false
}
if registry.hubs.contains(normalized), hasCredentials(for: normalized) {
// Re-pairing with a *rotated* token still requires an explicit
// sign-out first; silently replacing stored credentials from any
// scanned link would be an easy way to hijack a pairing.
switchHub(to: normalized)
infoNotice = String(
format: String(localized: "Already paired with %@ — switched to it."),
HubDisplay.host(normalized)
)
return true
}
// The confirm sheet is presented from the root; make room for it.
showAddHub = false
pendingPairing = PendingPairing(
hubUrl: link.hubUrl,
accessToken: link.accessToken,
source: .deepLink
)
return true
}
// MARK: - App lifecycle
/// Forwarded from the scene: foreground starts/resumes the active
/// session's SSE, background suspends it.
func handleScenePhase(_ phase: ScenePhase) {
switch phase {
case .active:
isForeground = true
session?.enterForeground()
case .background:
isForeground = false
session?.enterBackground()
case .inactive:
break // transitional; no connection changes
@unknown default:
break
}
}
// MARK: - Internals
/// Builds (and starts, when foregrounded) the session for `hub`,
/// replacing any previous one.
private func activate(hub: String) {
session?.shutdown()
session = nil
guard let newSession = HubSession(
hubUrl: hub,
credentialStore: credentialStore,
performer: performer
) else {
// Unreachable for registry-normalized origins; fail closed.
state = .unpaired
return
}
newSession.onTerminalAuthFailure = { [weak self] in
self?.handleTerminalAuthFailure(for: hub)
}
session = newSession
state = .paired(activeHub: hub)
hubs = registry.hubs
if isForeground {
newSession.enterForeground()
}
}
/// The hub rejected the stored access token (`POST /api/auth` → 401):
/// per the auth contract that is terminal — the token was rotated or
/// revoked, so the dead credentials are removed and the UI drops to
/// pairing (or the next hub) with a banner.
private func handleTerminalAuthFailure(for hub: String) {
session = nil // the session shut itself down before calling back
authFailureNotice = hub
let nextActive = pairingService.unpair(hubUrl: hub)
hubs = registry.hubs
if let nextActive {
activate(hub: nextActive)
} else {
state = .unpaired
}
}
private func hasCredentials(for hub: String) -> Bool {
(try? credentialStore.credentials(forHub: hub)) != nil
}
}
/// Presentation helpers shared by the pairing and home screens.
enum HubDisplay {
/// `https://hub.example.com:8005` → `hub.example.com:8005` (the scheme is
/// noise in tight UI; the full origin stays available for detail rows).
static func host(_ origin: String) -> String {
guard let components = URLComponents(string: origin), let host = components.host else {
return origin
}
if let port = components.port {
return "\(host):\(port)"
}
return host
}
/// `tok_9f8abc123:default` → `tok_…ault`; never reveals more than the
/// edges, and short tokens mask entirely.
static func maskedToken(_ token: String) -> String {
guard token.count > 10 else {
return String(repeating: "•", count: max(4, token.count))
}
return "\(token.prefix(4))…\(token.suffix(4))"
}
}