feat(ios): add native settings and storage controls

This commit is contained in:
chick
2026-09-09 02:06:34 +08:00
parent 9c3186252b
commit c568e496b2
8 changed files with 423 additions and 1 deletions
@@ -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
}
@@ -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 {
Label("日志", systemImage: "doc.text")
}
Text("设置")
SettingsView()
.tabItem {
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>(
_ transform: @escaping @Sendable (inout Store) 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
static func makeDeviceModel(instance: Instance) -> DeviceViewModel {
DeviceViewModel(