mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +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.
292 lines
9.7 KiB
Swift
292 lines
9.7 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import SwiftUI
|
|
|
|
/// Settings-home state (A-M4e): the owner gate for the usage/storage entries
|
|
/// and the About hub probe. Appearance/language live in their prefs stores;
|
|
/// this model only carries what needs async loading. Android reference:
|
|
/// `feature/settings/SettingsViewModel.kt`.
|
|
@MainActor @Observable
|
|
final class SettingsModel {
|
|
enum HubInfoState {
|
|
case loading
|
|
case loaded(HubHealthResponse)
|
|
case failed(String?)
|
|
}
|
|
|
|
/// True when the active hub's JWT namespace is `OwnerGate.ownerNamespace`.
|
|
/// Starts false (fail closed) until the gate resolves.
|
|
private(set) var isOwner = false
|
|
private(set) var hubInfo: HubInfoState = .loading
|
|
|
|
var hubUrl: String { session.hubUrl }
|
|
|
|
private let session: HubSession
|
|
private var started = false
|
|
|
|
init(session: HubSession) {
|
|
self.session = session
|
|
}
|
|
|
|
func start() {
|
|
guard !started else { return }
|
|
started = true
|
|
Task { await self.loadOwnerGate() }
|
|
Task { await self.loadHubInfo() }
|
|
}
|
|
|
|
func retryHubInfo() {
|
|
hubInfo = .loading
|
|
Task { await self.loadHubInfo() }
|
|
}
|
|
|
|
/// Reads the current JWT (loading the persisted one, refreshing when
|
|
/// stale) and peeks its `ns` claim — the Android `currentJwt()` +
|
|
/// `isOwnerNamespace` shape. Any failure leaves the gate closed.
|
|
private func loadOwnerGate() async {
|
|
let jwt = try? await session.authManager.validToken()
|
|
isOwner = OwnerGate.isOwnerNamespace(jwt: jwt)
|
|
}
|
|
|
|
private func loadHubInfo() async {
|
|
do {
|
|
hubInfo = .loaded(try await session.api.health())
|
|
} catch is CancellationError {
|
|
// View dismissed mid-flight; nothing to show.
|
|
} catch {
|
|
hubInfo = .failed(error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Settings home (A-M4e), presented as a sheet off the home toolbar menu:
|
|
/// Appearance (theme mode), Language (persist-only until M5), the owner-only
|
|
/// Usage/Storage entries, and About (app/protocol versions + hub health).
|
|
struct SettingsView: View {
|
|
@State private var model: SettingsModel
|
|
@State private var languagePrefs: LanguagePrefs
|
|
@Environment(ThemePrefs.self) private var themePrefs
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
private let session: HubSession
|
|
|
|
init(session: HubSession) {
|
|
_model = State(initialValue: SettingsModel(session: session))
|
|
_languagePrefs = State(initialValue: LanguagePrefs())
|
|
self.session = session
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
appearanceSection
|
|
languageSection
|
|
if model.isOwner {
|
|
insightsSection
|
|
}
|
|
aboutSection
|
|
}
|
|
.navigationTitle("Settings")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button("Done") {
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
model.start()
|
|
}
|
|
}
|
|
// The sheet is its own presentation; re-apply the explicit override
|
|
// so it does not fall back to the system scheme.
|
|
.preferredColorScheme(themePrefs.mode.preferredColorScheme)
|
|
}
|
|
|
|
// MARK: - Sections
|
|
|
|
private var appearanceSection: some View {
|
|
Section("Appearance") {
|
|
Picker("Theme", selection: themeBinding) {
|
|
ForEach(ThemeMode.allCases, id: \.self) { mode in
|
|
Text(mode.label).tag(mode)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var themeBinding: Binding<ThemeMode> {
|
|
Binding(
|
|
get: { themePrefs.mode },
|
|
set: { themePrefs.setMode($0) }
|
|
)
|
|
}
|
|
|
|
private var languageSection: some View {
|
|
Section {
|
|
Picker("App language", selection: languageBinding) {
|
|
ForEach(AppLanguage.allCases, id: \.self) { language in
|
|
Text(Self.pickerLabel(for: language)).tag(language)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Language")
|
|
} footer: {
|
|
Text("Applies after the app is relaunched.")
|
|
}
|
|
}
|
|
|
|
/// Explicit languages show their native names (package data); the
|
|
/// follow-system row is UI copy localized here (the package stays
|
|
/// language-free).
|
|
private static func pickerLabel(for language: AppLanguage) -> String {
|
|
language == .system ? String(localized: "Follow system") : language.displayName
|
|
}
|
|
|
|
/// SwiftUI has no supported in-place locale swap without replumbing every
|
|
/// scene, so an explicit pick writes the standard `AppleLanguages`
|
|
/// override (applied by the OS on next launch) and "Follow system"
|
|
/// removes it — the footer says a relaunch is needed.
|
|
private var languageBinding: Binding<AppLanguage> {
|
|
Binding(
|
|
get: { languagePrefs.language },
|
|
set: { language in
|
|
languagePrefs.setLanguage(language)
|
|
switch language {
|
|
case .system:
|
|
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
|
|
case .english:
|
|
UserDefaults.standard.set(["en"], forKey: "AppleLanguages")
|
|
case .simplifiedChinese:
|
|
UserDefaults.standard.set(["zh-Hans"], forKey: "AppleLanguages")
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
/// Owner-only dashboards — hidden unless the JWT namespace is `default`
|
|
/// (the endpoints 403 for everyone else anyway; this just avoids
|
|
/// dead-end rows).
|
|
private var insightsSection: some View {
|
|
Section("Insights") {
|
|
NavigationLink {
|
|
UsageView(api: session.api)
|
|
} label: {
|
|
detailRow(title: "Usage", detail: "Token usage across agents and models")
|
|
}
|
|
NavigationLink {
|
|
StorageView(api: session.api)
|
|
} label: {
|
|
detailRow(title: "Storage", detail: "Hub database size on disk")
|
|
}
|
|
}
|
|
}
|
|
|
|
private var aboutSection: some View {
|
|
Section("About") {
|
|
LabeledContent("App version", value: appVersion)
|
|
LabeledContent("Protocol version", value: String(ProtocolVersion.supported))
|
|
hubRow
|
|
}
|
|
}
|
|
|
|
private var hubRow: some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
LabeledContent("Hub", value: HubDisplay.host(model.hubUrl))
|
|
Text(model.hubUrl)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
switch model.hubInfo {
|
|
case .loading:
|
|
Text("Checking hub…")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
case .loaded(let health):
|
|
Text("Status: \(health.status) · protocol v\(health.protocolVersion)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
case .failed:
|
|
Button {
|
|
model.retryHubInfo()
|
|
} label: {
|
|
Text("Hub unreachable — tap to retry.")
|
|
.font(.caption)
|
|
.foregroundStyle(.orange)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func detailRow(title: LocalizedStringKey, detail: LocalizedStringKey) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
Text(detail)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private var appVersion: String {
|
|
let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
|
return short ?? "—"
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared dashboard states
|
|
|
|
/// Shared owner-gate/error body for both dashboards (usage + storage).
|
|
/// 403 means the hub rejected a non-owner namespace — explain, don't retry.
|
|
struct DashboardErrorView: View {
|
|
let isForbidden: Bool
|
|
let message: String?
|
|
let onRetry: () -> Void
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(isForbidden
|
|
? String(localized: "This dashboard is only available to the hub owner (default namespace).")
|
|
: String(localized: "Could not load this dashboard."))
|
|
.font(.subheadline)
|
|
if !isForbidden, let message, !message.isEmpty {
|
|
Text(message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if !isForbidden {
|
|
Button("Retry", action: onRetry)
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|
|
|
|
/// Rounded card container shared by the dashboard screens.
|
|
struct DashboardCard<Content: View>: View {
|
|
let title: LocalizedStringKey?
|
|
let content: Content
|
|
|
|
init(title: LocalizedStringKey? = nil, @ViewBuilder content: () -> Content) {
|
|
self.title = title
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if let title {
|
|
Text(title)
|
|
.font(.subheadline.weight(.semibold))
|
|
}
|
|
content
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(16)
|
|
.background(Color(.secondarySystemGroupedBackground), in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
}
|