- 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
131 lines
4.4 KiB
Swift
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
|
|
}
|
|
}
|