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