1491 lines
48 KiB
Markdown
1491 lines
48 KiB
Markdown
# iOS Native IPA Foundation Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build the native iOS foundation that can compile into an unsigned `MultiSimAdmin.ipa`, persist instances locally, connect safely to SimAdmin upstreams, and render the first fleet workbench.
|
|
|
|
**Architecture:** Add a standalone Swift Package under `apps/ios`. SwiftUI owns presentation, domain services own validation and orchestration, GRDB owns SQLite persistence, and an actor-based upstream client owns session cookies and route whitelisting. The build script compiles for iPhoneOS and assembles an unsigned app bundle manually.
|
|
|
|
**Tech Stack:** Swift 5.9+, SwiftUI, GRDB 7.7.0, XCTest, SwiftPM, `xcrun`, `zip`, `shasum`.
|
|
|
|
## Global Constraints
|
|
|
|
- Minimum deployment target is iOS 16.0.
|
|
- The executable name is `MultiSimAdmin`; the bundle name is `MultiSimAdmin.app`.
|
|
- The build must not require Apple signing, certificates, or provisioning profiles.
|
|
- The IPA output path is `build/ipa/MultiSimAdmin.ipa`.
|
|
- No Node.js runtime is embedded.
|
|
- No background modes, cron scheduling, automatic delivery, automatic backup, or console self-update.
|
|
- The application has no global access password; per-instance credentials remain supported.
|
|
- Cookies remain memory-only; instance passwords belong in Keychain, never in SQLite, logs, or exports.
|
|
- Preserve the existing visual language: light gray canvas, white cards, dark brand bar, teal primary buttons, red danger buttons, and consistent control sizing.
|
|
|
|
This is the first of six executable plans. Later plans cover messages, notifications, operations and audit, logs and maintenance, device modules, and backup/import compatibility.
|
|
|
|
---
|
|
|
|
### Task 1: Swift Package, App Entry, and Unsigned IPA Builder
|
|
|
|
**Files:**
|
|
- Create: `apps/ios/Package.swift`
|
|
- Create: `apps/ios/Sources/MultiSimAdmin/AppMetadata.swift`
|
|
- Create: `apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift`
|
|
- Create: `apps/ios/Sources/MultiSimAdmin/Resources/Info.plist`
|
|
- Create: `apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift`
|
|
- Create: `scripts/build-ios-ipa.sh`
|
|
|
|
**Interfaces:**
|
|
- Produces: `enum AppMetadata` with `name`, `bundleIdentifier`, `marketingVersion`, and `buildVersion`.
|
|
- Produces: SwiftUI entry `MultiSimAdminApp`.
|
|
- Produces: `scripts/build-ios-ipa.sh`, used by every later UI task.
|
|
|
|
- [ ] **Step 1: Write the metadata test**
|
|
|
|
Create `apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift`:
|
|
|
|
```swift
|
|
import XCTest
|
|
|
|
@testable import MultiSimAdmin
|
|
|
|
final class AppMetadataTests: XCTestCase {
|
|
func testMetadataUsesIPAFoundationContract() {
|
|
XCTAssertEqual(AppMetadata.name, "MultiSimAdmin")
|
|
XCTAssertEqual(AppMetadata.bundleIdentifier, "fun.chick.multi-simadmin")
|
|
XCTAssertEqual(AppMetadata.marketingVersion, "0.1.0")
|
|
XCTAssertEqual(AppMetadata.buildVersion, "1")
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify it fails**
|
|
|
|
Run from repo root:
|
|
|
|
```bash
|
|
cd apps/ios && swift test --filter AppMetadataTests
|
|
```
|
|
|
|
Expected: failure because `MultiSimAdmin` and `AppMetadata` do not yet exist.
|
|
|
|
- [ ] **Step 3: Create the package and minimal app**
|
|
|
|
Create `apps/ios/Package.swift`:
|
|
|
|
```swift
|
|
// 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>
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test and verify it passes**
|
|
|
|
```bash
|
|
cd apps/ios && swift test --filter AppMetadataTests
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
- [ ] **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"
|
|
```
|
|
|
|
- [ ] **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`.
|
|
|
|
- [ ] **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)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test and verify it fails**
|
|
|
|
```bash
|
|
cd apps/ios && swift test --filter AIThemeTests
|
|
```
|
|
|
|
Expected: FAIL because `AITheme` is undefined.
|
|
|
|
- [ ] **Step 3: Implement theme, buttons, cards, and brand bar**
|
|
|
|
Create `apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift`:
|
|
|
|
```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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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.
|
|
|
|
- [ ] **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`.
|
|
|
|
- [ ] **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)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests and verify failure**
|
|
|
|
```bash
|
|
cd apps/ios && swift test --filter InstanceRepositoryTests
|
|
```
|
|
|
|
Expected: FAIL because the persistence types are undefined.
|
|
|
|
- [ ] **Step 3: Define models and migration**
|
|
|
|
Create `apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift`:
|
|
|
|
```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
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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.
|
|
|
|
- [ ] **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`.
|
|
|
|
- [ ] **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)")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests and verify failure**
|
|
|
|
```bash
|
|
cd apps/ios && swift test --filter UpstreamClientTests
|
|
```
|
|
|
|
Expected: FAIL because upstream types are undefined.
|
|
|
|
- [ ] **Step 3: Implement routes and client**
|
|
|
|
Create `apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift`:
|
|
|
|
```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
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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:)`.
|
|
|
|
- [ ] **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))
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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.
|
|
|
|
- [ ] **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())
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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])`.
|
|
|
|
- [ ] **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])
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **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.
|
|
|
|
- [ ] **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(_:)`.
|
|
|
|
- [ ] **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()`.
|
|
|
|
- [ ] **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.
|
|
|