1503 lines
49 KiB
Markdown
1503 lines
49 KiB
Markdown
# 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
|
||
<?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>
|
||
```
|
||
|
||
- [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<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`:
|
||
|
||
```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<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(_:)`.
|
||
|
||
- [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.
|