Files
multi-simadmin/docs/superpowers/plans/2026-09-08-ios-native-ipa-foundation.md

49 KiB
Raw Permalink Blame History

iOS Native IPA Foundation Implementation Plan

状态:Task 1 - Task 6 已全部完成(2026-09-14,0.2.1 build 6)。

执行时有两处偏离本计划的规定,现行事实以 docs/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.

  • Step 1: Write the metadata test

Create apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.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:

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-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:

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:

import SwiftUI

@main
struct MultiSimAdminApp: App {
    var body: some Scene {
        WindowGroup {
            Text("MultiSimAdmin")
                .padding()
        }
    }
}

Create apps/ios/Sources/MultiSimAdmin/Resources/Info.plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key><string>zh_CN</string>
    <key>CFBundleDisplayName</key><string>MultiSimAdmin</string>
    <key>CFBundleExecutable</key><string>MultiSimAdmin</string>
    <key>CFBundleIdentifier</key><string>fun.chick.multi-simadmin</string>
    <key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
    <key>CFBundleName</key><string>MultiSimAdmin</string>
    <key>CFBundlePackageType</key><string>APPL</string>
    <key>CFBundleShortVersionString</key><string>0.1.0</string>
    <key>CFBundleVersion</key><string>1</string>
    <key>LSRequiresIPhoneOS</key><true/>
    <key>NSAppTransportSecurity</key>
    <dict>
        <key>NSAllowsArbitraryLoads</key><true/>
    </dict>
    <key>NSPrincipalClass</key><string>UIApplication</string>
    <key>UILaunchScreen</key>
    <dict/>
    <key>UIRequiredDeviceCapabilities</key>
    <array><string>arm64</string></array>
    <key>UISupportedInterfaceOrientations</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
    <key>UISupportedInterfaceOrientations~ipad</key>
    <array>
        <string>UIInterfaceOrientationPortrait</string>
        <string>UIInterfaceOrientationPortraitUpsideDown</string>
        <string>UIInterfaceOrientationLandscapeLeft</string>
        <string>UIInterfaceOrientationLandscapeRight</string>
    </array>
</dict>
</plist>
  • Step 4: Run the test and verify it passes
cd apps/ios && swift test --filter AppMetadataTests

Expected: PASS.

  • Step 5: Create the IPA builder

Create scripts/build-ios-ipa.sh:

#!/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:

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

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
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:

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:

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:

import SwiftUI

public struct AICard<Content: View>: 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:

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
cd apps/ios && swift test
cd ../.. && scripts/build-ios-ipa.sh

Expected: all tests PASS and the IPA is regenerated.

  • Step 5: Commit
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:

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
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:

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:

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:

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

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
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:

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:

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

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
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:

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:

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:

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

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
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:

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<UUID> = []
    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:

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