feat(ios): add native app foundation and unsigned ipa build
This commit is contained in:
@@ -9,4 +9,5 @@ apps/web/dist/
|
||||
.hermes/
|
||||
.local-run/
|
||||
artifacts/
|
||||
build/
|
||||
data/
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.build/
|
||||
@@ -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"]
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UUID> = []
|
||||
@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
|
||||
}
|
||||
}
|
||||
@@ -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<String>, 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 = "保存失败,请检查地址是否重复。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<T: Sendable>(_ 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<String>()
|
||||
return tags
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty && seen.insert($0.lowercased()).inserted }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?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>
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -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"
|
||||
Reference in New Issue
Block a user