# iOS Native IPA Foundation Implementation Plan > **状态:Task 1 - Task 6 已全部完成(2026-09-14,0.2.1 build 6)。** > > 执行时有两处偏离本计划的规定,现行事实以 [docs/ios-native.md](../../ios-native.md) 为准: > > - **持久化不用 GRDB/SQLite**。计划里的 `instances`、`instance_tags`、`device_groups`、 > `connection_logs` 等表改为四个沙盒 JSON 文件:`instances.json`、`instance-groups.json`、 > `credentials.json`、`log-center.json`,仓储与修订冲突语义保持不变。 > - **实例密码不进 Keychain**。TrollStore 环境下 Keychain 读写不稳定,改用应用沙盒内的 > `credentials.json`,配合只存在于进程内存的会话 Cookie。 > > 计划末尾提到的后续五份计划(消息、通知、操作与审计、日志与维护、设备模块、备份兼容)不再单独 > 编写:消息与日志已随 0.2.x 落地,通知、操作审计、维护与备份按产品决策移除或暂缓。 > **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. - [x] **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") } } ``` - [x] **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. - [x] **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 ``` - [x] **Step 4: Run the test and verify it passes** ```bash cd apps/ios && swift test --filter AppMetadataTests ``` Expected: PASS. - [x] **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" ``` - [x] **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`. - [x] **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) } } ``` - [x] **Step 2: Run the test and verify it fails** ```bash cd apps/ios && swift test --filter AIThemeTests ``` Expected: FAIL because `AITheme` is undefined. - [x] **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) } } } } ``` - [x] **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. - [x] **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`. - [x] **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) } } } ``` - [x] **Step 2: Run tests and verify failure** ```bash cd apps/ios && swift test --filter InstanceRepositoryTests ``` Expected: FAIL because the persistence types are undefined. - [x] **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 ) } } ``` - [x] **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. - [x] **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`. - [x] **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)") } } } ``` - [x] **Step 2: Run the tests and verify failure** ```bash cd apps/ios && swift test --filter UpstreamClientTests ``` Expected: FAIL because upstream types are undefined. - [x] **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 } } } ``` - [x] **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:)`. - [x] **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)) } } } ``` - [x] **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. - [x] **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()) } } } ``` - [x] **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])`. - [x] **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]) } } ``` - [x] **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. - [x] **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(_:)`. - [x] **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()`. - [x] **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.