diff --git a/docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md b/docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md
new file mode 100644
index 0000000..9d0936b
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md
@@ -0,0 +1,1490 @@
+# iOS Native IPA Foundation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the native iOS foundation that can compile into an unsigned `MultiSimAdmin.ipa`, persist instances locally, connect safely to SimAdmin upstreams, and render the first fleet workbench.
+
+**Architecture:** Add a standalone Swift Package under `apps/ios`. SwiftUI owns presentation, domain services own validation and orchestration, GRDB owns SQLite persistence, and an actor-based upstream client owns session cookies and route whitelisting. The build script compiles for iPhoneOS and assembles an unsigned app bundle manually.
+
+**Tech Stack:** Swift 5.9+, SwiftUI, GRDB 7.7.0, XCTest, SwiftPM, `xcrun`, `zip`, `shasum`.
+
+## Global Constraints
+
+- Minimum deployment target is iOS 16.0.
+- The executable name is `MultiSimAdmin`; the bundle name is `MultiSimAdmin.app`.
+- The build must not require Apple signing, certificates, or provisioning profiles.
+- The IPA output path is `build/ipa/MultiSimAdmin.ipa`.
+- No Node.js runtime is embedded.
+- No background modes, cron scheduling, automatic delivery, automatic backup, or console self-update.
+- The application has no global access password; per-instance credentials remain supported.
+- Cookies remain memory-only; instance passwords belong in Keychain, never in SQLite, logs, or exports.
+- Preserve the existing visual language: light gray canvas, white cards, dark brand bar, teal primary buttons, red danger buttons, and consistent control sizing.
+
+This is the first of six executable plans. Later plans cover messages, notifications, operations and audit, logs and maintenance, device modules, and backup/import compatibility.
+
+---
+
+### Task 1: Swift Package, App Entry, and Unsigned IPA Builder
+
+**Files:**
+- Create: `apps/ios/Package.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/AppMetadata.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Resources/Info.plist`
+- Create: `apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift`
+- Create: `scripts/build-ios-ipa.sh`
+
+**Interfaces:**
+- Produces: `enum AppMetadata` with `name`, `bundleIdentifier`, `marketingVersion`, and `buildVersion`.
+- Produces: SwiftUI entry `MultiSimAdminApp`.
+- Produces: `scripts/build-ios-ipa.sh`, used by every later UI task.
+
+- [ ] **Step 1: Write the metadata test**
+
+Create `apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift`:
+
+```swift
+import XCTest
+
+@testable import MultiSimAdmin
+
+final class AppMetadataTests: XCTestCase {
+ func testMetadataUsesIPAFoundationContract() {
+ XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
+ XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
+ XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0")
+ XCTAssertEqual(AppMetadata.buildVersion, "1")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test and verify it fails**
+
+Run from repo root:
+
+```bash
+cd apps/ios && swift test --filter AppMetadataTests
+```
+
+Expected: failure because `MultiSimAdmin` and `AppMetadata` do not yet exist.
+
+- [ ] **Step 3: Create the package and minimal app**
+
+Create `apps/ios/Package.swift`:
+
+```swift
+// swift-tools-version: 5.9
+import PackageDescription
+
+let package = Package(
+ name: "MultiSimAdmin",
+ platforms: [.iOS(.v16), .macOS(.v13)],
+ products: [.executable(name: "MultiSimAdmin", targets: ["MultiSimAdmin"])],
+ dependencies: [
+ .package(url: "https://github.com/groue/GRDB.swift.git", exact: "7.7.0")
+ ],
+ targets: [
+ .executableTarget(
+ name: "MultiSimAdmin",
+ dependencies: [.product(name: "GRDB", package: "GRDB.swift")]
+ ),
+ .testTarget(
+ name: "MultiSimAdminTests",
+ dependencies: [.product(name: "GRDB", package: "GRDB.swift")]
+ )
+ ]
+)
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/AppMetadata.swift`:
+
+```swift
+public enum AppMetadata {
+ public static let name = "MultiSimAdmin"
+ public static let bundleIdentifier = "fun.chick.multi-simadmin"
+ public static let marketingVersion = "0.1.0"
+ public static let buildVersion = "1"
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift`:
+
+```swift
+import SwiftUI
+
+@main
+struct MultiSimAdminApp: App {
+ var body: some Scene {
+ WindowGroup {
+ Text("MultiSimAdmin")
+ .padding()
+ }
+ }
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Resources/Info.plist`:
+
+```xml
+
+
+
+
+ CFBundleDevelopmentRegionzh_CN
+ CFBundleDisplayNameMultiSimAdmin
+ CFBundleExecutableMultiSimAdmin
+ CFBundleIdentifierfun.chick.multi-simadmin
+ CFBundleInfoDictionaryVersion6.0
+ CFBundleNameMultiSimAdmin
+ CFBundlePackageTypeAPPL
+ CFBundleShortVersionString0.1.0
+ CFBundleVersion1
+ LSRequiresIPhoneOS
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSPrincipalClassUIApplication
+ UILaunchScreen
+
+ UIRequiredDeviceCapabilities
+ arm64
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
+```
+
+- [ ] **Step 4: Run the test and verify it passes**
+
+```bash
+cd apps/ios && swift test --filter AppMetadataTests
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Create the IPA builder**
+
+Create `scripts/build-ios-ipa.sh`:
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+IOS_ROOT="$ROOT/apps/ios"
+BUILD_ROOT="$ROOT/build/ios"
+APP_NAME="MultiSimAdmin"
+SDK_PATH="$(xcrun --sdk iphoneos --show-sdk-path)"
+
+cd "$IOS_ROOT"
+swift build --configuration release --product "$APP_NAME" \
+ --triple arm64-apple-ios16.0 --sdk "$SDK_PATH"
+
+rm -rf "$BUILD_ROOT"
+mkdir -p "$BUILD_ROOT/Payload/$APP_NAME.app"
+cp ".build/arm64-apple-ios16.0/release/$APP_NAME" \
+ "$BUILD_ROOT/Payload/$APP_NAME.app/$APP_NAME"
+cp "Sources/$APP_NAME/Resources/Info.plist" \
+ "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
+
+plutil -lint "$BUILD_ROOT/Payload/$APP_NAME.app/Info.plist"
+cd "$BUILD_ROOT"
+zip -qry "$APP_NAME.ipa" Payload
+shasum -a 256 "$APP_NAME.ipa" > "$APP_NAME.ipa.sha256"
+
+printf 'IPA: %s/%s.ipa\n' "$BUILD_ROOT" "$APP_NAME"
+printf 'SHA-256: '
+cat "$APP_NAME.ipa.sha256"
+```
+
+Make it executable and commit:
+
+```bash
+chmod +x scripts/build-ios-ipa.sh
+git add apps/ios scripts/build-ios-ipa.sh
+git commit -m "feat(ios): add native app foundation"
+```
+
+- [ ] **Step 6: Verify clean IPA build**
+
+```bash
+scripts/build-ios-ipa.sh
+file build/ios/Payload/MultiSimAdmin.app/MultiSimAdmin
+unzip -t build/ipa/MultiSimAdmin.ipa
+```
+
+Expected: ARM64 Mach-O executable, valid ZIP, and printed SHA-256.
+
+---
+
+### Task 2: Design Tokens and Shared Controls
+
+**Files:**
+- Create: `apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift`
+- Test: `apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift`
+
+**Interfaces:**
+- Produces: `AITheme` with named colors and control dimensions.
+- Produces: `AIButtonStyle(kind:size:)`, `AICard`, `AITag`, and `AIBrandBar`.
+
+- [ ] **Step 1: Write the token test**
+
+```swift
+import SwiftUI
+import XCTest
+
+@testable import MultiSimAdmin
+
+final class AIThemeTests: XCTestCase {
+ func testControlDimensionsMatchWebScale() {
+ XCTAssertEqual(AITheme.buttonHeight, 38, accuracy: 0.1)
+ XCTAssertEqual(AITheme.compactButtonHeight, 34, accuracy: 0.1)
+ XCTAssertEqual(AITheme.largeButtonHeight, 42, accuracy: 0.1)
+ XCTAssertEqual(AITheme.buttonRadius, 10, accuracy: 0.1)
+ XCTAssertEqual(AITheme.iconButtonSize, 40, accuracy: 0.1)
+ }
+
+ func testButtonKindsHaveDistinctBaseColors() {
+ let colors = [
+ AIButtonStyle.Kind.primary,
+ .secondary,
+ .danger
+ ].map(\.background.color)
+ XCTAssertEqual(Set(colors).count, 3)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test and verify it fails**
+
+```bash
+cd apps/ios && swift test --filter AIThemeTests
+```
+
+Expected: FAIL because `AITheme` is undefined.
+
+- [ ] **Step 3: Implement theme, buttons, cards, and brand bar**
+
+Create `apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift`:
+
+```swift
+import SwiftUI
+
+public enum AITheme {
+ public static let canvas = Color(red: 0.953, green: 0.961, blue: 0.973)
+ public static let surface = Color.white
+ public static let ink = Color(red: 0.110, green: 0.153, blue: 0.220)
+ public static let muted = Color(red: 0.435, green: 0.486, blue: 0.561)
+ public static let line = Color(red: 0.878, green: 0.898, blue: 0.922)
+
+ public static let primary = Color(red: 0.067, green: 0.467, blue: 0.431)
+ public static let danger = Color(red: 0.714, green: 0.302, blue: 0.282)
+ public static let warning = Color(red: 0.722, green: 0.471, blue: 0.067)
+ public static let success = Color(red: 0.082, green: 0.584, blue: 0.439)
+
+ public static let buttonHeight: CGFloat = 38
+ public static let compactButtonHeight: CGFloat = 34
+ public static let largeButtonHeight: CGFloat = 42
+ public static let iconButtonSize: CGFloat = 40
+ public static let buttonRadius: CGFloat = 10
+ public static let cardRadius: CGFloat = 14
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift`:
+
+```swift
+import SwiftUI
+
+public struct AIButtonStyle: ButtonStyle {
+ public enum Kind { case primary, secondary, danger }
+ public enum Size { case compact, regular, large }
+
+ private let kind: Kind
+ private let size: Size
+
+ public init(kind: Kind = .secondary, size: Size = .regular) {
+ self.kind = kind
+ self.size = size
+ }
+
+ public func makeBody(configuration: Configuration) -> some View {
+ let height: CGFloat = switch size {
+ case .compact: AITheme.compactButtonHeight
+ case .large: AITheme.largeButtonHeight
+ case .regular: AITheme.buttonHeight
+ }
+ let (foreground, background) = switch kind {
+ case .primary: (Color.white, AITheme.primary)
+ case .danger: (Color.white, AITheme.danger)
+ case .secondary: (AITheme.ink, Color.white)
+ }
+ return configuration.label
+ .font(.system(size: 15, weight: .semibold))
+ .foregroundStyle(foreground)
+ .frame(minWidth: 44, minHeight: height)
+ .padding(.horizontal, 14)
+ .background(background)
+ .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius))
+ .overlay(
+ RoundedRectangle(cornerRadius: AITheme.buttonRadius)
+ .strokeBorder(kind == .secondary ? AITheme.line : .clear)
+ )
+ .opacity(configuration.isPressed ? 0.78 : 1)
+ }
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift`:
+
+```swift
+import SwiftUI
+
+public struct AICard: View {
+ private let content: Content
+
+ public init(@ViewBuilder content: () -> Content) {
+ self.content = content()
+ }
+
+ public var body: some View {
+ content
+ .padding(16)
+ .background(AITheme.surface)
+ .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius))
+ .overlay(
+ RoundedRectangle(cornerRadius: AITheme.cardRadius)
+ .strokeBorder(AITheme.line)
+ )
+ }
+}
+
+public struct AIBrandBar: View {
+ public init() {}
+ public var body: some View {
+ HStack(spacing: 10) {
+ Image(systemName: "antenna.radiowaves.left.and.right")
+ Text("MultiSimAdmin").font(.headline)
+ Spacer()
+ }
+ .foregroundStyle(.white)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+ .background(Color(red: 0.075, green: 0.118, blue: 0.188))
+ }
+}
+```
+
+Replace the temporary body in `MultiSimAdminApp.swift`:
+
+```swift
+import SwiftUI
+
+@main
+struct MultiSimAdminApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ZStack(alignment: .top) {
+ AITheme.canvas.ignoresSafeArea()
+ AIBrandBar()
+ Text("iOS 原生控制台")
+ .padding(.top, 80)
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests and IPA build**
+
+```bash
+cd apps/ios && swift test
+cd ../.. && scripts/build-ios-ipa.sh
+```
+
+Expected: all tests PASS and the IPA is regenerated.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/ios
+git commit -m "feat(ios): add shared design system"
+```
+
+---
+
+### Task 3: SQLite Schema and Instance Repository
+
+**Files:**
+- Create: `apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Persistence/Database.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Persistence/GRDBInstanceRepository.swift`
+- Test: `apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift`
+
+**Interfaces:**
+- Consumes: GRDB `DatabaseQueue`.
+- Produces: `Instance`, `InstanceInput`, `InstancePatch`, `InstanceQuery`, `InstanceRepository`.
+
+- [ ] **Step 1: Write repository tests**
+
+Create `apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift`:
+
+```swift
+import GRDB
+import XCTest
+
+@testable import MultiSimAdmin
+
+final class InstanceRepositoryTests: XCTestCase {
+ private var database: DatabaseQueue!
+
+ override func setUp() async throws {
+ database = DatabaseQueue()
+ try await Database.migrate(database)
+ }
+
+ func testCreateListUpdateAndDeleteInstance() async throws {
+ let repository = GRDBInstanceRepository(database: database)
+ let input = InstanceInput(
+ name: "Lab Alpha",
+ origin: URL(string: "http://192.168.3.55:3000")!,
+ tags: ["lab", "primary"],
+ groupId: nil,
+ password: "secret"
+ )
+
+ let created = try await repository.create(input)
+ XCTAssertEqual(created.configRevision, 1)
+ XCTAssertTrue(created.credentialConfigured)
+
+ let listed = try await repository.list(InstanceQuery())
+ XCTAssertEqual(listed.map(\.name), ["Lab Alpha"])
+ XCTAssertEqual(Set(listed[0].tags), ["lab", "primary"])
+
+ let patch = InstancePatch(name: "Alpha", tags: ["lab"], password: nil)
+ let updated = try await repository.update(created.id, expectedRevision: 1, patch)
+ XCTAssertEqual(updated.name, "Alpha")
+ XCTAssertEqual(updated.tags, ["lab"])
+ XCTAssertTrue(updated.credentialConfigured)
+
+ try await repository.delete(updated.id, expectedRevision: 2)
+ let afterDelete = try await repository.list(InstanceQuery())
+ XCTAssertTrue(afterDelete.isEmpty)
+ }
+
+ func testRejectsDuplicateOriginAndStaleRevision() async throws {
+ let repository = GRDBInstanceRepository(database: database)
+ let input = InstanceInput(
+ name: "One",
+ origin: URL(string: "http://192.168.1.20:3000")!,
+ tags: [], groupId: nil, password: nil
+ )
+ let first = try await repository.create(input)
+
+ do {
+ _ = try await repository.create(input)
+ XCTFail("Expected duplicate-origin failure")
+ } catch let error as RepositoryError {
+ XCTAssertEqual(error, .duplicateOrigin)
+ }
+
+ do {
+ _ = try await repository.update(first.id, expectedRevision: 99, InstancePatch(password: nil))
+ XCTFail("Expected stale-revision failure")
+ } catch let error as RepositoryError {
+ XCTAssertEqual(error, .revisionConflict)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run tests and verify failure**
+
+```bash
+cd apps/ios && swift test --filter InstanceRepositoryTests
+```
+
+Expected: FAIL because the persistence types are undefined.
+
+- [ ] **Step 3: Define models and migration**
+
+Create `apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift`:
+
+```swift
+import Foundation
+
+public struct Instance: Codable, Equatable, Identifiable, Sendable {
+ public let id: UUID
+ public var name: String
+ public var origin: URL
+ public var tags: [String]
+ public var groupId: UUID?
+ public var credentialConfigured: Bool
+ public var configRevision: Int
+ public var createdAt: Date
+ public var updatedAt: Date
+}
+
+public struct InstanceInput: Equatable, Sendable {
+ public var name: String
+ public var origin: URL
+ public var tags: [String]
+ public var groupId: UUID?
+ public var password: String?
+
+ public init(name: String, origin: URL, tags: [String], groupId: UUID?, password: String?) {
+ self.name = name
+ self.origin = origin
+ self.tags = tags
+ self.groupId = groupId
+ self.password = password
+ }
+}
+
+public struct InstancePatch: Equatable, Sendable {
+ public var name: String?
+ public var origin: URL?
+ public var tags: [String]?
+ public var groupId: UUID?
+ public var password: String?
+
+ public init(
+ name: String? = nil,
+ origin: URL? = nil,
+ tags: [String]? = nil,
+ groupId: UUID? = nil,
+ password: String? = nil
+ ) {
+ self.name = name
+ self.origin = origin
+ self.tags = tags
+ self.groupId = groupId
+ self.password = password
+ }
+}
+
+public struct InstanceQuery: Equatable, Sendable {
+ public var search: String?
+ public var tag: String?
+ public var groupId: UUID?
+
+ public init(search: String? = nil, tag: String? = nil, groupId: UUID? = nil) {
+ self.search = search
+ self.tag = tag
+ self.groupId = groupId
+ }
+}
+
+public enum RepositoryError: Error, Equatable {
+ case notFound
+ case duplicateOrigin
+ case revisionConflict
+ case validationFailed(String)
+ case databaseFailed
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Persistence/Database.swift`:
+
+```swift
+import Foundation
+import GRDB
+
+public enum Database {
+ public static func migrate(_ queue: DatabaseQueue) throws {
+ var migrator = DatabaseMigrator()
+ migrator.registerMigration("ios-v1") { database in
+ try database.create(table: "device_groups") { table in
+ table.primaryKey("id", .text)
+ table.column("name", .text).notNull()
+ table.column("description", .text).notNull().defaults(to: "")
+ table.column("created_at", .datetime).notNull()
+ }
+ try database.create(table: "instances") { table in
+ table.primaryKey("id", .text)
+ table.column("name", .text).notNull()
+ table.column("base_url", .text).notNull().unique()
+ table.column("group_id", .text).references("device_groups", onDelete: .setNull)
+ table.column("credential_configured", .boolean).notNull().defaults(to: false)
+ table.column("config_revision", .integer).notNull().defaults(to: 1)
+ table.column("created_at", .datetime).notNull()
+ table.column("updated_at", .datetime).notNull()
+ }
+ try database.create(table: "instance_tags") { table in
+ table.column("instance_id", .text).notNull()
+ .references("instances", onDelete: .cascade)
+ table.column("tag", .text).notNull()
+ table.primaryKey(["instance_id", "tag"])
+ }
+ try database.create(table: "tag_registry") { table in
+ table.primaryKey("tag", .text)
+ table.column("created_at", .datetime).notNull()
+ }
+ }
+ try migrator.migrate(queue)
+ }
+
+ public static func migrate(_ queue: DatabaseQueue) async throws {
+ try await Task.yield()
+ try Self.migrate(queue)
+ }
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Persistence/GRDBInstanceRepository.swift`:
+
+```swift
+import Foundation
+import GRDB
+
+public protocol InstanceRepository: Sendable {
+ func list(_ query: InstanceQuery) async throws -> [Instance]
+ func get(_ id: UUID) async throws -> Instance?
+ func create(_ input: InstanceInput) async throws -> Instance
+ func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance
+ func delete(_ id: UUID, expectedRevision: Int) async throws
+}
+
+public struct GRDBInstanceRepository: InstanceRepository {
+ private let database: DatabaseQueue
+
+ public init(database: DatabaseQueue) {
+ self.database = database
+ }
+
+ public func list(_ query: InstanceQuery) async throws -> [Instance] {
+ try await database.read { database in
+ var sql = "SELECT * FROM instances"
+ var arguments: [DatabaseValueConvertible] = []
+ if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines), !search.isEmpty {
+ sql += " WHERE (name LIKE ? OR id LIKE ?)"
+ arguments.append("%\(search)%")
+ arguments.append("%\(search)%")
+ }
+ sql += " ORDER BY name COLLATE NOCASE"
+ let rows = try InstanceRow.fetchAll(database, sql: sql, arguments: StatementArguments(arguments))
+ var result: [Instance] = []
+ for row in rows {
+ let tags = try String.fetchAll(
+ database,
+ sql: "SELECT tag FROM instance_tags WHERE instance_id = ? ORDER BY tag",
+ arguments: [row.id.uuidString]
+ )
+ result.append(row.instance(tags: tags))
+ }
+ return result
+ }
+ }
+
+ public func get(_ id: UUID) async throws -> Instance? {
+ try await list(InstanceQuery()).first { $0.id == id }
+ }
+
+ public func create(_ input: InstanceInput) async throws -> Instance {
+ let model = try Self.validateInput(input)
+ let id = UUID()
+ let now = Date()
+ do {
+ return try await database.write { database in
+ let row = InstanceRow(
+ id: id,
+ name: model.name,
+ origin: model.origin,
+ groupId: model.groupId,
+ credentialConfigured: model.password != nil,
+ configRevision: 1,
+ createdAt: now,
+ updatedAt: now
+ )
+ try row.insert(database)
+ try Self.replaceTags(row.id, model.tags, database: database)
+ return row.instance(tags: model.tags)
+ }
+ } catch DatabaseError.SQLConstraintViolation {
+ throw RepositoryError.duplicateOrigin
+ } catch {
+ throw RepositoryError.databaseFailed
+ }
+ }
+
+ public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance {
+ try await database.write { database in
+ guard let row = try InstanceRow.fetchOne(
+ database,
+ sql: "SELECT * FROM instances WHERE id = ?",
+ arguments: [id.uuidString]
+ ) else {
+ throw RepositoryError.notFound
+ }
+ guard row.configRevision == expectedRevision else {
+ throw RepositoryError.revisionConflict
+ }
+ let next = try Self.validatePatch(patch, current: row)
+ row.name = next.name
+ row.origin = next.origin
+ row.groupId = next.groupId
+ row.configRevision += 1
+ row.updatedAt = Date()
+ if let password = patch.password {
+ row.credentialConfigured = !password.isEmpty
+ }
+ try row.update(database)
+ if let tags = patch.tags {
+ try Self.replaceTags(id, tags, database: database)
+ }
+ let tags = try String.fetchAll(
+ database,
+ sql: "SELECT tag FROM instance_tags WHERE instance_id = ? ORDER BY tag",
+ arguments: [id.uuidString]
+ )
+ return row.instance(tags: tags)
+ }
+ }
+
+ public func delete(_ id: UUID, expectedRevision: Int) async throws {
+ try await database.write { database in
+ guard let row = try InstanceRow.fetchOne(
+ database,
+ sql: "SELECT * FROM instances WHERE id = ?",
+ arguments: [id.uuidString]
+ ) else {
+ throw RepositoryError.notFound
+ }
+ guard row.configRevision == expectedRevision else {
+ throw RepositoryError.revisionConflict
+ }
+ try row.delete(database)
+ }
+ }
+
+ private static func validateInput(_ input: InstanceInput) throws -> InstanceInput {
+ let name = input.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard (1...200).contains(name.count) else {
+ throw RepositoryError.validationFailed("Name must contain 1 to 200 characters")
+ }
+ guard input.origin.scheme == "http" || input.origin.scheme == "https" else {
+ throw RepositoryError.validationFailed("Origin must use HTTP or HTTPS")
+ }
+ guard input.origin.path.isEmpty || input.origin.path == "/" else {
+ throw RepositoryError.validationFailed("Origin must not contain a path")
+ }
+ return input
+ }
+
+ private static func validatePatch(_ patch: InstancePatch, current: InstanceRow) throws -> InstanceRow {
+ var next = current
+ if let name = patch.name {
+ let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard (1...200).contains(trimmed.count) else {
+ throw RepositoryError.validationFailed("Name must contain 1 to 200 characters")
+ }
+ next.name = trimmed
+ }
+ if let origin = patch.origin {
+ guard origin.scheme == "http" || origin.scheme == "https" else {
+ throw RepositoryError.validationFailed("Origin must use HTTP or HTTPS")
+ }
+ next.origin = origin
+ }
+ return next
+ }
+
+ private static func replaceTags(_ id: UUID, _ tags: [String], database: Database) throws {
+ try database.execute(
+ sql: "DELETE FROM instance_tags WHERE instance_id = ?",
+ arguments: [id.uuidString]
+ )
+ let normalized = Array(Set(tags.map {
+ $0.trimmingCharacters(in: .whitespacesAndNewlines)
+ }.filter { !$0.isEmpty })).sorted()
+ for tag in normalized {
+ try database.execute(
+ sql: "INSERT INTO instance_tags (instance_id, tag) VALUES (?, ?)",
+ arguments: [id.uuidString, tag]
+ )
+ try database.execute(
+ sql: "INSERT OR IGNORE INTO tag_registry (tag, created_at) VALUES (?, ?)",
+ arguments: [tag, Date()]
+ )
+ }
+ }
+}
+
+private struct InstanceRow: Codable, FetchableRecord, MutablePersistableRecord {
+ static let databaseTableName = "instances"
+
+ var id: UUID
+ var name: String
+ var origin: URL
+ var groupId: UUID?
+ var credentialConfigured: Bool
+ var configRevision: Int
+ var createdAt: Date
+ var updatedAt: Date
+
+ mutating func didInsert(_ inserted: InsertionSuccess) {
+ id = inserted.rowID.uuid
+ }
+
+ func instance(tags: [String]) -> Instance {
+ Instance(
+ id: id,
+ name: name,
+ origin: origin,
+ tags: tags,
+ groupId: groupId,
+ credentialConfigured: credentialConfigured,
+ configRevision: configRevision,
+ createdAt: createdAt,
+ updatedAt: updatedAt
+ )
+ }
+}
+```
+
+- [ ] **Step 4: Run repository tests**
+
+```bash
+cd apps/ios && swift test --filter InstanceRepositoryTests
+```
+
+Expected: PASS. If GRDB column mapping fails, fix only the mapping code, not the public interface.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/ios
+git commit -m "feat(ios): add instance persistence"
+```
+
+---
+
+### Task 4: Upstream Route Whitelist and Client
+
+**Files:**
+- Create: `apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Upstream/SimAdminClient.swift`
+- Test: `apps/ios/Tests/MultiSimAdminTests/UpstreamClientTests.swift`
+
+**Interfaces:**
+- Produces: `UpstreamRoute`, `UpstreamResponse`, `UpstreamError`, `SimAdminClient`.
+- Produces: `func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse`.
+
+- [ ] **Step 1: Write route and client tests**
+
+```swift
+import XCTest
+
+@testable import MultiSimAdmin
+
+final class UpstreamClientTests: XCTestCase {
+ func testKnownRoutesUseExactPathsAndMethods() {
+ XCTAssertEqual(UpstreamRoute.health.method, "GET")
+ XCTAssertEqual(UpstreamRoute.health.path, "/api/health")
+ XCTAssertEqual(UpstreamRoute.device.method, "GET")
+ XCTAssertEqual(UpstreamRoute.device.path, "/api/device")
+ XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path,
+ "/api/sms/list?limit=100&offset=0")
+ }
+
+ func testUnknownBaseURLPathIsRejected() async {
+ let client = SimAdminClient()
+ do {
+ _ = try await client.send(.health, baseURL: URL(string: "http://192.168.1.10/admin")!, cookie: nil)
+ XCTFail("Expected route validation failure")
+ } catch let error as UpstreamError {
+ XCTAssertEqual(error, .invalidRequest)
+ } catch {
+ XCTFail("Unexpected error: \(error)")
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests and verify failure**
+
+```bash
+cd apps/ios && swift test --filter UpstreamClientTests
+```
+
+Expected: FAIL because upstream types are undefined.
+
+- [ ] **Step 3: Implement routes and client**
+
+Create `apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift`:
+
+```swift
+import Foundation
+
+public enum UpstreamRoute: Equatable, Sendable {
+ case health
+ case device
+ case sim
+ case network
+ case stats
+ case connectivity
+ case smsList(limit: Int, offset: Int, direction: String?)
+ case smsSend(phoneNumber: String, content: String)
+
+ public var method: String {
+ switch self {
+ case .health, .device, .sim, .network, .stats, .connectivity, .smsList:
+ return "GET"
+ case .smsSend:
+ return "POST"
+ }
+ }
+
+ public var path: String {
+ switch self {
+ case .health: return "/api/health"
+ case .device: return "/api/device"
+ case .sim: return "/api/sim"
+ case .network: return "/api/network"
+ case .stats: return "/api/stats"
+ case .connectivity: return "/api/connectivity"
+ case .smsList(let limit, let offset, let direction):
+ var items = ["limit=\(limit)", "offset=\(offset)"]
+ if let direction {
+ items.append("direction=\(direction)")
+ }
+ return "/api/sms/list?\(items.joined(separator: "&"))"
+ case .smsSend:
+ return "/api/sms/send"
+ }
+ }
+
+ public var body: Data? {
+ switch self {
+ case .smsSend(let phoneNumber, let content):
+ let object = ["phone_number": phoneNumber, "content": content]
+ return try? JSONSerialization.data(withJSONObject: object)
+ default:
+ return nil
+ }
+ }
+}
+
+public enum UpstreamError: Error, Equatable {
+ case invalidRequest
+ case unauthorized
+ case forbidden
+ case notSupported
+ case timeout
+ case transportFailed
+ case invalidResponse
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Upstream/SimAdminClient.swift`:
+
+```swift
+import Foundation
+
+public struct UpstreamResponse: Equatable, Sendable {
+ public let status: Int
+ public let data: Data
+
+ public init(status: Int, data: Data) {
+ self.status = status
+ self.data = data
+ }
+}
+
+public struct SimAdminClient: Sendable {
+ private let session: URLSession
+ private let maximumBytes = 262_144
+
+ public init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ public func send(
+ _ route: UpstreamRoute,
+ baseURL: URL,
+ cookie: String?
+ ) async throws -> UpstreamResponse {
+ guard baseURL.path.isEmpty || baseURL.path == "/",
+ baseURL.scheme == "http" || baseURL.scheme == "https",
+ route.method == "GET" || route.body != nil
+ else {
+ throw UpstreamError.invalidRequest
+ }
+ guard let url = URL(string: baseURL.absoluteString + route.path) else {
+ throw UpstreamError.invalidRequest
+ }
+ var request = URLRequest(url: url, timeoutInterval: 30)
+ request.httpMethod = route.method
+ request.httpBody = route.body
+ request.setValue("application/json", forHTTPHeaderField: "accept")
+ if route.body != nil {
+ request.setValue("application/json", forHTTPHeaderField: "content-type")
+ }
+ if let cookie {
+ request.setValue(cookie, forHTTPHeaderField: "cookie")
+ }
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard data.count <= maximumBytes, let http = response as? HTTPURLResponse else {
+ throw UpstreamError.invalidResponse
+ }
+ switch http.statusCode {
+ case 200...299: break
+ case 401: throw UpstreamError.unauthorized
+ case 403: throw UpstreamError.forbidden
+ case 404, 405: throw UpstreamError.notSupported
+ default: throw UpstreamError.transportFailed
+ }
+ return UpstreamResponse(status: http.statusCode, data: data)
+ } catch let error as UpstreamError {
+ throw error
+ } catch is URLError where error.localizedDescription.contains("timed out") {
+ throw UpstreamError.timeout
+ } catch {
+ throw UpstreamError.transportFailed
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests and commit**
+
+```bash
+cd apps/ios && swift test
+git add apps/ios
+git commit -m "feat(ios): add safe SimAdmin upstream client"
+```
+
+Expected: all tests PASS.
+
+---
+
+### Task 5: Session Store, Credential Store, and Connection Probe
+
+**Files:**
+- Create: `apps/ios/Sources/MultiSimAdmin/Upstream/InstanceSessionStore.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift`
+- Test: `apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift`
+
+**Interfaces:**
+- Consumes: `SimAdminClient`, `InstanceRepository`.
+- Produces: `actor InstanceSessionStore`; protocol `CredentialStore`; `ConnectionProbe.test(_ instance:)`.
+
+- [ ] **Step 1: Write connection probe test**
+
+```swift
+import Foundation
+import XCTest
+
+@testable import MultiSimAdmin
+
+final class ConnectionProbeTests: XCTestCase {
+ func testProbeMapsUpstreamResults() async throws {
+ let client = MockClient(status: 401)
+ let probe = ConnectionProbe(client: client)
+ let instance = Self.instance()
+ let result = try await probe.test(instance)
+ XCTAssertEqual(result.status, .authRequired)
+ }
+
+ private static func instance() -> Instance {
+ Instance(
+ id: UUID(),
+ name: "Probe",
+ origin: URL(string: "http://192.168.1.20:3000")!,
+ tags: [],
+ groupId: nil,
+ credentialConfigured: false,
+ configRevision: 1,
+ createdAt: Date(),
+ updatedAt: Date()
+ )
+ }
+
+ private struct MockClient: SimAdminClientProtocol {
+ let status: Int
+ func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
+ UpstreamResponse(status: status, data: Data("{}".utf8))
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run the test and verify failure**
+
+```bash
+cd apps/ios && swift test --filter ConnectionProbeTests
+```
+
+Expected: FAIL because the probe and client protocol are undefined.
+
+- [ ] **Step 3: Implement session, credentials, probe, and client protocol**
+
+Create `apps/ios/Sources/MultiSimAdmin/Upstream/InstanceSessionStore.swift`:
+
+```swift
+import Foundation
+
+public actor InstanceSessionStore {
+ private var cookies: [UUID: (origin: URL, cookie: String)] = [:]
+
+ public init() {}
+
+ public func cookie(for id: UUID, origin: URL) -> String? {
+ guard let value = cookies[id], value.origin == origin else { return nil }
+ return value.cookie
+ }
+
+ public func set(_ id: UUID, origin: URL, cookie: String) {
+ cookies[id] = (origin, cookie)
+ }
+
+ public func clear(_ id: UUID) {
+ cookies.removeValue(forKey: id)
+ }
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift`:
+
+```swift
+import Foundation
+import Security
+
+public protocol CredentialStore: Sendable {
+ func get(instanceID: UUID) throws -> String?
+ func set(instanceID: UUID, password: String) throws
+ func remove(instanceID: UUID) throws
+}
+
+public struct KeychainCredentialStore: CredentialStore {
+ private let service = "fun.chick.multi-simadmin.instance-password"
+
+ public init() {}
+
+ public func get(instanceID: UUID) throws -> String? {
+ var query = baseQuery(instanceID)
+ query[kSecReturnData as String] = true
+ var item: CFTypeRef?
+ let status = SecItemCopyMatching(query as CFDictionary, &item)
+ guard status == errSecSuccess else { return nil }
+ guard let data = item as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ public func set(instanceID: UUID, password: String) throws {
+ try remove(instanceID)
+ let attributes: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: instanceID.uuidString,
+ kSecValueData as String: Data(password.utf8),
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ ]
+ let status = SecItemAdd(attributes as CFDictionary, nil)
+ guard status == errSecSuccess else {
+ throw RepositoryError.databaseFailed
+ }
+ }
+
+ public func remove(instanceID: UUID) throws {
+ let status = SecItemDelete(baseQuery(instanceID) as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw RepositoryError.databaseFailed
+ }
+ }
+
+ private func baseQuery(_ instanceID: UUID) -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: instanceID.uuidString
+ ]
+ }
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift`:
+
+```swift
+import Foundation
+
+public protocol SimAdminClientProtocol: Sendable {
+ func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse
+}
+
+extension SimAdminClient: SimAdminClientProtocol {}
+
+public enum ConnectionStatus: String, Sendable {
+ case online
+ case offline
+ case authRequired
+ case degraded
+ case unknown
+}
+
+public struct ConnectionResult: Equatable, Sendable {
+ public let status: ConnectionStatus
+ public let checkedAt: Date
+
+ public init(status: ConnectionStatus, checkedAt: Date) {
+ self.status = status
+ self.checkedAt = checkedAt
+ }
+}
+
+public struct ConnectionProbe: Sendable {
+ private let client: any SimAdminClientProtocol
+
+ public init(client: any SimAdminClientProtocol) {
+ self.client = client
+ }
+
+ public func test(_ instance: Instance) async throws -> ConnectionResult {
+ do {
+ _ = try await client.send(.health, baseURL: instance.origin, cookie: nil)
+ return ConnectionResult(status: .online, checkedAt: Date())
+ } catch UpstreamError.unauthorized {
+ return ConnectionResult(status: .authRequired, checkedAt: Date())
+ } catch UpstreamError.timeout, URLError {
+ return ConnectionResult(status: .offline, checkedAt: Date())
+ } catch {
+ return ConnectionResult(status: .degraded, checkedAt: Date())
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests and commit**
+
+```bash
+cd apps/ios && swift test
+git add apps/ios
+git commit -m "feat(ios): add sessions, credentials, and probes"
+```
+
+Expected: all tests PASS.
+
+---
+
+### Task 6: Fleet Workbench and Add Instance Flow
+
+**Files:**
+- Create: `apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift`
+- Create: `apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift`
+- Modify: `apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift`
+- Test: `apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift`
+
+**Interfaces:**
+- Consumes: `InstanceRepository`, `ConnectionProbe`, `CredentialStore`.
+- Produces: `@MainActor final class FleetViewModel` with `refresh()`, `add(_:)`, `update(_:)`, and `delete(_ ids: [UUID])`.
+
+- [ ] **Step 1: Write fleet view model test**
+
+```swift
+import XCTest
+
+@testable import MultiSimAdmin
+
+final class FleetViewModelTests: XCTestCase {
+ func testRefreshSortsByNameAndReportsStatus() async {
+ let repository = MemoryInstanceRepository()
+ let probe = ConnectionProbe(client: AlwaysOnlineClient())
+ let model = FleetViewModel(repository: repository, probe: probe, credentials: NoopCredentialStore())
+ try? await model.add(InstanceInput(
+ name: "Zulu",
+ origin: URL(string: "http://192.168.1.30:3000")!,
+ tags: ["lab"], groupId: nil, password: nil
+ ))
+ try? await model.add(InstanceInput(
+ name: "Alpha",
+ origin: URL(string: "http://192.168.1.20:3000")!,
+ tags: [], groupId: nil, password: nil
+ ))
+ await model.refresh()
+ XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"])
+ XCTAssertEqual(model.rows.map(\.status), [.online, .online])
+ }
+}
+```
+
+- [ ] **Step 2: Run the test and verify failure**
+
+```bash
+cd apps/ios && swift test --filter FleetViewModelTests
+```
+
+Expected: FAIL because the fleet model and test doubles are undefined.
+
+- [ ] **Step 3: Implement view model and SwiftUI screens**
+
+Create `apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift`:
+
+```swift
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+public final class FleetViewModel {
+ public struct Row: Equatable, Identifiable {
+ public let id: UUID
+ public var instance: Instance
+ public var status: ConnectionStatus
+ public var testing: Bool
+ }
+
+ @ObservationIgnored private let repository: any InstanceRepository
+ @ObservationIgnored private let probe: ConnectionProbe
+ @ObservationIgnored private let credentials: any CredentialStore
+
+ public private(set) var rows: [Row] = []
+ public var searchText = ""
+ public var selectedIDs: Set = []
+ public var errorMessage: String?
+
+ public init(
+ repository: any InstanceRepository,
+ probe: ConnectionProbe,
+ credentials: any CredentialStore
+ ) {
+ self.repository = repository
+ self.probe = probe
+ self.credentials = credentials
+ }
+
+ public func refresh() async {
+ errorMessage = nil
+ do {
+ let query = InstanceQuery(search: searchText.isEmpty ? nil : searchText)
+ let instances = try await repository.list(query)
+ rows = instances.map {
+ Row(id: $0.id, instance: $0, status: .unknown, testing: false)
+ }
+ await withTaskGroup(of: Void.self) { group in
+ for index in rows.indices {
+ let instance = rows[index].instance
+ group.addTask { [probe] in
+ let result = try? await probe.test(instance)
+ await self.applyProbe(instance.id, result)
+ }
+ }
+ }
+ } catch {
+ errorMessage = "无法刷新节点列表。"
+ }
+ }
+
+ private func applyProbe(_ id: UUID, _ result: ConnectionResult?) {
+ guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
+ rows[index].status = result?.status ?? .unknown
+ }
+
+ public func add(_ input: InstanceInput) async throws {
+ if let password = input.password, !password.isEmpty {
+ try await repository.create(input)
+ let created = try await repository.list(InstanceQuery()).last!
+ try credentials.set(instanceID: created.id, password: password)
+ } else {
+ try await repository.create(input)
+ }
+ await refresh()
+ }
+
+ public func delete(_ ids: [UUID]) async {
+ for id in ids {
+ if let row = rows.first(where: { $0.id == id }) {
+ try? await repository.delete(id, expectedRevision: row.instance.configRevision)
+ try? credentials.remove(instanceID: id)
+ }
+ }
+ selectedIDs.removeAll()
+ await refresh()
+ }
+}
+```
+
+Create `apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift` with a `NavigationStack`, `AITheme.canvas` background, `AICard` rows, status labels, a toolbar `Button` using `Image(systemName: "plus")` with `AIButtonStyle(kind: .primary)`, a pull-to-refresh task calling `model.refresh()`, and an empty state saying `暂无实例`. Keep action buttons the same height and radius through `AIButtonStyle`.
+
+Create `apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift` with validated fields for name, origin, tags, group, and optional password. The Save button uses `AIButtonStyle(kind: .primary)` and calls `model.add(_:)`.
+
+- [ ] **Step 4: Add in-memory test doubles**
+
+Append to `apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift`:
+
+```swift
+struct AlwaysOnlineClient: SimAdminClientProtocol {
+ func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse {
+ UpstreamResponse(status: 200, data: Data("{}".utf8))
+ }
+}
+
+struct NoopCredentialStore: CredentialStore {
+ func get(instanceID: UUID) throws -> String? { nil }
+ func set(instanceID: UUID, password: String) throws {}
+ func remove(instanceID: UUID) throws {}
+}
+
+struct MemoryInstanceRepository: InstanceRepository {
+ private var items: [Instance] = []
+
+ mutating func list(_: InstanceQuery) async throws -> [Instance] { items.sorted { $0.name < $1.name } }
+ mutating func get(_ id: UUID) async throws -> Instance? { items.first { $0.id == id } }
+ mutating func create(_ input: InstanceInput) async throws -> Instance {
+ let now = Date()
+ let instance = Instance(
+ id: UUID(), name: input.name, origin: input.origin, tags: input.tags,
+ groupId: input.groupId, credentialConfigured: input.password != nil,
+ configRevision: 1, createdAt: now, updatedAt: now
+ )
+ items.append(instance)
+ return instance
+ }
+ mutating func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance {
+ throw RepositoryError.notFound
+ }
+ mutating func delete(_: UUID, expectedRevision: Int) async throws {}
+}
+```
+
+Then adapt the test to `let repository = MemoryInstanceRepository()`.
+
+- [ ] **Step 5: Run tests, build IPA, and commit**
+
+```bash
+cd apps/ios && swift test
+cd ../.. && scripts/build-ios-ipa.sh
+git add apps/ios
+git commit -m "feat(ios): add fleet workbench"
+```
+
+Expected: all tests PASS and the IPA contains the fleet UI.
+
+---
+
+## Later Plan Sequence
+
+1. **Messages plan**: instance SMS, cross-instance archive, conversation view, send/delete, local outbox, manual flush.
+2. **Notifications plan**: channels, rules, queue, delivery adapters, manual evaluation and delivery.
+3. **Operations plan**: operation registry, prepare/execute, confirmation tokens, jobs, job attempts, audit.
+4. **Logs and maintenance plan**: runtime/connection logs, diagnostics, retention, cleanup, optimize, manual backups.
+5. **Device modules plan**: overview, cellular, SIM, calls, eSIM, WiFi Calling, device network, OTA, configuration, and per-instance panels.
+6. **Organization and compatibility plan**: groups, tags, identities, discovery, database import, component backup compatibility, and release audit.
+