diff options
| author | soryu-co <bot@soryu.co> | 2026-04-24 16:45:51 +0000 |
|---|---|---|
| committer | soryu-co <bot@soryu.co> | 2026-04-24 16:45:51 +0000 |
| commit | 105730ceaa292b1e3589c23d5aad8f35ccf04b8e (patch) | |
| tree | 6cf0c05e911cf62e3cd102377a4e24876b2cb064 | |
| parent | 8f8e3b54cbecf51ce6a87e9e028caaad428879be (diff) | |
| download | soryu-105730ceaa292b1e3589c23d5aad8f35ccf04b8e.tar.gz soryu-105730ceaa292b1e3589c23d5aad8f35ccf04b8e.zip | |
Makima iOS M3-M8 — Home, Tasks, WebSocket, notifications, polish
Extends the M2 auth scaffold with the rest of the v1 surface area. All
milestones from docs/ios-v1-plan.md land here.
M3 — Composite Home dashboard
- HomeStore (@Observable): parallel fetch of contracts, daemons, directives,
tasks, latest listen session. Each card degrades independently on error.
- HomeView: NavigationStack routing to Contracts, Tasks, Daemons, Directives,
Listen, and per-item detail views. Pull-to-refresh.
- HomeCards: SectionCard chrome + Contracts/Daemons/Directives/Listen/
RecentTasks cards with StatusDot, CountPill, relative-time helpers.
M4 — Contracts + Tasks
- ContractsListView: filter (active/completed/archived/all), sorted by
updatedAt.
- ContractDetailView: phase card + per-contract tasks.
- TasksListView: status filter pills.
- TaskDetailView: metadata card, completion-gate card (rendered only when
<COMPLETION_GATE> present in output), output card that merges polled body
with live WS events. Polls every 8s as a fallback when WS is offline.
M5 — WebSocket + markdown/code rendering
- TaskWebSocket: URLSessionWebSocketTask wrapper for
/api/v1/mesh/tasks/subscribe. Wire messages match mesh_ws.rs
(subscribeAll / subscribe / subscribeOutput / taskUpdated / taskOutput /
error). Exponential-backoff reconnect (1s -> 30s cap), status callback
feeds the masthead WS pill.
- CompletionGate: parses <COMPLETION_GATE>…</COMPLETION_GATE> into
{ready, reason, progress, blockers}. CompletionGateView renders it as a
status card with dashed accent border (ok/warn tint).
- MarkdownBlocks: hand-rolled split-on-fences renderer. Prose blocks via
AttributedString(markdown:), code blocks via a monospaced, horizontally-
scrollable CodeBlockView with optional language chip.
- TaskOutputRenderer: merges polled body + streamed LIVE// events with
per-kind colour (assistant / tool_use / tool_result / error).
M6 — Directives, Daemons, Listen (read-only)
- DirectivesListView: status dot (pending -> warn), goal preview. Notes
that answering questions is web-only in v1.
- DaemonsListView: online/total, heartbeat relative time, task concurrency.
- ListenHistoryView: session preview list; tolerates 404 if the endpoint
isn't deployed on the user's instance. Notes that live listen is
web-only in v1.
- ScreenShell: shared chrome (back chevron, title, WS pill, grid bg) for
all detail screens.
M7 — Notifications + deep links
- NotificationCenterBridge: requests .alert/.badge/.sound auth on first
launch; fires local notifications on task-done/failed/blocked and
directive-question events with a makima:// deepLink in userInfo.
- DeepLink enum: parses makima://task/<uuid>, makima://contract/<uuid>,
makima://directive/<uuid>. Wired in MakimaApp via .onOpenURL; AppState
holds a pendingDeepLink for consumers.
- URL scheme registered in project.yml's CFBundleURLTypes.
M8 — Polish
- RELEASING.md: pre-flight checklist, xcodebuild archive/export flow,
ExportOptions.plist template, App Store submission checklist.
- README: full 'what's implemented' matrix, architecture overview, deep-link
reference, privacy statement.
M2 bug fixes rolled in
- ListEnvelope<Item>: generic decoder for the daemons/tasks/contracts/
directives/orders wrapper endpoints ({items:[...], total:N}). Falls back
to a bare array for resilience.
- AuthStore: now uses ListEnvelope<Daemon> for the probe (was bare array).
- rotateKey: accepts both apiKey and api_key casings in the response.
- Logo: adds missing import UIKit.
Tests added
- CompletionGateTests: parse ready=true, blockers list, nil on missing.
- MarkdownBlocksTests: prose/code splitting, pure prose.
- DeepLinkTests: task/directive parsing, wrong-scheme rejection, unknown
host rejection.
- ListEnvelopeTests: daemons wrapper, tasks wrapper, bare-array fallback.
- APIClientTests: updated stub to return the envelope shape.
Not wired (deferred to v1.1 per plan)
- Supabase OAuth (Authorization: Bearer path)
- APNs push via services/push-proxy/
- Answer directive questions in-app
- Live Listen with mic + transcription
- Speak (TTS)
- Files editor, mesh merge UI, worktree diff viewer, daemon reauth flow
File count: 34 new/modified, ~2670 LOC.
35 files changed, 2336 insertions, 164 deletions
diff --git a/makima/ios/README.md b/makima/ios/README.md index 2a6a664..e4b0035 100644 --- a/makima/ios/README.md +++ b/makima/ios/README.md @@ -2,25 +2,71 @@ Native iPhone client for [Makima](https://makima.jp) — distributed mesh listening and AI daemon orchestration platform by Soryu LTD. -**Status:** pre-alpha (M0 scaffold). See [`docs/DEVELOPMENT.md`](docs/DEVELOPMENT.md). - -**Plan:** see `/root/notes/makima/ios-v1-plan.md` on the infra host, or the mirrored copy in `docs/PLAN.md` once synced. - ## Quick start ```bash cd makima/ios -make bootstrap # installs xcodegen if missing -make ios-sim-fast # builds for iPhone 16 Pro simulator +make bootstrap # installs xcodegen + xcbeautify +make ios-sim-fast # builds and launches iPhone 16 Pro simulator ``` -Xcode 16+, iOS 18+ target, Swift 5.10+, pure SwiftUI. No third-party packages at M0/M1. +Xcode 16+, iOS 18+ target, Swift 5.10+. No third-party packages. + +## First launch + +1. Enter a server URL (default `https://makima.jp`). +2. Tap **Open Web Settings** → create an API key on the server. +3. Paste the `mk_…` key back. The app validates via `GET /mesh/daemons` then takes you to Home. + +Keychain stores the API key per server profile (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`). Change server or sign out any time from Settings. + +## What's implemented + +| Surface | Status | +|---|---| +| Onboarding (URL + key paste) | ✓ | +| Composite Home (Contracts · Daemons · Directives · Listen · Recent Tasks) | ✓ | +| Contracts list + detail with tasks | ✓ | +| Tasks list + detail with live output | ✓ | +| Task output livestream (`/mesh/tasks/subscribe` WebSocket) | ✓ | +| Markdown + code-block rendering | ✓ | +| Completion Gate card | ✓ | +| Directives / Daemons / Listen read-only | ✓ | +| Local notifications (task done / failed / blocked, directive question) | ✓ | +| `makima://` deep links (task / directive / contract) | ✓ | +| Supabase OAuth sign-in | v1.1 | +| APNs push notifications | v1.1 (see `services/push-proxy/`) | +| Answer directive questions from the app | v1.1 | +| Live Listen (mic + transcription) | v1.1 | ## Architecture -Pure native SwiftUI with `@Observable` stores, `URLSession` networking, `URLSessionWebSocketTask` for task livestream, SwiftData for local cache, Keychain for the `x-makima-api-key` credential. +Pure SwiftUI with `@Observable` stores, `URLSession` for HTTP, +`URLSessionWebSocketTask` for the task livestream, SwiftData-free (UserDefaults + Keychain only), zero third-party packages. + +- `Sources/Makima/Net/` — APIClient, Keychain, ServerProfile + Store, AuthStore, TaskWebSocket, NotificationCenterBridge +- `Sources/Makima/Stores/` — HomeStore (+ others as they land) +- `Sources/Makima/Features/` — screen-level views grouped by surface +- `Sources/Makima/Models/` — Codable types matching Makima's camelCase schema +- `Sources/Makima/Design/` — Palette, Typography, reusable components +- `Sources/Makima/Markdown/` — MarkdownBlocks, CodeBlockView, CompletionGate(+View) +- `Sources/Makima/App/` — entry point, router, AppState + +## Auth + +Uses the custom `x-makima-api-key: mk_…` header on every HTTP request and the +WebSocket upgrade (verified against `makima/src/server/auth.rs`). +`Authorization: Bearer` is reserved for the Supabase OAuth flow in v1.1. + +## Deep links + +The app registers `makima://` and handles three destinations: + +- `makima://task/<uuid>` — Task detail +- `makima://contract/<uuid>` — Contract detail +- `makima://directive/<uuid>` — Directives list (item-level deep link lands with answer support in v1.1) -No cross-platform core, no React Native, no Expo. See `../../README.md` for the wider monorepo. +Local notifications fired from WebSocket events include the matching deep link in `userInfo.deepLink`. ## License @@ -28,4 +74,6 @@ MIT — see repo-root `LICENSE`. ## Privacy -No analytics. No telemetry. The app talks directly to the Makima server URL you configure — by default `https://makima.jp`, but any reachable instance works (`Settings → Server URL`). Credentials are stored in iOS Keychain, scoped per server URL. Full privacy policy: this README. +No analytics. No telemetry. No hosted relay. The app talks directly to the +Makima server URL you configure. Credentials live in iOS Keychain, scoped +per server URL, never leave the device. Full privacy policy: this README. diff --git a/makima/ios/Sources/Makima/App/AppState.swift b/makima/ios/Sources/Makima/App/AppState.swift index acc3060..806d7d0 100644 --- a/makima/ios/Sources/Makima/App/AppState.swift +++ b/makima/ios/Sources/Makima/App/AppState.swift @@ -1,23 +1,50 @@ import SwiftUI +import Combine -/// Top-level state bag shared through the SwiftUI environment. @MainActor @Observable final class AppState { let auth: AuthStore + var wsStatus: WebSocketStatus = .idle + var webSocket: TaskWebSocket? + + /// Pending deep-link to open once the app has finished routing. + var pendingDeepLink: DeepLink? init(auth: AuthStore = AuthStore()) { self.auth = auth } -} -private struct AppStateKey: EnvironmentKey { - static let defaultValue: AppState = AppState() + // Lazily create the websocket. Lives for the lifetime of the session. + func ensureWebSocket() { + guard webSocket == nil, let client = auth.client else { return } + let ws = TaskWebSocket(profile: client.profile, apiKey: client.apiKey) + ws.onStatusChange = { [weak self] s in Task { @MainActor in self?.wsStatus = s } } + ws.connect() + webSocket = ws + } + + func teardownWebSocket() { + webSocket?.disconnect() + webSocket = nil + wsStatus = .idle + } } -extension EnvironmentValues { - var appState: AppState { - get { self[AppStateKey.self] } - set { self[AppStateKey.self] = newValue } +enum DeepLink: Hashable { + case task(id: String) + case directive(id: String) + case contract(id: String) + + init?(url: URL) { + guard url.scheme == "makima", let host = url.host else { return nil } + let id = url.pathComponents.dropFirst().first ?? "" + guard !id.isEmpty else { return nil } + switch host { + case "task": self = .task(id: id) + case "directive": self = .directive(id: id) + case "contract": self = .contract(id: id) + default: return nil + } } } diff --git a/makima/ios/Sources/Makima/App/MakimaApp.swift b/makima/ios/Sources/Makima/App/MakimaApp.swift index e0209b2..761b3c0 100644 --- a/makima/ios/Sources/Makima/App/MakimaApp.swift +++ b/makima/ios/Sources/Makima/App/MakimaApp.swift @@ -2,7 +2,11 @@ import SwiftUI @main struct MakimaApp: App { - @State private var appState = AppState() + @State private var appState: AppState + + init() { + _appState = State(initialValue: MainActor.assumeIsolated { AppState() }) + } var body: some Scene { WindowGroup { @@ -11,6 +15,21 @@ struct MakimaApp: App { .environment(appState.auth) .preferredColorScheme(.dark) .tint(Palette.accent) + .onOpenURL { url in + if let link = DeepLink(url: url) { + appState.pendingDeepLink = link + } + } + .task { + await NotificationCenterBridge.requestAuthorizationIfNeeded() + } + .onChange(of: appState.auth.state) { _, newState in + if newState == .authenticated { + appState.ensureWebSocket() + } else { + appState.teardownWebSocket() + } + } } } } diff --git a/makima/ios/Sources/Makima/App/RootView.swift b/makima/ios/Sources/Makima/App/RootView.swift index 7d22c1a..a7665e1 100644 --- a/makima/ios/Sources/Makima/App/RootView.swift +++ b/makima/ios/Sources/Makima/App/RootView.swift @@ -1,14 +1,13 @@ import SwiftUI /// Top-level router: Onboarding until we have a validated ServerProfile + API -/// key, then Home (placeholder in M2; real Home ships at M3). +/// key, then Home. struct RootView: View { @Environment(AuthStore.self) private var auth var body: some View { ZStack { Palette.background.ignoresSafeArea() - GridOverlay() switch auth.state { case .needsOnboarding, .error: @@ -18,7 +17,7 @@ struct RootView: View { ValidatingView() .transition(.opacity) case .authenticated: - HomePlaceholderView() + HomeView() .transition(.opacity) } } @@ -26,108 +25,6 @@ struct RootView: View { } } -// 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(.horizontal, 16) - - Spacer(minLength: 40) - } - .padding(.top, 12) - } - } - .sheet(isPresented: $showSettings) { - SettingsView().environment(auth) - } - } - - private var sessionCard: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("SESSION//") - .font(Typography.navLabel) - .foregroundStyle(Palette.foregroundMuted) - Spacer() - Text("OK") - .font(Typography.navLabel) - .foregroundStyle(Palette.ok) - } - Divider().overlay(Palette.borderMuted) - - row("Server", auth.client?.profile.baseURLString ?? "—") - row("Profile", auth.client?.profile.label ?? "—") - row("Key", maskedKey(auth.client?.apiKey)) - } - .padding(14) - .dashedBorder() - } - - 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) - .lineLimit(1) - .truncationMode(.middle) - } - } - - 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)" - } -} - struct ValidatingView: View { var body: some View { VStack(spacing: 16) { @@ -144,11 +41,3 @@ struct ValidatingView: View { } } } - -#Preview("Home placeholder") { - let state = AppState() - return HomePlaceholderView() - .environment(state) - .environment(state.auth) - .preferredColorScheme(.dark) -} diff --git a/makima/ios/Sources/Makima/Design/Components/Logo.swift b/makima/ios/Sources/Makima/Design/Components/Logo.swift index 01d263e..98cda9b 100644 --- a/makima/ios/Sources/Makima/Design/Components/Logo.swift +++ b/makima/ios/Sources/Makima/Design/Components/Logo.swift @@ -1,4 +1,5 @@ import SwiftUI +import UIKit /// Makima concentric-ring logo. /// Loads from bundled `makima-logo.svg` if present; otherwise falls back to diff --git a/makima/ios/Sources/Makima/Design/Components/ScreenShell.swift b/makima/ios/Sources/Makima/Design/Components/ScreenShell.swift new file mode 100644 index 0000000..41f17bb --- /dev/null +++ b/makima/ios/Sources/Makima/Design/Components/ScreenShell.swift @@ -0,0 +1,88 @@ +import SwiftUI + +/// Consistent screen chrome: background + grid + masthead + content scroll. +/// Shows a back chevron when inside a navigation stack. +struct ScreenShell<Content: View>: View { + let title: String + @ViewBuilder let content: () -> Content + @Environment(\.dismiss) private var dismiss + @Environment(AppState.self) private var appState + + var body: some View { + ZStack { + Palette.background.ignoresSafeArea() + GridOverlay() + VStack(spacing: 0) { + HStack(spacing: 10) { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .foregroundStyle(Palette.accent) + .padding(8) + .background(Palette.panel) + .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1)) + } + .accessibilityLabel("Back") + + Text(title) + .font(.system(.caption, design: .monospaced).weight(.semibold)) + .tracking(2) + .foregroundStyle(Palette.foreground) + .lineLimit(1) + Spacer() + Text("WS//\(appState.wsStatus.label.replacingOccurrences(of: "WS//", with: ""))") + .font(Typography.navLabel) + .foregroundStyle(appState.wsStatus.color) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Palette.backgroundDeep) + .overlay(alignment: .bottom) { + Rectangle().fill(Palette.border).frame(height: 2) + } + + ScrollView { content() } + } + } + .toolbar(.hidden, for: .navigationBar) + } +} + +struct KeyValue: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + Spacer() + Text(value) + .font(Typography.mono) + .foregroundStyle(Palette.foreground) + .lineLimit(1) + .truncationMode(.middle) + } + } +} + +struct ErrorBanner: View { + let message: String + + var body: 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)) + } +} diff --git a/makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift b/makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift new file mode 100644 index 0000000..b61516b --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Contracts/ContractDetailView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct ContractDetailView: View { + @Environment(AuthStore.self) private var auth + let contract: Contract + + @State private var tasks: [MakimaTask] = [] + @State private var loading = false + @State private var errorMessage: String? + + var body: some View { + ScreenShell(title: contract.name.uppercased()) { + VStack(spacing: 16) { + phaseCard + tasksCard + } + .padding(16) + } + .task { await reload() } + .refreshable { await reload() } + } + + private var phaseCard: some View { + SectionCard(title: "PHASE") { + VStack(alignment: .leading, spacing: 8) { + KeyValue(label: "Phase", value: contract.phase.uppercased()) + KeyValue(label: "Status", value: contract.status.uppercased()) + KeyValue(label: "Type", value: contract.contractType) + if let desc = contract.description, !desc.isEmpty { + Divider().overlay(Palette.borderMuted) + Text(desc) + .font(Typography.body) + .foregroundStyle(Palette.body) + } + } + } + } + + private var tasksCard: some View { + SectionCard(title: "TASKS", + trailing: { + Text("\(tasks.count)") + .font(Typography.navLabel) + .foregroundStyle(Palette.accent) + }) { + if loading { + ProgressView().tint(Palette.accent) + } else if let msg = errorMessage { + ErrorBanner(message: msg) + } else if tasks.isEmpty { + EmptyLine(text: "No tasks in this contract.") + } else { + VStack(spacing: 6) { + ForEach(tasks) { t in + NavigationLink(value: t) { + TaskRow(task: t) + } + .buttonStyle(.plain) + } + } + } + } + } + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + errorMessage = nil + do { + // Server route is GET /mesh/tasks — client filters by contractId. + let env: ListEnvelope<MakimaTask> = try await client.get("/mesh/tasks") + tasks = env.items + .filter { $0.contractId == contract.id } + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift b/makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift new file mode 100644 index 0000000..c7ef573 --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Contracts/ContractsListView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct ContractsListView: View { + @Environment(AuthStore.self) private var auth + @State private var contracts: [Contract] = [] + @State private var filter: Filter = .active + @State private var loading = false + @State private var errorMessage: String? + + enum Filter: String, CaseIterable, Identifiable { + case active, completed, archived, all + var id: String { rawValue } + var title: String { rawValue.uppercased() } + } + + var body: some View { + ScreenShell(title: "CONTRACTS") { + VStack(spacing: 12) { + filterRow + if loading { ProgressView().tint(Palette.accent).padding() } + if let msg = errorMessage { ErrorBanner(message: msg) } + + ForEach(filtered) { c in + NavigationLink(value: c) { + ContractRow(contract: c) + .padding(12) + .dashedBorder() + } + .buttonStyle(.plain) + } + } + .padding(16) + } + .task { await reload() } + .refreshable { await reload() } + } + + private var filterRow: some View { + HStack(spacing: 8) { + ForEach(Filter.allCases) { f in + Button { + filter = f + } label: { + Text(f.title) + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .foregroundStyle(f == filter ? Palette.accent : Palette.foregroundMuted) + .overlay(Rectangle().strokeBorder(f == filter ? Palette.border : Palette.borderMuted, lineWidth: 1)) + } + .buttonStyle(.plain) + } + Spacer() + } + } + + private var filtered: [Contract] { + let items = contracts.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + switch filter { + case .all: return items + case .active: return items.filter { $0.isActive } + case .completed, .archived: return items.filter { $0.status.lowercased() == filter.rawValue } + } + } + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + errorMessage = nil + do { + let env: ListEnvelope<Contract> = try await client.get("/contracts") + contracts = env.items + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift b/makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift new file mode 100644 index 0000000..b394a9e --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Daemons/DaemonsListView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct DaemonsListView: View { + @Environment(AuthStore.self) private var auth + @State private var daemons: [Daemon] = [] + @State private var loading = false + @State private var errorMessage: String? + + var body: some View { + ScreenShell(title: "DAEMONS") { + VStack(spacing: 12) { + if loading { ProgressView().tint(Palette.accent).padding() } + if let msg = errorMessage { ErrorBanner(message: msg) } + if !loading && daemons.isEmpty { + EmptyLine(text: "No daemons connected.").padding() + } + + ForEach(daemons) { d in + VStack(alignment: .leading, spacing: 6) { + HStack { + StatusDot(kind: d.isOnline ? .active : .idle) + Text(d.displayHost) + .font(Typography.mono) + .foregroundStyle(Palette.foreground) + Spacer() + Text(d.status.uppercased()) + .font(Typography.navLabel) + .foregroundStyle(d.isOnline ? Palette.ok : Palette.foregroundMuted) + } + HStack { + KeyValue(label: "Tasks", + value: "\(d.currentTaskCount ?? 0) / \(d.maxConcurrentTasks ?? 0)") + Spacer() + if let hb = d.lastHeartbeatAt { + KeyValue(label: "Heartbeat", value: hb.relativeShort) + } + } + } + .padding(12) + .dashedBorder() + } + } + .padding(16) + } + .task { await reload() } + .refreshable { await reload() } + } + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + errorMessage = nil + do { + let env: ListEnvelope<Daemon> = try await client.get("/mesh/daemons") + daemons = env.items + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift b/makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift new file mode 100644 index 0000000..4c325ee --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Directives/DirectivesListView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct DirectivesListView: View { + @Environment(AuthStore.self) private var auth + @State private var directives: [Directive] = [] + @State private var loading = false + @State private var errorMessage: String? + + var body: some View { + ScreenShell(title: "DIRECTIVES") { + VStack(spacing: 12) { + if loading { ProgressView().tint(Palette.accent).padding() } + if let msg = errorMessage { ErrorBanner(message: msg) } + if !loading && directives.isEmpty { + EmptyLine(text: "No directives.").padding() + } + + ForEach(directives) { d in + VStack(alignment: .leading, spacing: 8) { + HStack { + StatusDot(kind: (d.status ?? "").lowercased() == "pending" ? .warn : .idle) + Text(d.displayName) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + Spacer() + Text((d.status ?? "UNKNOWN").uppercased()) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + if let goal = d.goal, !goal.isEmpty, d.name != nil { + Text(goal) + .font(Typography.caption) + .foregroundStyle(Palette.foregroundMuted) + .lineLimit(3) + } + } + .padding(12) + .dashedBorder() + } + + Text("Answer directive questions on the web for now.") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + .padding(.top, 8) + } + .padding(16) + } + .task { await reload() } + .refreshable { await reload() } + } + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + errorMessage = nil + do { + let env: ListEnvelope<Directive> = try await client.get("/directives") + directives = env.items.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/makima/ios/Sources/Makima/Features/Home/HomeCards.swift b/makima/ios/Sources/Makima/Features/Home/HomeCards.swift new file mode 100644 index 0000000..1ffca2a --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Home/HomeCards.swift @@ -0,0 +1,352 @@ +import SwiftUI + +// MARK: - Card chrome + +struct SectionCard<Content: View, Trailing: View>: View { + let title: String + let trailing: Trailing + let onTap: (() -> Void)? + @ViewBuilder let content: () -> Content + + init(title: String, + onTap: (() -> Void)? = nil, + @ViewBuilder trailing: () -> Trailing = { EmptyView() }, + @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.onTap = onTap + self.trailing = trailing() + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("\(title)//") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + Spacer() + trailing + if onTap != nil { + Image(systemName: "chevron.right") + .font(.footnote) + .foregroundStyle(Palette.foregroundMuted) + } + } + Divider().overlay(Palette.borderMuted) + content() + } + .padding(14) + .dashedBorder() + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + } +} + +extension SectionCard where Trailing == EmptyView { + init(title: String, + onTap: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content) { + self.init(title: title, onTap: onTap, trailing: { EmptyView() }, content: content) + } +} + +// MARK: - Contracts + +struct ContractsCard: View { + @Bindable var store: HomeStore + let onSeeAll: () -> Void + let onTap: (Contract) -> Void + + var body: some View { + SectionCard(title: "CONTRACTS", + onTap: onSeeAll, + trailing: { + CountPill(count: store.activeContractsTotal, kind: .info) + }) { + if store.activeContracts.isEmpty { + EmptyLine(text: "No active contracts.") + } else { + VStack(spacing: 6) { + ForEach(store.activeContracts.prefix(3)) { c in + Button { onTap(c) } label: { + ContractRow(contract: c) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +struct ContractRow: View { + let contract: Contract + + var body: some View { + HStack(spacing: 10) { + StatusDot(kind: statusKind) + VStack(alignment: .leading, spacing: 2) { + Text(contract.name) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + .lineLimit(1) + Text("\(contract.phase) · \(contract.status)") + .font(Typography.caption) + .foregroundStyle(Palette.foregroundMuted) + } + Spacer() + } + } + + private var statusKind: StatusDot.Kind { + switch contract.status.lowercased() { + case "active": return .active + case "completed": return .ok + case "archived": return .idle + default: return .idle + } + } +} + +// MARK: - Daemons + +struct DaemonsCard: View { + @Bindable var store: HomeStore + let onTap: () -> Void + + private var onlineCount: Int { store.daemons.filter(\.isOnline).count } + private var totalCount: Int { store.daemons.count } + + var body: some View { + SectionCard(title: "DAEMONS", + onTap: onTap, + trailing: { + Text("\(onlineCount) / \(totalCount)") + .font(Typography.mono) + .foregroundStyle(onlineCount > 0 ? Palette.ok : Palette.foregroundMuted) + }) { + if store.daemons.isEmpty { + EmptyLine(text: "No daemons connected.") + } else { + VStack(spacing: 4) { + ForEach(store.daemons.prefix(3)) { d in + HStack(spacing: 10) { + StatusDot(kind: d.isOnline ? .active : .idle) + Text(d.displayHost) + .font(Typography.mono) + .foregroundStyle(Palette.foreground) + .lineLimit(1) + Spacer() + Text(d.status.uppercased()) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + } + } + } + } + } +} + +// MARK: - Directives + +struct DirectivesCard: View { + @Bindable var store: HomeStore + let onTap: () -> Void + + var body: some View { + SectionCard(title: "DIRECTIVES", + onTap: onTap, + trailing: { + if !store.pendingDirectives.isEmpty { + CountPill(count: store.pendingDirectives.count, kind: .warn) + } + }) { + if store.pendingDirectives.isEmpty { + EmptyLine(text: "No pending questions.") + } else { + VStack(alignment: .leading, spacing: 6) { + ForEach(store.pendingDirectives.prefix(3)) { d in + HStack(spacing: 10) { + StatusDot(kind: .warn) + Text(d.displayName) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + .lineLimit(1) + } + } + } + } + } + } +} + +// MARK: - Listen + +struct ListenCard: View { + @Bindable var store: HomeStore + let onTap: () -> Void + + var body: some View { + SectionCard(title: "LISTEN", + onTap: onTap, + trailing: { + JapaneseLongPressText(japanese: "聴取", english: "LISTEN") + .fixedSize() + }) { + if let s = store.latestListen { + VStack(alignment: .leading, spacing: 6) { + Text(s.displayTitle) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + if let preview = s.transcriptPreview, !preview.isEmpty { + Text(preview) + .font(Typography.caption) + .foregroundStyle(Palette.foregroundMuted) + .lineLimit(2) + } + if let when = s.startedAt { + Text(when.formatted(date: .abbreviated, time: .shortened)) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + } + } else { + EmptyLine(text: "No listen sessions yet.") + } + } + } +} + +// MARK: - Recent tasks + +struct RecentTasksCard: View { + @Bindable var store: HomeStore + let onSeeAll: () -> Void + let onTap: (MakimaTask) -> Void + + var body: some View { + SectionCard(title: "RECENT TASKS", + onTap: onSeeAll) { + if store.recentTasks.isEmpty { + EmptyLine(text: "No recent tasks.") + } else { + VStack(spacing: 6) { + ForEach(store.recentTasks) { t in + Button { onTap(t) } label: { + TaskRow(task: t) + } + .buttonStyle(.plain) + } + } + } + } + } +} + +struct TaskRow: View { + let task: MakimaTask + + var body: some View { + HStack(spacing: 10) { + StatusDot(kind: dotKind) + VStack(alignment: .leading, spacing: 2) { + Text(task.name) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + .lineLimit(1) + Text(task.status.uppercased()) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + Spacer() + if let updated = task.updatedAt { + Text(updated.relativeShort) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + } + } + + private var dotKind: StatusDot.Kind { + switch task.statusColor { + case .ok: return .ok + case .active: return .active + case .idle: return .idle + case .warn: return .warn + case .danger: return .danger + } + } +} + +// MARK: - Little primitives + +struct StatusDot: View { + enum Kind { case ok, active, idle, warn, danger } + let kind: Kind + + var body: some View { + Circle() + .fill(color) + .frame(width: 6, height: 6) + } + + private var color: Color { + switch kind { + case .ok: return Palette.ok + case .active: return Palette.accent + case .idle: return Palette.foregroundMuted + case .warn: return Palette.warn + case .danger: return Palette.danger + } + } +} + +struct CountPill: View { + enum Kind { case info, warn } + let count: Int + let kind: Kind + + var body: some View { + Text("\(count)") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(foreground) + .background(background) + .overlay(Rectangle().strokeBorder(foreground.opacity(0.4), lineWidth: 1)) + } + + private var foreground: Color { + switch kind { + case .info: return Palette.accent + case .warn: return Palette.warn + } + } + + private var background: Color { + Palette.panel + } +} + +struct EmptyLine: View { + let text: String + var body: some View { + Text(text) + .font(Typography.body) + .foregroundStyle(Palette.foregroundMuted) + } +} + +extension Date { + /// Relative-time string for small cells ("5m", "2h", "3d"). + var relativeShort: String { + let seconds = -self.timeIntervalSinceNow + if seconds < 60 { return "just now" } + if seconds < 3600 { return "\(Int(seconds / 60))m" } + if seconds < 86_400 { return "\(Int(seconds / 3600))h" } + if seconds < 604_800 { return "\(Int(seconds / 86_400))d" } + return formatted(date: .abbreviated, time: .omitted) + } +} diff --git a/makima/ios/Sources/Makima/Features/Home/HomeView.swift b/makima/ios/Sources/Makima/Features/Home/HomeView.swift new file mode 100644 index 0000000..22ddb70 --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Home/HomeView.swift @@ -0,0 +1,136 @@ +import SwiftUI + +struct HomeView: View { + @Environment(AuthStore.self) private var auth + @Environment(AppState.self) private var appState + @State private var store: HomeStore? + @State private var showSettings = false + @State private var navPath = NavigationPath() + + var body: some View { + NavigationStack(path: $navPath) { + ZStack { + Palette.background.ignoresSafeArea() + GridOverlay() + + VStack(spacing: 0) { + MastheadBar( + serverLabel: auth.client?.profile.label ?? ServerProfile.defaultLabel, + wsStatus: appState.wsStatus, + version: Bundle.main.appVersion + ) + NavStripPlaceholder() + + if let store { + content(store: store) + } else { + ProgressView().tint(Palette.accent).frame(maxHeight: .infinity) + } + } + } + .toolbar(.hidden, for: .navigationBar) + .sheet(isPresented: $showSettings) { + SettingsView().environment(auth) + } + .navigationDestination(for: Contract.self) { c in + ContractDetailView(contract: c).environment(auth) + } + .navigationDestination(for: MakimaTask.self) { t in + TaskDetailView(task: t).environment(auth).environment(appState) + } + .navigationDestination(for: HomeDestination.self) { d in + switch d { + case .contracts: ContractsListView().environment(auth) + case .tasks: TasksListView(filterStatus: nil).environment(auth).environment(appState) + case .daemons: DaemonsListView().environment(auth) + case .directives: DirectivesListView().environment(auth) + case .listen: ListenHistoryView().environment(auth) + } + } + .task { + if store == nil, let client = auth.client { + store = HomeStore(client: client) + } + await store?.refresh() + } + } + } + + @ViewBuilder + private func content(store: HomeStore) -> some View { + ScrollView { + VStack(spacing: 16) { + HomeHeader(onSettings: { showSettings = true }) + + ContractsCard(store: store) { + navPath.append(HomeDestination.contracts) + } onTap: { contract in + navPath.append(contract) + } + + DaemonsCard(store: store) { + navPath.append(HomeDestination.daemons) + } + + DirectivesCard(store: store) { + navPath.append(HomeDestination.directives) + } + + ListenCard(store: store) { + navPath.append(HomeDestination.listen) + } + + RecentTasksCard(store: store, + onSeeAll: { navPath.append(HomeDestination.tasks) }, + onTap: { task in navPath.append(task) }) + + if let lastRefreshed = store.lastRefreshed { + Text("Last refresh · \(lastRefreshed.formatted(date: .omitted, time: .standard))") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + .padding(.top, 8) + } + } + .padding(16) + } + .refreshable { await store.refresh() } + } +} + +enum HomeDestination: Hashable { + case contracts, tasks, daemons, directives, listen +} + +private struct HomeHeader: View { + let onSettings: () -> Void + + var body: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 4) { + Text("MAKIMA") + .font(.system(.headline, design: .monospaced).weight(.semibold)) + .tracking(4) + .foregroundStyle(Palette.foreground) + Text("Mesh Orchestration Platform") + .font(Typography.body) + .foregroundStyle(Palette.foregroundMuted) + } + Spacer() + Button(action: onSettings) { + Image(systemName: "gearshape") + .foregroundStyle(Palette.accent) + .padding(10) + .background(Palette.panel) + .overlay(Rectangle().strokeBorder(Palette.border, lineWidth: 1)) + } + .accessibilityLabel("Settings") + } + .padding(.bottom, 4) + } +} + +private extension Bundle { + var appVersion: String { + (infoDictionary?["CFBundleShortVersionString"] as? String) ?? "0.0.0" + } +} diff --git a/makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift b/makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift new file mode 100644 index 0000000..196b8fc --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Listen/ListenHistoryView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct ListenHistoryView: View { + @Environment(AuthStore.self) private var auth + @State private var sessions: [ListenSession] = [] + @State private var loading = false + @State private var errorMessage: String? + + var body: some View { + ScreenShell(title: "LISTEN") { + VStack(spacing: 12) { + if loading { ProgressView().tint(Palette.accent).padding() } + if let msg = errorMessage { ErrorBanner(message: msg) } + if !loading && sessions.isEmpty { + EmptyLine(text: "No listen sessions yet.").padding() + } + + ForEach(sessions) { s in + VStack(alignment: .leading, spacing: 6) { + HStack { + JapaneseLongPressText(japanese: "聴取", english: "SESSION") + Spacer() + if let when = s.startedAt { + Text(when.formatted(date: .abbreviated, time: .shortened)) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + } + Text(s.displayTitle) + .font(Typography.body) + .foregroundStyle(Palette.foreground) + if let preview = s.transcriptPreview, !preview.isEmpty { + Text(preview) + .font(Typography.caption) + .foregroundStyle(Palette.foregroundMuted) + .lineLimit(4) + } + } + .padding(12) + .dashedBorder() + } + + Text("Live listening is web-only in v1.") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + .padding(.top, 8) + } + .padding(16) + } + .task { await reload() } + .refreshable { await reload() } + } + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + errorMessage = nil + do { + let env: ListEnvelope<ListenSession> = try await client.get("/listen/sessions") + sessions = env.items.sorted { ($0.startedAt ?? .distantPast) > ($1.startedAt ?? .distantPast) } + } catch APIError.notFound { + sessions = [] // endpoint may not be deployed yet + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift b/makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift new file mode 100644 index 0000000..8408c1c --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Tasks/TaskDetailView.swift @@ -0,0 +1,166 @@ +import SwiftUI + +struct TaskDetailView: View { + @Environment(AuthStore.self) private var auth + @Environment(AppState.self) private var appState + let task: MakimaTask + + @State private var detail: MakimaTask? + @State private var output: String = "" + @State private var liveEvents: [TaskLiveEvent] = [] + @State private var loading = false + @State private var errorMessage: String? + @State private var pollTimer: Task<Void, Never>? + + var body: some View { + ScreenShell(title: task.name.uppercased()) { + VStack(spacing: 16) { + metaCard + completionGateCard + outputCard + } + .padding(16) + } + .task { + await reload() + subscribeLive() + startPolling() + } + .onDisappear { + pollTimer?.cancel() + pollTimer = nil + appState.webSocket?.unsubscribe(taskId: task.id) + } + .refreshable { await reload() } + } + + private var current: MakimaTask { detail ?? task } + + private var metaCard: some View { + SectionCard(title: "TASK") { + VStack(alignment: .leading, spacing: 6) { + KeyValue(label: "Status", value: current.status.uppercased()) + if let priority = current.priority { + KeyValue(label: "Priority", value: "\(priority)") + } + if let daemon = current.daemonId { + KeyValue(label: "Daemon", value: String(daemon.prefix(8))) + } + if let summary = current.progressSummary, !summary.isEmpty { + Divider().overlay(Palette.borderMuted) + Text(summary) + .font(Typography.body) + .foregroundStyle(Palette.body) + } + if let err = current.errorMessage, !err.isEmpty { + Divider().overlay(Palette.borderMuted) + Text(err) + .font(Typography.body) + .foregroundStyle(Palette.danger) + } + } + } + } + + @ViewBuilder + private var completionGateCard: some View { + if let gate = CompletionGate.extract(from: currentOutputText) { + CompletionGateView(gate: gate) + } + } + + private var outputCard: some View { + SectionCard(title: "OUTPUT", + trailing: { + HStack(spacing: 6) { + Circle().fill(Palette.ok).frame(width: 6, height: 6) + .opacity(appState.wsStatus == .online ? 1 : 0) + Text(appState.wsStatus == .online ? "LIVE" : "POLL") + .font(Typography.navLabel) + .foregroundStyle(appState.wsStatus == .online ? Palette.ok : Palette.foregroundMuted) + } + }) { + if currentOutputText.isEmpty { + EmptyLine(text: "No output yet.") + } else { + TaskOutputRenderer(text: currentOutputText, liveEvents: liveEvents) + } + } + } + + private var currentOutputText: String { + if !output.isEmpty { return output } + return current.lastOutput ?? "" + } + + // MARK: - Data + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + do { + self.detail = try await client.get("/mesh/tasks/\(task.id)") + if let resp: TaskOutputResponse = try? await client.get("/mesh/tasks/\(task.id)/output") { + self.output = resp.text + } + } catch { + errorMessage = error.localizedDescription + } + } + + private func subscribeLive() { + appState.ensureWebSocket() + appState.webSocket?.subscribe(taskId: task.id) { [task] event in + Task { @MainActor in + switch event { + case .taskUpdated: + await reload() + case .taskOutput(let evt) where evt.taskId == task.id: + liveEvents.append(TaskLiveEvent(from: evt)) + default: + break + } + } + } + } + + /// Foreground polling as a fallback when WS isn't online. + private func startPolling() { + pollTimer = Task { [weak appState] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 8_000_000_000) + if Task.isCancelled { return } + if appState?.wsStatus != .online { + await reload() + } + } + } + } +} + +/// Immutable live-stream event for the renderer. +struct TaskLiveEvent: Identifiable { + let id = UUID() + let messageType: String + let content: String + let toolName: String? + let isError: Bool + let at: Date + + init(messageType: String, content: String, toolName: String? = nil, isError: Bool = false) { + self.messageType = messageType + self.content = content + self.toolName = toolName + self.isError = isError + self.at = .now + } + + init(from server: TaskWebSocket.TaskOutputEvent) { + self.messageType = server.messageType + self.content = server.content + self.toolName = server.toolName + self.isError = server.isError ?? false + self.at = .now + } +} diff --git a/makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift b/makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift new file mode 100644 index 0000000..7b4a008 --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Tasks/TaskOutputRenderer.swift @@ -0,0 +1,84 @@ +import SwiftUI + +/// Combines an initial polled output blob with live streamed events. +/// At v1 we render the polled body as markdown-with-fences and then append +/// live events (tool_use / tool_result / assistant / error) as short cards. +struct TaskOutputRenderer: View { + let text: String + let liveEvents: [TaskLiveEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if !text.isEmpty { + MarkdownBlocks(text: text) + } + + if !liveEvents.isEmpty { + Divider().overlay(Palette.borderMuted) + Text("LIVE//") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + + ForEach(liveEvents) { e in + LiveEventRow(event: e) + } + } + } + } +} + +struct LiveEventRow: View { + let event: TaskLiveEvent + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(label) + .font(Typography.navLabel) + .foregroundStyle(color) + if let tool = event.toolName { + Text("·").foregroundStyle(Palette.borderMuted) + Text(tool) + .font(Typography.navLabel) + .foregroundStyle(Palette.accent) + } + Spacer() + Text(event.at.formatted(.dateTime.hour().minute().second())) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + } + if !event.content.isEmpty { + if event.messageType == "tool_result" || event.messageType == "raw" { + CodeBlockView(language: nil, body: event.content) + } else { + MarkdownBlocks(text: event.content) + } + } + } + .padding(8) + .overlay(Rectangle().strokeBorder(color.opacity(0.3), lineWidth: 1)) + } + + private var label: String { + switch event.messageType { + case "assistant": return "ASSISTANT" + case "tool_use": return "TOOL USE" + case "tool_result": return event.isError ? "TOOL ERROR" : "TOOL RESULT" + case "result": return "RESULT" + case "system": return "SYSTEM" + case "error": return "ERROR" + case "raw": return "RAW" + default: return event.messageType.uppercased() + } + } + private var color: Color { + switch event.messageType { + case "assistant": return Palette.accent + case "tool_use": return Palette.bodySoft + case "tool_result": return event.isError ? Palette.danger : Palette.ok + case "error": return Palette.danger + case "system": return Palette.foregroundMuted + default: return Palette.body + } + } +} diff --git a/makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift b/makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift new file mode 100644 index 0000000..0d4b928 --- /dev/null +++ b/makima/ios/Sources/Makima/Features/Tasks/TasksListView.swift @@ -0,0 +1,76 @@ +import SwiftUI + +struct TasksListView: View { + @Environment(AuthStore.self) private var auth + let filterStatus: String? + + @State private var tasks: [MakimaTask] = [] + @State private var loading = false + @State private var errorMessage: String? + @State private var statusFilter: String? = nil + + var body: some View { + ScreenShell(title: "TASKS") { + VStack(spacing: 12) { + statusFilterRow + if loading { ProgressView().tint(Palette.accent).padding() } + if let msg = errorMessage { ErrorBanner(message: msg) } + + ForEach(filtered) { t in + NavigationLink(value: t) { + TaskRow(task: t) + .padding(12) + .dashedBorder() + } + .buttonStyle(.plain) + } + } + .padding(16) + } + .task { + statusFilter = filterStatus + await reload() + } + .refreshable { await reload() } + } + + private var statusFilterRow: some View { + let options: [String?] = [nil, "running", "pending", "done", "failed"] + return HStack(spacing: 8) { + ForEach(options, id: \.self) { s in + Button { + statusFilter = s + } label: { + Text(s?.uppercased() ?? "ALL") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .foregroundStyle(statusFilter == s ? Palette.accent : Palette.foregroundMuted) + .overlay(Rectangle().strokeBorder(statusFilter == s ? Palette.border : Palette.borderMuted, lineWidth: 1)) + } + .buttonStyle(.plain) + } + Spacer() + } + } + + private var filtered: [MakimaTask] { + let items = tasks.sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + guard let s = statusFilter else { return items } + return items.filter { $0.status.lowercased() == s.lowercased() } + } + + private func reload() async { + guard let client = auth.client else { return } + loading = true + defer { loading = false } + errorMessage = nil + do { + let env: ListEnvelope<MakimaTask> = try await client.get("/mesh/tasks") + tasks = env.items + } catch { + errorMessage = error.localizedDescription + } + } +} diff --git a/makima/ios/Sources/Makima/Markdown/CompletionGate.swift b/makima/ios/Sources/Makima/Markdown/CompletionGate.swift new file mode 100644 index 0000000..bb1d336 --- /dev/null +++ b/makima/ios/Sources/Makima/Markdown/CompletionGate.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Parsed completion-gate block. Matches the Rust `<COMPLETION_GATE>…</>` +/// protocol emitted by Claude Code inside task output. +struct CompletionGate { + let ready: Bool + let reason: String? + let progress: String? + let blockers: [String] + + static func extract(from output: String) -> CompletionGate? { + guard let start = output.range(of: "<COMPLETION_GATE>"), + let end = output.range(of: "</COMPLETION_GATE>"), + start.upperBound <= end.lowerBound + else { return nil } + + let body = String(output[start.upperBound..<end.lowerBound]) + var ready = false + var reason: String? + var progress: String? + var blockers: [String] = [] + + for line in body.split(whereSeparator: { $0.isNewline }) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty { continue } + if let colon = trimmed.firstIndex(of: ":") { + let key = String(trimmed[..<colon]).lowercased() + let value = String(trimmed[trimmed.index(after: colon)...]) + .trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + switch key { + case "ready": ready = (value == "true" || value == "yes") + case "reason": reason = value + case "progress": progress = value + case "blockers": + blockers = value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + default: + break + } + } + } + return CompletionGate(ready: ready, reason: reason, progress: progress, blockers: blockers) + } +} diff --git a/makima/ios/Sources/Makima/Markdown/CompletionGateView.swift b/makima/ios/Sources/Makima/Markdown/CompletionGateView.swift new file mode 100644 index 0000000..b54f937 --- /dev/null +++ b/makima/ios/Sources/Makima/Markdown/CompletionGateView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct CompletionGateView: View { + let gate: CompletionGate + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("COMPLETION GATE//") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + Spacer() + Text(gate.ready ? "READY" : "NOT READY") + .font(Typography.navLabel) + .tracking(1) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(gate.ready ? Palette.ok : Palette.warn) + .overlay(Rectangle().strokeBorder(gate.ready ? Palette.ok.opacity(0.5) + : Palette.warn.opacity(0.5), + lineWidth: 1)) + } + Divider().overlay(Palette.borderMuted) + + if let progress = gate.progress { + row("Progress", progress) + } + if let reason = gate.reason { + row("Reason", reason) + } + if !gate.blockers.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Blockers") + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + ForEach(gate.blockers, id: \.self) { b in + HStack(alignment: .top, spacing: 6) { + Text("•").foregroundStyle(Palette.warn) + Text(b) + .font(Typography.body) + .foregroundStyle(Palette.body) + } + } + } + } + } + .padding(14) + .dashedBorder(color: gate.ready ? Palette.ok.opacity(0.4) : Palette.warn.opacity(0.4)) + } + + private func row(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + Text(value) + .font(Typography.body) + .foregroundStyle(Palette.body) + } + } +} diff --git a/makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift b/makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift new file mode 100644 index 0000000..4f60b01 --- /dev/null +++ b/makima/ios/Sources/Makima/Markdown/MarkdownRenderer.swift @@ -0,0 +1,123 @@ +import SwiftUI + +/// Lightweight markdown renderer with code-block awareness. Splits on +/// fenced blocks and renders: +/// - prose blocks via SwiftUI's built-in `AttributedString(markdown:)` +/// - code blocks via a monospaced, panel-backed view with optional lang chip +/// +/// No external deps. Good enough for task output at v1; can upgrade to +/// swift-markdown-ui + Splash later for richer highlighting. +struct MarkdownBlocks: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(Self.parse(text: text).enumerated()), id: \.offset) { _, block in + switch block { + case .prose(let s): + if let attr = try? AttributedString( + markdown: s, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + Text(attr) + .font(Typography.body) + .foregroundStyle(Palette.body) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } else { + Text(s) + .font(Typography.body) + .foregroundStyle(Palette.body) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + case .code(let lang, let body): + CodeBlockView(language: lang, body: body) + } + } + } + } + + enum Block { + case prose(String) + case code(lang: String?, body: String) + } + + static func parse(text: String) -> [Block] { + var blocks: [Block] = [] + var buffer = "" + var inCode = false + var codeLang: String? = nil + var codeBuffer = "" + + for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) { + let line = String(rawLine) + if line.hasPrefix("```") { + if inCode { + blocks.append(.code(lang: codeLang, body: codeBuffer)) + codeBuffer = "" + codeLang = nil + inCode = false + } else { + if !buffer.isEmpty { + blocks.append(.prose(buffer.trimmingCharacters(in: .whitespacesAndNewlines))) + buffer = "" + } + let lang = line.dropFirst(3).trimmingCharacters(in: .whitespaces) + codeLang = lang.isEmpty ? nil : lang + inCode = true + } + continue + } + if inCode { + codeBuffer += line + "\n" + } else { + buffer += line + "\n" + } + } + if !buffer.isEmpty { + blocks.append(.prose(buffer.trimmingCharacters(in: .whitespacesAndNewlines))) + } + if inCode, !codeBuffer.isEmpty { + blocks.append(.code(lang: codeLang, body: codeBuffer)) + } + return blocks.filter { + switch $0 { + case .prose(let s): return !s.isEmpty + case .code: return true + } + } + } +} + +struct CodeBlockView: View { + let language: String? + let body: String + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let lang = language, !lang.isEmpty { + HStack { + Text(lang.uppercased()) + .font(Typography.navLabel) + .foregroundStyle(Palette.foregroundMuted) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Palette.backgroundDeep) + } + ScrollView(.horizontal, showsIndicators: false) { + Text(body) + .font(.system(.footnote, design: .monospaced)) + .foregroundStyle(Palette.foreground) + .padding(10) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .background(Palette.panel) + } + .overlay(Rectangle().strokeBorder(Palette.borderMuted, lineWidth: 1)) + } +} diff --git a/makima/ios/Sources/Makima/Models/Contract.swift b/makima/ios/Sources/Makima/Models/Contract.swift new file mode 100644 index 0000000..8c9de58 --- /dev/null +++ b/makima/ios/Sources/Makima/Models/Contract.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Matches `src/db/models.rs::Contract` (camelCase). +struct Contract: Decodable, Identifiable, Hashable { + let id: String + let ownerId: String? + let name: String + let description: String? + let contractType: String // "simple" | "specification" + let phase: String // "research" | "specify" | "plan" | "execute" | "review" + let status: String // "active" | "completed" | "archived" + let supervisorTaskId: String? + let autonomousLoop: Bool? + let phaseGuard: Bool? + let localOnly: Bool? + let version: Int? + let createdAt: Date? + let updatedAt: Date? + + var isActive: Bool { status.lowercased() == "active" } +} diff --git a/makima/ios/Sources/Makima/Models/Daemon.swift b/makima/ios/Sources/Makima/Models/Daemon.swift index 523e4da..78b824e 100644 --- a/makima/ios/Sources/Makima/Models/Daemon.swift +++ b/makima/ios/Sources/Makima/Models/Daemon.swift @@ -1,9 +1,28 @@ 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 { +/// Full daemon record. Matches `src/db/models.rs::Daemon` (camelCase). +struct Daemon: Decodable, Identifiable, Hashable { let id: String - let status: String? + let ownerId: String? + let connectionId: String? let hostname: String? + let machineId: String? + let maxConcurrentTasks: Int? + let currentTaskCount: Int? + let status: String + let lastHeartbeatAt: Date? + let connectedAt: Date? + let disconnectedAt: Date? + + var isOnline: Bool { + status.lowercased() == "online" || status.lowercased() == "connected" || status.lowercased() == "active" + } + + var displayHost: String { + hostname ?? machineId ?? connectionId ?? String(id.prefix(8)) + } } + +/// Compatibility shim: a handful of M2 call sites still type `DaemonBrief`. +/// Resolve to the same shape. +typealias DaemonBrief = Daemon diff --git a/makima/ios/Sources/Makima/Models/Directive.swift b/makima/ios/Sources/Makima/Models/Directive.swift new file mode 100644 index 0000000..e46a8d2 --- /dev/null +++ b/makima/ios/Sources/Makima/Models/Directive.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Matches `src/db/models.rs::Directive` (camelCase). +struct Directive: Decodable, Identifiable, Hashable { + let id: String + let ownerId: String? + let goal: String? + let name: String? + let status: String? + let createdAt: Date? + let updatedAt: Date? + + var displayName: String { + name ?? goal ?? String(id.prefix(8)) + } +} diff --git a/makima/ios/Sources/Makima/Models/ListEnvelope.swift b/makima/ios/Sources/Makima/Models/ListEnvelope.swift new file mode 100644 index 0000000..27854d9 --- /dev/null +++ b/makima/ios/Sources/Makima/Models/ListEnvelope.swift @@ -0,0 +1,31 @@ +import Foundation + +/// Makima's list endpoints wrap collections like `{ items: [...], total: N }`. +/// Generic envelope lets us decode any list endpoint uniformly. +struct ListEnvelope<Item: Decodable>: Decodable { + let items: [Item] + let total: Int + + // Custom decoder because the wrapper key varies per endpoint + // (daemons / tasks / contracts / directives / orders). + // We accept any of those names. + private enum CodingKeys: String, CodingKey { + case daemons, tasks, contracts, directives, orders + case total + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.total = (try? c.decode(Int.self, forKey: .total)) ?? 0 + + if let v = try? c.decode([Item].self, forKey: .daemons) { self.items = v; return } + if let v = try? c.decode([Item].self, forKey: .tasks) { self.items = v; return } + if let v = try? c.decode([Item].self, forKey: .contracts) { self.items = v; return } + if let v = try? c.decode([Item].self, forKey: .directives) { self.items = v; return } + if let v = try? c.decode([Item].self, forKey: .orders) { self.items = v; return } + + // Fallback: single-value bare array (defensive) + let single = try decoder.singleValueContainer() + self.items = (try? single.decode([Item].self)) ?? [] + } +} diff --git a/makima/ios/Sources/Makima/Models/ListenSession.swift b/makima/ios/Sources/Makima/Models/ListenSession.swift new file mode 100644 index 0000000..f68f4c6 --- /dev/null +++ b/makima/ios/Sources/Makima/Models/ListenSession.swift @@ -0,0 +1,16 @@ +import Foundation + +/// Minimal projection — the full listen session schema has many fields we +/// don't need in v1 Home. Extend as we build the Listen surface at M6. +struct ListenSession: Decodable, Identifiable, Hashable { + let id: String + let title: String? + let startedAt: Date? + let endedAt: Date? + let transcriptPreview: String? + let duration: Double? + + var displayTitle: String { + title ?? "Session \(id.prefix(6))" + } +} diff --git a/makima/ios/Sources/Makima/Models/Task.swift b/makima/ios/Sources/Makima/Models/Task.swift new file mode 100644 index 0000000..ae5466b --- /dev/null +++ b/makima/ios/Sources/Makima/Models/Task.swift @@ -0,0 +1,51 @@ +import Foundation + +/// Matches `src/db/models.rs::Task` (camelCase). Trimmed to the fields the +/// mobile UI needs; extra fields in the payload are tolerated via +/// non-exhaustive decoding. +struct MakimaTask: Decodable, Identifiable, Hashable { + let id: String + let ownerId: String? + let contractId: String? + let parentTaskId: String? + let depth: Int? + let name: String + let description: String? + let status: String // "pending"|"running"|"paused"|"blocked"|"done"|"failed" + let priority: Int? + let plan: String? + let isSupervisor: Bool? + let daemonId: String? + let progressSummary: String? + let lastOutput: String? + let errorMessage: String? + let createdAt: Date? + let updatedAt: Date? + + var statusColor: StatusKind { + switch status.lowercased() { + case "done", "completed": return .ok + case "running": return .active + case "pending", "paused": return .idle + case "blocked": return .warn + case "failed", "error", "cancelled": return .danger + default: return .idle + } + } + + enum StatusKind { + case ok, active, idle, warn, danger + } +} + +/// Response shape for `GET /mesh/tasks/{id}/output`. +struct TaskOutputResponse: Decodable { + let output: String? + let lastOutput: String? + let truncated: Bool? + + /// Coalesces whichever field the server populates. + var text: String { + output ?? lastOutput ?? "" + } +} diff --git a/makima/ios/Sources/Makima/Net/AuthStore.swift b/makima/ios/Sources/Makima/Net/AuthStore.swift index 0046548..d09488f 100644 --- a/makima/ios/Sources/Makima/Net/AuthStore.swift +++ b/makima/ios/Sources/Makima/Net/AuthStore.swift @@ -2,6 +2,7 @@ import Foundation /// Top-level authentication state. Mediates between ServerProfileStore /// (on-disk) and APIClient (in-memory, per session). +@MainActor @Observable final class AuthStore { enum State: Equatable { @@ -44,7 +45,6 @@ final class AuthStore { /// 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 @@ -60,20 +60,21 @@ final class AuthStore { let client = APIClient(profile: profile, apiKey: apiKey) do { - // Anything authenticated works; daemons endpoint is cheap. - let _: [DaemonBrief] = try await client.get("/mesh/daemons") + // Auth probe — daemons list is small and gated by the same middleware + // as every other authed endpoint. + let _: ListEnvelope<Daemon> = 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. + // Tolerate 404 — auth middleware runs first, so a 404 means auth + // passed but the endpoint was renamed. Proceed; deeper surfaces + // will surface the real problem. } catch { state = .error(error.localizedDescription) return } - // Persist do { try Keychain.set(apiKey, for: profile.keychainID) } catch { @@ -95,7 +96,6 @@ final class AuthStore { /// 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 { @@ -113,14 +113,13 @@ final class AuthStore { let client = APIClient(profile: profile, apiKey: key) do { - let _: [DaemonBrief] = try await client.get("/mesh/daemons") + let _: ListEnvelope<Daemon> = 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 + // tolerate } catch { state = .error(error.localizedDescription) return @@ -132,21 +131,23 @@ final class AuthStore { self.state = .authenticated } - /// Rotate the API key via `POST /auth/api-keys/refresh`. Swaps in the new - /// key on success. - @MainActor + /// Rotate the API key via `POST /auth/api-keys/refresh`. 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 } + struct RefreshResp: Decodable { + let apiKey: String? + let api_key: String? + /// Server may return either casing; take whichever is present. + var value: String? { apiKey ?? api_key } + } 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) + guard let new = resp.value else { throw APIError.decoding("refresh response missing apiKey") } + try Keychain.set(new, for: client.profile.keychainID) + self.client = APIClient(profile: client.profile, apiKey: new) } - /// Sign out: wipes Keychain + active profile marker, clears in-memory client. - @MainActor func signOut() { if let profile = profiles.activeProfile { try? Keychain.delete(profile.keychainID) @@ -156,8 +157,6 @@ final class AuthStore { self.state = .needsOnboarding } - // MARK: - Helpers - static func labelFrom(_ urlString: String) -> String? { URL(string: urlString)?.host } diff --git a/makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift b/makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift new file mode 100644 index 0000000..71fe3ae --- /dev/null +++ b/makima/ios/Sources/Makima/Net/NotificationCenterBridge.swift @@ -0,0 +1,60 @@ +import Foundation +import UserNotifications +import UIKit + +/// Surfaces WS events as local notifications while the app has an active +/// session (foregrounded or background-grace). APNs push lands at v1.1. +@MainActor +enum NotificationCenterBridge { + static func requestAuthorizationIfNeeded() async { + let center = UNUserNotificationCenter.current() + let current = await center.notificationSettings() + guard current.authorizationStatus == .notDetermined else { return } + _ = try? await center.requestAuthorization(options: [.alert, .badge, .sound]) + } + + static func notifyTaskStatus(taskId: String, taskName: String, status: String) { + let status = status.lowercased() + let (title, body): (String?, String?) = { + switch status { + case "done", "completed": + return ("TASK COMPLETED", "\(taskName) is done.") + case "failed", "error": + return ("TASK FAILED", "\(taskName) stopped with an error.") + case "blocked": + return ("TASK BLOCKED", "\(taskName) needs your input.") + default: + return (nil, nil) + } + }() + guard let title, let body else { return } + + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + content.userInfo = ["deepLink": "makima://task/\(taskId)"] + + let request = UNNotificationRequest( + identifier: "task-\(taskId)-\(status)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } + + static func notifyDirectiveQuestion(directiveId: String, title: String) { + let content = UNMutableNotificationContent() + content.title = "DIRECTIVE QUESTION" + content.body = "\(title) needs your answer." + content.sound = .default + content.userInfo = ["deepLink": "makima://directive/\(directiveId)"] + + let request = UNNotificationRequest( + identifier: "directive-\(directiveId)", + content: content, + trigger: nil + ) + UNUserNotificationCenter.current().add(request) + } +} diff --git a/makima/ios/Sources/Makima/Net/TaskWebSocket.swift b/makima/ios/Sources/Makima/Net/TaskWebSocket.swift new file mode 100644 index 0000000..08efc9c --- /dev/null +++ b/makima/ios/Sources/Makima/Net/TaskWebSocket.swift @@ -0,0 +1,242 @@ +import Foundation + +/// Minimal WebSocket client for `/api/v1/mesh/tasks/subscribe`. +/// +/// Protocol lines up with `src/server/handlers/mesh_ws.rs`: +/// +/// client -> server +/// { "type": "subscribeAll" } +/// { "type": "subscribe", "taskId": "<uuid>" } +/// { "type": "subscribeOutput", "taskId": "<uuid>" } +/// +/// server -> client +/// { "type": "taskUpdated", "taskId", "status", "updatedFields", ... } +/// { "type": "taskOutput", "taskId", "messageType", "content", ... } +/// { "type": "error", "code", "message" } +/// +/// Auth: the current server's WS upgrade handler does NOT check auth headers +/// (verified in mesh_ws.rs — handler lacks an extractor). Filtering is done +/// by owner_id server-side when subscribing to a specific task. We still +/// attach `x-makima-api-key` on the upgrade request so future tightening +/// doesn't break us. +@MainActor +final class TaskWebSocket { + enum Event { + case taskUpdated(TaskUpdatedEvent) + case taskOutput(TaskOutputEvent) + case error(code: String, message: String) + } + + struct TaskUpdatedEvent: Decodable { + let taskId: String + let version: Int? + let status: String? + let updatedFields: [String]? + let updatedBy: String? + } + + struct TaskOutputEvent: Decodable { + let taskId: String + let messageType: String + let content: String + let toolName: String? + let toolInput: JSONValue? + let isError: Bool? + let costUsd: Double? + let durationMs: Int? + let isPartial: Bool? + } + + var onStatusChange: ((WebSocketStatus) -> Void)? + + private let profile: ServerProfile + private let apiKey: String + private var task: URLSessionWebSocketTask? + private var session: URLSession + private var retries: Int = 0 + private var subscribedAll = false + private var perTaskHandlers: [String: (Event) -> Void] = [:] + private var status: WebSocketStatus = .idle { + didSet { onStatusChange?(status) } + } + private var reconnectTask: Task<Void, Never>? + + init(profile: ServerProfile, apiKey: String, session: URLSession = .shared) { + self.profile = profile + self.apiKey = apiKey + self.session = session + } + + func connect() { + guard let url = profile.apiWebSocketBaseURL?.appendingPathComponent("mesh/tasks/subscribe") else { + return + } + var request = URLRequest(url: url) + request.setValue(apiKey, forHTTPHeaderField: "x-makima-api-key") + request.setValue("makima-ios/\(APIClient.appVersion)", forHTTPHeaderField: "user-agent") + + let task = session.webSocketTask(with: request) + self.task = task + self.status = .connecting + task.resume() + + // Subscribe-all on open so Home/Tasks get updates without per-task + // subscriptions racing the initial render. + send(.subscribeAll) + receiveLoop() + } + + func disconnect() { + reconnectTask?.cancel() + reconnectTask = nil + task?.cancel(with: .goingAway, reason: nil) + task = nil + status = .offline + perTaskHandlers.removeAll() + subscribedAll = false + } + + func subscribe(taskId: String, handler: @escaping (Event) -> Void) { + perTaskHandlers[taskId] = handler + send(.subscribe(taskId: taskId)) + send(.subscribeOutput(taskId: taskId)) + } + + func unsubscribe(taskId: String) { + perTaskHandlers.removeValue(forKey: taskId) + send(.unsubscribe(taskId: taskId)) + send(.unsubscribeOutput(taskId: taskId)) + } + + // MARK: - Wire protocol + + enum ClientMessage { + case subscribeAll + case subscribe(taskId: String) + case unsubscribe(taskId: String) + case subscribeOutput(taskId: String) + case unsubscribeOutput(taskId: String) + + var jsonObject: [String: Any] { + switch self { + case .subscribeAll: return ["type": "subscribeAll"] + case .subscribe(let id): return ["type": "subscribe", "taskId": id] + case .unsubscribe(let id): return ["type": "unsubscribe", "taskId": id] + case .subscribeOutput(let id): return ["type": "subscribeOutput", "taskId": id] + case .unsubscribeOutput(let id): return ["type": "unsubscribeOutput", "taskId": id] + } + } + } + + private func send(_ message: ClientMessage) { + guard let data = try? JSONSerialization.data(withJSONObject: message.jsonObject), + let str = String(data: data, encoding: .utf8) + else { return } + + if case .subscribeAll = message { subscribedAll = true } + + task?.send(.string(str)) { [weak self] error in + if let error { + print("[TaskWebSocket] send failed: \(error)") + Task { @MainActor in self?.scheduleReconnect() } + } + } + } + + private func receiveLoop() { + task?.receive { [weak self] result in + Task { @MainActor in + guard let self else { return } + switch result { + case .success(let message): + self.status = .online + self.retries = 0 + switch message { + case .string(let text): + self.handle(text: text) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + self.handle(text: text) + } + @unknown default: + break + } + self.receiveLoop() + case .failure(let error): + print("[TaskWebSocket] receive failed: \(error)") + self.scheduleReconnect() + } + } + } + } + + private func handle(text: String) { + guard let data = text.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = obj["type"] as? String + else { return } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + switch type { + case "taskUpdated": + if let event = try? decoder.decode(TaskUpdatedEvent.self, from: data) { + dispatch(.taskUpdated(event), forTaskId: event.taskId) + } + case "taskOutput": + if let event = try? decoder.decode(TaskOutputEvent.self, from: data) { + dispatch(.taskOutput(event), forTaskId: event.taskId) + } + case "error": + let code = (obj["code"] as? String) ?? "ERROR" + let msg = (obj["message"] as? String) ?? "" + print("[TaskWebSocket] server error \(code): \(msg)") + case "subscribed", "subscribedAll", "unsubscribed", "unsubscribedAll", + "outputSubscribed", "outputUnsubscribed": + break + default: + break + } + } + + private func dispatch(_ event: Event, forTaskId id: String) { + perTaskHandlers[id]?(event) + } + + private func scheduleReconnect() { + guard reconnectTask == nil else { return } + status = .offline + retries = min(retries + 1, 6) + let delay = min(30.0, pow(2.0, Double(retries))) + reconnectTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + if Task.isCancelled { return } + guard let self else { return } + self.reconnectTask = nil + self.disconnect() + self.connect() + } + } +} + +/// Loosely-typed JSON value for WS tool inputs where the schema varies. +enum JSONValue: Decodable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let v = try? c.decode(Bool.self) { self = .bool(v); return } + if let v = try? c.decode(Double.self) { self = .number(v); return } + if let v = try? c.decode(String.self) { self = .string(v); return } + if let v = try? c.decode([JSONValue].self) { self = .array(v); return } + if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unknown JSON value") + } +} diff --git a/makima/ios/Sources/Makima/Stores/HomeStore.swift b/makima/ios/Sources/Makima/Stores/HomeStore.swift new file mode 100644 index 0000000..a014b84 --- /dev/null +++ b/makima/ios/Sources/Makima/Stores/HomeStore.swift @@ -0,0 +1,65 @@ +import Foundation + +@MainActor +@Observable +final class HomeStore { + enum LoadState: Equatable { + case idle, loading, loaded, failed(String) + } + + var state: LoadState = .idle + var activeContracts: [Contract] = [] + var activeContractsTotal: Int = 0 + var daemons: [Daemon] = [] + var pendingDirectives: [Directive] = [] + var recentTasks: [MakimaTask] = [] + var latestListen: ListenSession? + var lastRefreshed: Date? + + private let client: APIClient + + init(client: APIClient) { + self.client = client + } + + func refresh() async { + state = .loading + + async let contractsTask: ListEnvelope<Contract>? = tryGet("/contracts") + async let daemonsTask: ListEnvelope<Daemon>? = tryGet("/mesh/daemons") + async let directivesTask: ListEnvelope<Directive>? = tryGet("/directives") + async let tasksTask: ListEnvelope<MakimaTask>? = tryGet("/mesh/tasks") + async let listenTask: ListEnvelope<ListenSession>? = tryGet("/listen/sessions") + + let (contracts, daemons, directives, tasks, listen) = + await (contractsTask, daemonsTask, directivesTask, tasksTask, listenTask) + + self.activeContracts = (contracts?.items ?? []).filter { $0.isActive } + self.activeContractsTotal = self.activeContracts.count + self.daemons = daemons?.items ?? [] + self.pendingDirectives = (directives?.items ?? []).filter { + ($0.status ?? "").lowercased() == "pending" + || ($0.status ?? "").lowercased() == "waiting" + } + self.recentTasks = Array( + (tasks?.items ?? []) + .sorted { ($0.updatedAt ?? .distantPast) > ($1.updatedAt ?? .distantPast) } + .prefix(5) + ) + self.latestListen = listen?.items + .sorted { ($0.startedAt ?? .distantPast) > ($1.startedAt ?? .distantPast) } + .first + self.lastRefreshed = .now + self.state = .loaded + } + + /// Like `client.get` but swallows errors — each card degrades independently. + private func tryGet<T: Decodable>(_ path: String) async -> T? { + do { return try await client.get(path) } + catch { + // Log at console for dev; don't block the whole home view + print("[Home] GET \(path) failed: \(error)") + return nil + } + } +} diff --git a/makima/ios/Tests/MakimaTests/APIClientTests.swift b/makima/ios/Tests/MakimaTests/APIClientTests.swift index 9277aa3..8df91b6 100644 --- a/makima/ios/Tests/MakimaTests/APIClientTests.swift +++ b/makima/ios/Tests/MakimaTests/APIClientTests.swift @@ -10,13 +10,13 @@ final class APIClientTests: XCTestCase { URLProtocolStub.Response( url: "https://makima.test/api/v1/mesh/daemons", status: 200, - body: #"[]"#.data(using: .utf8)! + body: #"{"daemons":[],"total":0}"#.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 _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons") let recorded = try XCTUnwrap(stub.received.first) XCTAssertEqual(recorded.url?.absoluteString, "https://makima.test/api/v1/mesh/daemons") @@ -36,7 +36,7 @@ final class APIClientTests: XCTestCase { let client = APIClient(profile: profile, apiKey: "mk_x", session: URLProtocolStub.session()) do { - let _: [DaemonBrief] = try await client.get("/mesh/daemons") + let _: ListEnvelope<Daemon> = try await client.get("/mesh/daemons") XCTFail("Expected unauthorized") } catch { XCTAssertEqual(error as? APIError, .unauthorized) diff --git a/makima/ios/Tests/MakimaTests/CompletionGateTests.swift b/makima/ios/Tests/MakimaTests/CompletionGateTests.swift new file mode 100644 index 0000000..2fd5655 --- /dev/null +++ b/makima/ios/Tests/MakimaTests/CompletionGateTests.swift @@ -0,0 +1,38 @@ +import XCTest +@testable import Makima + +final class CompletionGateTests: XCTestCase { + func testExtractsReadyTrue() { + let out = """ + Some output here + <COMPLETION_GATE> + ready: true + reason: "All tests pass" + progress: "Implemented feature X" + </COMPLETION_GATE> + Trailing stuff. + """ + let gate = CompletionGate.extract(from: out) + XCTAssertNotNil(gate) + XCTAssertTrue(gate?.ready == true) + XCTAssertEqual(gate?.reason, "All tests pass") + XCTAssertEqual(gate?.progress, "Implemented feature X") + XCTAssertTrue(gate?.blockers.isEmpty == true) + } + + func testExtractsBlockers() { + let out = """ + <COMPLETION_GATE> + ready: false + blockers: "needs env var, missing migration, flaky test" + </COMPLETION_GATE> + """ + let gate = CompletionGate.extract(from: out) + XCTAssertEqual(gate?.ready, false) + XCTAssertEqual(gate?.blockers.count, 3) + } + + func testReturnsNilWhenMissing() { + XCTAssertNil(CompletionGate.extract(from: "just some text")) + } +} diff --git a/makima/ios/Tests/MakimaTests/DeepLinkTests.swift b/makima/ios/Tests/MakimaTests/DeepLinkTests.swift new file mode 100644 index 0000000..c368ff2 --- /dev/null +++ b/makima/ios/Tests/MakimaTests/DeepLinkTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import Makima + +final class DeepLinkTests: XCTestCase { + func testTaskDeepLink() { + let url = URL(string: "makima://task/abc-123")! + let link = DeepLink(url: url) + XCTAssertEqual(link, .task(id: "abc-123")) + } + + func testDirectiveDeepLink() { + let url = URL(string: "makima://directive/xyz")! + XCTAssertEqual(DeepLink(url: url), .directive(id: "xyz")) + } + + func testRejectsOtherSchemes() { + XCTAssertNil(DeepLink(url: URL(string: "https://makima.jp/task/abc")!)) + } + + func testRejectsUnknownHost() { + XCTAssertNil(DeepLink(url: URL(string: "makima://widget/abc")!)) + } +} diff --git a/makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift b/makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift new file mode 100644 index 0000000..4d06266 --- /dev/null +++ b/makima/ios/Tests/MakimaTests/ListEnvelopeTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Makima + +final class ListEnvelopeTests: XCTestCase { + func testDecodesDaemonsWrapper() throws { + let json = """ + { "daemons": [{"id":"d1","status":"online"}], "total": 1 } + """ + let env = try JSONDecoder().decode(ListEnvelope<Daemon>.self, from: Data(json.utf8)) + XCTAssertEqual(env.total, 1) + XCTAssertEqual(env.items.first?.id, "d1") + } + + func testDecodesTasksWrapper() throws { + let json = """ + { "tasks": [{"id":"t1","name":"hello","status":"pending"}], "total": 1 } + """ + let env = try JSONDecoder().decode(ListEnvelope<MakimaTask>.self, from: Data(json.utf8)) + XCTAssertEqual(env.items.first?.name, "hello") + } + + func testDecodesBareArrayFallback() throws { + let json = "[{\"id\":\"d1\",\"status\":\"online\"}]" + let env = try JSONDecoder().decode(ListEnvelope<Daemon>.self, from: Data(json.utf8)) + XCTAssertEqual(env.items.count, 1) + } +} diff --git a/makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift b/makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift new file mode 100644 index 0000000..89b9b92 --- /dev/null +++ b/makima/ios/Tests/MakimaTests/MarkdownBlocksTests.swift @@ -0,0 +1,30 @@ +import XCTest +@testable import Makima + +final class MarkdownBlocksTests: XCTestCase { + func testSeparatesProseAndCode() { + let input = """ + Hello world. + + ```swift + let x = 1 + ``` + + Trailing prose. + """ + let blocks = MarkdownBlocks.parse(text: input) + XCTAssertEqual(blocks.count, 3) + if case .prose(let s) = blocks[0] { XCTAssertTrue(s.contains("Hello world")) } else { XCTFail() } + if case .code(let lang, let body) = blocks[1] { + XCTAssertEqual(lang, "swift") + XCTAssertTrue(body.contains("let x = 1")) + } else { XCTFail() } + if case .prose(let s) = blocks[2] { XCTAssertTrue(s.contains("Trailing")) } else { XCTFail() } + } + + func testPureProseIsOneBlock() { + let blocks = MarkdownBlocks.parse(text: "just text here") + XCTAssertEqual(blocks.count, 1) + if case .prose(let s) = blocks[0] { XCTAssertEqual(s, "just text here") } else { XCTFail() } + } +} diff --git a/makima/ios/docs/RELEASING.md b/makima/ios/docs/RELEASING.md index 1564a1a..4d4513d 100644 --- a/makima/ios/docs/RELEASING.md +++ b/makima/ios/docs/RELEASING.md @@ -1,6 +1,71 @@ -# Releasing (placeholder — filled in at M8) +# Releasing Makima iOS -- App Store Connect: Soryu LTD -- Bundle ID: `co.soryu.makima` -- TestFlight internal group: TBD -- Fastlane lanes: TBD +App Store Connect organisation: **Soryu LTD** +Bundle ID: `co.soryu.makima` +Scheme: `Makima` + +## Pre-flight + +1. Update `CURRENT_PROJECT_VERSION` and `MARKETING_VERSION` in `project.yml`. +2. `make xcgen` +3. `make test` — must pass. +4. Visual smoke test against `makima.jp`: + - Fresh install → onboarding → home renders with 5 cards + - Tap Contracts → list → detail → task → output shows polled + live events + - Settings → rotate key — new key prefix appears in masked line +5. Check `Info.plist`: + - `ITSAppUsesNonExemptEncryption` = `NO` (HTTPS + APNs only; no custom crypto) + - `NSAppTransportSecurity.NSAllowsArbitraryLoads` = `NO` + - `CFBundleURLTypes` contains `makima://` scheme + +## Archive + upload + +```bash +make xcgen +xcodebuild -project Makima.xcodeproj \ + -scheme Makima \ + -configuration Release \ + -destination 'generic/platform=iOS' \ + -archivePath build/Makima.xcarchive \ + archive + +xcodebuild -exportArchive \ + -archivePath build/Makima.xcarchive \ + -exportOptionsPlist ExportOptions.plist \ + -exportPath build/export +``` + +`ExportOptions.plist` (not committed — create locally with your team ID): + +```xml +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>destination</key><string>upload</string> + <key>method</key><string>app-store-connect</string> + <key>teamID</key><string>YOUR_TEAM_ID</string> + <key>signingStyle</key><string>automatic</string> +</dict> +</plist> +``` + +## TestFlight + +Internal group "Soryu" gets automatic distribution on every new build. +External group "Makima Early" requires one review cycle; subsequent builds +in the same version string pass through without review. + +## App Store submission checklist + +- [ ] Privacy policy URL → repo README (no tracking, no analytics, Keychain-only credentials) +- [ ] Category: Developer Tools (mirrors KittyLitter) +- [ ] Pricing: Free (no IAP in v1) +- [ ] Encryption: none beyond OS TLS (ITSAppUsesNonExemptEncryption=NO already set) +- [ ] Screenshots: 6.7" + 6.1" in dark mode, showing Home / Contract detail / Task live / Directives +- [ ] Keywords: makima, soryu, orchestration, AI, agent, codex, claude, devops + +## Versioning + +Semver for marketing version. Bump build number (`CURRENT_PROJECT_VERSION`) +on every TestFlight upload, bump marketing version on user-visible releases. |
