From 8f8e3b54cbecf51ce6a87e9e028caaad428879be Mon Sep 17 00:00:00 2001 From: soryu-co Date: Fri, 24 Apr 2026 14:16:03 +0000 Subject: Makima iOS M2 — networking, auth, onboarding, settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #91 (M0+M1 scaffold). Wires the app up to a real Makima server via a two-step onboarding flow. Networking - APIClient: async URLSession wrapper. Injects x-makima-api-key on every request (verified against src/server/auth.rs — API keys use the custom header, not Authorization: Bearer). Standard error mapping: 401/403 -> unauthorized, 404 -> notFound, 2xx -> decode, else -> http(status, msg). - APIError: LocalizedError, Equatable. - ServerProfile: id, label, base URL, last-connected timestamp. Derived apiBaseURL ('/api/v1') and apiWebSocketBaseURL (ws/wss scheme upgrade). Keychain ID is stable per profile so key storage survives label/URL edits. - Keychain: thin wrapper over SecItem, scoped to service co.soryu.makima. kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. - ServerProfileStore: @Observable, UserDefaults-backed list of profiles with an active-profile UUID. List-ready today for v1.x multi-profile. Auth state - AuthStore: @Observable state machine — needsOnboarding | validating | authenticated | error(message). configure() validates (server, key) via GET /mesh/daemons, then persists. updateBaseURL() hot-swaps the server while keeping the key (forces re-onboarding on 401). rotateKey() hits POST /auth/api-keys/refresh and swaps in the new key. signOut() wipes Keychain + profile. - AppState: top-level environment bag. UI - RootView routes to OnboardingFlow / ValidatingView / HomePlaceholderView based on AuthStore.state. Home placeholder shows the masked key so you can eyeball that round-trip worked; real Home lands at M3. - OnboardingFlow: two steps (Server URL, API key paste). Dashed-border cards with a 01 SERVER -> 02 KEY pill indicator. 'Open Web Settings' deep-links to /settings via UIApplication openURL. Inline 'mk_' prefix validation on the key field. - SettingsView: server URL edit, rotate key (with in-flight spinner + error surface), sign out. Opened as a sheet from Home. Tests - ServerProfileTests: URL normalisation, WebSocket scheme upgrade, Keychain ID stability across encode/decode. - APIClientTests: URLProtocol stub verifies x-makima-api-key injection + URL composition + 401 -> APIError.unauthorized mapping. Not in this PR (landing at M3+): Home composite dashboard, Contracts, Tasks, WebSocket client, markdown/code rendering, notifications. --- makima/ios/Sources/Makima/App/AppState.swift | 23 +++ makima/ios/Sources/Makima/App/MakimaApp.swift | 4 + makima/ios/Sources/Makima/App/RootView.swift | 205 ++++++++++++--------- .../Makima/Features/Onboarding/APIKeyStep.swift | 89 +++++++++ .../Features/Onboarding/OnboardingFlow.swift | 96 ++++++++++ .../Makima/Features/Onboarding/ServerURLStep.swift | 58 ++++++ .../Makima/Features/Settings/SettingsView.swift | 145 +++++++++++++++ makima/ios/Sources/Makima/Models/Daemon.swift | 9 + makima/ios/Sources/Makima/Net/APIClient.swift | 125 +++++++++++++ makima/ios/Sources/Makima/Net/APIError.swift | 33 ++++ makima/ios/Sources/Makima/Net/AuthStore.swift | 164 +++++++++++++++++ makima/ios/Sources/Makima/Net/Keychain.swift | 75 ++++++++ makima/ios/Sources/Makima/Net/ServerProfile.swift | 52 ++++++ .../Sources/Makima/Net/ServerProfileStore.swift | 62 +++++++ makima/ios/Tests/MakimaTests/APIClientTests.swift | 98 ++++++++++ .../ios/Tests/MakimaTests/ServerProfileTests.swift | 30 +++ 16 files changed, 1180 insertions(+), 88 deletions(-) create mode 100644 makima/ios/Sources/Makima/App/AppState.swift create mode 100644 makima/ios/Sources/Makima/Features/Onboarding/APIKeyStep.swift create mode 100644 makima/ios/Sources/Makima/Features/Onboarding/OnboardingFlow.swift create mode 100644 makima/ios/Sources/Makima/Features/Onboarding/ServerURLStep.swift create mode 100644 makima/ios/Sources/Makima/Features/Settings/SettingsView.swift create mode 100644 makima/ios/Sources/Makima/Models/Daemon.swift create mode 100644 makima/ios/Sources/Makima/Net/APIClient.swift create mode 100644 makima/ios/Sources/Makima/Net/APIError.swift create mode 100644 makima/ios/Sources/Makima/Net/AuthStore.swift create mode 100644 makima/ios/Sources/Makima/Net/Keychain.swift create mode 100644 makima/ios/Sources/Makima/Net/ServerProfile.swift create mode 100644 makima/ios/Sources/Makima/Net/ServerProfileStore.swift create mode 100644 makima/ios/Tests/MakimaTests/APIClientTests.swift create mode 100644 makima/ios/Tests/MakimaTests/ServerProfileTests.swift diff --git a/makima/ios/Sources/Makima/App/AppState.swift b/makima/ios/Sources/Makima/App/AppState.swift new file mode 100644 index 0000000..acc3060 --- /dev/null +++ b/makima/ios/Sources/Makima/App/AppState.swift @@ -0,0 +1,23 @@ +import SwiftUI + +/// Top-level state bag shared through the SwiftUI environment. +@MainActor +@Observable +final class AppState { + let auth: AuthStore + + init(auth: AuthStore = AuthStore()) { + self.auth = auth + } +} + +private struct AppStateKey: EnvironmentKey { + static let defaultValue: AppState = AppState() +} + +extension EnvironmentValues { + var appState: AppState { + get { self[AppStateKey.self] } + set { self[AppStateKey.self] = newValue } + } +} diff --git a/makima/ios/Sources/Makima/App/MakimaApp.swift b/makima/ios/Sources/Makima/App/MakimaApp.swift index 907bf8e..e0209b2 100644 --- a/makima/ios/Sources/Makima/App/MakimaApp.swift +++ b/makima/ios/Sources/Makima/App/MakimaApp.swift @@ -2,9 +2,13 @@ import SwiftUI @main struct MakimaApp: App { + @State private var appState = AppState() + var body: some Scene { WindowGroup { RootView() + .environment(appState) + .environment(appState.auth) .preferredColorScheme(.dark) .tint(Palette.accent) } diff --git a/makima/ios/Sources/Makima/App/RootView.swift b/makima/ios/Sources/Makima/App/RootView.swift index bb724fc..7d22c1a 100644 --- a/makima/ios/Sources/Makima/App/RootView.swift +++ b/makima/ios/Sources/Makima/App/RootView.swift @@ -1,125 +1,154 @@ import SwiftUI -/// Root view for M1. Shows the masthead + a placeholder "system online" card -/// so we can judge the aesthetic port against makima.jp side-by-side. +/// Top-level router: Onboarding until we have a validated ServerProfile + API +/// key, then Home (placeholder in M2; real Home ships at M3). struct RootView: View { + @Environment(AuthStore.self) private var auth + var body: some View { ZStack { Palette.background.ignoresSafeArea() GridOverlay() - VStack(spacing: 0) { - MastheadBar( - serverLabel: "makima.jp", - wsStatus: .idle, - version: Bundle.main.shortVersion - ) - NavStripPlaceholder() - - ScrollView { - VStack(spacing: 24) { - Spacer(minLength: 32) - - Logo(size: 140) - - VStack(spacing: 6) { - Badge(text: "支配する", subtitle: "CONTROL SYSTEM") - Text("Mesh Orchestration Platform") - .font(Typography.titleChrome) - .foregroundStyle(Palette.foreground) - .tracking(1) - Text("Makima is listening.") - .font(Typography.body) - .foregroundStyle(Palette.foregroundMuted) - } - - statusCard - .padding(.horizontal, 16) - - languageDemoCard - .padding(.horizontal, 16) - - Spacer(minLength: 40) + switch auth.state { + case .needsOnboarding, .error: + OnboardingFlow() + .transition(.opacity) + case .validating: + ValidatingView() + .transition(.opacity) + case .authenticated: + HomePlaceholderView() + .transition(.opacity) + } + } + .animation(.easeInOut(duration: 0.25), value: auth.state) + } +} + +// MARK: - Home placeholder (real Home arrives at M3) + +struct HomePlaceholderView: View { + @Environment(AuthStore.self) private var auth + @State private var showSettings = false + + var body: some View { + VStack(spacing: 0) { + MastheadBar( + serverLabel: auth.client?.profile.label ?? ServerProfile.defaultLabel, + wsStatus: .idle, + version: (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + ) + NavStripPlaceholder() + + ScrollView { + VStack(spacing: 24) { + Spacer(minLength: 24) + Logo(size: 120) + + VStack(spacing: 6) { + Badge(text: "支配する", subtitle: "CONTROL SYSTEM") + Text("Authenticated") + .font(Typography.titleChrome) + .foregroundStyle(Palette.foreground) + .tracking(1) + Text("Home arrives at M3. Settings below.") + .font(Typography.body) + .foregroundStyle(Palette.foregroundMuted) + } + + sessionCard + .padding(.horizontal, 16) + + Button { + showSettings = true + } label: { + Text("OPEN SETTINGS") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 18) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Palette.panel) + .foregroundStyle(Palette.accent) + .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1)) } - .padding(.top, 12) + .padding(.horizontal, 16) + + Spacer(minLength: 40) } + .padding(.top, 12) } } + .sheet(isPresented: $showSettings) { + SettingsView().environment(auth) + } } - private var statusCard: some View { + private var sessionCard: some View { VStack(alignment: .leading, spacing: 10) { HStack { - Text("SYSTEM//") + Text("SESSION//") .font(Typography.navLabel) .foregroundStyle(Palette.foregroundMuted) Spacer() - Text("M0 + M1") + Text("OK") .font(Typography.navLabel) - .foregroundStyle(Palette.accent) + .foregroundStyle(Palette.ok) } Divider().overlay(Palette.borderMuted) - ForEach(StatusRow.samples) { row in - HStack { - Circle() - .fill(row.ok ? Palette.ok : Palette.warn) - .frame(width: 6, height: 6) - Text(row.label) - .font(Typography.body) - .foregroundStyle(Palette.foreground) - Spacer() - Text(row.value) - .font(Typography.mono) - .foregroundStyle(Palette.foregroundMuted) - } - } + + row("Server", auth.client?.profile.baseURLString ?? "—") + row("Profile", auth.client?.profile.label ?? "—") + row("Key", maskedKey(auth.client?.apiKey)) } .padding(14) .dashedBorder() } - private var languageDemoCard: some View { - VStack(alignment: .leading, spacing: 8) { - Text("GLOSSARY//") - .font(Typography.navLabel) + private func row(_ label: String, _ value: String) -> some View { + HStack { + Text(label) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + Spacer() + Text(value) + .font(Typography.mono) .foregroundStyle(Palette.foregroundMuted) - Divider().overlay(Palette.borderMuted) - VStack(alignment: .leading, spacing: 8) { - JapaneseLongPressText(japanese: "命令", english: "Directives") - JapaneseLongPressText(japanese: "契約", english: "Contracts") - JapaneseLongPressText(japanese: "聴取", english: "Listen") - JapaneseLongPressText(japanese: "史料", english: "History") - } - Text("Long-press any term to reveal the English gloss.") - .font(Typography.caption) - .foregroundStyle(Palette.foregroundMuted) - .padding(.top, 4) + .lineLimit(1) + .truncationMode(.middle) } - .padding(14) - .dashedBorder() } -} -private struct StatusRow: Identifiable { - let id = UUID() - let label: String - let value: String - let ok: Bool - - static let samples: [StatusRow] = [ - StatusRow(label: "Scaffold", value: "READY", ok: true), - StatusRow(label: "Design Sys", value: "M1", ok: true), - StatusRow(label: "Network", value: "NOT WIRED", ok: false), - StatusRow(label: "WebSocket", value: "NOT WIRED", ok: false) - ] + private func maskedKey(_ key: String?) -> String { + guard let key, key.count > 6 else { return "—" } + let prefix = key.prefix(4) + let suffix = key.suffix(4) + return "\(prefix)…\(suffix)" + } } -private extension Bundle { - var shortVersion: String { - (infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" +struct ValidatingView: View { + var body: some View { + VStack(spacing: 16) { + Spacer() + Logo(size: 80) + ProgressView() + .progressViewStyle(.circular) + .tint(Palette.accent) + Text("CONNECTING…") + .font(Typography.navLabel) + .tracking(2) + .foregroundStyle(Palette.foregroundMuted) + Spacer() + } } } -#Preview { - RootView() +#Preview("Home placeholder") { + let state = AppState() + return HomePlaceholderView() + .environment(state) + .environment(state.auth) + .preferredColorScheme(.dark) } diff --git a/makima/ios/Sources/Makima/Features/Onboarding/APIKeyStep.swift b/makima/ios/Sources/Makima/Features/Onboarding/APIKeyStep.swift new file mode 100644 index 0000000..18eef4e --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Onboarding/APIKeyStep.swift @@ -0,0 +1,89 @@ +import SwiftUI + +struct APIKeyStep: View { + let baseURL: String + @Binding var apiKey: String + let onBack: () -> Void + let onSubmit: () -> Void + + @Environment(\.openURL) private var openURL + + private var isValid: Bool { + let trimmed = apiKey.trimmingCharacters(in: .whitespaces) + return trimmed.hasPrefix("mk_") && trimmed.count >= 12 + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + sectionLabel("KEY//") + Text("Paste an API key from \(URL(string: baseURL)?.host ?? "the server").") + .font(Typography.titleChrome) + .foregroundStyle(Palette.foreground) + Text("Create one in the web Settings panel — it starts with `mk_`. Makima shows the full key only once; save it somewhere if you'll need it on another device.") + .font(Typography.body) + .foregroundStyle(Palette.bodySoft) + + Button { + if let u = URL(string: "\(baseURL.trimmingCharacters(in: CharacterSet(charactersIn: "/")))/settings") { + openURL(u) + } + } label: { + HStack { + Text("OPEN WEB SETTINGS") + .font(Typography.navLabel) + .tracking(1) + Spacer() + Image(systemName: "arrow.up.right.square") + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Palette.panel) + .foregroundStyle(Palette.accent) + .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1)) + } + + SecureField("mk_…", text: $apiKey) + .textContentType(.password) + .autocapitalization(.none) + .autocorrectionDisabled() + .font(Typography.mono) + .foregroundStyle(Palette.foreground) + .padding(10) + .background(Palette.panel) + .overlay(Rectangle().strokeBorder(Palette.borderMuted, lineWidth: 1)) + + HStack(spacing: 10) { + Button(action: onBack) { + Text("BACK") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(Palette.panel) + .foregroundStyle(Palette.foregroundMuted) + .overlay(Rectangle().strokeBorder(Palette.borderMuted, lineWidth: 1)) + } + Button(action: onSubmit) { + Text("CONNECT") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(isValid ? Palette.panel : Palette.panel.opacity(0.5)) + .foregroundStyle(isValid ? Palette.accent : Palette.foregroundMuted.opacity(0.4)) + .overlay(Rectangle().strokeBorder(isValid ? Palette.border : Palette.borderMuted, + lineWidth: 1)) + } + .disabled(!isValid) + } + } + .padding(14) + .dashedBorder() + } + + private func sectionLabel(_ text: String) -> some View { + Text(text).font(Typography.navLabel).foregroundStyle(Palette.foregroundMuted) + } +} diff --git a/makima/ios/Sources/Makima/Features/Onboarding/OnboardingFlow.swift b/makima/ios/Sources/Makima/Features/Onboarding/OnboardingFlow.swift new file mode 100644 index 0000000..fe5b4c6 --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Onboarding/OnboardingFlow.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct OnboardingFlow: View { + @Environment(AuthStore.self) private var auth + + @State private var baseURL: String = ServerProfile.defaultBaseURL + @State private var apiKey: String = "" + @State private var step: Step = .server + + enum Step { case server, key } + + var body: some View { + VStack(spacing: 0) { + MastheadBar(serverLabel: "onboarding", + wsStatus: .idle, + version: (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0") + + ScrollView { + VStack(spacing: 28) { + Spacer(minLength: 24) + Logo(size: 110) + + Badge(text: "支配する", subtitle: "CONTROL SYSTEM") + + stepIndicator + .padding(.horizontal, 16) + + content + .padding(.horizontal, 16) + + if case let .error(message) = auth.state { + errorCard(message) + .padding(.horizontal, 16) + } + + Spacer(minLength: 40) + } + .padding(.top, 8) + } + } + } + + @ViewBuilder + private var content: some View { + switch step { + case .server: + ServerURLStep(baseURL: $baseURL) { + withAnimation { step = .key } + } + case .key: + APIKeyStep(baseURL: baseURL, apiKey: $apiKey, + onBack: { withAnimation { step = .server } }, + onSubmit: submit) + } + } + + private var stepIndicator: some View { + HStack(spacing: 8) { + pill("01 SERVER", active: step == .server) + Rectangle().fill(Palette.borderMuted).frame(height: 1) + pill("02 KEY", active: step == .key) + } + } + + private func pill(_ text: String, active: Bool) -> some View { + Text(text) + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(active ? Palette.accent : Palette.foregroundMuted.opacity(0.6)) + .overlay(Rectangle().strokeBorder(active ? Palette.border : Palette.borderMuted, + lineWidth: 1)) + } + + private func errorCard(_ message: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Rectangle().fill(Palette.danger).frame(width: 3) + VStack(alignment: .leading, spacing: 4) { + Text("ERROR//") + .font(Typography.navLabel) + .foregroundStyle(Palette.danger) + Text(message) + .font(Typography.body) + .foregroundStyle(Palette.body) + } + } + .padding(10) + .background(Palette.panel) + .overlay(Rectangle().strokeBorder(Palette.danger.opacity(0.4), lineWidth: 1)) + } + + private func submit() { + Task { await auth.configure(baseURLString: baseURL, apiKey: apiKey) } + } +} diff --git a/makima/ios/Sources/Makima/Features/Onboarding/ServerURLStep.swift b/makima/ios/Sources/Makima/Features/Onboarding/ServerURLStep.swift new file mode 100644 index 0000000..ed43e5b --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Onboarding/ServerURLStep.swift @@ -0,0 +1,58 @@ +import SwiftUI + +struct ServerURLStep: View { + @Binding var baseURL: String + let onNext: () -> Void + + private var isValid: Bool { + guard let url = URL(string: baseURL.trimmingCharacters(in: .whitespaces)), + let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http", + url.host?.isEmpty == false + else { return false } + return true + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + sectionLabel("SERVER//") + Text("Where does your Makima server live?") + .font(Typography.titleChrome) + .foregroundStyle(Palette.foreground) + Text("Default is makima.jp. Any reachable Makima instance works — self-hosted, staging, whatever. You can change this later in Settings.") + .font(Typography.body) + .foregroundStyle(Palette.bodySoft) + + TextField("https://makima.jp", text: $baseURL) + .textContentType(.URL) + .autocapitalization(.none) + .keyboardType(.URL) + .autocorrectionDisabled() + .font(Typography.mono) + .foregroundStyle(Palette.foreground) + .padding(10) + .background(Palette.panel) + .overlay(Rectangle().strokeBorder(Palette.borderMuted, lineWidth: 1)) + + Button(action: onNext) { + Text("NEXT — OBTAIN API KEY") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + .background(isValid ? Palette.panel : Palette.panel.opacity(0.5)) + .foregroundStyle(isValid ? Palette.accent : Palette.foregroundMuted.opacity(0.4)) + .overlay(Rectangle().strokeBorder(isValid ? Palette.border : Palette.borderMuted, + lineWidth: 1)) + } + .disabled(!isValid) + } + .padding(14) + .dashedBorder() + } + + private func sectionLabel(_ text: String) -> some View { + Text(text).font(Typography.navLabel).foregroundStyle(Palette.foregroundMuted) + } +} diff --git a/makima/ios/Sources/Makima/Features/Settings/SettingsView.swift b/makima/ios/Sources/Makima/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..83a61ed --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Settings/SettingsView.swift @@ -0,0 +1,145 @@ +import SwiftUI + +struct SettingsView: View { + @Environment(AuthStore.self) private var auth + @Environment(\.dismiss) private var dismiss + + @State private var editedURL: String = "" + @State private var rotating = false + @State private var rotateError: String? + + var body: some View { + NavigationStack { + ZStack { + Palette.background.ignoresSafeArea() + GridOverlay() + ScrollView { + VStack(spacing: 18) { + serverCard + keyCard + dangerCard + } + .padding(16) + } + } + .navigationTitle("SETTINGS") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + .foregroundStyle(Palette.accent) + } + } + .onAppear { + editedURL = auth.client?.profile.baseURLString ?? ServerProfile.defaultBaseURL + } + } + } + + private var serverCard: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("SERVER//") + TextField(ServerProfile.defaultBaseURL, text: $editedURL) + .textContentType(.URL) + .autocapitalization(.none) + .keyboardType(.URL) + .autocorrectionDisabled() + .font(Typography.mono) + .foregroundStyle(Palette.foreground) + .padding(10) + .background(Palette.panel) + .overlay(Rectangle().strokeBorder(Palette.borderMuted, lineWidth: 1)) + + Button { + Task { await auth.updateBaseURL(editedURL) } + } label: { + Text("APPLY") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Palette.panel) + .foregroundStyle(Palette.accent) + .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1)) + } + } + .padding(14) + .dashedBorder() + } + + private var keyCard: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("API KEY//") + HStack { + Text("Current") + .font(Typography.body) + .foregroundStyle(Palette.foreground) + Spacer() + Text(masked(auth.client?.apiKey)) + .font(Typography.mono) + .foregroundStyle(Palette.foregroundMuted) + } + if let rotateError { + Text(rotateError) + .font(Typography.caption) + .foregroundStyle(Palette.danger) + } + Button { + Task { + rotateError = nil + rotating = true + defer { rotating = false } + do { try await auth.rotateKey() } + catch { rotateError = error.localizedDescription } + } + } label: { + HStack { + if rotating { ProgressView().tint(Palette.accent) } + Text(rotating ? "ROTATING…" : "ROTATE KEY") + .font(Typography.navLabel) + .tracking(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Palette.panel) + .foregroundStyle(Palette.accent) + .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1)) + } + .disabled(rotating || auth.client == nil) + } + .padding(14) + .dashedBorder() + } + + private var dangerCard: some View { + VStack(alignment: .leading, spacing: 10) { + sectionLabel("SESSION//") + Button(role: .destructive) { + auth.signOut() + dismiss() + } label: { + Text("SIGN OUT") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity) + .background(Palette.panel) + .foregroundStyle(Palette.danger) + .overlay(Rectangle().strokeBorder(Palette.danger.opacity(0.5), lineWidth: 1)) + } + } + .padding(14) + .dashedBorder() + } + + private func sectionLabel(_ text: String) -> some View { + Text(text).font(Typography.navLabel).foregroundStyle(Palette.foregroundMuted) + } + + private func masked(_ key: String?) -> String { + guard let key, key.count > 6 else { return "—" } + return "\(key.prefix(4))…\(key.suffix(4))" + } +} diff --git a/makima/ios/Sources/Makima/Models/Daemon.swift b/makima/ios/Sources/Makima/Models/Daemon.swift new file mode 100644 index 0000000..523e4da --- /dev/null +++ b/makima/ios/Sources/Makima/Models/Daemon.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Minimal projection of the daemon object for the auth probe. Full model +/// lands at M6 when we wire the Daemons screen. +struct DaemonBrief: Decodable, Identifiable { + let id: String + let status: String? + let hostname: String? +} diff --git a/makima/ios/Sources/Makima/Net/APIClient.swift b/makima/ios/Sources/Makima/Net/APIClient.swift new file mode 100644 index 0000000..1cb01c9 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/APIClient.swift @@ -0,0 +1,125 @@ +import Foundation + +/// Async HTTP client for the Makima API. +/// +/// Injects the `x-makima-api-key` header on every request (verified in +/// `src/server/auth.rs` — API keys live in a custom header, not `Authorization`). +/// +/// Instances are cheap; one per active `ServerProfile`. +final class APIClient { + let profile: ServerProfile + let apiKey: String + let session: URLSession + + init(profile: ServerProfile, apiKey: String, session: URLSession = .shared) { + self.profile = profile + self.apiKey = apiKey + self.session = session + } + + // MARK: - Public + + func get(_ path: String, as _: T.Type = T.self) async throws -> T { + try await send(method: "GET", path: path, body: nil as Empty?) + } + + @discardableResult + func post(_ path: String, body: B) async throws -> R { + try await send(method: "POST", path: path, body: body) + } + + @discardableResult + func post(_ path: String) async throws -> R { + try await send(method: "POST", path: path, body: nil as Empty?) + } + + @discardableResult + func delete(_ path: String, as _: R.Type = R.self) async throws -> R { + try await send(method: "DELETE", path: path, body: nil as Empty?) + } + + /// Make the request without decoding a response body. Use for endpoints + /// whose response we don't model yet. + func send(method: String, path: String) async throws { + let (_, status) = try await rawSend(method: method, path: path, body: nil as Empty?) + try mapStatus(status, data: Data()) + } + + // MARK: - Internals + + private struct Empty: Encodable {} + + private func send(method: String, path: String, body: B?) async throws -> R { + let (data, status) = try await rawSend(method: method, path: path, body: body) + try mapStatus(status, data: data) + + if R.self == EmptyResponse.self { + return EmptyResponse() as! R + } + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(R.self, from: data) + } catch { + throw APIError.decoding(String(describing: error)) + } + } + + private func rawSend(method: String, path: String, body: B?) async throws -> (Data, Int) { + guard let base = profile.apiBaseURL else { + throw APIError.invalidURL(profile.baseURLString) + } + + // Compose URL. `path` may start with `/` — normalise. + let trimmed = path.hasPrefix("/") ? String(path.dropFirst()) : path + guard let url = URL(string: trimmed, relativeTo: base)?.absoluteURL else { + throw APIError.invalidURL(trimmed) + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue(apiKey, forHTTPHeaderField: "x-makima-api-key") + request.setValue("application/json", forHTTPHeaderField: "accept") + request.setValue("makima-ios/\(Self.appVersion)", forHTTPHeaderField: "user-agent") + + if let body { + request.setValue("application/json", forHTTPHeaderField: "content-type") + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + do { + request.httpBody = try encoder.encode(body) + } catch { + throw APIError.decoding("encoding request body: \(error)") + } + } + + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + return (data, http.statusCode) + } catch let urlErr as URLError where urlErr.code == .cancelled { + throw APIError.cancelled + } catch { + throw APIError.network(String(describing: error)) + } + } + + private func mapStatus(_ status: Int, data: Data) throws { + switch status { + case 200..<300: return + case 401, 403: throw APIError.unauthorized + case 404: throw APIError.notFound + default: + let msg = (try? JSONDecoder().decode(APIErrorBody.self, from: data))?.message + throw APIError.http(status: status, message: msg) + } + } + + static var appVersion: String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + } +} + +struct EmptyResponse: Decodable {} diff --git a/makima/ios/Sources/Makima/Net/APIError.swift b/makima/ios/Sources/Makima/Net/APIError.swift new file mode 100644 index 0000000..a936aa9 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/APIError.swift @@ -0,0 +1,33 @@ +import Foundation + +enum APIError: Error, LocalizedError, Equatable { + case notConfigured + case invalidURL(String) + case invalidResponse + case http(status: Int, message: String?) + case unauthorized + case notFound + case decoding(String) + case network(String) + case cancelled + + var errorDescription: String? { + switch self { + case .notConfigured: return "No server profile configured" + case .invalidURL(let s): return "Invalid server URL: \(s)" + case .invalidResponse: return "Invalid response from server" + case .http(let code, let msg): return msg ?? "HTTP \(code)" + case .unauthorized: return "Unauthorized — check your API key" + case .notFound: return "Not found" + case .decoding(let s): return "Decoding error: \(s)" + case .network(let s): return "Network error: \(s)" + case .cancelled: return "Cancelled" + } + } +} + +/// Shape of Makima's standard error payload (`{"code":"…","message":"…"}`). +struct APIErrorBody: Decodable { + let code: String? + let message: String? +} diff --git a/makima/ios/Sources/Makima/Net/AuthStore.swift b/makima/ios/Sources/Makima/Net/AuthStore.swift new file mode 100644 index 0000000..0046548 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/AuthStore.swift @@ -0,0 +1,164 @@ +import Foundation + +/// Top-level authentication state. Mediates between ServerProfileStore +/// (on-disk) and APIClient (in-memory, per session). +@Observable +final class AuthStore { + enum State: Equatable { + case needsOnboarding + case validating + case authenticated + case error(String) + } + + private(set) var state: State = .needsOnboarding + private(set) var client: APIClient? + + let profiles: ServerProfileStore + + init(profiles: ServerProfileStore = ServerProfileStore()) { + self.profiles = profiles + bootstrap() + } + + // MARK: - Bootstrap from persistence + + private func bootstrap() { + guard let profile = profiles.activeProfile else { + state = .needsOnboarding + return + } + do { + guard let key = try Keychain.get(profile.keychainID), !key.isEmpty else { + state = .needsOnboarding + return + } + self.client = APIClient(profile: profile, apiKey: key) + self.state = .authenticated + } catch { + state = .error(String(describing: error)) + } + } + + // MARK: - Onboarding entry point + + /// Validate and persist a new (server, key) pair. On success, becomes the + /// active profile and flips state to `.authenticated`. + @MainActor + func configure(baseURLString: String, apiKey: String, label: String? = nil) async { + state = .validating + + let profile = ServerProfile( + label: label ?? Self.labelFrom(baseURLString) ?? ServerProfile.defaultLabel, + baseURLString: baseURLString + ) + + guard profile.baseURL != nil else { + state = .error("Server URL isn't a valid https:// or http:// address.") + return + } + + let client = APIClient(profile: profile, apiKey: apiKey) + do { + // Anything authenticated works; daemons endpoint is cheap. + let _: [DaemonBrief] = try await client.get("/mesh/daemons") + } catch APIError.unauthorized { + state = .error("Server rejected the API key. Double-check `mk_…` and try again.") + return + } catch APIError.notFound { + // 404 on /mesh/daemons is odd but shouldn't block onboarding — the auth + // layer ran first and didn't reject, so the key is probably fine. + } catch { + state = .error(error.localizedDescription) + return + } + + // Persist + do { + try Keychain.set(apiKey, for: profile.keychainID) + } catch { + state = .error("Couldn't save to Keychain: \(error)") + return + } + + var persisted = profile + persisted.lastConnectedAt = .now + profiles.upsert(persisted) + profiles.setActive(persisted.id) + + self.client = APIClient(profile: persisted, apiKey: apiKey) + self.state = .authenticated + } + + // MARK: - Settings operations + + /// Overwrite the server URL on the active profile. Re-validates; on 401 + /// prompts for a new key (caller shows UI). Keeps the existing key if the + /// URL works against it. + @MainActor + func updateBaseURL(_ baseURLString: String) async { + guard var profile = profiles.activeProfile, + let key = try? Keychain.get(profile.keychainID), !key.isEmpty else { + state = .needsOnboarding + return + } + state = .validating + profile.baseURLString = baseURLString + profile.label = Self.labelFrom(baseURLString) ?? profile.label + + guard profile.baseURL != nil else { + state = .error("Server URL isn't a valid https:// or http:// address.") + return + } + + let client = APIClient(profile: profile, apiKey: key) + do { + let _: [DaemonBrief] = try await client.get("/mesh/daemons") + } catch APIError.unauthorized { + // Keep the profile but force re-auth + profiles.upsert(profile) + state = .needsOnboarding + return + } catch APIError.notFound { + // tolerate as above + } catch { + state = .error(error.localizedDescription) + return + } + + profile.lastConnectedAt = .now + profiles.upsert(profile) + self.client = client + self.state = .authenticated + } + + /// Rotate the API key via `POST /auth/api-keys/refresh`. Swaps in the new + /// key on success. + @MainActor + func rotateKey() async throws { + guard let client = client else { throw APIError.notConfigured } + struct RefreshBody: Encodable { let confirm: Bool = true } + struct RefreshResp: Decodable { let api_key: String } + + let resp: RefreshResp = try await client.post("/auth/api-keys/refresh", body: RefreshBody()) + try Keychain.set(resp.api_key, for: client.profile.keychainID) + self.client = APIClient(profile: client.profile, apiKey: resp.api_key) + } + + /// Sign out: wipes Keychain + active profile marker, clears in-memory client. + @MainActor + func signOut() { + if let profile = profiles.activeProfile { + try? Keychain.delete(profile.keychainID) + profiles.remove(profile.id) + } + self.client = nil + self.state = .needsOnboarding + } + + // MARK: - Helpers + + static func labelFrom(_ urlString: String) -> String? { + URL(string: urlString)?.host + } +} diff --git a/makima/ios/Sources/Makima/Net/Keychain.swift b/makima/ios/Sources/Makima/Net/Keychain.swift new file mode 100644 index 0000000..9dc8fe1 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/Keychain.swift @@ -0,0 +1,75 @@ +import Foundation +import Security + +/// Thin wrapper over the Keychain for storing API keys. Service-scoped so +/// values never collide with other apps. +enum Keychain { + static let service = "co.soryu.makima" + + enum Error: Swift.Error, Equatable { + case unhandled(OSStatus) + case invalidData + } + + @discardableResult + static func set(_ value: String, for account: String) throws -> Bool { + let data = Data(value.utf8) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let attrs: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + + // Try update first + let updateStatus = SecItemUpdate(query as CFDictionary, attrs as CFDictionary) + if updateStatus == errSecSuccess { return true } + if updateStatus != errSecItemNotFound { + throw Error.unhandled(updateStatus) + } + + var addQuery = query + for (k, v) in attrs { addQuery[k] = v } + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw Error.unhandled(addStatus) + } + return true + } + + static func get(_ account: String) throws -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw Error.unhandled(status) } + guard let data = item as? Data, let string = String(data: data, encoding: .utf8) else { + throw Error.invalidData + } + return string + } + + @discardableResult + static func delete(_ account: String) throws -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account + ] + let status = SecItemDelete(query as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { return true } + throw Error.unhandled(status) + } +} diff --git a/makima/ios/Sources/Makima/Net/ServerProfile.swift b/makima/ios/Sources/Makima/Net/ServerProfile.swift new file mode 100644 index 0000000..75a6ab1 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/ServerProfile.swift @@ -0,0 +1,52 @@ +import Foundation + +/// A configured Makima server. +/// Stored as a list in UserDefaults; the API key lives in Keychain keyed by +/// `keychainID`. Allows future multi-profile support without migration. +struct ServerProfile: Codable, Identifiable, Hashable { + let id: UUID + var label: String // "makima.jp" or user-named + var baseURLString: String // e.g. "https://makima.jp" + var lastConnectedAt: Date? + + init(id: UUID = UUID(), label: String, baseURLString: String, lastConnectedAt: Date? = nil) { + self.id = id + self.label = label + self.baseURLString = baseURLString + self.lastConnectedAt = lastConnectedAt + } + + /// Validated URL, or nil if malformed. Strips trailing slash. + var baseURL: URL? { + let trimmed = baseURLString.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard let url = URL(string: trimmed) else { return nil } + guard let scheme = url.scheme?.lowercased(), + scheme == "https" || scheme == "http" else { return nil } + return url + } + + /// API root: `/api/v1` + var apiBaseURL: URL? { + baseURL?.appendingPathComponent("api/v1") + } + + /// WebSocket scheme version of `apiBaseURL`. + var apiWebSocketBaseURL: URL? { + guard let url = apiBaseURL, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return nil } + switch components.scheme { + case "http": components.scheme = "ws" + case "https": components.scheme = "wss" + default: return nil + } + return components.url + } + + /// Stable Keychain identifier — never changes after the profile is minted. + var keychainID: String { "profile.\(id.uuidString)" } + + static let defaultLabel = "makima.jp" + static let defaultBaseURL = "https://makima.jp" +} diff --git a/makima/ios/Sources/Makima/Net/ServerProfileStore.swift b/makima/ios/Sources/Makima/Net/ServerProfileStore.swift new file mode 100644 index 0000000..2100a07 --- /dev/null +++ b/makima/ios/Sources/Makima/Net/ServerProfileStore.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Persists the list of server profiles in UserDefaults. API keys live in +/// Keychain, keyed by the profile's `keychainID`. +/// +/// v1 keeps exactly one profile, but the storage shape is list-ready so v1.x +/// can grow to multi-profile with no migration. +@Observable +final class ServerProfileStore { + private(set) var profiles: [ServerProfile] + var activeProfileID: UUID? + + private let defaults: UserDefaults + private static let profilesKey = "makima.profiles.v1" + private static let activeIDKey = "makima.profiles.activeID.v1" + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.profilesKey), + let decoded = try? JSONDecoder().decode([ServerProfile].self, from: data) { + self.profiles = decoded + } else { + self.profiles = [] + } + if let raw = defaults.string(forKey: Self.activeIDKey), + let uuid = UUID(uuidString: raw) { + self.activeProfileID = uuid + } + } + + var activeProfile: ServerProfile? { + guard let id = activeProfileID else { return nil } + return profiles.first(where: { $0.id == id }) + } + + func upsert(_ profile: ServerProfile) { + if let i = profiles.firstIndex(where: { $0.id == profile.id }) { + profiles[i] = profile + } else { + profiles.append(profile) + } + persistProfiles() + } + + func setActive(_ id: UUID?) { + activeProfileID = id + if let id { defaults.set(id.uuidString, forKey: Self.activeIDKey) } + else { defaults.removeObject(forKey: Self.activeIDKey) } + } + + func remove(_ id: UUID) { + profiles.removeAll { $0.id == id } + if activeProfileID == id { setActive(profiles.first?.id) } + persistProfiles() + } + + private func persistProfiles() { + if let data = try? JSONEncoder().encode(profiles) { + defaults.set(data, forKey: Self.profilesKey) + } + } +} diff --git a/makima/ios/Tests/MakimaTests/APIClientTests.swift b/makima/ios/Tests/MakimaTests/APIClientTests.swift new file mode 100644 index 0000000..9277aa3 --- /dev/null +++ b/makima/ios/Tests/MakimaTests/APIClientTests.swift @@ -0,0 +1,98 @@ +import XCTest +@testable import Makima + +final class APIClientTests: XCTestCase { + func testInjectsCustomHeaderAndBuildsURL() async throws { + let stub = URLProtocolStub.install() + defer { URLProtocolStub.uninstall() } + + stub.responses = [ + URLProtocolStub.Response( + url: "https://makima.test/api/v1/mesh/daemons", + status: 200, + body: #"[]"#.data(using: .utf8)! + ) + ] + + let profile = ServerProfile(label: "test", baseURLString: "https://makima.test") + let client = APIClient(profile: profile, apiKey: "mk_testkey", session: URLProtocolStub.session()) + let _: [DaemonBrief] = try await client.get("/mesh/daemons") + + let recorded = try XCTUnwrap(stub.received.first) + XCTAssertEqual(recorded.url?.absoluteString, "https://makima.test/api/v1/mesh/daemons") + XCTAssertEqual(recorded.value(forHTTPHeaderField: "x-makima-api-key"), "mk_testkey") + XCTAssertEqual(recorded.httpMethod, "GET") + } + + func testMapsStatusCodes() async { + let stub = URLProtocolStub.install() + defer { URLProtocolStub.uninstall() } + + stub.responses = [ + URLProtocolStub.Response(url: "https://makima.test/api/v1/mesh/daemons", + status: 401, body: Data()) + ] + let profile = ServerProfile(label: "test", baseURLString: "https://makima.test") + let client = APIClient(profile: profile, apiKey: "mk_x", session: URLProtocolStub.session()) + + do { + let _: [DaemonBrief] = try await client.get("/mesh/daemons") + XCTFail("Expected unauthorized") + } catch { + XCTAssertEqual(error as? APIError, .unauthorized) + } + } +} + +// MARK: - URLProtocol stub + +final class URLProtocolStub: URLProtocol, @unchecked Sendable { + struct Response { + let url: String + let status: Int + let body: Data + } + nonisolated(unsafe) static var shared: URLProtocolStub? + var responses: [Response] = [] + var received: [URLRequest] = [] + + static func install() -> URLProtocolStub { + let s = URLProtocolStub() + shared = s + URLProtocol.registerClass(URLProtocolStub.self) + return s + } + static func uninstall() { + URLProtocol.unregisterClass(URLProtocolStub.self) + shared = nil + } + static func session() -> URLSession { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [URLProtocolStub.self] + return URLSession(configuration: cfg) + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let shared = URLProtocolStub.shared else { return } + shared.received.append(request) + let wanted = request.url?.absoluteString ?? "" + let match = shared.responses.first { $0.url == wanted } ?? shared.responses.first + guard let match else { + client?.urlProtocol(self, didFailWithError: URLError(.badURL)) + return + } + let response = HTTPURLResponse( + url: request.url!, + statusCode: match.status, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: match.body) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/makima/ios/Tests/MakimaTests/ServerProfileTests.swift b/makima/ios/Tests/MakimaTests/ServerProfileTests.swift new file mode 100644 index 0000000..fd79e38 --- /dev/null +++ b/makima/ios/Tests/MakimaTests/ServerProfileTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Makima + +final class ServerProfileTests: XCTestCase { + func testBaseURLStripsTrailingSlash() { + let p = ServerProfile(label: "x", baseURLString: "https://makima.jp/") + XCTAssertEqual(p.baseURL?.absoluteString, "https://makima.jp") + } + + func testBaseURLRejectsBareString() { + let p = ServerProfile(label: "x", baseURLString: "makima.jp") + XCTAssertNil(p.baseURL) + } + + func testApiWebSocketBaseURLUpgrades() { + let p = ServerProfile(label: "x", baseURLString: "https://makima.jp") + XCTAssertEqual(p.apiWebSocketBaseURL?.absoluteString, "wss://makima.jp/api/v1") + + let p2 = ServerProfile(label: "d", baseURLString: "http://localhost:8080") + XCTAssertEqual(p2.apiWebSocketBaseURL?.absoluteString, "ws://localhost:8080/api/v1") + } + + func testKeychainIDIsStablePerProfile() { + let p = ServerProfile(label: "x", baseURLString: "https://a.example") + XCTAssertTrue(p.keychainID.hasPrefix("profile.")) + let encoded = try! JSONEncoder().encode(p) + let decoded = try! JSONDecoder().decode(ServerProfile.self, from: encoded) + XCTAssertEqual(p.keychainID, decoded.keychainID) + } +} -- cgit v1.2.3