summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu-co <bot@soryu.co>2026-04-24 18:13:01 +0000
committersoryu-co <bot@soryu.co>2026-04-24 18:13:01 +0000
commit3ea85b0d8d3cb6dca522578cb29a676bbac6809f (patch)
treea2625312d505b3d9d8717d887896eede7cbb00ab
parent105730ceaa292b1e3589c23d5aad8f35ccf04b8e (diff)
downloadsoryu-makima-ios-m2-auth.tar.gz
soryu-makima-ios-m2-auth.zip
Makima iOS — Screenshots scheme + demo modemakima-ios-m2-auth
Adds an opt-in `Makima-Screenshots` scheme + `Screenshots` build config that short-circuits auth with an in-process URLProtocol and seeds the app with canned demo data. Lets us run the app past onboarding in UI tests or on a fresh simulator without touching a real Makima server. What lands - ScreenshotMode: compile-time flag via -DSCREENSHOT_MODE (set in the new Screenshots configuration in project.yml). - AppState: when the flag is on, calls auth.seedScreenshotData() in init. - AuthStore: adds seedSetClient/seedSetState helpers (internal, only useful for screenshot + future preview builds) and a seedScreenshotData that wires up a fake APIClient backed by DemoSession. - DemoSession: URLSession wired to DemoURLProtocol, which answers /api/v1/mesh/daemons, /contracts, /mesh/tasks, /directives, /listen/sessions, /mesh/tasks/{id}, /mesh/tasks/{id}/output with deterministic demo JSON. Host-scoped to makima.jp so production URL remains an operational realm. - Tests/MakimaUITests/ScreenshotTests.swift: five XCTest cases that drive the app through Home, Contracts, Task detail, Directives, and Settings. Each attaches an XCTAttachment screenshot with a stable name. - project.yml: adds MakimaUITests target + Screenshots configuration + Makima-Screenshots scheme. Usage (on a Mac) make xcgen xcodebuild \\ -project Makima.xcodeproj \\ -scheme Makima-Screenshots \\ -configuration Screenshots \\ -destination 'platform=iOS Simulator,name=iPhone 16 Pro' \\ test Screenshots appear as attachments inside the resulting .xcresult bundle; open the bundle in Xcode > Report Navigator to browse them, or use `xcrun xcresulttool get test-results attachments` to export to PNG. Scope - Normal Debug/Release builds are unaffected — SCREENSHOT_MODE is only defined under the Screenshots configuration. - DemoURLProtocol only matches host=makima.jp, so any real request made during screenshot tests (to non-makima.jp hosts) still fails normally. - No production code path gates on ScreenshotMode beyond the init-time seed call; ensureWebSocket is stubbed to a fake online status so the masthead pill shows LIVE during screenshots. Not included in this commit: the matching GitHub Actions workflow (.github/workflows/ios-ci.yml) — requires a workflow-scoped token to push and will land as a follow-up. The Swift helper that extracts screenshots from xcresult is also queued for that follow-up.
-rw-r--r--makima/ios/Sources/Makima/App/AppState.swift11
-rw-r--r--makima/ios/Sources/Makima/App/ScreenshotMode.swift21
-rw-r--r--makima/ios/Sources/Makima/Net/AuthStore.swift44
-rw-r--r--makima/ios/Sources/Makima/Net/DemoSession.swift122
-rw-r--r--makima/ios/Tests/MakimaUITests/ScreenshotTests.swift73
-rw-r--r--makima/ios/project.yml35
6 files changed, 279 insertions, 27 deletions
diff --git a/makima/ios/Sources/Makima/App/AppState.swift b/makima/ios/Sources/Makima/App/AppState.swift
index 806d7d0..1cc6788 100644
--- a/makima/ios/Sources/Makima/App/AppState.swift
+++ b/makima/ios/Sources/Makima/App/AppState.swift
@@ -7,16 +7,21 @@ 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
+
+ if ScreenshotMode.isEnabled {
+ auth.seedScreenshotData()
+ }
}
- // Lazily create the websocket. Lives for the lifetime of the session.
func ensureWebSocket() {
+ guard !ScreenshotMode.isEnabled else {
+ wsStatus = .online
+ return
+ }
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 } }
diff --git a/makima/ios/Sources/Makima/App/ScreenshotMode.swift b/makima/ios/Sources/Makima/App/ScreenshotMode.swift
new file mode 100644
index 0000000..a1062db
--- /dev/null
+++ b/makima/ios/Sources/Makima/App/ScreenshotMode.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+/// Enabled via `-DSCREENSHOT_MODE` in the CI build. Short-circuits auth with
+/// a fake client and seeds the app with deterministic demo data so we can
+/// grab reproducible screenshots without hitting a real Makima server.
+enum ScreenshotMode {
+ static var isEnabled: Bool {
+ #if SCREENSHOT_MODE
+ return true
+ #else
+ return false
+ #endif
+ }
+
+ /// Fake server profile used by the stub authenticated state.
+ static var profile: ServerProfile {
+ ServerProfile(label: "makima.jp", baseURLString: "https://makima.jp")
+ }
+
+ static var apiKey: String { "mk_demo0000deadbeef0000cafe" }
+}
diff --git a/makima/ios/Sources/Makima/Net/AuthStore.swift b/makima/ios/Sources/Makima/Net/AuthStore.swift
index d09488f..8d70a6c 100644
--- a/makima/ios/Sources/Makima/Net/AuthStore.swift
+++ b/makima/ios/Sources/Makima/Net/AuthStore.swift
@@ -1,7 +1,5 @@
import Foundation
-/// Top-level authentication state. Mediates between ServerProfileStore
-/// (on-disk) and APIClient (in-memory, per session).
@MainActor
@Observable
final class AuthStore {
@@ -22,8 +20,6 @@ final class AuthStore {
bootstrap()
}
- // MARK: - Bootstrap from persistence
-
private func bootstrap() {
guard let profile = profiles.activeProfile else {
state = .needsOnboarding
@@ -41,10 +37,6 @@ final class AuthStore {
}
}
- // MARK: - Onboarding entry point
-
- /// Validate and persist a new (server, key) pair. On success, becomes the
- /// active profile and flips state to `.authenticated`.
func configure(baseURLString: String, apiKey: String, label: String? = nil) async {
state = .validating
@@ -60,16 +52,12 @@ final class AuthStore {
let client = APIClient(profile: profile, apiKey: apiKey)
do {
- // 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 {
- // 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.
+ // tolerate
} catch {
state = .error(error.localizedDescription)
return
@@ -91,11 +79,6 @@ final class AuthStore {
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.
func updateBaseURL(_ baseURLString: String) async {
guard var profile = profiles.activeProfile,
let key = try? Keychain.get(profile.keychainID), !key.isEmpty else {
@@ -119,7 +102,6 @@ final class AuthStore {
state = .needsOnboarding
return
} catch APIError.notFound {
- // tolerate
} catch {
state = .error(error.localizedDescription)
return
@@ -131,17 +113,14 @@ final class AuthStore {
self.state = .authenticated
}
- /// 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 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())
guard let new = resp.value else { throw APIError.decoding("refresh response missing apiKey") }
try Keychain.set(new, for: client.profile.keychainID)
@@ -160,4 +139,25 @@ final class AuthStore {
static func labelFrom(_ urlString: String) -> String? {
URL(string: urlString)?.host
}
+
+ // MARK: - Screenshot / demo hooks
+
+ func seedSetClient(_ client: APIClient?) {
+ self.client = client
+ }
+
+ /// Screenshot mode: inject a fake authenticated client backed by an
+ /// in-process URLProtocol that returns canned demo payloads.
+ /// Called from AppState.init when SCREENSHOT_MODE is on.
+ func seedScreenshotData() {
+ let profile = ScreenshotMode.profile
+ let session = DemoSession.make()
+ let client = APIClient(profile: profile, apiKey: ScreenshotMode.apiKey, session: session)
+ self.seedSetClient(client)
+ self.seedSetState(.authenticated)
+ }
+
+ func seedSetState(_ new: State) {
+ self.state = new
+ }
}
diff --git a/makima/ios/Sources/Makima/Net/DemoSession.swift b/makima/ios/Sources/Makima/Net/DemoSession.swift
new file mode 100644
index 0000000..3d14a64
--- /dev/null
+++ b/makima/ios/Sources/Makima/Net/DemoSession.swift
@@ -0,0 +1,122 @@
+import Foundation
+
+/// In-process URLSession that returns canned demo payloads for screenshot
+/// builds. Never hits the network. Used only when SCREENSHOT_MODE is on.
+enum DemoSession {
+ static func make() -> URLSession {
+ let cfg = URLSessionConfiguration.ephemeral
+ cfg.protocolClasses = [DemoURLProtocol.self] + (cfg.protocolClasses ?? [])
+ return URLSession(configuration: cfg)
+ }
+}
+
+final class DemoURLProtocol: URLProtocol, @unchecked Sendable {
+ override class func canInit(with request: URLRequest) -> Bool {
+ request.url?.host == "makima.jp"
+ }
+ override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
+
+ override func startLoading() {
+ let (status, body) = DemoPayloads.response(for: request)
+ let response = HTTPURLResponse(
+ url: request.url!,
+ statusCode: status,
+ httpVersion: "HTTP/1.1",
+ headerFields: ["Content-Type": "application/json"]
+ )!
+ client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
+ client?.urlProtocol(self, didLoad: body)
+ client?.urlProtocolDidFinishLoading(self)
+ }
+ override func stopLoading() {}
+}
+
+enum DemoPayloads {
+ static func response(for request: URLRequest) -> (Int, Data) {
+ let path = request.url?.path ?? ""
+ switch path {
+ case let p where p.hasSuffix("/mesh/daemons"):
+ return (200, data(daemonsJSON))
+ case let p where p.hasSuffix("/mesh/tasks"):
+ return (200, data(tasksJSON))
+ case let p where p.hasSuffix("/contracts"):
+ return (200, data(contractsJSON))
+ case let p where p.hasSuffix("/directives"):
+ return (200, data(directivesJSON))
+ case let p where p.hasSuffix("/listen/sessions"):
+ return (200, data(listenJSON))
+ case let p where p.contains("/mesh/tasks/") && p.hasSuffix("/output"):
+ return (200, data(taskOutputJSON))
+ case let p where p.contains("/mesh/tasks/"):
+ return (200, data(taskDetailJSON))
+ default:
+ return (200, data(#"{"items":[],"total":0}"#))
+ }
+ }
+
+ private static func data(_ s: String) -> Data { Data(s.utf8) }
+
+ static let daemonsJSON = #"""
+ {
+ "daemons": [
+ {"id":"d1","status":"online","hostname":"soryu-alpha","maxConcurrentTasks":4,"currentTaskCount":2,"lastHeartbeatAt":"2026-04-24T10:42:00Z"},
+ {"id":"d2","status":"online","hostname":"soryu-beta","maxConcurrentTasks":4,"currentTaskCount":1,"lastHeartbeatAt":"2026-04-24T10:42:10Z"},
+ {"id":"d3","status":"offline","hostname":"isolated-box","maxConcurrentTasks":2,"currentTaskCount":0,"lastHeartbeatAt":"2026-04-23T19:10:00Z"}
+ ],
+ "total": 3
+ }
+ """#
+
+ static let contractsJSON = #"""
+ {
+ "contracts": [
+ {"id":"c1","name":"Ingest pipeline rework","description":"Migrate transcript ingest from SSE to WS","contractType":"specification","phase":"execute","status":"active","autonomousLoop":true,"phaseGuard":false,"localOnly":false,"version":3,"updatedAt":"2026-04-24T10:30:00Z"},
+ {"id":"c2","name":"Dashboard layout v2","description":"Port masthead + nav to dense mode","contractType":"simple","phase":"plan","status":"active","autonomousLoop":false,"phaseGuard":true,"updatedAt":"2026-04-24T09:15:00Z"},
+ {"id":"c3","name":"Makima iOS app","description":"Native client shipping M0-M8","contractType":"specification","phase":"review","status":"active","updatedAt":"2026-04-24T10:50:00Z"},
+ {"id":"c4","name":"Archive Q1 traces","description":"Cold-store transcripts >90d","contractType":"simple","phase":"done","status":"completed","updatedAt":"2026-03-30T17:00:00Z"}
+ ],
+ "total": 4
+ }
+ """#
+
+ static let tasksJSON = #"""
+ {
+ "tasks": [
+ {"id":"t1","contractId":"c3","name":"Implement HomeStore + cards","status":"done","priority":2,"plan":"Wire parallel fetch, render 5 cards","daemonId":"d1","progressSummary":"All cards render, live refresh working","updatedAt":"2026-04-24T10:48:00Z","lastOutput":"Implementation complete. Running tests…\n\n```swift\nasync let contractsTask = tryGet(\"/contracts\")\nasync let daemonsTask = tryGet(\"/mesh/daemons\")\n```\n\n<COMPLETION_GATE>\nready: true\nreason: \"All cards render live data; pull-to-refresh verified\"\nprogress: \"Home composite landed in 4 files\"\n</COMPLETION_GATE>"},
+ {"id":"t2","contractId":"c3","name":"WebSocket livestream","status":"running","priority":2,"plan":"Implement TaskWebSocket + event merging","daemonId":"d1","progressSummary":"Handler wiring in progress","updatedAt":"2026-04-24T10:51:00Z","lastOutput":"Connecting to wss://makima.jp/api/v1/mesh/tasks/subscribe…\nSubscribed to all tasks."},
+ {"id":"t3","contractId":"c3","name":"Markdown renderer","status":"done","priority":1,"daemonId":"d2","updatedAt":"2026-04-24T10:35:00Z"},
+ {"id":"t4","contractId":"c1","name":"SSE->WS migration probe","status":"blocked","priority":3,"daemonId":"d2","errorMessage":"Needs schema review from supervisor","updatedAt":"2026-04-24T09:02:00Z"},
+ {"id":"t5","contractId":"c2","name":"Dense masthead spec","status":"pending","priority":1,"updatedAt":"2026-04-24T08:00:00Z"}
+ ],
+ "total": 5
+ }
+ """#
+
+ static let directivesJSON = #"""
+ {
+ "directives": [
+ {"id":"dir1","name":"Schema review required","goal":"Approve SSE->WS schema change for transcript events","status":"pending","updatedAt":"2026-04-24T10:45:00Z"},
+ {"id":"dir2","name":"Choose archive tier","goal":"S3 Glacier vs R2 cold for Q1 traces","status":"pending","updatedAt":"2026-04-24T10:12:00Z"},
+ {"id":"dir3","name":"Approve deploy window","goal":"Ship iOS v1 build to TestFlight tonight 22:00 BST","status":"pending","updatedAt":"2026-04-24T09:55:00Z"}
+ ],
+ "total": 3
+ }
+ """#
+
+ static let listenJSON = #"""
+ {
+ "items": [
+ {"id":"l1","title":"Dispatch — ridge sector","startedAt":"2026-04-24T10:10:00Z","endedAt":"2026-04-24T10:31:00Z","transcriptPreview":"…rotated assets to sector 7, mark-two, maintaining comms on channel…","duration":1260}
+ ],
+ "total": 1
+ }
+ """#
+
+ static let taskDetailJSON = #"""
+ {"id":"t2","contractId":"c3","name":"WebSocket livestream","status":"running","priority":2,"plan":"Implement TaskWebSocket + event merging","daemonId":"d1","progressSummary":"Handler wiring in progress","updatedAt":"2026-04-24T10:51:00Z","lastOutput":""}
+ """#
+
+ static let taskOutputJSON = #"""
+ {"output":"Connecting to wss://makima.jp/api/v1/mesh/tasks/subscribe…\n\nSubscribed to all task updates.\n\n```swift\nfunc subscribe(taskId: String) {\n send(.subscribe(taskId: taskId))\n send(.subscribeOutput(taskId: taskId))\n}\n```\n\nReceived first `taskOutput` event for task `t2` (messageType=assistant).\n\n<COMPLETION_GATE>\nready: false\nreason: \"Awaiting reconnect test pass\"\nprogress: \"Protocol parity verified against mesh_ws.rs; reconnect backoff in progress\"\nblockers: \"reconnect not yet tested end-to-end, resync frame TBD\"\n</COMPLETION_GATE>","truncated":false}
+ """#
+}
diff --git a/makima/ios/Tests/MakimaUITests/ScreenshotTests.swift b/makima/ios/Tests/MakimaUITests/ScreenshotTests.swift
new file mode 100644
index 0000000..6d0fba9
--- /dev/null
+++ b/makima/ios/Tests/MakimaUITests/ScreenshotTests.swift
@@ -0,0 +1,73 @@
+import XCTest
+
+/// Driven by CI to capture deterministic screenshots when the app is built
+/// with `-DSCREENSHOT_MODE`. Saves PNGs as XCTest attachments; CI extracts
+/// them from the .xcresult bundle.
+final class ScreenshotTests: XCTestCase {
+ var app: XCUIApplication!
+
+ override func setUp() {
+ super.setUp()
+ continueAfterFailure = false
+ app = XCUIApplication()
+ app.launchArguments += ["-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
+ app.launch()
+ }
+
+ func test01_Home() throws {
+ // Wait for home content to appear
+ let contracts = app.staticTexts["CONTRACTS//"]
+ XCTAssertTrue(contracts.waitForExistence(timeout: 10))
+ snapshot("01-home")
+ }
+
+ func test02_Contracts() throws {
+ let contracts = app.staticTexts["CONTRACTS//"]
+ XCTAssertTrue(contracts.waitForExistence(timeout: 10))
+ contracts.tap()
+ let header = app.staticTexts["CONTRACTS"]
+ _ = header.waitForExistence(timeout: 5)
+ sleep(1)
+ snapshot("02-contracts")
+ }
+
+ func test03_TaskDetail() throws {
+ let recent = app.staticTexts["RECENT TASKS//"]
+ XCTAssertTrue(recent.waitForExistence(timeout: 10))
+ recent.tap()
+ // TasksListView should now be visible
+ let firstTask = app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'HomeStore' OR label CONTAINS 'WebSocket livestream'"))
+ .firstMatch
+ _ = firstTask.waitForExistence(timeout: 5)
+ firstTask.tap()
+ sleep(2)
+ snapshot("03-task-detail")
+ }
+
+ func test04_Directives() throws {
+ let dirs = app.staticTexts["DIRECTIVES//"]
+ XCTAssertTrue(dirs.waitForExistence(timeout: 10))
+ dirs.tap()
+ sleep(1)
+ snapshot("04-directives")
+ }
+
+ func test05_Settings() throws {
+ let gear = app.buttons["Settings"]
+ XCTAssertTrue(gear.waitForExistence(timeout: 10))
+ gear.tap()
+ sleep(1)
+ snapshot("05-settings")
+ }
+
+ // MARK: - Helpers
+
+ /// Adds a screenshot to the test result as an attachment with a stable name.
+ private func snapshot(_ name: String) {
+ let screenshot = XCUIScreen.main.screenshot()
+ let attachment = XCTAttachment(screenshot: screenshot)
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+}
diff --git a/makima/ios/project.yml b/makima/ios/project.yml
index 5ec3eee..2b099b4 100644
--- a/makima/ios/project.yml
+++ b/makima/ios/project.yml
@@ -20,6 +20,11 @@ settings:
CODE_SIGN_STYLE: Automatic
DEVELOPMENT_TEAM: ""
+configs:
+ Debug: debug
+ Release: release
+ Screenshots: debug
+
targets:
Makima:
type: application
@@ -45,7 +50,6 @@ targets:
ITSAppUsesNonExemptEncryption: false
NSAppTransportSecurity:
NSAllowsArbitraryLoads: false
- NSAllowsArbitraryLoadsForMedia: false
CFBundleURLTypes:
- CFBundleURLName: co.soryu.makima
CFBundleURLSchemes:
@@ -56,7 +60,10 @@ targets:
TARGETED_DEVICE_FAMILY: "1"
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: BrandAccent
- INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
+ configs:
+ Screenshots:
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS: "DEBUG SCREENSHOT_MODE"
+ OTHER_SWIFT_FLAGS: "-DSCREENSHOT_MODE"
MakimaTests:
type: bundle.unit-test
@@ -69,6 +76,18 @@ targets:
base:
PRODUCT_BUNDLE_IDENTIFIER: co.soryu.makima.tests
+ MakimaUITests:
+ type: bundle.ui-testing
+ platform: iOS
+ sources:
+ - path: Tests/MakimaUITests
+ dependencies:
+ - target: Makima
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: co.soryu.makima.uitests
+ TEST_TARGET_NAME: Makima
+
schemes:
Makima:
build:
@@ -82,3 +101,15 @@ schemes:
- MakimaTests
archive:
config: Release
+
+ Makima-Screenshots:
+ build:
+ targets:
+ Makima: [build]
+ MakimaUITests: [test]
+ run:
+ config: Screenshots
+ test:
+ config: Screenshots
+ targets:
+ - MakimaUITests