mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-19 21:09:50 +00:00
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.
262 lines
9.3 KiB
Swift
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))"
|
|
}
|
|
}
|