KO9Ltw4)HQ_onF1zW9
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
Tf~x5M*woiG*_W^FaOwX739X54
literal 0
HcmV?d00001
diff --git a/ios/UnraidManagerIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/UnraidManagerIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..f22e10c
--- /dev/null
+++ b/ios/UnraidManagerIOS/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,14 @@
+{
+ "images" : [
+ {
+ "filename" : "AppIcon-1024.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/UnraidManagerIOS/Resources/Assets.xcassets/Contents.json b/ios/UnraidManagerIOS/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/UnraidManagerIOS/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/UnraidManagerIOS/Resources/Info.plist b/ios/UnraidManagerIOS/Resources/Info.plist
new file mode 100644
index 0000000..f0b0dc1
--- /dev/null
+++ b/ios/UnraidManagerIOS/Resources/Info.plist
@@ -0,0 +1,51 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Unraid Manager
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS
+
+ NSAppTransportSecurity
+
+ NSAllowsArbitraryLoads
+
+ NSAllowsLocalNetworking
+
+
+ NSLocalNetworkUsageDescription
+ 连接并管理您明确配置的局域网 Unraid 服务器。
+ UILaunchScreen
+
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+ UIInterfaceOrientationPortraitUpsideDown
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/ios/UnraidManagerIOS/Sources/AppStore.swift b/ios/UnraidManagerIOS/Sources/AppStore.swift
new file mode 100644
index 0000000..809541c
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/AppStore.swift
@@ -0,0 +1,174 @@
+import Foundation
+import SwiftUI
+
+@MainActor
+final class AppStore: ObservableObject {
+ @Published var configuration: ServerConfiguration
+ @Published var snapshot: ServerSnapshot?
+ @Published var isLoading = false
+ @Published var errorMessage: String?
+ @Published var showConfiguration = false
+ @Published var operations: [OperationRecord] = []
+ @Published var colorSchemePreference = "system"
+ @Published var autoRefresh = true
+
+ private let api = UnraidAPIClient()
+ private let defaults = UserDefaults.standard
+
+ init() {
+ configuration = ServerConfiguration(
+ name: UserDefaults.standard.string(forKey: "server.name") ?? "我的 Unraid",
+ url: UserDefaults.standard.string(forKey: "server.url") ?? "",
+ apiKey: KeychainStore.loadAPIKey(),
+ upsBridgeURL: UserDefaults.standard.string(forKey: "ups.bridgeURL") ?? "",
+ fileManagerURL: UserDefaults.standard.string(forKey: "files.url") ?? "",
+ fileManagerUsername: UserDefaults.standard.string(forKey: "files.username") ?? "",
+ fileManagerPassword: KeychainStore.loadFileManagerPassword(),
+ fileManagerRootPath: UserDefaults.standard.string(forKey: "files.root") ?? "",
+ mockMode: UserDefaults.standard.bool(forKey: "server.mockMode")
+ )
+ colorSchemePreference = defaults.string(forKey: "appearance.scheme") ?? "system"
+ autoRefresh = defaults.object(forKey: "refresh.enabled") as? Bool ?? true
+ if let data = defaults.data(forKey: "operations"), let stored = try? JSONDecoder().decode([OperationRecord].self, from: data) { operations = stored }
+ showConfiguration = configuration.url.isEmpty && !configuration.mockMode
+ }
+
+ var isConfigured: Bool {
+ configuration.mockMode || (!configuration.normalizedURL.isEmpty && !configuration.apiKey.isEmpty)
+ }
+
+ func save(configuration newValue: ServerConfiguration) async -> Bool {
+ do {
+ let cleanValue = ServerConfiguration(
+ name: newValue.name.trimmingCharacters(in: .whitespacesAndNewlines),
+ url: newValue.normalizedURL,
+ apiKey: newValue.apiKey.trimmingCharacters(in: .whitespacesAndNewlines),
+ upsBridgeURL: newValue.upsBridgeURL.trimmingCharacters(in: .whitespacesAndNewlines),
+ fileManagerURL: newValue.fileManagerURL.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")),
+ fileManagerUsername: newValue.fileManagerUsername.trimmingCharacters(in: .whitespacesAndNewlines),
+ fileManagerPassword: newValue.fileManagerPassword,
+ fileManagerRootPath: Self.normalizePath(newValue.fileManagerRootPath),
+ mockMode: newValue.mockMode
+ )
+ try KeychainStore.saveAPIKey(cleanValue.apiKey)
+ try KeychainStore.saveFileManagerPassword(cleanValue.fileManagerPassword)
+ configuration = cleanValue
+ defaults.set(cleanValue.name, forKey: "server.name")
+ defaults.set(cleanValue.url, forKey: "server.url")
+ defaults.set(cleanValue.mockMode, forKey: "server.mockMode")
+ defaults.set(cleanValue.upsBridgeURL, forKey: "ups.bridgeURL")
+ defaults.set(cleanValue.fileManagerURL, forKey: "files.url")
+ defaults.set(cleanValue.fileManagerUsername, forKey: "files.username")
+ defaults.set(cleanValue.fileManagerRootPath, forKey: "files.root")
+ if cleanValue.mockMode {
+ snapshot = .demo
+ showConfiguration = false
+ return true
+ }
+ await refresh()
+ if snapshot != nil {
+ showConfiguration = false
+ return true
+ }
+ return false
+ } catch {
+ errorMessage = error.localizedDescription
+ return false
+ }
+ }
+
+ func refresh() async {
+ guard isConfigured else {
+ showConfiguration = true
+ return
+ }
+ if configuration.mockMode {
+ snapshot = .demo
+ errorMessage = nil
+ return
+ }
+ isLoading = true
+ errorMessage = nil
+ do {
+ snapshot = try await api.fetchSnapshot(configuration: configuration)
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ isLoading = false
+ }
+
+ func perform(_ action: ContainerOperation, on container: ContainerInfo) async {
+ guard !configuration.mockMode else {
+ errorMessage = "演示模式不会向服务器发送操作"
+ return
+ }
+ isLoading = true
+ errorMessage = nil
+ do {
+ switch action {
+ case .start: try await api.perform(configuration: configuration, containerID: container.id, action: .start)
+ case .stop: try await api.perform(configuration: configuration, containerID: container.id, action: .stop)
+ case .restart: try await api.restart(configuration: configuration, containerID: container.id)
+ case .update: try await api.perform(configuration: configuration, containerID: container.id, action: .updateContainer)
+ }
+ snapshot = try await api.fetchSnapshot(configuration: configuration)
+ record("\(action.rawValue) \(container.name)", detail: "操作完成并重新读取服务器状态", succeeded: true)
+ } catch {
+ errorMessage = error.localizedDescription
+ record("\(action.rawValue) \(container.name)", detail: error.localizedDescription, succeeded: false)
+ }
+ isLoading = false
+ }
+
+ func logs(for container: ContainerInfo) async throws -> [String] {
+ if configuration.mockMode { return ["12:44:01 Demo container initialized", "12:44:03 Service is ready"] }
+ return try await api.fetchContainerLogs(configuration: configuration, containerID: container.id)
+ }
+
+ func checkContainerUpdates() async {
+ guard !configuration.mockMode else { return }
+ isLoading = true
+ do {
+ let results = try await api.checkContainerUpdates(configuration: configuration)
+ if let current = snapshot {
+ snapshot = current.replacingContainers(current.containers.map { item in
+ var copy = item
+ copy.updateState = results[item.name] ?? (item.updateAvailable ? .available : .unknown)
+ return copy
+ })
+ }
+ } catch { errorMessage = error.localizedDescription }
+ isLoading = false
+ }
+
+ func savePreferences() {
+ defaults.set(colorSchemePreference, forKey: "appearance.scheme")
+ defaults.set(autoRefresh, forKey: "refresh.enabled")
+ }
+
+ private func record(_ title: String, detail: String, succeeded: Bool) {
+ operations.insert(OperationRecord(id: UUID(), title: title, detail: detail, date: Date(), succeeded: succeeded), at: 0)
+ operations = Array(operations.prefix(50))
+ if let data = try? JSONEncoder().encode(operations) { defaults.set(data, forKey: "operations") }
+ }
+
+ func clearOperations() { operations = []; defaults.removeObject(forKey: "operations") }
+
+ private static func normalizePath(_ value: String) -> String {
+ value.replacingOccurrences(of: "\\", with: "/").split(separator: "/").filter { $0 != "." && $0 != ".." }.joined(separator: "/")
+ }
+}
+
+enum ContainerOperation: String, Identifiable {
+ case start = "启动"
+ case stop = "停止"
+ case restart = "重启"
+ case update = "更新"
+ var id: String { rawValue }
+}
+
+private extension ServerSnapshot {
+ func replacingContainers(_ value: [ContainerInfo]) -> ServerSnapshot {
+ ServerSnapshot(hostname: hostname, release: release, kernel: kernel, uptime: uptime, cpuBrand: cpuBrand, cpuPercent: cpuPercent, cpuCores: cpuCores, cpuThreads: cpuThreads, cpuCorePercents: cpuCorePercents, cpuTemperature: cpuTemperature, cpuPowerWatts: cpuPowerWatts, memoryPercent: memoryPercent, memoryAvailableBytes: memoryAvailableBytes, memoryTotalBytes: memoryTotalBytes, memoryUsedBytes: memoryUsedBytes, memoryFreeBytes: memoryFreeBytes, memoryActiveBytes: memoryActiveBytes, memoryBufferCacheBytes: memoryBufferCacheBytes, swapTotalBytes: swapTotalBytes, swapUsedBytes: swapUsedBytes, arrayState: arrayState, arrayTotalKB: arrayTotalKB, arrayUsedKB: arrayUsedKB, arrayFreeKB: arrayFreeKB, disks: disks, caches: caches, containers: value, virtualMachines: virtualMachines, ups: ups)
+ }
+}
diff --git a/ios/UnraidManagerIOS/Sources/ContainerStatsViewModel.swift b/ios/UnraidManagerIOS/Sources/ContainerStatsViewModel.swift
new file mode 100644
index 0000000..2ad9f20
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/ContainerStatsViewModel.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+@MainActor
+final class ContainerStatsViewModel: ObservableObject {
+ @Published private(set) var stats: ContainerRuntimeStats?
+ @Published private(set) var status = "尚未连接实时指标"
+
+ private var socket: URLSessionWebSocketTask?
+ private var receiveTask: Task?
+ private var targetContainerID = ""
+
+ func connect(configuration: ServerConfiguration, containerID: String) {
+ disconnect()
+ guard !configuration.mockMode else {
+ stats = ContainerRuntimeStats(id: containerID, cpuPercent: 1.8, memoryUsage: "128 MiB / 2 GiB", memoryPercent: 6.25, networkIO: "24 MB / 8 MB", blockIO: "4 MB / 1 MB")
+ status = "演示实时指标"
+ return
+ }
+ guard var components = URLComponents(string: configuration.normalizedURL) else { status = "实时指标地址无效"; return }
+ components.scheme = components.scheme == "https" ? "wss" : "ws"
+ components.path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/graphql"
+ if !components.path.hasPrefix("/") { components.path = "/" + components.path }
+ guard let url = components.url else { status = "实时指标地址无效"; return }
+ targetContainerID = containerID
+ var request = URLRequest(url: url)
+ request.setValue("graphql-transport-ws", forHTTPHeaderField: "Sec-WebSocket-Protocol")
+ let task = URLSession.shared.webSocketTask(with: request)
+ socket = task
+ task.resume()
+ status = "正在连接实时指标…"
+ receiveTask = Task { [weak self] in
+ guard let self else { return }
+ await self.send(type: "connection_init", payload: ["x-api-key": configuration.apiKey])
+ await self.receiveLoop()
+ }
+ }
+
+ func disconnect() {
+ receiveTask?.cancel()
+ receiveTask = nil
+ if let socket {
+ let complete = try? JSONSerialization.data(withJSONObject: ["id": "docker-stats", "type": "complete"])
+ if let complete, let text = String(data: complete, encoding: .utf8) { socket.send(.string(text)) { _ in } }
+ socket.cancel(with: .normalClosure, reason: nil)
+ }
+ socket = nil
+ }
+
+ private func receiveLoop() async {
+ while !Task.isCancelled, let socket {
+ do {
+ let message = try await socket.receive()
+ guard case .string(let text) = message, let data = text.data(using: .utf8), let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
+ switch root["type"] as? String {
+ case "connection_ack":
+ status = "实时指标已连接"
+ await send(type: "subscribe", id: "docker-stats", payload: ["query": Self.subscription])
+ case "next": parseNext(root)
+ case "ping": await send(type: "pong", payload: root["payload"])
+ case "error": status = "实时指标订阅失败"
+ case "complete": status = "实时指标订阅已结束"
+ default: break
+ }
+ } catch {
+ if !Task.isCancelled { status = "实时指标已断开:\(error.localizedDescription)" }
+ return
+ }
+ }
+ }
+
+ private func send(type: String, id: String? = nil, payload: Any? = nil) async {
+ var root: [String: Any] = ["type": type]
+ if let id { root["id"] = id }
+ if let payload { root["payload"] = payload }
+ guard let data = try? JSONSerialization.data(withJSONObject: root), let text = String(data: data, encoding: .utf8) else { return }
+ try? await socket?.send(.string(text))
+ }
+
+ private func parseNext(_ root: [String: Any]) {
+ guard let payload = root["payload"] as? [String: Any], let data = payload["data"] as? [String: Any], let item = data["dockerContainerStats"] as? [String: Any] else { return }
+ let id = sanitize(item["id"] as? String ?? "")
+ guard id == targetContainerID || id.hasSuffix(targetContainerID) || targetContainerID.hasSuffix(id) else { return }
+ stats = ContainerRuntimeStats(id: id, cpuPercent: (item["cpuPercent"] as? NSNumber)?.doubleValue ?? 0, memoryUsage: item["memUsage"] as? String ?? "—", memoryPercent: (item["memPercent"] as? NSNumber)?.doubleValue ?? 0, networkIO: item["netIO"] as? String ?? "—", blockIO: item["blockIO"] as? String ?? "—")
+ }
+
+ private func sanitize(_ value: String) -> String { value.unicodeScalars.filter { !CharacterSet.controlCharacters.contains($0) }.map(String.init).joined() }
+
+ private static let subscription = "subscription DockerStats { dockerContainerStats { id cpuPercent memUsage memPercent netIO blockIO } }"
+}
diff --git a/ios/UnraidManagerIOS/Sources/FileManagerViews.swift b/ios/UnraidManagerIOS/Sources/FileManagerViews.swift
new file mode 100644
index 0000000..0b90e5a
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/FileManagerViews.swift
@@ -0,0 +1,106 @@
+import SwiftUI
+import UniformTypeIdentifiers
+#if os(iOS)
+import QuickLook
+#endif
+
+struct FileBrowserView: View {
+ @EnvironmentObject private var store: AppStore
+ var body: some View {
+ if store.configuration.fileManagerURL.isEmpty {
+ ContentUnavailableView("未配置 XFileManager", systemImage: "folder.badge.questionmark", description: Text("请在设置中填写 XFileManager 地址和专用账号"))
+ .navigationTitle("文件")
+ } else {
+ FileDirectoryView(path: store.configuration.fileManagerRootPath)
+ }
+ }
+}
+
+private struct FileDirectoryView: View {
+ @EnvironmentObject private var store: AppStore
+ let path: String
+ private let client = XFileManagerClient()
+ @State private var entries: [RemoteFileEntry] = []
+ @State private var loading = false
+ @State private var error: String?
+ @State private var showImporter = false
+ @State private var newFolder = ""
+ @State private var showNewFolder = false
+ @State private var renameEntry: RemoteFileEntry?
+ @State private var renameValue = ""
+ @State private var deleteEntry: RemoteFileEntry?
+ @State private var shareURL: URL?
+
+ var body: some View {
+ List {
+ if entries.isEmpty && !loading { ContentUnavailableView("文件夹为空", systemImage: "folder") }
+ ForEach(entries) { entry in
+ if entry.isDirectory {
+ NavigationLink { FileDirectoryView(path: entry.path) } label: { FileRow(entry: entry) }
+ .contextMenu { actions(entry) }
+ } else {
+ NavigationLink { RemoteFilePreviewView(entry: entry, client: client) } label: { FileRow(entry: entry) }
+ .contextMenu { actions(entry) }
+ }
+ }
+ }
+ .navigationTitle(path.split(separator: "/").last.map(String.init) ?? "文件")
+ .overlay { if loading { ProgressView() } }
+ .refreshable { await load() }
+ .task { await load() }
+ .toolbar { ToolbarItemGroup(placement: .primaryAction) { Button { showNewFolder = true } label: { Image(systemName: "folder.badge.plus") }; Button { showImporter = true } label: { Image(systemName: "square.and.arrow.up") } } }
+ .fileImporter(isPresented: $showImporter, allowedContentTypes: [.item]) { result in Task { await upload(result) } }
+ .alert("新建文件夹", isPresented: $showNewFolder) { TextField("名称", text: $newFolder); Button("创建") { Task { await createFolder() } }; Button("取消", role: .cancel) {} }
+ .alert("重命名", isPresented: Binding(get: { renameEntry != nil }, set: { if !$0 { renameEntry = nil } })) { TextField("新名称", text: $renameValue); Button("保存") { Task { await rename() } }; Button("取消", role: .cancel) {} }
+ .confirmationDialog("确认删除 \(deleteEntry?.name ?? "")?", isPresented: Binding(get: { deleteEntry != nil }, set: { if !$0 { deleteEntry = nil } })) { Button("永久删除", role: .destructive) { Task { await delete() } }; Button("取消", role: .cancel) {} } message: { Text("此操作不能撤销。") }
+ .alert("XFileManager", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("好", role: .cancel) {} } message: { Text(error ?? "") }
+ .sheet(item: Binding(get: { shareURL.map(ShareItem.init) }, set: { if $0 == nil { shareURL = nil } })) { item in NavigationStack { VStack(spacing: 24) { Image(systemName: "link.circle.fill").font(.system(size: 64)).foregroundStyle(.orange); Text(item.url.absoluteString).textSelection(.enabled).multilineTextAlignment(.center); ShareLink(item: item.url) { Label("分享链接", systemImage: "square.and.arrow.up") }.buttonStyle(.borderedProminent) }.padding().navigationTitle("分享链接").toolbar { Button("完成") { shareURL = nil } } } }
+ }
+
+ @ViewBuilder private func actions(_ entry: RemoteFileEntry) -> some View {
+ Button { renameEntry = entry; renameValue = entry.name } label: { Label("重命名", systemImage: "pencil") }
+ Button { Task { await share(entry) } } label: { Label("创建 24 小时分享", systemImage: "link") }
+ Button(role: .destructive) { deleteEntry = entry } label: { Label("删除", systemImage: "trash") }
+ }
+ private func load() async { loading = true; do { entries = try await client.list(configuration: store.configuration, path: path) } catch { self.error = error.localizedDescription }; loading = false }
+ private func createFolder() async { do { try await client.createDirectory(configuration: store.configuration, parent: path, name: newFolder); newFolder = ""; await load() } catch { self.error = error.localizedDescription } }
+ private func rename() async { guard let entry = renameEntry else { return }; renameEntry = nil; do { try await client.rename(configuration: store.configuration, entryPath: entry.path, newName: renameValue); await load() } catch { self.error = error.localizedDescription } }
+ private func delete() async { guard let entry = deleteEntry else { return }; deleteEntry = nil; do { try await client.delete(configuration: store.configuration, entryPath: entry.path); await load() } catch { self.error = error.localizedDescription } }
+ private func share(_ entry: RemoteFileEntry) async { do { shareURL = try await client.createShareLink(configuration: store.configuration, entry: entry).url } catch { self.error = error.localizedDescription } }
+ private func upload(_ result: Result) async { do { let url = try result.get(); let access = url.startAccessingSecurityScopedResource(); defer { if access { url.stopAccessingSecurityScopedResource() } }; try await client.upload(configuration: store.configuration, parent: path, fileURL: url); await load() } catch { self.error = error.localizedDescription } }
+}
+
+private struct ShareItem: Identifiable { let url: URL; var id: String { url.absoluteString } }
+
+private struct FileRow: View {
+ let entry: RemoteFileEntry
+ var body: some View { HStack { Image(systemName: entry.isDirectory ? "folder.fill" : icon).foregroundStyle(entry.isDirectory ? .orange : .blue).frame(width: 30); VStack(alignment: .leading) { Text(entry.name).lineLimit(1); HStack { if !entry.isDirectory { Text(ByteCountFormatter.string(fromByteCount: entry.sizeBytes, countStyle: .file)) }; if let date = entry.modified { Text(date, style: .date) } }.font(.caption).foregroundStyle(.secondary) } } }
+ private var icon: String { let ext = (entry.name as NSString).pathExtension.lowercased(); if ["jpg", "jpeg", "png", "gif", "heic"].contains(ext) { return "photo" }; if ext == "pdf" { return "doc.richtext" }; if ["mp4", "mov", "mkv"].contains(ext) { return "film" }; if ["mp3", "m4a", "flac"].contains(ext) { return "waveform" }; return "doc" }
+}
+
+private struct RemoteFilePreviewView: View {
+ @EnvironmentObject private var store: AppStore
+ let entry: RemoteFileEntry
+ let client: XFileManagerClient
+ @State private var localURL: URL?
+ @State private var text: String?
+ @State private var error: String?
+ private var isText: Bool { ["txt", "md", "json", "xml", "yaml", "yml", "log", "conf", "ini", "swift", "kt", "js", "ts", "css", "html", "py", "sh"].contains((entry.name as NSString).pathExtension.lowercased()) }
+ var body: some View { Group { if let text { ScrollView { Text(text).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding() } } else if let localURL { QuickLookView(url: localURL) } else if let error { ContentUnavailableView("无法预览", systemImage: "exclamationmark.triangle", description: Text(error)) } else { ProgressView() } }.navigationTitle(entry.name).toolbar { if let localURL { ShareLink(item: localURL) { Image(systemName: "square.and.arrow.up") } } }.task { await load() } }
+ private func load() async { do { if isText { text = try await client.textPreview(configuration: store.configuration, entry: entry) } else { localURL = try await client.download(configuration: store.configuration, entry: entry) } } catch { self.error = error.localizedDescription } }
+}
+
+#if os(iOS)
+private struct QuickLookView: UIViewControllerRepresentable {
+ let url: URL
+ func makeCoordinator() -> Coordinator { Coordinator(url: url) }
+ func makeUIViewController(context: Context) -> QLPreviewController { let controller = QLPreviewController(); controller.dataSource = context.coordinator; return controller }
+ func updateUIViewController(_ controller: QLPreviewController, context: Context) { context.coordinator.url = url; controller.reloadData() }
+ final class Coordinator: NSObject, QLPreviewControllerDataSource { var url: URL; init(url: URL) { self.url = url }; func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }; func previewController(_ controller: QLPreviewController, previewItemAt index: Int) -> QLPreviewItem { url as NSURL } }
+}
+#else
+private struct QuickLookView: View {
+ let url: URL
+ var body: some View { ContentUnavailableView("预览仅支持 iOS", systemImage: "doc") }
+}
+#endif
diff --git a/ios/UnraidManagerIOS/Sources/KeychainStore.swift b/ios/UnraidManagerIOS/Sources/KeychainStore.swift
new file mode 100644
index 0000000..c772790
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/KeychainStore.swift
@@ -0,0 +1,48 @@
+import Foundation
+import Security
+
+enum KeychainStore {
+ private static let service = "com.bigfeng.unraidmanager.ios"
+ private static let apiAccount = "unraid-api-key"
+ private static let fileManagerAccount = "xfilemanager-password"
+
+ static func saveAPIKey(_ value: String) throws { try save(value, account: apiAccount) }
+ static func saveFileManagerPassword(_ value: String) throws { try save(value, account: fileManagerAccount) }
+ static func loadAPIKey() -> String { load(account: apiAccount) }
+ static func loadFileManagerPassword() -> String { load(account: fileManagerAccount) }
+
+ private static func save(_ value: String, account: String) throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ SecItemDelete(query as CFDictionary)
+ guard !value.isEmpty else { return }
+ var item = query
+ item[kSecValueData as String] = Data(value.utf8)
+ item[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
+ let status = SecItemAdd(item as CFDictionary, nil)
+ guard status == errSecSuccess else { throw KeychainError.status(status) }
+ }
+
+ private static func load(account: String) -> String {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: CFTypeRef?
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
+ let data = result as? Data else { return "" }
+ return String(decoding: data, as: UTF8.self)
+ }
+
+ private enum KeychainError: LocalizedError {
+ case status(OSStatus)
+ var errorDescription: String? { "Keychain 写入失败(\(code))" }
+ private var code: OSStatus { if case .status(let value) = self { value } else { 0 } }
+ }
+}
diff --git a/ios/UnraidManagerIOS/Sources/Models.swift b/ios/UnraidManagerIOS/Sources/Models.swift
new file mode 100644
index 0000000..64b8959
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/Models.swift
@@ -0,0 +1,179 @@
+import Foundation
+
+struct ServerConfiguration: Equatable, Sendable {
+ var name = "我的 Unraid"
+ var url = ""
+ var apiKey = ""
+ var upsBridgeURL = ""
+ var fileManagerURL = ""
+ var fileManagerUsername = ""
+ var fileManagerPassword = ""
+ var fileManagerRootPath = ""
+ var mockMode = false
+
+ var normalizedURL: String {
+ url.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ }
+}
+
+enum HealthState: String, Sendable {
+ case healthy, warning, stopped
+}
+
+enum ContainerUpdateState: String, Sendable {
+ case notChecked, checking, upToDate, available, rebuildReady, unknown
+
+ var label: String {
+ switch self {
+ case .notChecked: "尚未检查"
+ case .checking: "检查中"
+ case .upToDate: "已是最新"
+ case .available: "有可用更新"
+ case .rebuildReady: "可重建"
+ case .unknown: "无法判断"
+ }
+ }
+}
+
+struct UPSInfo: Sendable {
+ let name: String
+ let model: String
+ let status: String
+ let chargePercent: Int
+ let runtimeSeconds: Int
+ let batteryHealth: String
+ let inputVoltage: Double
+ let outputVoltage: Double
+ let loadPercent: Int
+ let nominalPowerWatts: Int?
+ let currentPowerWatts: Double?
+}
+
+struct DiskInfo: Identifiable, Sendable {
+ let id: String
+ let name: String
+ let sizeKB: Int64
+ let usedKB: Int64
+ let freeKB: Int64
+ let temperature: Int?
+ let status: String
+ let errors: Int64
+}
+
+struct ContainerInfo: Identifiable, Sendable {
+ let id: String
+ let name: String
+ let image: String
+ let state: HealthState
+ let status: String
+ let ports: String
+ let iconURL: URL?
+ let webUIURL: URL?
+ let templatePath: String
+ let updateAvailable: Bool
+ var updateState: ContainerUpdateState = .notChecked
+}
+
+struct ContainerRuntimeStats: Sendable {
+ let id: String
+ let cpuPercent: Double
+ let memoryUsage: String
+ let memoryPercent: Double
+ let networkIO: String
+ let blockIO: String
+}
+
+struct VMInfo: Identifiable, Sendable {
+ let id: String
+ let name: String
+ let state: HealthState
+}
+
+struct ServerSnapshot: Sendable {
+ let hostname: String
+ let release: String
+ let kernel: String
+ let uptime: String
+ let cpuBrand: String
+ let cpuPercent: Double
+ let cpuCores: Int
+ let cpuThreads: Int
+ let cpuCorePercents: [Double]
+ let cpuTemperature: Double?
+ let cpuPowerWatts: Double?
+ let memoryPercent: Double
+ let memoryAvailableBytes: Int64
+ let memoryTotalBytes: Int64
+ let memoryUsedBytes: Int64
+ let memoryFreeBytes: Int64
+ let memoryActiveBytes: Int64
+ let memoryBufferCacheBytes: Int64
+ let swapTotalBytes: Int64
+ let swapUsedBytes: Int64
+ let arrayState: String
+ let arrayTotalKB: Int64
+ let arrayUsedKB: Int64
+ let arrayFreeKB: Int64
+ let disks: [DiskInfo]
+ let caches: [DiskInfo]
+ let containers: [ContainerInfo]
+ let virtualMachines: [VMInfo]
+ let ups: UPSInfo?
+
+ static let demo = ServerSnapshot(
+ hostname: "Tower", release: "7.3.2", kernel: "6.x", uptime: "12 天 8 小时",
+ cpuBrand: "Demo CPU", cpuPercent: 18.4, cpuCores: 8, cpuThreads: 16,
+ cpuCorePercents: [12, 22, 8, 31, 17, 19, 9, 28, 11, 24, 16, 21, 7, 34, 13, 23],
+ cpuTemperature: 42, cpuPowerWatts: 36, memoryPercent: 43.2,
+ memoryAvailableBytes: 36 * 1_073_741_824, memoryTotalBytes: 64 * 1_073_741_824,
+ memoryUsedBytes: 28 * 1_073_741_824, memoryFreeBytes: 4 * 1_073_741_824,
+ memoryActiveBytes: 22 * 1_073_741_824, memoryBufferCacheBytes: 8 * 1_073_741_824,
+ swapTotalBytes: 8 * 1_073_741_824, swapUsedBytes: 512 * 1_048_576, arrayState: "STARTED",
+ arrayTotalKB: 24_000_000_000, arrayUsedKB: 13_200_000_000, arrayFreeKB: 10_800_000_000,
+ disks: [DiskInfo(id: "disk1", name: "disk1", sizeKB: 8_000_000_000, usedKB: 4_200_000_000, freeKB: 3_800_000_000, temperature: 35, status: "DISK_OK", errors: 0)],
+ caches: [],
+ containers: [
+ ContainerInfo(id: "demo-nginx", name: "reverse-proxy", image: "nginx:stable", state: .healthy, status: "Up 2 days", ports: "8080 → 80/tcp", iconURL: nil, webUIURL: nil, templatePath: "/boot/config/plugins/dockerMan/templates-user/my-nginx.xml", updateAvailable: false),
+ ContainerInfo(id: "demo-worker", name: "demo-worker", image: "example/worker:latest", state: .stopped, status: "Exited (1)", ports: "无公开端口", iconURL: nil, webUIURL: nil, templatePath: "", updateAvailable: true)
+ ],
+ virtualMachines: [VMInfo(id: "demo-vm", name: "Ubuntu", state: .healthy)],
+ ups: UPSInfo(name: "UPS", model: "Demo 1500VA", status: "ONLINE", chargePercent: 100, runtimeSeconds: 2400, batteryHealth: "Good", inputVoltage: 230, outputVoltage: 230, loadPercent: 23, nominalPowerWatts: 900, currentPowerWatts: 207)
+ )
+}
+
+enum ContainerAction: String, Sendable {
+ case start, stop, updateContainer
+}
+
+struct OperationRecord: Identifiable, Codable, Sendable {
+ let id: UUID
+ let title: String
+ let detail: String
+ let date: Date
+ let succeeded: Bool
+}
+
+enum AppError: LocalizedError {
+ case invalidURL
+ case invalidResponse
+ case http(Int)
+ case api(String)
+ case emptyData
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidURL: "服务器地址必须以 http:// 或 https:// 开头"
+ case .invalidResponse: "服务器返回了无法识别的响应"
+ case .http(401), .http(403): "API Key 无效或权限不足"
+ case .http(let code): "Unraid API 返回 HTTP \(code)"
+ case .api(let message): message
+ case .emptyData: "Unraid API 返回数据为空"
+ }
+ }
+}
+
+extension Int64 {
+ var storageText: String {
+ ByteCountFormatter.string(fromByteCount: self * 1024, countStyle: .binary)
+ }
+}
diff --git a/ios/UnraidManagerIOS/Sources/SystemViews.swift b/ios/UnraidManagerIOS/Sources/SystemViews.swift
new file mode 100644
index 0000000..666b018
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/SystemViews.swift
@@ -0,0 +1,114 @@
+import SwiftUI
+
+struct HealthDetailView: View {
+ let snapshot: ServerSnapshot
+ private var checks: [(String, String, Bool)] {
+ let allDisks = snapshot.disks + snapshot.caches
+ let diskErrors = allDisks.reduce(0) { $0 + $1.errors }
+ let hottest = allDisks.compactMap(\.temperature).max()
+ let stopped = snapshot.containers.filter { $0.state == .stopped }.count
+ return [
+ ("阵列状态", snapshot.arrayState, snapshot.arrayState.uppercased() == "STARTED"),
+ ("磁盘健康", diskErrors == 0 ? "未发现 I/O 错误" : "\(diskErrors) 个 I/O 错误", diskErrors == 0),
+ ("温度", hottest.map { "最高 \($0)℃" } ?? "没有温度数据", (hottest ?? 0) < 50),
+ ("存储容量", "已使用 \(Int(snapshot.arrayTotalKB > 0 ? Double(snapshot.arrayUsedKB) / Double(snapshot.arrayTotalKB) * 100 : 0))%", snapshot.arrayTotalKB == 0 || Double(snapshot.arrayUsedKB) / Double(snapshot.arrayTotalKB) < 0.85),
+ ("CPU 负载", "\(Int(snapshot.cpuPercent))%", snapshot.cpuPercent < 80),
+ ("内存压力", "\(Int(snapshot.memoryPercent))%", snapshot.memoryPercent < 85),
+ ("Docker", stopped == 0 ? "全部运行" : "\(stopped) 个容器停止", stopped == 0),
+ ("UPS", snapshot.ups.map { "\($0.status) · \($0.chargePercent)%" } ?? "未检测到 UPS", snapshot.ups?.status.lowercased().contains("battery") != true)
+ ]
+ }
+ private var score: Int { max(0, 100 - checks.filter { !$0.2 }.count * 10) }
+ var body: some View {
+ List {
+ Section { HStack { VStack(alignment: .leading) { Text("健康评分").foregroundStyle(.secondary); Text("\(score)").font(.system(size: 52, weight: .bold)); Text(score >= 90 ? "运行稳定" : "需要关注").font(.headline) }; Spacer(); Gauge(value: Double(score), in: 0...100) { EmptyView() }.gaugeStyle(.accessoryCircular).tint(score >= 90 ? .green : .orange).scaleEffect(1.5) } }
+ Section("检查项目") { ForEach(Array(checks.enumerated()), id: \.offset) { _, check in HStack { Image(systemName: check.2 ? "checkmark.circle.fill" : "exclamationmark.triangle.fill").foregroundStyle(check.2 ? .green : .orange); VStack(alignment: .leading) { Text(check.0).font(.headline); Text(check.1).font(.caption).foregroundStyle(.secondary) }; Spacer() } } }
+ }.navigationTitle("服务器健康")
+ }
+}
+
+struct CPUDetailView: View {
+ let snapshot: ServerSnapshot
+ var body: some View {
+ List {
+ Section {
+ MetricGauge(title: "整体负载", value: snapshot.cpuPercent, color: .orange)
+ LabeledContent("型号", value: snapshot.cpuBrand)
+ LabeledContent("拓扑", value: "\(snapshot.cpuCores) 核心 · \(snapshot.cpuThreads) 线程")
+ if let temp = snapshot.cpuTemperature { LabeledContent("温度", value: oneDecimal(temp) + "℃") }
+ if let power = snapshot.cpuPowerWatts { LabeledContent("功耗", value: oneDecimal(power) + " W") }
+ }
+ Section("逻辑 CPU") { if snapshot.cpuCorePercents.isEmpty { Text("当前 API 只返回总负载").foregroundStyle(.secondary) } else { ForEach(Array(snapshot.cpuCorePercents.enumerated()), id: \.offset) { index, value in VStack(alignment: .leading) { HStack { Text("CPU \(index)"); Spacer(); Text("\(value, specifier: "%.1f")%").foregroundStyle(.secondary) }; ProgressView(value: value, total: 100).tint(value >= 85 ? .red : .orange) } } } }
+ }.navigationTitle("处理器")
+ }
+ private func oneDecimal(_ value: Double) -> String { value.formatted(.number.precision(.fractionLength(1))) }
+}
+
+struct MemoryDetailView: View {
+ let snapshot: ServerSnapshot
+ var body: some View {
+ List {
+ Section { MetricGauge(title: "内存占用", value: snapshot.memoryPercent, color: .blue) }
+ Section("内存明细") { ByteRow("总计", snapshot.memoryTotalBytes); ByteRow("已使用", snapshot.memoryUsedBytes); ByteRow("活动", snapshot.memoryActiveBytes); ByteRow("缓冲/缓存", snapshot.memoryBufferCacheBytes); ByteRow("可用", snapshot.memoryAvailableBytes); ByteRow("空闲", snapshot.memoryFreeBytes) }
+ Section("Swap") { ByteRow("已使用", snapshot.swapUsedBytes); ByteRow("总计", snapshot.swapTotalBytes) }
+ }.navigationTitle("内存")
+ }
+ @ViewBuilder private func ByteRow(_ title: String, _ bytes: Int64) -> some View { LabeledContent(title, value: ByteCountFormatter.string(fromByteCount: bytes, countStyle: .binary)) }
+}
+
+struct StorageDetailView: View {
+ let snapshot: ServerSnapshot
+ var body: some View {
+ List {
+ Section("阵列") { LabeledContent("状态", value: snapshot.arrayState); LabeledContent("总容量", value: snapshot.arrayTotalKB.storageText); LabeledContent("已使用", value: snapshot.arrayUsedKB.storageText); LabeledContent("可用", value: snapshot.arrayFreeKB.storageText) }
+ DiskSection(title: "数据盘", disks: snapshot.disks)
+ DiskSection(title: "缓存池", disks: snapshot.caches)
+ }.navigationTitle("存储")
+ }
+}
+
+private struct DiskSection: View {
+ let title: String, disks: [DiskInfo]
+ var body: some View { Section(title) { if disks.isEmpty { Text("无数据").foregroundStyle(.secondary) } else { ForEach(disks) { disk in NavigationLink { DiskDetailView(disk: disk) } label: { VStack(alignment: .leading) { HStack { Text(disk.name).font(.headline); Spacer(); if let temp = disk.temperature { Text("\(temp)℃") } }; Text("\(disk.usedKB.storageText) / \(disk.sizeKB.storageText) · \(disk.status)").font(.caption).foregroundStyle(disk.errors > 0 ? .red : .secondary) } } } } } }
+}
+
+private struct DiskDetailView: View {
+ let disk: DiskInfo
+ var body: some View { List { Section { LabeledContent("状态", value: disk.status); LabeledContent("温度", value: disk.temperature.map { "\($0)℃" } ?? "—"); LabeledContent("I/O 错误", value: "\(disk.errors)") }; Section("容量") { LabeledContent("总计", value: disk.sizeKB.storageText); LabeledContent("已使用", value: disk.usedKB.storageText); LabeledContent("可用", value: disk.freeKB.storageText); ProgressView(value: disk.sizeKB > 0 ? Double(disk.usedKB) / Double(disk.sizeKB) : 0) } }.navigationTitle(disk.name) }
+}
+
+struct UPSDetailView: View {
+ let ups: UPSInfo?
+ var body: some View { Group { if let ups { List { Section { MetricGauge(title: "电池电量", value: Double(ups.chargePercent), color: .green); LabeledContent("状态", value: ups.status); LabeledContent("电池健康", value: ups.batteryHealth); LabeledContent("预计续航", value: duration(ups.runtimeSeconds)) }; Section("设备") { LabeledContent("名称", value: ups.name); LabeledContent("型号", value: ups.model) }; Section("电力") { LabeledContent("输入电压", value: oneDecimal(ups.inputVoltage) + " V"); LabeledContent("输出电压", value: oneDecimal(ups.outputVoltage) + " V"); LabeledContent("负载", value: "\(ups.loadPercent)%"); if let value = ups.currentPowerWatts { LabeledContent("当前功耗", value: oneDecimal(value) + " W") }; if let value = ups.nominalPowerWatts { LabeledContent("额定功率", value: "\(value) W") } } } } else { ContentUnavailableView("未检测到 UPS", systemImage: "battery.0percent", description: Text("请确认 Unraid UPS 服务已经启用")) } }.navigationTitle("UPS") }
+ private func duration(_ seconds: Int) -> String { "\(seconds / 3600) 小时 \((seconds % 3600) / 60) 分" }
+ private func oneDecimal(_ value: Double) -> String { value.formatted(.number.precision(.fractionLength(1))) }
+}
+
+private struct MetricGauge: View {
+ let title: String, value: Double, color: Color
+ var body: some View { VStack(alignment: .leading, spacing: 10) { HStack { Text(title).font(.headline); Spacer(); Text("\(value, specifier: "%.1f")%").font(.title2.bold()) }; ProgressView(value: value, total: 100).tint(value >= 90 ? .red : color) } }
+}
+
+struct VMListView: View {
+ @EnvironmentObject private var store: AppStore
+ var body: some View { Group { if let vms = store.snapshot?.virtualMachines, !vms.isEmpty { List(vms) { vm in NavigationLink { VMDetailView(vm: vm) } label: { HStack { Image(systemName: "desktopcomputer").foregroundStyle(vm.state == .healthy ? .green : .secondary); VStack(alignment: .leading) { Text(vm.name).font(.headline); Text(vm.state == .healthy ? "运行中" : "已停止").font(.caption).foregroundStyle(.secondary) } } } } } else { ContentUnavailableView("没有虚拟机", systemImage: "desktopcomputer") } }.navigationTitle("虚拟机").refreshable { await store.refresh() } }
+}
+
+private struct VMDetailView: View {
+ let vm: VMInfo
+ var body: some View { List { Section { LabeledContent("名称", value: vm.name); LabeledContent("状态", value: vm.state == .healthy ? "运行中" : "已停止"); LabeledContent("ID", value: vm.id) }; Section { Label("iOS 版本当前仅提供 VM 只读状态,不发送关机、重启或配置修改。", systemImage: "lock.shield") } }.navigationTitle(vm.name) }
+}
+
+struct OperationHistoryView: View {
+ @EnvironmentObject private var store: AppStore
+ var body: some View { List { if store.operations.isEmpty { ContentUnavailableView("没有操作记录", systemImage: "clock.arrow.circlepath") } else { ForEach(store.operations) { item in VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: item.succeeded ? "checkmark.circle.fill" : "xmark.circle.fill").foregroundStyle(item.succeeded ? .green : .red); Text(item.title).font(.headline); Spacer(); Text(item.date, style: .relative).font(.caption).foregroundStyle(.secondary) }; Text(item.detail).font(.caption).foregroundStyle(.secondary) } } } }.navigationTitle("操作历史").toolbar { if !store.operations.isEmpty { Button("清空", role: .destructive) { store.clearOperations() } } } }
+}
+
+struct ContainerLogsView: View {
+ @EnvironmentObject private var store: AppStore
+ let container: ContainerInfo
+ @State private var lines: [String] = []
+ @State private var error: String?
+ var body: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 5) { ForEach(Array(lines.enumerated()), id: \.offset) { _, line in Text(line).font(.system(.caption, design: .monospaced)).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading) } }.padding() }.background(Color.black).foregroundStyle(Color.green.opacity(0.9)).navigationTitle("日志").toolbar { Button { Task { await load() } } label: { Image(systemName: "arrow.clockwise") } }.task { await load() }.alert("读取日志失败", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("好", role: .cancel) {} } message: { Text(error ?? "") } }
+ private func load() async { do { lines = try await store.logs(for: container) } catch { self.error = error.localizedDescription } }
+}
diff --git a/ios/UnraidManagerIOS/Sources/UnraidAPIClient.swift b/ios/UnraidManagerIOS/Sources/UnraidAPIClient.swift
new file mode 100644
index 0000000..d20427a
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/UnraidAPIClient.swift
@@ -0,0 +1,260 @@
+import Foundation
+
+actor UnraidAPIClient {
+ private let session: URLSession
+
+ init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ func fetchSnapshot(configuration: ServerConfiguration) async throws -> ServerSnapshot {
+ let root: [String: Any]
+ do {
+ root = try await execute(configuration: configuration, query: Self.dashboardQuery)
+ } catch {
+ root = try await execute(configuration: configuration, query: Self.compatibleDashboardQuery)
+ }
+ guard let data = root["data"] as? [String: Any] else { throw AppError.emptyData }
+ var ups = try? await fetchUPS(configuration: configuration)
+ if ups == nil, !configuration.upsBridgeURL.isEmpty {
+ ups = try? await fetchUPSBridge(configuration: configuration)
+ }
+ return try parseSnapshot(data, serverURL: configuration.normalizedURL, ups: ups)
+ }
+
+ func fetchContainerLogs(configuration: ServerConfiguration, containerID: String, tail: Int = 160) async throws -> [String] {
+ let query = """
+ query ContainerLogs($id: PrefixedID!, $tail: Int!) {
+ docker { logs(id: $id, tail: $tail) { lines { timestamp message } cursor } }
+ }
+ """
+ let root = try await execute(configuration: configuration, query: query, variables: ["id": containerID, "tail": min(max(tail, 20), 500)])
+ let lines = ((root["data"] as? [String: Any])?.object("docker").object("logs").array("lines")) ?? []
+ return lines.map { item in
+ let stamp = String(item.string("timestamp").suffix(8))
+ let message = Self.redact(item.string("message"))
+ return [stamp, message].filter { !$0.isEmpty }.joined(separator: " ")
+ }
+ }
+
+ func checkContainerUpdates(configuration: ServerConfiguration) async throws -> [String: ContainerUpdateState] {
+ let query = "query CheckDockerUpdates { docker { containerUpdateStatuses { name updateStatus } } }"
+ let root = try await execute(configuration: configuration, query: query)
+ let items = ((root["data"] as? [String: Any])?.object("docker").array("containerUpdateStatuses")) ?? []
+ return Dictionary(uniqueKeysWithValues: items.compactMap { item in
+ let name = item.string("name")
+ guard !name.isEmpty else { return nil }
+ let state: ContainerUpdateState = switch item.string("updateStatus") {
+ case "UP_TO_DATE": .upToDate
+ case "UPDATE_AVAILABLE": .available
+ case "REBUILD_READY": .rebuildReady
+ default: .unknown
+ }
+ return (name, state)
+ })
+ }
+
+ func perform(configuration: ServerConfiguration, containerID: String, action: ContainerAction) async throws {
+ let field = action.rawValue
+ let mutation = """
+ mutation ContainerAction($id: PrefixedID!) {
+ docker { \(field)(id: $id) { id state status } }
+ }
+ """
+ _ = try await execute(configuration: configuration, query: mutation, variables: ["id": containerID])
+ }
+
+ private func fetchUPS(configuration: ServerConfiguration) async throws -> UPSInfo? {
+ let query = """
+ query UnraidManagerUpsStatus {
+ upsConfiguration { service }
+ upsDevices { id name model status battery { chargeLevel estimatedRuntime health } power { inputVoltage outputVoltage loadPercentage nominalPower currentPower } }
+ }
+ """
+ let root = try await execute(configuration: configuration, query: query)
+ guard let data = root["data"] as? [String: Any], let device = data.array("upsDevices").first else { return nil }
+ let battery = device.object("battery"), power = device.object("power")
+ return UPSInfo(name: device.string("name", fallback: "UPS"), model: device.string("model"), status: device.string("status", fallback: "Unknown"), chargePercent: battery.int("chargeLevel"), runtimeSeconds: battery.int("estimatedRuntime"), batteryHealth: battery.string("health", fallback: "Unknown"), inputVoltage: power.double("inputVoltage"), outputVoltage: power.double("outputVoltage"), loadPercent: power.int("loadPercentage"), nominalPowerWatts: power.optionalInt("nominalPower"), currentPowerWatts: power.optionalDouble("currentPower"))
+ }
+
+ private func fetchUPSBridge(configuration: ServerConfiguration) async throws -> UPSInfo {
+ guard let url = URL(string: configuration.upsBridgeURL), url.scheme == "http" || url.scheme == "https" else { throw AppError.invalidURL }
+ var request = URLRequest(url: url)
+ request.timeoutInterval = 8
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(configuration.apiKey, forHTTPHeaderField: "x-api-key")
+ request.setValue("Unraid-Manager-iOS/0.2.0", forHTTPHeaderField: "User-Agent")
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { throw AppError.api("UPS Bridge 连接失败") }
+ guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw AppError.invalidResponse }
+ let battery = root.object("battery"), power = root.object("power")
+ return UPSInfo(name: root.string("name", fallback: "UPS"), model: root.string("model"), status: root.string("status", fallback: "Unknown"), chargePercent: battery["chargeLevel"] == nil ? root.int("chargePercent") : battery.int("chargeLevel"), runtimeSeconds: battery["estimatedRuntime"] == nil ? root.int("estimatedRuntimeSeconds") : battery.int("estimatedRuntime"), batteryHealth: battery.string("health", fallback: root.string("batteryHealth", fallback: "Unknown")), inputVoltage: power["inputVoltage"] == nil ? root.double("inputVoltage") : power.double("inputVoltage"), outputVoltage: power["outputVoltage"] == nil ? root.double("outputVoltage") : power.double("outputVoltage"), loadPercent: power["loadPercentage"] == nil ? root.int("loadPercent") : power.int("loadPercentage"), nominalPowerWatts: power.optionalInt("nominalPower"), currentPowerWatts: power.optionalDouble("currentPower"))
+ }
+
+ func restart(configuration: ServerConfiguration, containerID: String) async throws {
+ try await perform(configuration: configuration, containerID: containerID, action: .stop)
+ for _ in 0..<10 {
+ try await Task.sleep(for: .seconds(1))
+ let snapshot = try await fetchSnapshot(configuration: configuration)
+ if snapshot.containers.first(where: { $0.id == containerID })?.state == .stopped { break }
+ }
+ try await perform(configuration: configuration, containerID: containerID, action: .start)
+ }
+
+ private func execute(configuration: ServerConfiguration, query: String, variables: [String: Any] = [:]) async throws -> [String: Any] {
+ guard !configuration.apiKey.isEmpty else { throw AppError.api("请填写统一 Unraid API Key") }
+ guard var components = URLComponents(string: configuration.normalizedURL),
+ components.scheme == "http" || components.scheme == "https" else { throw AppError.invalidURL }
+ if !components.path.hasSuffix("/graphql") {
+ components.path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/graphql"
+ if !components.path.hasPrefix("/") { components.path = "/" + components.path }
+ }
+ guard let url = components.url else { throw AppError.invalidURL }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.timeoutInterval = 20
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ request.setValue(configuration.apiKey, forHTTPHeaderField: "x-api-key")
+ request.setValue("Unraid-Manager-iOS/0.2.0", forHTTPHeaderField: "User-Agent")
+ request.httpBody = try JSONSerialization.data(withJSONObject: ["query": query, "variables": variables])
+
+ let (body, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw AppError.invalidResponse }
+ guard (200...299).contains(http.statusCode) else { throw AppError.http(http.statusCode) }
+ guard let root = try JSONSerialization.jsonObject(with: body) as? [String: Any] else { throw AppError.invalidResponse }
+ if let first = (root["errors"] as? [[String: Any]])?.first,
+ let message = first["message"] as? String { throw AppError.api(message) }
+ return root
+ }
+
+ private func parseSnapshot(_ data: [String: Any], serverURL: String, ups: UPSInfo?) throws -> ServerSnapshot {
+ let info = data.object("info"), os = info.object("os"), cpu = info.object("cpu")
+ let cpuPackages = cpu.object("packages")
+ let metrics = data.object("metrics"), cpuMetrics = metrics.object("cpu"), memory = metrics.object("memory")
+ let array = data.object("array"), capacity = array.object("capacity").object("kilobytes")
+ let docker = data.object("docker"), vms = data.object("vms")
+ return ServerSnapshot(
+ hostname: os.string("hostname", fallback: "Unraid"), release: os.string("release", fallback: "未知版本"),
+ kernel: os.string("kernel"), uptime: os.string("uptime"), cpuBrand: cpu.string("brand"),
+ cpuPercent: cpuMetrics.double("percentTotal"), cpuCores: cpu.int("cores"), cpuThreads: cpu.int("threads"),
+ cpuCorePercents: cpuMetrics.array("cpus").map { $0.double("percentTotal") },
+ cpuTemperature: cpuPackages.firstNumber("temp"), cpuPowerWatts: cpuPackages.optionalDouble("totalPower"), memoryPercent: memory.double("percentTotal"),
+ memoryAvailableBytes: memory.int64("available"), memoryTotalBytes: memory.int64("total"), memoryUsedBytes: memory.int64("used"), memoryFreeBytes: memory.int64("free"), memoryActiveBytes: memory.int64("active"), memoryBufferCacheBytes: memory.int64("buffcache"), swapTotalBytes: memory.int64("swapTotal"), swapUsedBytes: memory.int64("swapUsed"), arrayState: array.string("state", fallback: "UNKNOWN"),
+ arrayTotalKB: capacity.int64("total"), arrayUsedKB: capacity.int64("used"), arrayFreeKB: capacity.int64("free"),
+ disks: parseDisks(array.array("disks")), caches: parseDisks(array.array("caches")),
+ containers: parseContainers(docker.array("containers"), serverURL: serverURL),
+ virtualMachines: vms.array("domains").enumerated().map { index, item in
+ let state: HealthState = item.string("state").uppercased() == "RUNNING" ? .healthy : .stopped
+ return VMInfo(id: item.string("id", fallback: "vm-\(index)"), name: item.string("name", fallback: "VM"), state: state)
+ }, ups: ups
+ )
+ }
+
+ private func parseDisks(_ items: [[String: Any]]) -> [DiskInfo] {
+ items.enumerated().map { index, item in
+ DiskInfo(id: item.string("device", fallback: "disk-\(index)"), name: item.string("name", fallback: "disk"),
+ sizeKB: item.int64("size"), usedKB: item.int64("fsUsed"), freeKB: item.int64("fsFree"),
+ temperature: item.optionalInt("temp"), status: item.string("status", fallback: "UNKNOWN"), errors: item.int64("numErrors"))
+ }
+ }
+
+ private func parseContainers(_ items: [[String: Any]], serverURL: String) -> [ContainerInfo] {
+ items.enumerated().map { index, item in
+ let rawState = item.string("state").uppercased()
+ let state: HealthState = rawState == "RUNNING" ? .healthy : (rawState == "PAUSED" ? .warning : .stopped)
+ let names = item["names"] as? [String]
+ return ContainerInfo(
+ id: item.string("id", fallback: "container-\(index)"), name: (names?.first ?? "container-\(index)").trimmingCharacters(in: CharacterSet(charactersIn: "/")),
+ image: item.string("image", fallback: "未知镜像"), state: state,
+ status: item.string("status", fallback: rawState), ports: item.string("lanIpPorts", fallback: "无公开端口"),
+ iconURL: resolveURL(item.flexibleString("iconUrl"), serverURL: serverURL),
+ webUIURL: resolveURL(item.flexibleString("webUiUrl"), serverURL: serverURL),
+ templatePath: item.string("templatePath"),
+ updateAvailable: item["isUpdateAvailable"] as? Bool ?? false
+ )
+ }.sorted { ($0.state == .healthy ? 0 : 1, $0.name.lowercased()) < ($1.state == .healthy ? 0 : 1, $1.name.lowercased()) }
+ }
+
+ private func resolveURL(_ value: String, serverURL: String) -> URL? {
+ guard !value.isEmpty else { return nil }
+ if let url = URL(string: value), url.scheme != nil { return url }
+ guard let base = URL(string: serverURL) else { return nil }
+ return URL(string: value, relativeTo: base)?.absoluteURL
+ }
+
+ private static let dashboardQuery = """
+ query UnraidManagerDashboard {
+ info { os { hostname release uptime kernel } cpu { brand cores threads packages { totalPower temp } } }
+ metrics { cpu { percentTotal cpus { percentTotal } } memory { total used free available active buffcache percentTotal swapTotal swapUsed } }
+ array {
+ state capacity { kilobytes { free used total } }
+ disks { name device size status temp fsFree fsUsed fsType numErrors }
+ caches { name device size status temp fsFree fsUsed fsType numErrors }
+ }
+ docker { containers { id names image imageId state status lanIpPorts autoStart isUpdateAvailable templatePath iconUrl webUiUrl } }
+ vms { domains { id name state } }
+ }
+ """
+
+ private static let compatibleDashboardQuery = dashboardQuery
+ .replacingOccurrences(of: " packages { totalPower temp }", with: "")
+ .replacingOccurrences(of: " cpus { percentTotal }", with: "")
+ .replacingOccurrences(of: " active buffcache", with: "")
+ .replacingOccurrences(of: " swapTotal swapUsed", with: "")
+ .replacingOccurrences(of: " iconUrl webUiUrl", with: "")
+
+ private static func redact(_ text: String) -> String {
+ let patterns = ["(?i)(authorization:|x-api-key:|api[_-]?key=|token=|password=)\\s*[^\\s]+"]
+ return patterns.reduce(text) { value, pattern in
+ guard let regex = try? NSRegularExpression(pattern: pattern) else { return value }
+ return regex.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: "$1 [REDACTED]")
+ }
+ }
+}
+
+private extension Dictionary where Key == String, Value == Any {
+ func object(_ key: String) -> [String: Any] { self[key] as? [String: Any] ?? [:] }
+ func array(_ key: String) -> [[String: Any]] { self[key] as? [[String: Any]] ?? [] }
+ func string(_ key: String, fallback: String = "") -> String {
+ if let value = self[key] as? String { return value }
+ if let value = self[key] as? NSNumber { return value.stringValue }
+ return fallback
+ }
+ func flexibleString(_ key: String) -> String {
+ if let value = self[key] as? String { return value.trimmingCharacters(in: .whitespacesAndNewlines) }
+ return (self[key] as? [String])?.last(where: { !$0.isEmpty }) ?? ""
+ }
+ func double(_ key: String) -> Double {
+ if let value = self[key] as? NSNumber { return value.doubleValue }
+ return Double(string(key)) ?? 0
+ }
+ func optionalDouble(_ key: String) -> Double? {
+ guard self[key] != nil, !(self[key] is NSNull) else { return nil }
+ if let value = self[key] as? NSNumber { return value.doubleValue }
+ return Double(string(key))
+ }
+ func int(_ key: String) -> Int {
+ if let value = self[key] as? NSNumber { return value.intValue }
+ return Int(string(key)) ?? 0
+ }
+ func int64(_ key: String) -> Int64 {
+ if let value = self[key] as? NSNumber { return value.int64Value }
+ return Int64(string(key)) ?? 0
+ }
+ func optionalInt(_ key: String) -> Int? {
+ guard self[key] != nil, !(self[key] is NSNull) else { return nil }
+ if let value = self[key] as? NSNumber { return value.intValue }
+ return Int(string(key))
+ }
+ func firstNumber(_ key: String) -> Double? {
+ if let values = self[key] as? [NSNumber] { return values.first?.doubleValue }
+ if let values = self[key] as? [[String: Any]] {
+ for item in values {
+ if let value = item.optionalDouble("value") ?? item.optionalDouble("current") { return value }
+ }
+ }
+ return optionalDouble(key)
+ }
+}
diff --git a/ios/UnraidManagerIOS/Sources/UnraidManagerApp.swift b/ios/UnraidManagerIOS/Sources/UnraidManagerApp.swift
new file mode 100644
index 0000000..3b6d0e9
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/UnraidManagerApp.swift
@@ -0,0 +1,14 @@
+import SwiftUI
+
+@main
+struct UnraidManagerApp: App {
+ @StateObject private var store = AppStore()
+
+ var body: some Scene {
+ WindowGroup {
+ RootView()
+ .environmentObject(store)
+ .tint(Color(red: 0.96, green: 0.35, blue: 0.12))
+ }
+ }
+}
diff --git a/ios/UnraidManagerIOS/Sources/Views.swift b/ios/UnraidManagerIOS/Sources/Views.swift
new file mode 100644
index 0000000..5e7397a
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/Views.swift
@@ -0,0 +1,233 @@
+import SwiftUI
+
+struct RootView: View {
+ @EnvironmentObject private var store: AppStore
+
+ var body: some View {
+ TabView {
+ NavigationStack { DashboardView() }
+ .tabItem { Label("概览", systemImage: "gauge.with.dots.needle.67percent") }
+ NavigationStack { DockerView() }
+ .tabItem { Label("Docker", systemImage: "shippingbox") }
+ NavigationStack { VMListView() }
+ .tabItem { Label("虚拟机", systemImage: "desktopcomputer") }
+ NavigationStack { FileBrowserView() }
+ .tabItem { Label("文件", systemImage: "folder") }
+ NavigationStack { SettingsView() }
+ .tabItem { Label("设置", systemImage: "gearshape") }
+ }
+ .task { if store.snapshot == nil { await store.refresh() } }
+ .sheet(isPresented: $store.showConfiguration) { ConfigurationView() }
+ .alert("连接失败", isPresented: Binding(get: { store.errorMessage != nil }, set: { if !$0 { store.errorMessage = nil } })) {
+ Button("好", role: .cancel) { store.errorMessage = nil }
+ } message: { Text(store.errorMessage ?? "未知错误") }
+ .overlay { if store.isLoading { ProgressView().padding(24).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } }
+ .preferredColorScheme(store.colorSchemePreference == "dark" ? .dark : store.colorSchemePreference == "light" ? .light : nil)
+ .task {
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(15))
+ if store.autoRefresh { await store.refresh() }
+ }
+ }
+ }
+}
+
+struct DashboardView: View {
+ @EnvironmentObject private var store: AppStore
+
+ var body: some View {
+ Group {
+ if let snapshot = store.snapshot {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ VStack(alignment: .leading, spacing: 5) {
+ Text(snapshot.hostname).font(.largeTitle.bold())
+ Text("Unraid \(snapshot.release) · \(snapshot.uptime)").foregroundStyle(.secondary)
+ }
+ HStack(spacing: 12) {
+ NavigationLink { CPUDetailView(snapshot: snapshot) } label: { MetricCard(title: "CPU", value: snapshot.cpuPercent, detail: snapshot.cpuBrand) }.buttonStyle(.plain)
+ NavigationLink { MemoryDetailView(snapshot: snapshot) } label: { MetricCard(title: "内存", value: snapshot.memoryPercent, detail: "可用 \(ByteCountFormatter.string(fromByteCount: snapshot.memoryAvailableBytes, countStyle: .binary))") }.buttonStyle(.plain)
+ }
+ NavigationLink { StorageDetailView(snapshot: snapshot) } label: { ArrayCard(snapshot: snapshot) }.buttonStyle(.plain)
+ NavigationLink { HealthDetailView(snapshot: snapshot) } label: { SummaryRow(title: "健康检查", value: "查看评分与告警", icon: "heart.text.square.fill") }.buttonStyle(.plain)
+ NavigationLink { UPSDetailView(ups: snapshot.ups) } label: { SummaryRow(title: "UPS", value: snapshot.ups.map { "\($0.status) · \($0.chargePercent)%" } ?? "未检测到", icon: "battery.100percent.bolt") }.buttonStyle(.plain)
+ SummaryRow(title: "Docker", value: "\(snapshot.containers.filter { $0.state == .healthy }.count) / \(snapshot.containers.count) 运行", icon: "shippingbox.fill")
+ SummaryRow(title: "虚拟机", value: "\(snapshot.virtualMachines.filter { $0.state == .healthy }.count) / \(snapshot.virtualMachines.count) 运行", icon: "desktopcomputer")
+ if !snapshot.disks.isEmpty {
+ Text("磁盘").font(.headline)
+ ForEach(snapshot.disks) { disk in DiskRow(disk: disk) }
+ }
+ }.padding()
+ }
+ .refreshable { await store.refresh() }
+ } else {
+ ContentUnavailableView("尚未连接服务器", systemImage: "server.rack", description: Text("请在设置中填写服务器地址和 API Key"))
+ }
+ }
+ .navigationTitle(store.configuration.name)
+ .toolbar { Button { Task { await store.refresh() } } label: { Image(systemName: "arrow.clockwise") } }
+ }
+}
+
+private struct MetricCard: View {
+ let title: String
+ let value: Double
+ let detail: String
+ var body: some View {
+ VStack(alignment: .leading, spacing: 9) {
+ Text(title).font(.headline)
+ Text(value, format: .number.precision(.fractionLength(1))).font(.system(size: 30, weight: .bold)) + Text("%").foregroundStyle(.secondary)
+ ProgressView(value: min(max(value / 100, 0), 1)).tint(value > 85 ? .red : .orange)
+ Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(1)
+ }.padding().frame(maxWidth: .infinity, alignment: .leading).background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
+ }
+}
+
+private struct ArrayCard: View {
+ let snapshot: ServerSnapshot
+ var fraction: Double { snapshot.arrayTotalKB > 0 ? Double(snapshot.arrayUsedKB) / Double(snapshot.arrayTotalKB) : 0 }
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack { Label("存储阵列", systemImage: "externaldrive.fill").font(.headline); Spacer(); Text(snapshot.arrayState).font(.caption.bold()).foregroundStyle(snapshot.arrayState == "STARTED" ? .green : .orange) }
+ ProgressView(value: fraction)
+ HStack { Text("已用 \(snapshot.arrayUsedKB.storageText)"); Spacer(); Text("共 \(snapshot.arrayTotalKB.storageText)") }.font(.caption).foregroundStyle(.secondary)
+ }.padding().background(.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
+ }
+}
+
+private struct SummaryRow: View {
+ let title: String, value: String, icon: String
+ var body: some View { HStack { Image(systemName: icon).frame(width: 28).foregroundStyle(.orange); Text(title).font(.headline); Spacer(); Text(value).foregroundStyle(.secondary) }.padding().background(.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14)) }
+}
+
+private struct DiskRow: View {
+ let disk: DiskInfo
+ var body: some View {
+ HStack {
+ Image(systemName: disk.errors > 0 ? "externaldrive.badge.exclamationmark" : "externaldrive")
+ VStack(alignment: .leading) { Text(disk.name).font(.headline); Text("\(disk.usedKB.storageText) / \(disk.sizeKB.storageText)").font(.caption).foregroundStyle(.secondary) }
+ Spacer()
+ if let temperature = disk.temperature { Text("\(temperature)℃").foregroundStyle(temperature >= 50 ? .red : .secondary) }
+ }.padding(.vertical, 4)
+ }
+}
+
+struct DockerView: View {
+ @EnvironmentObject private var store: AppStore
+ @State private var search = ""
+ var containers: [ContainerInfo] { store.snapshot?.containers.filter { search.isEmpty || $0.name.localizedCaseInsensitiveContains(search) || $0.image.localizedCaseInsensitiveContains(search) } ?? [] }
+ var body: some View {
+ Group {
+ if containers.isEmpty { ContentUnavailableView("没有容器", systemImage: "shippingbox") }
+ else { List(containers) { container in NavigationLink { ContainerDetailView(container: container) } label: { ContainerRow(container: container) } } }
+ }
+ .navigationTitle("Docker")
+ .searchable(text: $search, prompt: "搜索名称或镜像")
+ .refreshable { await store.refresh() }
+ .toolbar { Button("检查更新") { Task { await store.checkContainerUpdates() } } }
+ }
+}
+
+private struct ContainerRow: View {
+ let container: ContainerInfo
+ var body: some View {
+ HStack(spacing: 12) {
+ AsyncImage(url: container.iconURL) { image in image.resizable().scaledToFit() } placeholder: { Image(systemName: "shippingbox.fill").foregroundStyle(.orange) }.frame(width: 38, height: 38)
+ VStack(alignment: .leading, spacing: 3) { Text(container.name).font(.headline); Text(container.image).font(.caption).foregroundStyle(.secondary).lineLimit(1); Text(container.status).font(.caption2).foregroundStyle(container.state == .healthy ? .green : .secondary) }
+ Spacer()
+ if container.updateAvailable || container.updateState == .available { Image(systemName: "arrow.down.circle.fill").foregroundStyle(.orange) }
+ }
+ }
+}
+
+struct ContainerDetailView: View {
+ @EnvironmentObject private var store: AppStore
+ let container: ContainerInfo
+ @State private var pendingOperation: ContainerOperation?
+ @StateObject private var liveStats = ContainerStatsViewModel()
+ var body: some View {
+ List {
+ Section("状态") { LabeledContent("镜像", value: container.image); LabeledContent("状态", value: container.status); LabeledContent("端口", value: container.ports) }
+ Section("实时指标") {
+ if let stats = liveStats.stats {
+ LabeledContent("CPU", value: stats.cpuPercent.formatted(.number.precision(.fractionLength(1))) + "%")
+ LabeledContent("内存", value: stats.memoryUsage)
+ LabeledContent("内存占比", value: stats.memoryPercent.formatted(.number.precision(.fractionLength(1))) + "%")
+ LabeledContent("网络 I/O", value: stats.networkIO)
+ LabeledContent("磁盘 I/O", value: stats.blockIO)
+ }
+ Text(liveStats.status).font(.caption).foregroundStyle(.secondary)
+ }
+ Section("镜像更新") { LabeledContent("检查状态", value: container.updateState.label); if !container.templatePath.isEmpty && (container.updateAvailable || container.updateState == .available || container.updateState == .rebuildReady) { Button("更新并重建容器") { pendingOperation = .update } } else if container.templatePath.isEmpty { Text("缺少 DockerMan 模板,已禁用直接更新。").font(.caption).foregroundStyle(.secondary) } }
+ if let webUIURL = container.webUIURL { Section { Link(destination: webUIURL) { Label("打开 WebUI", systemImage: "safari") } } }
+ Section { NavigationLink { ContainerLogsView(container: container) } label: { Label("查看最近日志", systemImage: "terminal") } }
+ Section("安全操作") {
+ if container.state == .healthy {
+ Button("停止容器", role: .destructive) { pendingOperation = .stop }
+ Button("重启容器") { pendingOperation = .restart }
+ } else { Button("启动容器") { pendingOperation = .start } }
+ }
+ }
+ .navigationTitle(container.name)
+ .task { liveStats.connect(configuration: store.configuration, containerID: container.id) }
+ .onDisappear { liveStats.disconnect() }
+ .confirmationDialog("确认\(pendingOperation?.rawValue ?? "操作") \(container.name)?", isPresented: Binding(get: { pendingOperation != nil }, set: { if !$0 { pendingOperation = nil } }), titleVisibility: .visible) {
+ if let operation = pendingOperation { Button("确认\(operation.rawValue)", role: operation == .stop ? .destructive : nil) { pendingOperation = nil; Task { await store.perform(operation, on: container) } } }
+ Button("取消", role: .cancel) { pendingOperation = nil }
+ } message: { Text("操作将立即发送到 Unraid,完成后会重新读取容器状态。") }
+ }
+}
+
+struct SettingsView: View {
+ @EnvironmentObject private var store: AppStore
+ var body: some View {
+ List {
+ Section("服务器") { LabeledContent("名称", value: store.configuration.name); LabeledContent("地址", value: store.configuration.mockMode ? "演示模式" : store.configuration.url); Button("修改连接设置") { store.showConfiguration = true } }
+ Section("文件管理") { LabeledContent("XFileManager", value: store.configuration.fileManagerURL.isEmpty ? "未配置" : store.configuration.fileManagerURL); LabeledContent("初始目录", value: store.configuration.fileManagerRootPath.isEmpty ? "/" : store.configuration.fileManagerRootPath) }
+ Section("安全") { Label("API Key 保存在 iOS Keychain", systemImage: "key.fill"); Label("TLS 证书由系统验证", systemImage: "checkmark.shield.fill") }
+ Section("刷新与外观") {
+ Toggle("每 15 秒自动刷新", isOn: $store.autoRefresh).onChange(of: store.autoRefresh) { store.savePreferences() }
+ Picker("外观", selection: $store.colorSchemePreference) { Text("跟随系统").tag("system"); Text("浅色").tag("light"); Text("深色").tag("dark") }.onChange(of: store.colorSchemePreference) { store.savePreferences() }
+ }
+ Section("记录") { NavigationLink { OperationHistoryView() } label: { Label("操作历史", systemImage: "clock.arrow.circlepath") } }
+ Section("关于") { LabeledContent("iOS 客户端", value: "0.2.0"); Text("不包含分析、广告或遥测 SDK。") }
+ }.navigationTitle("设置")
+ }
+}
+
+struct ConfigurationView: View {
+ @EnvironmentObject private var store: AppStore
+ @Environment(\.dismiss) private var dismiss
+ @State private var draft = ServerConfiguration()
+ var body: some View {
+ NavigationStack {
+ Form {
+ Section("Unraid 服务器") {
+ TextField("显示名称", text: $draft.name)
+ TextField("https://tower.example.com", text: $draft.url)
+ SecureField("统一 Unraid API Key", text: $draft.apiKey)
+ }
+ Section {
+ Toggle("演示模式", isOn: $draft.mockMode)
+ } footer: {
+ Text("推荐权限范围:role:viewer,docker:update。HTTPS 服务器必须使用系统信任的有效证书。")
+ }
+ Section("XFileManager(可选)") {
+ TextField("http://tower.local:38876", text: $draft.fileManagerURL)
+ TextField("用户名", text: $draft.fileManagerUsername)
+ SecureField("密码", text: $draft.fileManagerPassword)
+ TextField("限制访问的初始目录", text: $draft.fileManagerRootPath)
+ }
+ Section("UPS Bridge(可选)") {
+ TextField("https://example.com/bigfeng-ups/status", text: $draft.upsBridgeURL)
+ }
+ }
+ .navigationTitle("连接设置")
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) { if store.isConfigured { Button("取消") { dismiss() } } }
+ ToolbarItem(placement: .confirmationAction) { Button("保存并验证") { Task { if await store.save(configuration: draft) { dismiss() } } }.disabled(!draft.mockMode && (draft.url.isEmpty || draft.apiKey.isEmpty)) }
+ }
+ .onAppear { draft = store.configuration }
+ }.interactiveDismissDisabled(!store.isConfigured)
+ }
+}
diff --git a/ios/UnraidManagerIOS/Sources/XFileManagerClient.swift b/ios/UnraidManagerIOS/Sources/XFileManagerClient.swift
new file mode 100644
index 0000000..41eb375
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/XFileManagerClient.swift
@@ -0,0 +1,161 @@
+import Foundation
+
+struct RemoteFileEntry: Identifiable, Sendable {
+ var id: String { path }
+ let name: String
+ let path: String
+ let isDirectory: Bool
+ let isLink: Bool
+ let sizeBytes: Int64
+ let modified: Date?
+}
+
+struct RemoteShareLink: Sendable {
+ let url: URL
+ let token: String
+}
+
+actor XFileManagerClient {
+ private let session = URLSession.shared
+ private var token: String?
+ private var sessionIdentity = ""
+
+ func list(configuration: ServerConfiguration, path: String) async throws -> [RemoteFileEntry] {
+ let safePath = try withinRoot(path, root: configuration.fileManagerRootPath)
+ let data = try await authorized(configuration: configuration, path: "/api/files/list", query: [URLQueryItem(name: "path", value: safePath), URLQueryItem(name: "limit", value: "500"), URLQueryItem(name: "offset", value: "0")])
+ let value = try JSONSerialization.jsonObject(with: data)
+ let rawItems: [[String: Any]]
+ if let items = value as? [[String: Any]] { rawItems = items }
+ else if let root = value as? [String: Any] { rawItems = (root["items"] as? [[String: Any]]) ?? (root["files"] as? [[String: Any]]) ?? [] }
+ else { throw AppError.invalidResponse }
+ return rawItems.compactMap { item in
+ let name = (item["Name"] as? String) ?? (item["name"] as? String) ?? ""
+ guard !name.isEmpty, name != ".", name != ".." else { return nil }
+ let fullPath = (item["FullPath"] as? String) ?? (item["path"] as? String) ?? join(safePath, name)
+ guard (try? withinRoot(fullPath, root: configuration.fileManagerRootPath)) != nil else { return nil }
+ let seconds = ((item["ModTime"] as? NSNumber) ?? (item["modTime"] as? NSNumber))?.doubleValue
+ return RemoteFileEntry(name: name, path: normalize(fullPath), isDirectory: (item["IsDir"] as? Bool) ?? (item["isDir"] as? Bool) ?? false, isLink: (item["IsLink"] as? Bool) ?? (item["isLink"] as? Bool) ?? false, sizeBytes: ((item["Size"] as? NSNumber) ?? (item["size"] as? NSNumber))?.int64Value ?? 0, modified: seconds.map { Date(timeIntervalSince1970: $0 > 10_000_000_000 ? $0 / 1000 : $0) })
+ }.sorted { $0.isDirectory != $1.isDirectory ? $0.isDirectory : $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+
+ func createDirectory(configuration: ServerConfiguration, parent: String, name: String) async throws {
+ try validateName(name)
+ _ = try await authorizedJSON(configuration: configuration, path: "/api/files/mkdir", method: "POST", body: ["path": try withinRoot(parent, root: configuration.fileManagerRootPath), "name": name])
+ }
+
+ func rename(configuration: ServerConfiguration, entryPath: String, newName: String) async throws {
+ try validateName(newName)
+ _ = try await authorizedJSON(configuration: configuration, path: "/api/files/rename", method: "POST", body: ["oldPath": try withinRoot(entryPath, root: configuration.fileManagerRootPath), "newName": newName])
+ }
+
+ func delete(configuration: ServerConfiguration, entryPath: String) async throws {
+ let target = try withinRoot(entryPath, root: configuration.fileManagerRootPath)
+ guard target != normalize(configuration.fileManagerRootPath) else { throw AppError.api("不能删除文件管理根目录") }
+ _ = try await authorizedJSON(configuration: configuration, path: "/api/files/delete", method: "POST", body: ["path": target])
+ }
+
+ func download(configuration: ServerConfiguration, entry: RemoteFileEntry) async throws -> URL {
+ let target = try withinRoot(entry.path, root: configuration.fileManagerRootPath)
+ let data = try await authorized(configuration: configuration, path: "/api/files/download", query: [URLQueryItem(name: "path", value: target)])
+ let directory = FileManager.default.temporaryDirectory.appending(path: "UnraidManager", directoryHint: .isDirectory)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let output = directory.appending(path: entry.name)
+ try data.write(to: output, options: .atomic)
+ return output
+ }
+
+ func textPreview(configuration: ServerConfiguration, entry: RemoteFileEntry) async throws -> String {
+ let target = try withinRoot(entry.path, root: configuration.fileManagerRootPath)
+ let data = try await authorized(configuration: configuration, path: "/api/files/download", query: [URLQueryItem(name: "path", value: target), URLQueryItem(name: "inline", value: "true")])
+ let prefix = data.prefix(1_048_576)
+ return String(data: prefix, encoding: .utf8) ?? String(data: prefix, encoding: .unicode) ?? "无法解码文本"
+ }
+
+ func upload(configuration: ServerConfiguration, parent: String, fileURL: URL) async throws {
+ let parent = try withinRoot(parent, root: configuration.fileManagerRootPath)
+ try validateName(fileURL.lastPathComponent)
+ let boundary = "Boundary-\(UUID().uuidString)"
+ let bodyURL = FileManager.default.temporaryDirectory.appending(path: "upload-\(UUID().uuidString).multipart")
+ guard FileManager.default.createFile(atPath: bodyURL.path, contents: nil) else { throw AppError.api("无法准备上传临时文件") }
+ defer { try? FileManager.default.removeItem(at: bodyURL) }
+ let output = try FileHandle(forWritingTo: bodyURL)
+ defer { try? output.close() }
+ try output.write(contentsOf: "--\(boundary)\r\nContent-Disposition: form-data; name=\"path\"\r\n\r\n\(parent)\r\n".data(using: .utf8)!)
+ try output.write(contentsOf: "--\(boundary)\r\nContent-Disposition: form-data; name=\"files\"; filename=\"\(fileURL.lastPathComponent)\"\r\nContent-Type: application/octet-stream\r\n\r\n".data(using: .utf8)!)
+ let input = try FileHandle(forReadingFrom: fileURL)
+ defer { try? input.close() }
+ while let chunk = try input.read(upToCount: 1_048_576), !chunk.isEmpty { try output.write(contentsOf: chunk) }
+ try output.write(contentsOf: "\r\n--\(boundary)--\r\n".data(using: .utf8)!)
+ try output.synchronize()
+ _ = try await authorizedUpload(configuration: configuration, path: "/api/files/upload", bodyURL: bodyURL, contentType: "multipart/form-data; boundary=\(boundary)")
+ }
+
+ func createShareLink(configuration: ServerConfiguration, entry: RemoteFileEntry, expireHours: Int = 24) async throws -> RemoteShareLink {
+ let data = try await authorizedJSON(configuration: configuration, path: "/api/files/share", method: "POST", body: ["path": try withinRoot(entry.path, root: configuration.fileManagerRootPath), "expire_hours": expireHours])
+ guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw AppError.invalidResponse }
+ let share = (root["share"] as? [String: Any]) ?? root
+ let token = share["token"] as? String ?? ""
+ let raw = (share["share_url"] as? String) ?? (token.isEmpty ? "" : "/s/\(token)")
+ guard !raw.isEmpty, let base = URL(string: configuration.fileManagerURL), let url = URL(string: raw, relativeTo: base)?.absoluteURL else { throw AppError.api("XFileManager 没有返回分享链接") }
+ return RemoteShareLink(url: url, token: token)
+ }
+
+ private func authorizedJSON(configuration: ServerConfiguration, path: String, method: String, body: [String: Any]) async throws -> Data {
+ try await authorized(configuration: configuration, path: path, method: method, body: JSONSerialization.data(withJSONObject: body), contentType: "application/json")
+ }
+
+ private func authorizedUpload(configuration: ServerConfiguration, path: String, bodyURL: URL, contentType: String, retry: Bool = true) async throws -> Data {
+ let accessToken = try await authenticate(configuration)
+ var request = try request(configuration: configuration, path: path)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ request.setValue(contentType, forHTTPHeaderField: "Content-Type")
+ let (data, response) = try await session.upload(for: request, fromFile: bodyURL)
+ guard let http = response as? HTTPURLResponse else { throw AppError.invalidResponse }
+ if http.statusCode == 401, retry { token = nil; return try await authorizedUpload(configuration: configuration, path: path, bodyURL: bodyURL, contentType: contentType, retry: false) }
+ guard (200...299).contains(http.statusCode) else { throw AppError.api("XFileManager 上传失败(HTTP \(http.statusCode))") }
+ return data
+ }
+
+ private func authorized(configuration: ServerConfiguration, path: String, query: [URLQueryItem] = [], method: String = "GET", body: Data? = nil, contentType: String? = nil, retry: Bool = true) async throws -> Data {
+ let accessToken = try await authenticate(configuration)
+ var request = try request(configuration: configuration, path: path, query: query)
+ request.httpMethod = method
+ request.httpBody = body
+ request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
+ if let contentType { request.setValue(contentType, forHTTPHeaderField: "Content-Type") }
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw AppError.invalidResponse }
+ if http.statusCode == 401, retry { token = nil; return try await authorized(configuration: configuration, path: path, query: query, method: method, body: body, contentType: contentType, retry: false) }
+ guard (200...299).contains(http.statusCode) else { throw AppError.api("XFileManager 操作失败(HTTP \(http.statusCode))") }
+ return data
+ }
+
+ private func authenticate(_ configuration: ServerConfiguration) async throws -> String {
+ let identity = "\(configuration.fileManagerURL)\0\(configuration.fileManagerUsername)\0\(configuration.fileManagerPassword)"
+ if identity == sessionIdentity, let token { return token }
+ guard !configuration.fileManagerUsername.isEmpty, !configuration.fileManagerPassword.isEmpty else { throw AppError.api("请填写 XFileManager 用户名和密码") }
+ var request = try request(configuration: configuration, path: "/api/auth/login")
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: ["username": configuration.fileManagerUsername, "password": configuration.fileManagerPassword])
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode), let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], root["success"] as? Bool == true, let value = root["token"] as? String, !value.isEmpty else { throw AppError.api("XFileManager 登录失败,请检查地址和凭据") }
+ token = value; sessionIdentity = identity
+ return value
+ }
+
+ private func request(configuration: ServerConfiguration, path: String, query: [URLQueryItem] = []) throws -> URLRequest {
+ guard var components = URLComponents(string: configuration.fileManagerURL), components.scheme == "http" || components.scheme == "https" else { throw AppError.api("请填写完整的 XFileManager 地址") }
+ components.path = components.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + path
+ if !components.path.hasPrefix("/") { components.path = "/" + components.path }
+ components.queryItems = query
+ guard let url = components.url else { throw AppError.invalidURL }
+ var request = URLRequest(url: url); request.timeoutInterval = 90; request.setValue("Unraid-Manager-iOS/0.2.0", forHTTPHeaderField: "User-Agent"); return request
+ }
+
+ private func normalize(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "/").split(separator: "/").joined(separator: "/") }
+ private func join(_ parent: String, _ child: String) -> String { [normalize(parent), normalize(child)].filter { !$0.isEmpty }.joined(separator: "/") }
+ private func withinRoot(_ value: String, root: String) throws -> String { let value = normalize(value), root = normalize(root); guard root.isEmpty || value == root || value.hasPrefix(root + "/") else { throw AppError.api("已阻止访问文件管理根目录之外的路径") }; return value }
+ private func validateName(_ value: String) throws { guard !value.isEmpty, value != ".", value != "..", !value.contains("/"), !value.contains("\\"), !value.contains("\0") else { throw AppError.api("文件名包含不允许的字符") } }
+}
diff --git a/ios/dist/SHA256SUMS b/ios/dist/SHA256SUMS
new file mode 100644
index 0000000..eeec772
--- /dev/null
+++ b/ios/dist/SHA256SUMS
@@ -0,0 +1 @@
+c8587a036b824eaa3ff388355a86f8260252392fd13ac4a5657f68d1bc9fca35 UnraidManager-0.2.0-unsigned.ipa
diff --git a/ios/dist/UnraidManager-0.2.0-unsigned.ipa b/ios/dist/UnraidManager-0.2.0-unsigned.ipa
new file mode 100644
index 0000000000000000000000000000000000000000..7d91cc7c4bcec08d87c0e8fdd32b718dae1b6188
GIT binary patch
literal 410911
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
4^PT1#6nZG)bn^=dU8-PON2c;gp
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