Files
multi-simadmin/apps/ios/Sources/MultiSimAdmin/Persistence/FileInstanceGroupStore.swift
T
chick 2ff23d1fb0 fix(ios): improve fleet overview and app identity
- Show stable group health in a fixed 2x2 metric grid
- Add link-chain state to fleet cards and parse hardware/control/SIM/cellular
- Replace the wrapped icon with a full-bleed app icon
- Bump the IPA build number
2026-09-13 00:46:26 +08:00

131 lines
4.4 KiB
Swift

import Foundation
public struct InstanceGroup: Codable, Equatable, Identifiable, Sendable {
public var id: UUID
public var name: String
public var createdAt: Date
public init(id: UUID, name: String, createdAt: Date) {
self.id = id
self.name = name
self.createdAt = createdAt
}
}
public struct InstanceGroupInput: Sendable {
public var name: String
public init(name: String) {
self.name = name
}
}
public protocol InstanceGroupStore: Sendable {
func list() async throws -> [InstanceGroup]
func create(_ input: InstanceGroupInput) async throws -> InstanceGroup
func update(_ id: UUID, _ name: String) async throws -> InstanceGroup
func delete(_ id: UUID) async throws
}
public final class FileInstanceGroupStore: @unchecked Sendable, InstanceGroupStore {
private struct FileModel: Codable {
var groups: [InstanceGroup] = []
}
private let location: URL
private let encoder: JSONEncoder
private let decoder: JSONDecoder
private let lock = NSLock()
private var fileModel = FileModel()
private let queue = DispatchQueue(label: "fun.chick.multi-simadmin.instance-group-store")
public init(location: URL) {
self.location = URL(fileURLWithPath: location.path)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
self.decoder = decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
self.encoder = encoder
lock.withLock {
if let data = try? Data(contentsOf: location),
let decoded = try? decoder.decode(FileModel.self, from: data) {
fileModel = decoded
}
}
}
public func list() async throws -> [InstanceGroup] {
lock.withLock {
fileModel.groups.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
public func create(_ input: InstanceGroupInput) async throws -> InstanceGroup {
try await run { model in
let name = try Self.normalized(input.name)
guard !model.groups.contains(where: { $0.name == name }) else {
throw RepositoryError.duplicateOrigin
}
let group = InstanceGroup(id: UUID(), name: name, createdAt: Date())
model.groups.append(group)
return group
}
}
public func update(_ id: UUID, _ name: String) async throws -> InstanceGroup {
try await run { model in
let normalized = try Self.normalized(name)
guard let index = model.groups.firstIndex(where: { $0.id == id }) else {
throw RepositoryError.notFound
}
guard !model.groups.contains(where: { $0.id != id && $0.name == normalized }) else {
throw RepositoryError.duplicateOrigin
}
model.groups[index].name = normalized
return model.groups[index]
}
}
public func delete(_ id: UUID) async throws {
try await run { model in
model.groups.removeAll { $0.id == id }
}
}
private func run<T: Sendable>(
_ transform: @escaping @Sendable (inout FileModel) throws -> T
) async throws -> T {
try await withCheckedThrowingContinuation { continuation in
queue.async {
do {
var model = self.lock.withLock { self.fileModel }
let output = try transform(&model)
try self.write(model)
self.lock.withLock { self.fileModel = model }
continuation.resume(returning: output)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private func write(_ model: FileModel) throws {
let directory = location.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try encoder.encode(model).write(to: location, options: [.atomic])
}
private static func normalized(_ value: String) throws -> String {
let name = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.count <= 100 else {
throw RepositoryError.invalidOrigin
}
return name
}
}