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
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user