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. +