summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu-co <bot@soryu.co>2026-04-24 14:16:03 +0000
committersoryu-co <bot@soryu.co>2026-04-24 14:16:03 +0000
commit8f8e3b54cbecf51ce6a87e9e028caaad428879be (patch)
tree67d9b4b0b61056a9b503a2fc6a106ef6f9ea2a27
parentdb092c79a175e3283f479ee0b234b24bde3c736e (diff)
downloadsoryu-8f8e3b54cbecf51ce6a87e9e028caaad428879be.tar.gz
soryu-8f8e3b54cbecf51ce6a87e9e028caaad428879be.zip
Makima iOS M2 — networking, auth, onboarding, settings
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 ('<base>/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 <server>/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.
-rw-r--r--makima/ios/Sources/Makima/App/AppState.swift23
-rw-r--r--makima/ios/Sources/Makima/App/MakimaApp.swift4
-rw-r--r--makima/ios/Sources/Makima/App/RootView.swift205
-rw-r--r--makima/ios/Sources/Makima/Features/Onboarding/APIKeyStep.swift89
-rw-r--r--makima/ios/Sources/Makima/Features/Onboarding/OnboardingFlow.swift96
-rw-r--r--makima/ios/Sources/Makima/Features/Onboarding/ServerURLStep.swift58
-rw-r--r--makima/ios/Sources/Makima/Features/Settings/SettingsView.swift145
-rw-r--r--makima/ios/Sources/Makima/Models/Daemon.swift9
-rw-r--r--makima/ios/Sources/Makima/Net/APIClient.swift125
-rw-r--r--makima/ios/Sources/Makima/Net/APIError.swift33
-rw-r--r--makima/ios/Sources/Makima/Net/AuthStore.swift164
-rw-r--r--makima/ios/Sources/Makima/Net/Keychain.swift75
-rw-r--r--makima/ios/Sources/Makima/Net/ServerProfile.swift52
-rw-r--r--makima/ios/Sources/Makima/Net/ServerProfileStore.swift62
-rw-r--r--makima/ios/Tests/MakimaTests/APIClientTests.swift98
-rw-r--r--makima/ios/Tests/MakimaTests/ServerProfileTests.swift30
16 files changed, 1180 insertions, 88 deletions
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<T: Decodable>(_ path: String, as _: T.Type = T.self) async throws -> T {
+ try await send(method: "GET", path: path, body: nil as Empty?)
+ }
+
+ @discardableResult
+ func post<B: Encodable, R: Decodable>(_ path: String, body: B) async throws -> R {
+ try await send(method: "POST", path: path, body: body)
+ }
+
+ @discardableResult
+ func post<R: Decodable>(_ path: String) async throws -> R {
+ try await send(method: "POST", path: path, body: nil as Empty?)
+ }
+
+ @discardableResult
+ func delete<R: Decodable>(_ 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<B: Encodable, R: Decodable>(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<B: Encodable>(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: `<base>/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)
+ }
+}