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