feat(ios): add native settings and storage controls
This commit is contained in:
@@ -197,4 +197,5 @@ public protocol LogStore: Sendable {
|
|||||||
func connectionLogs() async throws -> [ConnectionLogEntry]
|
func connectionLogs() async throws -> [ConnectionLogEntry]
|
||||||
func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws
|
func append(runtime: [RuntimeLogEntry], connections: [ConnectionLogEntry]) async throws
|
||||||
func pruneConnections(before: Date) async throws -> Int
|
func pruneConnections(before: Date) async throws -> Int
|
||||||
|
func clearRuntimeLogs() async throws -> Int
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ struct MultiSimAdminApp: App {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("日志", systemImage: "doc.text")
|
Label("日志", systemImage: "doc.text")
|
||||||
}
|
}
|
||||||
Text("设置")
|
SettingsView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("设置", systemImage: "gearshape")
|
Label("设置", systemImage: "gearshape")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<T: Sendable>(
|
private func run<T: Sendable>(
|
||||||
_ transform: @escaping @Sendable (inout Store) throws -> T
|
_ transform: @escaping @Sendable (inout Store) throws -> T
|
||||||
) async throws -> T {
|
) async throws -> T {
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ enum DefaultContainer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
static func makeSettingsModel() -> SettingsViewModel {
|
||||||
|
SettingsViewModel(store: FileLogStore(location: documentFileURL("log-center.json")))
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
|
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
|
||||||
DeviceViewModel(
|
DeviceViewModel(
|
||||||
|
|||||||
@@ -174,6 +174,10 @@ private final class MemoryLogStore: LogStore, @unchecked Sendable {
|
|||||||
func pruneConnections(before _: Date) async throws -> Int {
|
func pruneConnections(before _: Date) async throws -> Int {
|
||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearRuntimeLogs() async throws -> Int {
|
||||||
|
0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LogCenterTestClient: SimAdminClientProtocol {
|
private struct LogCenterTestClient: SimAdminClientProtocol {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user