From 54eb8dcb86b6b041f5f2462f38bf7859c5fbc214 Mon Sep 17 00:00:00 2001 From: chick Date: Wed, 9 Sep 2026 00:05:51 +0800 Subject: [PATCH] feat(ios): add native app foundation and unsigned ipa build --- .gitignore | 1 + apps/ios/.gitignore | 1 + apps/ios/Package.swift | 21 ++ .../Sources/MultiSimAdmin/AppMetadata.swift | 6 + .../Features/Fleet/FleetView.swift | 147 ++++++++++++++ .../Features/Fleet/FleetViewModel.swift | 87 ++++++++ .../Features/Fleet/InstanceFormView.swift | 98 +++++++++ .../MultiSimAdmin/Models/InstanceModels.swift | 107 ++++++++++ .../MultiSimAdmin/MultiSimAdminApp.swift | 32 +++ .../Persistence/FileInstanceRepository.swift | 186 ++++++++++++++++++ .../MultiSimAdmin/Resources/Info.plist | 38 ++++ .../Services/ConnectionProbe.swift | 62 ++++++ .../Services/DefaultContainer.swift | 17 ++ .../Services/InstanceSessionService.swift | 62 ++++++ .../Services/KeychainCredentialStore.swift | 61 ++++++ .../MultiSimAdmin/Theme/AIButtonStyle.swift | 59 ++++++ .../Sources/MultiSimAdmin/Theme/AICard.swift | 57 ++++++ .../Sources/MultiSimAdmin/Theme/AITheme.swift | 21 ++ .../Upstream/InstanceSessionStore.swift | 20 ++ .../Upstream/SimAdminClient.swift | 71 +++++++ .../Upstream/UpstreamRoute.swift | 70 +++++++ .../MultiSimAdminTests/AIThemeTests.swift | 23 +++ .../MultiSimAdminTests/AppMetadataTests.swift | 12 ++ .../ConnectionProbeTests.swift | 45 +++++ .../FleetViewModelTests.swift | 133 +++++++++++++ .../InstanceRepositoryTests.swift | 110 +++++++++++ .../InstanceSessionServiceTests.swift | 50 +++++ .../UpstreamRouteTests.swift | 54 +++++ scripts/build-ios-ipa.sh | 29 +++ 29 files changed, 1680 insertions(+) create mode 100644 apps/ios/.gitignore create mode 100644 apps/ios/Package.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/AppMetadata.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Resources/Info.plist create mode 100644 apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Upstream/InstanceSessionStore.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Upstream/SimAdminClient.swift create mode 100644 apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift create mode 100644 apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift create mode 100755 scripts/build-ios-ipa.sh diff --git a/.gitignore b/.gitignore index f90f35c..491778c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ apps/web/dist/ .hermes/ .local-run/ artifacts/ +build/ data/ diff --git a/apps/ios/.gitignore b/apps/ios/.gitignore new file mode 100644 index 0000000..30bcfa4 --- /dev/null +++ b/apps/ios/.gitignore @@ -0,0 +1 @@ +.build/ diff --git a/apps/ios/Package.swift b/apps/ios/Package.swift new file mode 100644 index 0000000..c4ed1f0 --- /dev/null +++ b/apps/ios/Package.swift @@ -0,0 +1,21 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "MultiSimAdmin", + platforms: [.iOS(.v16), .macOS(.v13)], + products: [ + .executable(name: "MultiSimAdmin", targets: ["MultiSimAdmin"]) + ], + dependencies: [], + targets: [ + .executableTarget( + name: "MultiSimAdmin", + exclude: ["Resources/Info.plist"] + ), + .testTarget( + name: "MultiSimAdminTests", + dependencies: ["MultiSimAdmin"] + ) + ] +) diff --git a/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift new file mode 100644 index 0000000..ff26da9 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/AppMetadata.swift @@ -0,0 +1,6 @@ +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" +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift new file mode 100644 index 0000000..5da2802 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +struct FleetView: View { + @StateObject private var model: FleetViewModel + @Environment(\.refresh) private var refreshEnvironment + + init(model: FleetViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeFleetModel()) + } + + var body: some View { + NavigationStack { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + LazyVStack(spacing: 12) { + searchField + if model.rows.isEmpty && !model.isLoading { + AICard { + VStack(spacing: 10) { + Image(systemName: "server.rack") + .font(.system(size: 28)) + .foregroundStyle(AITheme.muted) + Text("暂无实例") + .font(.headline) + Text("添加 SimAdmin 实例后即可开始管理。") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 18) + } + } + ForEach(model.rows) { row in + instanceCard(row) + } + } + .padding(16) + } + } + .navigationTitle("节点总览") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + model.selectionMode.toggle() + model.selectedIDs.removeAll() + } label: { + Label("批量选择", systemImage: model.selectionMode ? "xmark" : "checklist") + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) + + Button { + model.showingForm = true + } label: { + Label("添加实例", systemImage: "plus") + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + } + } + .refreshable { + await model.refresh() + } + .task { + await model.refresh() + } + .sheet(isPresented: $model.showingForm) { + InstanceFormView(model: model) + } + } + } + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(AITheme.muted) + TextField("搜索实例或地址", text: $model.searchText) + .textFieldStyle(.plain) + .onSubmit { + Task { await model.refresh() } + } + } + .padding(.horizontal, 14) + .frame(minHeight: AITheme.buttonHeight) + .background(AITheme.surface) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.buttonRadius) + .strokeBorder(AITheme.line) + } + } + + private func instanceCard(_ row: FleetViewModel.Row) -> some View { + AICard { + HStack(alignment: .top, spacing: 12) { + if model.selectionMode { + Button { + toggle(row.id) + } label: { + Image(systemName: model.selectedIDs.contains(row.id) + ? "checkmark.circle.fill" : "circle") + .font(.system(size: 20)) + .foregroundStyle(model.selectedIDs.contains(row.id) ? AITheme.primary : AITheme.muted) + } + .buttonStyle(.plain) + .frame(width: 32, height: 32) + } + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text(row.instance.name) + .font(.headline) + .foregroundStyle(AITheme.ink) + Spacer() + AITag(row.status.label, tint: statusColor(row.status)) + } + Text(row.instance.origin.absoluteString) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + if !row.instance.tags.isEmpty { + HStack(spacing: 6) { + ForEach(row.instance.tags, id: \.self) { tag in + AITag(tag) + } + } + } + } + } + } + } + + private func statusColor(_ status: ConnectionStatus) -> Color { + switch status { + case .online: return AITheme.success + case .offline: return AITheme.danger + case .authRequired: return AITheme.warning + case .degraded: return AITheme.warning + case .unknown: return AITheme.muted + } + } + + private func toggle(_ id: UUID) { + if model.selectedIDs.contains(id) { + model.selectedIDs.remove(id) + } else { + model.selectedIDs.insert(id) + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift new file mode 100644 index 0000000..5d4a2f4 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/FleetViewModel.swift @@ -0,0 +1,87 @@ +import Foundation + +@MainActor +final class FleetViewModel: ObservableObject { + struct Row: Equatable, Identifiable { + let id: UUID + var instance: Instance + var status: ConnectionStatus + var testing: Bool + } + + @Published var rows: [Row] = [] + @Published var searchText = "" + @Published var selectedIDs: Set = [] + @Published var selectionMode = false + @Published var isLoading = false + @Published var errorMessage: String? + @Published var showingForm = false + + private let repository: any InstanceRepository + private let probe: ConnectionProbe + private let credentials: any CredentialStore + + init( + repository: any InstanceRepository, + probe: ConnectionProbe, + credentials: any CredentialStore + ) { + self.repository = repository + self.probe = probe + self.credentials = credentials + } + + func refresh() async { + isLoading = true + 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: true) + } + await withTaskGroup(of: Void.self) { group in + for instance in instances { + group.addTask { [probe] in + let result = try? await probe.test(instance) + await self.applyProbe(instance.id, result) + } + } + } + } catch { + errorMessage = "无法刷新节点列表" + } + isLoading = false + } + + func add(_ input: InstanceInput) async throws { + let created = try await repository.create(input) + if let password = input.password, !password.isEmpty { + try credentials.set(instanceID: created.id, password: password) + } + showingForm = false + await refresh() + } + + func delete(_ ids: [UUID]) async { + for id in ids { + if let row = rows.first(where: { $0.id == id }) { + do { + try await repository.delete(id, expectedRevision: row.instance.configRevision) + try credentials.remove(instanceID: id) + } catch { + errorMessage = "删除 \(row.instance.name) 失败" + } + } + } + selectedIDs.removeAll() + selectionMode = false + await refresh() + } + + private func applyProbe(_ id: UUID, _ result: ConnectionResult?) { + guard let index = rows.firstIndex(where: { $0.id == id }) else { return } + rows[index].status = result?.status ?? .unknown + rows[index].testing = false + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift new file mode 100644 index 0000000..f657046 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Fleet/InstanceFormView.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct InstanceFormView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject var model: FleetViewModel + @State private var name = "" + @State private var originText = "" + @State private var tagsText = "" + @State private var password = "" + @State private var validationMessage: String? + + var body: some View { + NavigationStack { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + VStack(spacing: 12) { + AICard { + VStack(alignment: .leading, spacing: 14) { + field("名称", text: $name, placeholder: "Home Node") + field("地址", text: $originText, placeholder: "http://192.168.1.10:3000") + field("标签", text: $tagsText, placeholder: "home, primary") + SecureField("实例密码(可选)", text: $password) + .textContentType(.password) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(AITheme.canvas) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + } + } + if let validationMessage { + Text(validationMessage) + .font(.subheadline) + .foregroundStyle(AITheme.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(16) + } + } + .navigationTitle("添加实例") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { dismiss() } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) + } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { + save() + } + .buttonStyle(AIButtonStyle(kind: .primary, size: .regular)) + } + } + } + } + + private func field(_ title: String, text: Binding, placeholder: String) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AITheme.ink) + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .padding(.horizontal, 12) + .frame(minHeight: 38) + .background(AITheme.canvas) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + } + } + + private func save() { + guard let origin = URL(string: originText.trimmingCharacters(in: .whitespacesAndNewlines)), + origin.scheme == "http" || origin.scheme == "https", + origin.host != nil + else { + validationMessage = "请输入有效的 HTTP 或 HTTPS 根地址。" + return + } + let tags = tagsText + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + let input = InstanceInput( + name: name, + origin: origin, + tags: tags, + password: password.isEmpty ? nil : password + ) + Task { + do { + try await model.add(input) + dismiss() + } catch { + validationMessage = "保存失败,请检查地址是否重复。" + } + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift b/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift new file mode 100644 index 0000000..11d08be --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Models/InstanceModels.swift @@ -0,0 +1,107 @@ +import Foundation + +public enum RepositoryError: Error, Equatable, Sendable { + case invalidOrigin + case duplicateOrigin + case revisionConflict + case notFound + case databaseFailed +} + +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 let createdAt: Date + public var updatedAt: Date + + public init( + id: UUID, + name: String, + origin: URL, + tags: [String] = [], + groupId: UUID? = nil, + credentialConfigured: Bool = false, + configRevision: Int = 1, + createdAt: Date, + updatedAt: Date + ) { + self.id = id + self.name = name + self.origin = origin + self.tags = tags + self.groupId = groupId + self.credentialConfigured = credentialConfigured + self.configRevision = configRevision + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} + +public struct InstanceInput: 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? = nil, + password: String? = nil + ) { + self.name = name + self.origin = origin + self.tags = tags + self.groupId = groupId + self.password = password + } +} + +public struct InstancePatch: 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: Sendable { + public var search: String? + public var tags: [String]? + public var groupId: UUID? + + public init(search: String? = nil, tags: [String]? = nil, groupId: UUID? = nil) { + self.search = search + self.tags = tags + self.groupId = groupId + } +} + +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 +} diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift new file mode 100644 index 0000000..70d50f5 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -0,0 +1,32 @@ +import SwiftUI + +@main +struct MultiSimAdminApp: App { + var body: some Scene { + WindowGroup { + TabView { + FleetView() + .tabItem { + Label("节点", systemImage: "server.rack") + } + Text("消息") + .tabItem { + Label("消息", systemImage: "message") + } + Text("通知") + .tabItem { + Label("通知", systemImage: "bell") + } + Text("日志") + .tabItem { + Label("日志", systemImage: "doc.text") + } + Text("设置") + .tabItem { + Label("设置", systemImage: "gearshape") + } + } + .tint(AITheme.primary) + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift new file mode 100644 index 0000000..7054232 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceRepository.swift @@ -0,0 +1,186 @@ +import Foundation + +public struct FileInstanceRepository: InstanceRepository { + private struct Store: Codable { + var instances: [Instance] = [] + } + + private let location: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-repository") + + public init(location: URL) { + self.location = location + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + self.encoder = encoder + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + self.decoder = decoder + } + + public func list(_ query: InstanceQuery) async throws -> [Instance] { + try await run { store in + var instances = store.instances + if let search = query.search?.trimmingCharacters(in: .whitespacesAndNewlines), + !search.isEmpty { + instances = instances.filter { + $0.name.localizedCaseInsensitiveContains(search) + || $0.origin.absoluteString.localizedCaseInsensitiveContains(search) + } + } + if let tags = query.tags, !tags.isEmpty { + instances = instances.filter { !Set($0.tags).isDisjoint(with: tags) } + } + if let groupId = query.groupId { + instances = instances.filter { $0.groupId == groupId } + } + return instances.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } + } + } + + public func get(_ id: UUID) async throws -> Instance? { + try await run { $0.instances.first { $0.id == id } } + } + + public func create(_ input: InstanceInput) async throws -> Instance { + try await run { store in + guard !input.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw RepositoryError.invalidOrigin + } + let origin = try Self.normalizedOrigin(input.origin) + guard !store.instances.contains(where: { $0.origin == origin }) else { + throw RepositoryError.duplicateOrigin + } + let now = Date() + let instance = Instance( + id: UUID(), + name: input.name.trimmingCharacters(in: .whitespacesAndNewlines), + origin: origin, + tags: Self.normalizedTags(input.tags), + groupId: input.groupId, + credentialConfigured: !(input.password ?? "").isEmpty, + createdAt: now, + updatedAt: now + ) + store.instances.append(instance) + return instance + } + } + + public func update(_ id: UUID, expectedRevision: Int, _ patch: InstancePatch) async throws -> Instance { + try await run { store in + guard let index = store.instances.firstIndex(where: { $0.id == id }) else { + throw RepositoryError.notFound + } + guard store.instances[index].configRevision == expectedRevision else { + throw RepositoryError.revisionConflict + } + + var instance = store.instances[index] + if let name = patch.name { + let normalized = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { throw RepositoryError.invalidOrigin } + instance.name = normalized + } + if let origin = patch.origin { + let normalized = try Self.normalizedOrigin(origin) + guard !store.instances.contains(where: { $0.id != id && $0.origin == normalized }) else { + throw RepositoryError.duplicateOrigin + } + instance.origin = normalized + } + if let tags = patch.tags { + instance.tags = Self.normalizedTags(tags) + } + if patch.groupId != nil || instance.groupId != nil { + instance.groupId = patch.groupId + } + if let password = patch.password { + instance.credentialConfigured = !password.isEmpty + } + instance.updatedAt = Date() + instance.configRevision += 1 + store.instances[index] = instance + return instance + } + } + + public func delete(_ id: UUID, expectedRevision: Int) async throws { + try await run { store in + guard let index = store.instances.firstIndex(where: { $0.id == id }) else { + throw RepositoryError.notFound + } + guard store.instances[index].configRevision == expectedRevision else { + throw RepositoryError.revisionConflict + } + store.instances.remove(at: index) + } + } + + private func run(_ transform: @escaping @Sendable (inout Store) throws -> T) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + queue.async { + do { + var store = try self.readStore() + let output = try transform(&store) + try self.writeStore(store) + continuation.resume(returning: output) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + private func readStore() throws -> Store { + guard FileManager.default.fileExists(atPath: location.path) else { + return Store() + } + do { + return try decoder.decode(Store.self, from: Data(contentsOf: location)) + } catch { + throw RepositoryError.databaseFailed + } + } + + private func writeStore(_ store: Store) throws { + do { + let directory = location.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try encoder.encode(store).write(to: location, options: [.atomic]) + } catch { + throw RepositoryError.databaseFailed + } + } + + private static func normalizedOrigin(_ url: URL) throws -> URL { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.scheme == "http" || components.scheme == "https", + let host = components.host, !host.isEmpty + else { + throw RepositoryError.invalidOrigin + } + guard components.path.isEmpty || components.path == "/" else { + throw RepositoryError.invalidOrigin + } + var normalized = components + normalized.path = "" + normalized.query = nil + normalized.fragment = nil + guard let result = normalized.url else { + throw RepositoryError.invalidOrigin + } + return result + } + + private static func normalizedTags(_ tags: [String]) -> [String] { + var seen = Set() + return tags + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist new file mode 100644 index 0000000..292d904 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Resources/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegionzh_CN + CFBundleDisplayNameMultiSimAdmin + CFBundleExecutableMultiSimAdmin + CFBundleIdentifierfun.chick.multi-simadmin + CFBundleInfoDictionaryVersion6.0 + CFBundleNameMultiSimAdmin + CFBundlePackageTypeAPPL + CFBundleShortVersionString0.1.0 + CFBundleVersion1 + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSPrincipalClassUIApplication + UILaunchScreen + + UIRequiredDeviceCapabilities + arm64 + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift b/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift new file mode 100644 index 0000000..a68ec79 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Services/ConnectionProbe.swift @@ -0,0 +1,62 @@ +import Foundation + +public protocol SimAdminClientProtocol: Sendable { + func send(_ route: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse +} + +extension SimAdminClient: SimAdminClientProtocol {} + +public enum ConnectionStatus: String, Equatable, Sendable { + case online + case offline + case authRequired + case degraded + case unknown + + public var label: String { + switch self { + case .online: return "在线" + case .offline: return "离线" + case .authRequired: return "需登录" + case .degraded: return "降级" + case .unknown: return "未知" + } + } +} + +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 { + let response = try await client.send(.health, baseURL: instance.origin, cookie: nil) + if response.status == 401 || response.status == 403 { + return ConnectionResult(status: .authRequired, checkedAt: Date()) + } + return ConnectionResult(status: .online, checkedAt: Date()) + } catch { + switch error { + case UpstreamError.unauthorized: + return ConnectionResult(status: .authRequired, checkedAt: Date()) + case UpstreamError.timeout, is URLError: + return ConnectionResult(status: .offline, checkedAt: Date()) + default: + return ConnectionResult(status: .degraded, checkedAt: Date()) + } + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift new file mode 100644 index 0000000..e43dade --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -0,0 +1,17 @@ +import Foundation + +enum DefaultContainer { + static func documentFileURL(_ name: String) -> URL { + let directory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + return directory.appendingPathComponent(name) + } + + @MainActor + static func makeFleetModel() -> FleetViewModel { + FleetViewModel( + repository: FileInstanceRepository(location: documentFileURL("instances.json")), + probe: ConnectionProbe(client: SimAdminClient()), + credentials: KeychainCredentialStore() + ) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift b/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift new file mode 100644 index 0000000..aa16498 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Services/InstanceSessionService.swift @@ -0,0 +1,62 @@ +import Foundation + +public struct SessionMetadata: Equatable, Sendable { + public let instanceID: UUID + public let authenticated: Bool + public let checkedAt: Date + + public init(instanceID: UUID, authenticated: Bool, checkedAt: Date) { + self.instanceID = instanceID + self.authenticated = authenticated + self.checkedAt = checkedAt + } +} + +public struct InstanceSessionService: Sendable { + private let store: InstanceSessionStore + private let client: any SimAdminClientProtocol + + public init(store: InstanceSessionStore, client: any SimAdminClientProtocol) { + self.store = store + self.client = client + } + + public func login(_ instance: Instance, password: String) async throws -> SessionMetadata { + do { + let response = try await client.send( + .authLogin(password: password), + baseURL: instance.origin, + cookie: nil + ) + if let cookie = Self.sessionCookie(response.setCookie) { + await store.set(instance.id, origin: instance.origin, cookie: cookie) + return SessionMetadata(instanceID: instance.id, authenticated: true, checkedAt: Date()) + } + } catch { + // Rejected credentials fall through to the same explicit unauthenticated state. + } + await store.clear(instance.id) + return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date()) + } + + public func logout(_ instance: Instance) async throws -> SessionMetadata { + if let cookie = await store.cookie(for: instance.id, origin: instance.origin) { + _ = try? await client.send(.authLogout, baseURL: instance.origin, cookie: cookie) + } + await store.clear(instance.id) + return SessionMetadata(instanceID: instance.id, authenticated: false, checkedAt: Date()) + } + + public func cookie(for instance: Instance) async -> String? { + await store.cookie(for: instance.id, origin: instance.origin) + } + + static func sessionCookie(_ header: String?) -> String? { + guard let header, + let range = header.range(of: "simadmin_session=([^;\\s,]+)", options: .regularExpression) + else { + return nil + } + return String(header[range]) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift b/apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift new file mode 100644 index 0000000..854c007 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Services/KeychainCredentialStore.swift @@ -0,0 +1,61 @@ +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 { + if status == errSecItemNotFound { return nil } + throw CredentialStoreError.keychainFailed + } + guard let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + public func set(instanceID: UUID, password: String) throws { + try self.remove(instanceID: 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 CredentialStoreError.keychainFailed + } + } + + public func remove(instanceID: UUID) throws { + let status = SecItemDelete(baseQuery(instanceID) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw CredentialStoreError.keychainFailed + } + } + + private func baseQuery(_ instanceID: UUID) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: instanceID.uuidString + ] + } +} + +public enum CredentialStoreError: Error, Equatable { + case keychainFailed +} diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift new file mode 100644 index 0000000..5930c5e --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AIButtonStyle.swift @@ -0,0 +1,59 @@ +import SwiftUI + +public struct AIButtonStyle: ButtonStyle { + public enum Kind: Hashable { + case primary + case secondary + case danger + + var foregroundColor: Color { + switch self { + case .primary, .danger: return .white + case .secondary: return AITheme.ink + } + } + + var backgroundColor: Color { + switch self { + case .primary: return AITheme.primary + case .secondary: return AITheme.surface + case .danger: return AITheme.danger + } + } + } + + public enum Size: Hashable { + case compact + case regular + case 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 + } + + configuration.label + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(kind.foregroundColor) + .frame(minWidth: 44, minHeight: height) + .padding(.horizontal, 14) + .background(kind.backgroundColor) + .clipShape(RoundedRectangle(cornerRadius: AITheme.buttonRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.buttonRadius) + .strokeBorder(kind == .secondary ? AITheme.line : .clear) + } + .opacity(configuration.isPressed ? 0.78 : 1) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift new file mode 100644 index 0000000..2e73e38 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AICard.swift @@ -0,0 +1,57 @@ +import SwiftUI + +public struct AICard: View { + private let content: Content + + public init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + public var body: some View { + content + .padding(16) + .background(AITheme.surface) + .clipShape(RoundedRectangle(cornerRadius: AITheme.cardRadius)) + .overlay { + RoundedRectangle(cornerRadius: AITheme.cardRadius) + .strokeBorder(AITheme.line) + } + } +} + +public struct AITag: View { + private let text: String + private let tint: Color + + public init(_ text: String, tint: Color = AITheme.primary) { + self.text = text + self.tint = tint + } + + public var body: some View { + Text(text) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 9) + .frame(minHeight: 26) + .background(tint.opacity(0.10)) + .clipShape(Capsule()) + } +} + +public struct AIBrandBar: View { + public init() {} + + public var body: some View { + HStack(spacing: 10) { + Image(systemName: "antenna.radiowaves.left.and.right") + Text(AppMetadata.name) + .font(.headline) + Spacer() + } + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color(red: 0.075, green: 0.118, blue: 0.188)) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift new file mode 100644 index 0000000..a2b8711 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Theme/AITheme.swift @@ -0,0 +1,21 @@ +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 +} diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/InstanceSessionStore.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/InstanceSessionStore.swift new file mode 100644 index 0000000..97c3bf3 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/InstanceSessionStore.swift @@ -0,0 +1,20 @@ +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) + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/SimAdminClient.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/SimAdminClient.swift new file mode 100644 index 0000000..2e8361a --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/SimAdminClient.swift @@ -0,0 +1,71 @@ +import Foundation + +public struct UpstreamResponse: Equatable, Sendable { + public let status: Int + public let data: Data + public let setCookie: String? + + public init(status: Int, data: Data, setCookie: String? = nil) { + self.status = status + self.data = data + self.setCookie = setCookie + } +} + +public struct SimAdminClient: Sendable { + private let session: URLSession + private let maximumBytes: Int + + public init(session: URLSession = .shared, maximumBytes: Int = 262_144) { + self.session = session + self.maximumBytes = maximumBytes + } + + public func send( + _ route: UpstreamRoute, + baseURL: URL, + cookie: String? + ) async throws -> UpstreamResponse { + guard baseURL.scheme == "http" || baseURL.scheme == "https", + baseURL.path.isEmpty || baseURL.path == "/" + 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 let http = response as? HTTPURLResponse, data.count <= maximumBytes 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 + } + let setCookie = http.allHeaderFields["Set-Cookie"] as? String + return UpstreamResponse(status: http.statusCode, data: data, setCookie: setCookie) + } catch let error as UpstreamError { + throw error + } catch let error as URLError where error.code == .timedOut { + throw UpstreamError.timeout + } catch { + throw UpstreamError.transportFailed + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift new file mode 100644 index 0000000..521578f --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Upstream/UpstreamRoute.swift @@ -0,0 +1,70 @@ +import Foundation + +public enum UpstreamRoute: Equatable, Sendable { + case health + case device + case sim + case network + case signalStrength + case connectivity + case smsList(limit: Int, offset: Int, direction: String?) + case smsSend(phoneNumber: String, content: String) + case smsBatchDelete(ids: [Int]) + case authLogin(password: String) + case authLogout + + public var method: String { + switch self { + case .health, .device, .sim, .network, .signalStrength, .connectivity, .smsList: + return "GET" + case .smsSend, .smsBatchDelete, .authLogin, .authLogout: + 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 .signalStrength: return "/api/network/signal-strength" + 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" + case .smsBatchDelete: return "/api/sms/batch-delete" + case .authLogin: return "/api/auth/login" + case .authLogout: return "/api/auth/logout" + } + } + + public var body: Data? { + switch self { + case .smsSend(let phoneNumber, let content): + return try? JSONSerialization.data( + withJSONObject: ["phone_number": phoneNumber, "content": content] + ) + case .smsBatchDelete(let ids): + return try? JSONSerialization.data(withJSONObject: ["ids": ids]) + case .authLogin(let password): + return try? JSONSerialization.data(withJSONObject: ["password": password]) + default: + return nil + } + } +} + +public enum UpstreamError: Error, Equatable { + case invalidRequest + case unauthorized + case forbidden + case notSupported + case timeout + case transportFailed + case invalidResponse +} diff --git a/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift new file mode 100644 index 0000000..6985c85 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/AIThemeTests.swift @@ -0,0 +1,23 @@ +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(\.backgroundColor) + XCTAssertEqual(Set(colors).count, 3) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift new file mode 100644 index 0000000..66c2ab8 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/AppMetadataTests.swift @@ -0,0 +1,12 @@ +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") + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift b/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift new file mode 100644 index 0000000..1ef12d9 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/ConnectionProbeTests.swift @@ -0,0 +1,45 @@ +import XCTest + +@testable import MultiSimAdmin + +final class ConnectionProbeTests: XCTestCase { + func testProbeMapsUpstreamResults() async { + let unauthorized = ConnectionProbe(client: MockClient(status: 401)) + let online = ConnectionProbe(client: MockClient(status: 200)) + let unavailable = ConnectionProbe(client: MockClient(status: 0)) + let instance = Self.instance() + + let auth = try? await unauthorized.test(instance) + let reachable = try? await online.test(instance) + let offline = try? await unavailable.test(instance) + + XCTAssertEqual(auth?.status, .authRequired) + XCTAssertEqual(reachable?.status, .online) + XCTAssertEqual(offline?.status, .offline) + } + + 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 { + if status == 0 { + throw UpstreamError.timeout + } + return UpstreamResponse(status: status, data: Data("{}".utf8)) + } + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift new file mode 100644 index 0000000..55cf60b --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/FleetViewModelTests.swift @@ -0,0 +1,133 @@ +import XCTest + +@testable import MultiSimAdmin + +@MainActor +final class FleetViewModelTests: XCTestCase { + func testRefreshSortsByNameAndReportsStatus() async throws { + let repository = MemoryInstanceRepository() + let model = FleetViewModel( + repository: repository, + probe: ConnectionProbe(client: AlwaysOnlineClient()), + credentials: NoopCredentialStore() + ) + try await model.add(InstanceInput( + name: "Zulu", + origin: URL(string: "http://192.168.1.30:3000")!, + tags: ["lab"], + password: nil + )) + try await model.add(InstanceInput( + name: "Alpha", + origin: URL(string: "http://192.168.1.20:3000")!, + tags: [], + password: nil + )) + + await model.refresh() + + XCTAssertEqual(model.rows.map(\.instance.name), ["Alpha", "Zulu"]) + XCTAssertEqual(model.rows.map(\.status), [.online, .online]) + } + + func testAddValidatesPasswordAndDeleteRemovesCredential() async throws { + let repository = MemoryInstanceRepository() + let credentials = NoopCredentialStore() + let model = FleetViewModel( + repository: repository, + probe: ConnectionProbe(client: AlwaysOnlineClient()), + credentials: credentials + ) + try await model.add(InstanceInput( + name: "Secret", + origin: URL(string: "http://192.168.1.40:3000")!, + password: "pass" + )) + + let row = try XCTUnwrap(model.rows.first) + XCTAssertTrue(row.instance.credentialConfigured) + XCTAssertEqual(credentials.saved[row.instance.id], "pass") + + await model.delete([row.instance.id]) + XCTAssertTrue(model.rows.isEmpty) + XCTAssertNil(credentials.saved[row.instance.id]) + } +} + +struct AlwaysOnlineClient: SimAdminClientProtocol { + func send(_: UpstreamRoute, baseURL: URL, cookie: String?) async throws -> UpstreamResponse { + UpstreamResponse(status: 200, data: Data("{}".utf8)) + } +} + +final class NoopCredentialStore: CredentialStore { + private let lock = NSLock() + private var savedStorage: [UUID: String] = [:] + private var removedStorage: [UUID] = [] + + var saved: [UUID: String] { + lock.withLock { savedStorage } + } + + var removed: [UUID] { + lock.withLock { removedStorage } + } + + func get(instanceID: UUID) throws -> String? { + saved[instanceID] + } + + func set(instanceID: UUID, password: String) throws { + lock.withLock { savedStorage[instanceID] = password } + } + + func remove(instanceID: UUID) throws { + lock.withLock { + savedStorage[instanceID] = nil + removedStorage.append(instanceID) + } + } +} + +final class MemoryInstanceRepository: InstanceRepository { + private let lock = NSLock() + private var itemsStorage: [Instance] = [] + + private var items: [Instance] { + get { lock.withLock { itemsStorage } } + set { lock.withLock { itemsStorage = newValue } } + } + + func list(_: InstanceQuery) async throws -> [Instance] { + items.sorted { $0.name.localizedCompare($1.name) == .orderedAscending } + } + + func get(_ id: UUID) async throws -> Instance? { + items.first { $0.id == id } + } + + 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 ?? "").isEmpty, + configRevision: 1, + createdAt: now, + updatedAt: now + ) + items.append(instance) + return instance + } + + func update(_: UUID, expectedRevision: Int, _: InstancePatch) async throws -> Instance { + throw RepositoryError.notFound + } + + func delete(_ id: UUID, expectedRevision: Int) async throws { + items.removeAll { $0.id == id } + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift b/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift new file mode 100644 index 0000000..cfa11a7 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/InstanceRepositoryTests.swift @@ -0,0 +1,110 @@ +import XCTest + +@testable import MultiSimAdmin + +final class InstanceRepositoryTests: XCTestCase { + private var location: URL! + + override func setUp() { + super.setUp() + location = FileManager.default.temporaryDirectory + .appendingPathComponent("multi-simadmin-\(UUID().uuidString).json") + } + + override func tearDown() { + try? FileManager.default.removeItem(at: location) + super.tearDown() + } + + func testCreateListUpdateAndDeleteInstance() async throws { + let repository = FileInstanceRepository(location: location) + let input = InstanceInput( + name: "Lab Alpha", + origin: URL(string: "http://192.168.3.55:3000/")!, + tags: ["primary", "lab"], + groupId: nil, + password: "secret" + ) + + let created = try await repository.create(input) + XCTAssertEqual(created.name, "Lab Alpha") + XCTAssertEqual(created.origin.absoluteString, "http://192.168.3.55:3000") + XCTAssertEqual(created.tags, ["primary", "lab"]) + XCTAssertEqual(created.configRevision, 1) + XCTAssertTrue(created.credentialConfigured) + + let patch = InstancePatch(name: "Alpha", tags: ["lab"]) + let updated = try await repository.update(created.id, expectedRevision: 1, patch) + XCTAssertEqual(updated.name, "Alpha") + XCTAssertEqual(updated.tags, ["lab"]) + XCTAssertEqual(updated.configRevision, 2) + XCTAssertTrue(updated.credentialConfigured) + + try await repository.delete(updated.id, expectedRevision: 2) + let afterDelete = try await repository.list(InstanceQuery()) + XCTAssertTrue(afterDelete.isEmpty) + } + + func testRejectsInvalidOriginDuplicateOriginAndStaleRevision() async throws { + let repository = FileInstanceRepository(location: location) + let origin = URL(string: "http://192.168.1.20:3000")! + let input = InstanceInput(name: "One", origin: origin, tags: [], groupId: nil, password: nil) + let first = try await repository.create(input) + + do { + let nested = InstanceInput( + name: "Nested", + origin: URL(string: "http://192.168.1.20:3000/admin")!, + tags: [], + groupId: nil, + password: nil + ) + _ = try await repository.create(nested) + XCTFail("Expected nested path rejection") + } catch let error as RepositoryError { + XCTAssertEqual(error, .invalidOrigin) + } + + 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(name: "New")) + XCTFail("Expected stale-revision failure") + } catch let error as RepositoryError { + XCTAssertEqual(error, .revisionConflict) + } + } + + func testListFiltersBySearchTagAndGroup() async throws { + let repository = FileInstanceRepository(location: location) + let groupId = UUID() + _ = try await repository.create(InstanceInput( + name: "Lab Alpha", + origin: URL(string: "http://192.168.1.10:3000")!, + tags: ["production"], + groupId: groupId, + password: nil + )) + _ = try await repository.create(InstanceInput( + name: "Home Beta", + origin: URL(string: "http://192.168.1.20:3000")!, + tags: ["lab"], + groupId: nil, + password: nil + )) + + let search = try await repository.list(InstanceQuery(search: "alpha")) + XCTAssertEqual(search.map(\.name), ["Lab Alpha"]) + let tagged = try await repository.list(InstanceQuery(tags: ["lab"])) + XCTAssertEqual(tagged.map(\.name), ["Home Beta"]) + let grouped = try await repository.list(InstanceQuery(groupId: groupId)) + XCTAssertEqual(grouped.map(\.name), ["Lab Alpha"]) + let sorted = try await repository.list(InstanceQuery()) + XCTAssertEqual(sorted.map(\.name), ["Home Beta", "Lab Alpha"]) + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift b/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift new file mode 100644 index 0000000..dec61e8 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/InstanceSessionServiceTests.swift @@ -0,0 +1,50 @@ +import XCTest + +@testable import MultiSimAdmin + +final class InstanceSessionServiceTests: XCTestCase { + func testLoginStoresAndClearsSessionCookie() async throws { + let store = InstanceSessionStore() + let client = MockSessionClient(status: 204) + let service = InstanceSessionService(store: store, client: client) + let instance = Self.instance() + + let result = try await service.login(instance, password: "secret") + XCTAssertTrue(result.authenticated) + let cookie = await store.cookie(for: instance.id, origin: instance.origin) + XCTAssertEqual(cookie, "simadmin_session=token") + + let rejected = try await service.login(instance, password: "bad") + XCTAssertFalse(rejected.authenticated) + let removed = await store.cookie(for: instance.id, origin: instance.origin) + XCTAssertEqual(removed, nil) + } + + private static func instance() -> Instance { + Instance( + id: UUID(), + name: "Session", + origin: URL(string: "http://192.168.1.20:3000")!, + configRevision: 1, + createdAt: Date(), + updatedAt: Date() + ) + } + + private struct MockSessionClient: SimAdminClientProtocol { + let status: Int + + func send( + _ route: UpstreamRoute, + baseURL: URL, + cookie: String? + ) async throws -> UpstreamResponse { + guard case let .authLogin(submitted) = route else { + return UpstreamResponse(status: 200, data: Data("{}.utf8".utf8), setCookie: nil) + } + let authorized = submitted == "secret" + let setCookie = authorized ? "simadmin_session=token; Path=/" : nil + return UpstreamResponse(status: authorized ? status : 200, data: Data("{}".utf8), setCookie: setCookie) + } + } +} diff --git a/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift b/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift new file mode 100644 index 0000000..059636e --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/UpstreamRouteTests.swift @@ -0,0 +1,54 @@ +import XCTest + +@testable import MultiSimAdmin + +final class UpstreamRouteTests: XCTestCase { + func testKnownRoutesUseExactPathsMethodsAndPayloads() throws { + XCTAssertEqual(UpstreamRoute.health.method, "GET") + XCTAssertEqual(UpstreamRoute.health.path, "/api/health") + XCTAssertEqual(UpstreamRoute.device.path, "/api/device") + XCTAssertEqual(UpstreamRoute.sim.path, "/api/sim") + XCTAssertEqual(UpstreamRoute.network.path, "/api/network") + XCTAssertEqual(UpstreamRoute.signalStrength.path, "/api/network/signal-strength") + XCTAssertEqual(UpstreamRoute.smsList(limit: 100, offset: 0, direction: nil).path, + "/api/sms/list?limit=100&offset=0") + XCTAssertEqual(UpstreamRoute.smsList(limit: 20, offset: 2, direction: "outgoing").path, + "/api/sms/list?limit=20&offset=2&direction=outgoing") + + let login = UpstreamRoute.authLogin(password: "secret") + XCTAssertEqual(login.method, "POST") + XCTAssertEqual(login.path, "/api/auth/login") + let loginObject = try XCTUnwrap(JSONSerialization.jsonObject(with: XCTUnwrap(login.body)) as? [String: String]) + XCTAssertEqual(loginObject, ["password": "secret"]) + + let delete = UpstreamRoute.smsBatchDelete(ids: [2, 1]) + XCTAssertEqual(delete.method, "POST") + XCTAssertEqual(delete.path, "/api/sms/batch-delete") + let deleteObject = try XCTUnwrap( + JSONSerialization.jsonObject(with: XCTUnwrap(delete.body)) as? [String: [Int]] + ) + XCTAssertEqual(deleteObject, ["ids": [2, 1]]) + } + + func testSessionStoreIsIsolatedByInstanceAndOrigin() async { + let store = InstanceSessionStore() + let origin = URL(string: "http://192.168.1.10:3000")! + await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: origin, cookie: "a") + await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, origin: origin, cookie: "b") + + let sameID = URL(string: "https://other.example")! + await store.set(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, origin: sameID, cookie: "c") + await store.clear(UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + + let first = await store.cookie( + for: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, + origin: origin + ) + let second = await store.cookie( + for: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, + origin: origin + ) + XCTAssertEqual(first, nil) + XCTAssertEqual(second, "b") + } +} diff --git a/scripts/build-ios-ipa.sh b/scripts/build-ios-ipa.sh new file mode 100755 index 0000000..0e10d4e --- /dev/null +++ b/scripts/build-ios-ipa.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IOS_ROOT="$ROOT/apps/ios" +BUILD_ROOT="$ROOT/build/ipa" +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-ios/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 'Size: %s bytes\n' "$(stat -f %z "$APP_NAME.ipa")" +printf 'SHA-256: ' +cat "$APP_NAME.ipa.sha256"