feat(ios): add native app foundation and unsigned ipa build
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user