diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift index 37f69fc..afed071 100644 --- a/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift +++ b/apps/ios/Sources/MultiSimAdmin/Features/Logs/LogCenterModels.swift @@ -197,4 +197,5 @@ public protocol LogStore: Sendable { func connectionLogs() async throws -> [ConnectionLogEntry] func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws func pruneConnections(before: Date) async throws -> Int + func clearRuntimeLogs() async throws -> Int } diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..5ad826d --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsView.swift @@ -0,0 +1,154 @@ +import SwiftUI + +struct SettingsView: View { + @StateObject private var model: SettingsViewModel + @State private var showsClearDialog = false + + init(model: SettingsViewModel? = nil) { + _model = StateObject(wrappedValue: model ?? DefaultContainer.makeSettingsModel()) + } + + var body: some View { + ZStack { + AITheme.canvas.ignoresSafeArea() + ScrollView { + LazyVStack(spacing: 12) { + if let errorMessage = model.errorMessage { + AICard { + Text(errorMessage) + .font(.subheadline) + .foregroundStyle(AITheme.danger) + } + } + if let notice = model.notice { + AICard { + Text(notice) + .font(.subheadline) + .foregroundStyle(AITheme.success) + } + } + + runtimeCard + storageCard + securityCard + backgroundCard + } + .padding(16) + } + } + .navigationTitle("设置") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button { + Task { await model.refresh() } + } label: { + Label("刷新", systemImage: "arrow.clockwise") + } + .buttonStyle(AIButtonStyle(kind: .secondary, size: .regular)) + .disabled(model.isLoading) + } + } + .refreshable { + await model.refresh() + } + .confirmationDialog( + "确认清除运行日志?此操作不可恢复。", + isPresented: $showsClearDialog, + titleVisibility: .visible + ) { + Button("清除运行日志", role: .destructive) { + Task { await model.clearRuntimeLogs() } + } + Button("取消", role: .cancel) {} + } + .task { + await model.refresh() + } + } + + private var runtimeCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + sectionTitle("应用信息", systemImage: "info.circle") + if let runtime = model.runtime { + infoRow("名称", runtime.appName) + infoRow("版本", "\(runtime.marketingVersion) (\(runtime.buildVersion))") + infoRow("标识符", AppMetadata.bundleIdentifier) + infoRow("设备", "\(runtime.platform) · \(runtime.deviceModel)") + } else { + Text("正在读取应用信息…") + .font(.subheadline) + .foregroundStyle(AITheme.muted) + } + } + } + } + + private var storageCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + sectionTitle("本地数据", systemImage: "externaldrive") + infoRow("实例配置", "App Documents/instances.json") + infoRow("日志中心", "App Documents/log-center.json") + infoRow("日志数量", model.storageLabel) + Button { + showsClearDialog = true + } label: { + Label("清除运行日志", systemImage: "trash") + } + .buttonStyle(AIButtonStyle(kind: .danger, size: .regular)) + } + } + } + + private var securityCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + sectionTitle("安全与登录", systemImage: "lock.shield") + infoRow("实例密码", "保存在 iOS 钥匙串") + infoRow("登录会话", "仅保存在内存") + infoRow("访问密码", "已停用") + } + } + } + + private var backgroundCard: some View { + AICard { + VStack(alignment: .leading, spacing: 12) { + sectionTitle("后台能力", systemImage: "clock.badge.exclamationmark") + Text(model.serverCapabilitiesNotice) + .font(.subheadline) + .foregroundStyle(AITheme.muted) + if model.serverCapabilities.isEmpty { + AITag("无后台任务", tint: AITheme.muted) + } else { + ForEach(model.serverCapabilities) { capability in + infoRow(capability.name, capability.detail) + } + } + } + } + } + + private func sectionTitle(_ title: String, systemImage: String) -> some View { + HStack(spacing: 8) { + Image(systemName: systemImage) + .foregroundStyle(AITheme.primary) + Text(title) + .font(.headline.weight(.bold)) + Spacer() + } + } + + private func infoRow(_ title: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.caption) + .foregroundStyle(AITheme.muted) + Text(value) + .font(.subheadline) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift new file mode 100644 index 0000000..3e95903 --- /dev/null +++ b/apps/ios/Sources/MultiSimAdmin/Features/Settings/SettingsViewModel.swift @@ -0,0 +1,87 @@ +import Foundation +import Darwin + +struct SettingsRuntime: Equatable { + let appName: String + let marketingVersion: String + let buildVersion: String + let platform: String + let deviceModel: String +} + +struct ServerCapability: Equatable, Identifiable { + let id: String + let name: String + let detail: String +} + +@MainActor +final class SettingsViewModel: ObservableObject { + @Published private(set) var runtime: SettingsRuntime? + @Published private(set) var serverCapabilities: [ServerCapability] = [] + @Published private(set) var isLoading = false + @Published var storageLabel = "--" + @Published var serverCapabilitiesNotice = "" + @Published var notice: String? + @Published var errorMessage: String? + + private let store: any LogStore + + init(store: any LogStore) { + self.store = store + } + + func refresh() async { + isLoading = true + errorMessage = nil + + runtime = SettingsRuntime( + appName: AppMetadata.name, + marketingVersion: AppMetadata.marketingVersion, + buildVersion: AppMetadata.buildVersion, + platform: "iOS", + deviceModel: Self.deviceModel() + ) + serverCapabilities = [] + serverCapabilitiesNotice = "定时任务与后台刷新已在 iOS 版停用。" + await loadStorageLabel() + + isLoading = false + } + + func clearRuntimeLogs() async -> Int { + do { + let removed = try await store.clearRuntimeLogs() + notice = removed > 0 ? "已清除 \(removed) 条运行日志。" : "没有可清除的运行日志。" + errorMessage = nil + await loadStorageLabel() + return removed + } catch { + notice = nil + errorMessage = "清除运行日志失败。" + return 0 + } + } + + private func loadStorageLabel() async { + do { + let runtimeCount = try await store.runtimeLogs().count + let connectionCount = try await store.connectionLogs().count + storageLabel = "\(runtimeCount) 条运行日志 · \(connectionCount) 条连接日志" + } catch { + storageLabel = "--" + errorMessage = "无法读取本地数据状态。" + } + } + + private static func deviceModel() -> String { + var systemInfo = utsname() + guard uname(&systemInfo) == 0 else { return "未知设备" } + let bytes = withUnsafeBytes(of: systemInfo.machine) { Data($0) } + let model = String( + decoding: bytes.prefix { $0 != 0 }, + as: UTF8.self + ) + return model.isEmpty ? "未知设备" : model + } +} diff --git a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift index 57d23e6..1dee336 100644 --- a/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift +++ b/apps/ios/Sources/MultiSimAdmin/MultiSimAdminApp.swift @@ -21,7 +21,7 @@ struct MultiSimAdminApp: App { .tabItem { Label("日志", systemImage: "doc.text") } - Text("设置") + SettingsView() .tabItem { Label("设置", systemImage: "gearshape") } diff --git a/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift b/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift index 635d4f1..3cc4538 100644 --- a/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift +++ b/apps/ios/Sources/MultiSimAdmin/Persistence/FileLogStore.swift @@ -62,6 +62,14 @@ public struct FileLogStore: LogStore { } } + public func clearRuntimeLogs() async throws -> Int { + try await run { store in + let removed = store.runtime.count + store.runtime = [] + return removed + } + } + private func run( _ transform: @escaping @Sendable (inout Store) throws -> T ) async throws -> T { diff --git a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift index 4b1e659..3a3ee5d 100644 --- a/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift +++ b/apps/ios/Sources/MultiSimAdmin/Services/DefaultContainer.swift @@ -54,6 +54,11 @@ enum DefaultContainer { ) } + @MainActor + static func makeSettingsModel() -> SettingsViewModel { + SettingsViewModel(store: FileLogStore(location: documentFileURL("log-center.json"))) + } + @MainActor static func makeDeviceModel(instance: Instance) -> DeviceViewModel { DeviceViewModel( diff --git a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift index 50f64c4..8ff6ba3 100644 --- a/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift +++ b/apps/ios/Tests/MultiSimAdminTests/LogCenterTests.swift @@ -174,6 +174,10 @@ private final class MemoryLogStore: LogStore, @unchecked Sendable { func pruneConnections(before _: Date) async throws -> Int { 0 } + + func clearRuntimeLogs() async throws -> Int { + 0 + } } private struct LogCenterTestClient: SimAdminClientProtocol { diff --git a/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift new file mode 100644 index 0000000..74e55a5 --- /dev/null +++ b/apps/ios/Tests/MultiSimAdminTests/SettingsViewModelTests.swift @@ -0,0 +1,163 @@ +import XCTest + +@testable import MultiSimAdmin + +final class SettingsViewModelTests: XCTestCase { + @MainActor + func testLoadsRuntimeAppAndDisabledServerCapabilities() async throws { + let store = SettingsMemoryLogStore() + let model = SettingsViewModel(store: store) + + await model.refresh() + + XCTAssertEqual(model.runtime?.appName, "MultiSimAdmin") + XCTAssertEqual(model.runtime?.marketingVersion, "0.1.0") + XCTAssertEqual(model.runtime?.buildVersion, "1") + XCTAssertEqual(model.runtime?.platform, "iOS") + XCTAssertTrue(model.runtime?.deviceModel.isEmpty == false) + + XCTAssertTrue(model.serverCapabilities.isEmpty) + XCTAssertEqual(model.serverCapabilitiesNotice, "定时任务与后台刷新已在 iOS 版停用。") + XCTAssertNil(model.errorMessage) + } + + @MainActor + func testClearsRuntimeLogsOnlyAndReportsRemovedCount() async throws { + let store = SettingsMemoryLogStore() + try await store.append( + runtime: [Self.runtimeLog()], + connections: [Self.connectionLog()] + ) + let model = SettingsViewModel(store: store) + + let removed = await model.clearRuntimeLogs() + + XCTAssertEqual(removed, 1) + XCTAssertTrue(store.runtimeStorage.isEmpty) + XCTAssertEqual(store.connectionStorage.count, 1) + XCTAssertEqual(model.notice, "已清除 1 条运行日志。") + XCTAssertNil(model.errorMessage) + } + + @MainActor + func testClearFailureSetsErrorWithoutChangingStorage() async throws { + let store = SettingsMemoryLogStore() + try await store.append( + runtime: [Self.runtimeLog()], + connections: [Self.connectionLog()] + ) + let failing = FailingLogStore(base: store) + let model = SettingsViewModel(store: failing) + + let removed = await model.clearRuntimeLogs() + + XCTAssertEqual(removed, 0) + XCTAssertEqual(store.runtimeStorage.count, 1) + XCTAssertEqual(store.connectionStorage.count, 1) + XCTAssertEqual(model.errorMessage, "清除运行日志失败。") + XCTAssertNil(model.notice) + } + + private static func runtimeLog() -> RuntimeLogEntry { + RuntimeLogEntry( + id: "runtime-1", + source: .delivery, + level: .info, + message: "测试日志", + occurredAt: Date() + ) + } + + private static func connectionLog() -> ConnectionLogEntry { + ConnectionLogEntry( + id: "connection-1", + instanceID: UUID(), + instanceName: "Node", + outcome: .success, + state: "online", + durationMs: 20, + observedAt: Date() + ) + } +} + +private final class SettingsMemoryLogStore: LogStore, @unchecked Sendable { + private let lock = NSLock() + private var runtimeItems: [RuntimeLogEntry] = [] + private var connectionItems: [ConnectionLogEntry] = [] + + var runtimeStorage: [RuntimeLogEntry] { + lock.withLock { runtimeItems } + } + + var connectionStorage: [ConnectionLogEntry] { + lock.withLock { connectionItems } + } + + func runtimeLogs() async throws -> [RuntimeLogEntry] { + runtimeStorage + } + + func connectionLogs() async throws -> [ConnectionLogEntry] { + connectionStorage + } + + func append( + runtime: [RuntimeLogEntry], + connections: [ConnectionLogEntry] + ) async throws { + lock.withLock { + var mergedRuntime = runtime + for existing in runtimeItems where !runtime.contains(where: { $0.id == existing.id }) { + mergedRuntime.append(existing) + } + runtimeItems = mergedRuntime.sorted { $0.occurredAt > $1.occurredAt } + + var mergedConnections = connections + for existing in connectionItems + where !connections.contains(where: { $0.id == existing.id }) { + mergedConnections.append(existing) + } + connectionItems = mergedConnections.sorted { $0.observedAt > $1.observedAt } + } + } + + func pruneConnections(before _: Date) async throws -> Int { + 0 + } + + func clearRuntimeLogs() async throws -> Int { + lock.withLock { + let removed = runtimeItems.count + runtimeItems = [] + return removed + } + } +} + +private struct FailingLogStore: LogStore { + let base: SettingsMemoryLogStore + + func runtimeLogs() async throws -> [RuntimeLogEntry] { + try await base.runtimeLogs() + } + + func connectionLogs() async throws -> [ConnectionLogEntry] { + try await base.connectionLogs() + } + + func append( + runtime: [RuntimeLogEntry], + connections: [ConnectionLogEntry] + ) async throws { + try await base.append(runtime: runtime, connections: connections) + } + + func pruneConnections(before cutoff: Date) async throws -> Int { + try await base.pruneConnections(before: cutoff) + } + + func clearRuntimeLogs() async throws -> Int { + throw RepositoryError.databaseFailed + } +}