49 KiB
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 isMultiSimAdmin.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 AppMetadatawithname,bundleIdentifier,marketingVersion, andbuildVersion. -
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:
AIThemewith named colors and control dimensions. -
Produces:
AIButtonStyle(kind:size:),AICard,AITag, andAIBrandBar. -
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; protocolCredentialStore;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 FleetViewModelwithrefresh(),add(_:),update(_:), anddelete(_ 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
- Messages plan: instance SMS, cross-instance archive, conversation view, send/delete, local outbox, manual flush.
- Notifications plan: channels, rules, queue, delivery adapters, manual evaluation and delivery.
- Operations plan: operation registry, prepare/execute, confirmation tokens, jobs, job attempts, audit.
- Logs and maintenance plan: runtime/connection logs, diagnostics, retention, cleanup, optimize, manual backups.
- Device modules plan: overview, cellular, SIM, calls, eSIM, WiFi Calling, device network, OTA, configuration, and per-instance panels.
- Organization and compatibility plan: groups, tags, identities, discovery, database import, component backup compatibility, and release audit.