diff --git a/README.md b/README.md
index 31d970a..fe54fc7 100644
--- a/README.md
+++ b/README.md
@@ -57,6 +57,13 @@ $env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
The APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
+### iOS preview client
+
+A native SwiftUI client and Xcode project are available under [`ios/`](ios/README.md).
+It covers secure server configuration, detailed monitoring, Docker operations and logs,
+VM status, operation history, and automatic FileBrowser/XFileManager file management. Xcode 16 or newer is
+required to build and sign it.
+
## Safety boundary
- The APK does not contain an Unraid password or API key.
diff --git a/ios/AppIcon.svg b/ios/AppIcon.svg
new file mode 100644
index 0000000..5ac2fd5
--- /dev/null
+++ b/ios/AppIcon.svg
@@ -0,0 +1,11 @@
+
+
diff --git a/ios/README.md b/ios/README.md
new file mode 100644
index 0000000..3eb4fc9
--- /dev/null
+++ b/ios/README.md
@@ -0,0 +1,28 @@
+# Unraid Manager for iOS
+
+原生 SwiftUI 客户端,提供服务器监控、Docker 运维、VM 只读状态和 FileBrowser/XFileManager 文件管理。
+
+## 构建
+
+1. 使用 Xcode 16 或更新版本打开 `UnraidManagerIOS.xcodeproj`。
+2. 在 Target **UnraidManagerIOS > Signing & Capabilities** 中选择自己的 Team,并按需修改 Bundle Identifier。
+3. 选择真机或模拟器后运行。归档发布时选择 **Product > Archive**。
+
+最低系统版本为 iOS 17。API Key 存放于 iOS Keychain,服务器名称和地址存放于 UserDefaults。客户端不绕过 TLS 验证;公网访问建议使用有效 HTTPS 证书。
+
+为了兼容局域网 IP 上常见的明文 `http://` Unraid 服务,当前包允许用户主动填写的 HTTP 地址;这不会绕过 HTTPS 的证书校验。提交 App Store 前建议为服务器配置 HTTPS,并移除 Info.plist 中的 `NSAllowsArbitraryLoads`,否则审核时需要说明用途。
+
+## 当前功能
+
+- 登录验证、演示模式、Keychain 凭据存储、系统/浅色/深色外观和定时刷新。
+- 健康评分、CPU 总负载/逻辑线程、完整内存与 Swap、阵列/磁盘详情、UPS 原生 API 与 Lucky HTTPS Bridge 回退、电力指标。
+- Docker 列表和搜索、容器详情、GraphQL WebSocket 实时 CPU/内存/网络/磁盘 I/O、WebUI、脱敏日志、原生更新检查、模板保护的单容器更新,以及启停和两步重启。
+- VM 列表和详情保持只读,不暴露尚未验证的 VM 写操作。
+- 操作历史持久化,记录容器操作成功或失败。
+- 自动识别 FileBrowser 2.x 或兼容 XFileManager API,支持登录、受限根目录浏览、新建文件夹、上传、下载、Quick Look/文本预览、重命名、确认删除和 24 小时分享链接。
+- 安全边界:每个写操作均要求确认;没有容器删除、批量更新或 VM 写操作。
+- 尚未移植:原始 NUT 3493 TCP 回退、Android 应用映射和后台系统通知。这些功能需要额外网络协议、iOS 后台权限或平台专属能力。
+
+## FileBrowser 配置
+
+在“设置 → FileBrowser / XFileManager”填写服务地址、专用用户名和密码。局域网示例地址为 `http://tower.local:10001`。初始目录可留空以使用账号的根目录,或填写共享目录名称以进一步限制 App 的浏览范围。凭据只保存在 iOS Keychain,不会写入源码或 IPA。
diff --git a/ios/UnraidManagerIOS.xcodeproj/project.pbxproj b/ios/UnraidManagerIOS.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..f759fd7
--- /dev/null
+++ b/ios/UnraidManagerIOS.xcodeproj/project.pbxproj
@@ -0,0 +1,364 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 77;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 0088B9F2BD0E55838C4C8615 /* XFileManagerClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D077B38A10E83CDE3C8B06AC /* XFileManagerClient.swift */; };
+ 3E72E76D8F4AD6BE7C0D9762 /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = C5D297BFBB5F71D96B680427 /* KeychainStore.swift */; };
+ 581002999EF52536702B3013 /* FileManagerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8659384B3D35AD4C5B7AD89 /* FileManagerViews.swift */; };
+ 5EC822FE517E37FFC9752B79 /* SystemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38E12CE2A786EA26AD4AEF3 /* SystemViews.swift */; };
+ 7328192F787F3AE363366F6E /* AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E4F3E65CEDB11803E6C11F5 /* AppStore.swift */; };
+ 94E16BE7003CB43447228088 /* Views.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B190865C370858831B276B9 /* Views.swift */; };
+ A5EF26900D5109A64234DDCF /* UnraidAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE1B181287ACC654B5978009 /* UnraidAPIClient.swift */; };
+ AF8E349692DC7732B45A5ECB /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = EACE1548A11B35CF896839C2 /* Models.swift */; };
+ BC4FC81451EB2DF8D918C5BA /* ContainerStatsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 229FC71FE30CBB8B6901D37D /* ContainerStatsViewModel.swift */; };
+ CA74785BE8D28AEE3604F7BA /* UnraidManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC62D2707014E5B2758E14EC /* UnraidManagerApp.swift */; };
+ CE9923A73CD8354462AC421F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F207AB118F3419E984923ED8 /* Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXFileReference section */
+ 0B190865C370858831B276B9 /* Views.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Views.swift; sourceTree = ""; };
+ 229FC71FE30CBB8B6901D37D /* ContainerStatsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerStatsViewModel.swift; sourceTree = ""; };
+ 2E4F3E65CEDB11803E6C11F5 /* AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStore.swift; sourceTree = ""; };
+ 95311F871C995A50AF540A46 /* UnraidManagerIOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = UnraidManagerIOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ BE1B181287ACC654B5978009 /* UnraidAPIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnraidAPIClient.swift; sourceTree = ""; };
+ C38E12CE2A786EA26AD4AEF3 /* SystemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemViews.swift; sourceTree = ""; };
+ C5D297BFBB5F71D96B680427 /* KeychainStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainStore.swift; sourceTree = ""; };
+ D077B38A10E83CDE3C8B06AC /* XFileManagerClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XFileManagerClient.swift; sourceTree = ""; };
+ D8659384B3D35AD4C5B7AD89 /* FileManagerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagerViews.swift; sourceTree = ""; };
+ DC62D2707014E5B2758E14EC /* UnraidManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnraidManagerApp.swift; sourceTree = ""; };
+ EACE1548A11B35CF896839C2 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; };
+ F207AB118F3419E984923ED8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXGroup section */
+ 87BC9B7B9674E3BA96688163 /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 95311F871C995A50AF540A46 /* UnraidManagerIOS.app */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 9021D115A9F0F4EC2D20325E /* UnraidManagerIOS */ = {
+ isa = PBXGroup;
+ children = (
+ FAA375DC4B47E30F6EA7DB38 /* Resources */,
+ F6E774FF1073ED7AC8069FA0 /* Sources */,
+ );
+ path = UnraidManagerIOS;
+ sourceTree = "";
+ };
+ D3CDC7088A473AA0C5735471 = {
+ isa = PBXGroup;
+ children = (
+ 9021D115A9F0F4EC2D20325E /* UnraidManagerIOS */,
+ 87BC9B7B9674E3BA96688163 /* Products */,
+ );
+ sourceTree = "";
+ };
+ F6E774FF1073ED7AC8069FA0 /* Sources */ = {
+ isa = PBXGroup;
+ children = (
+ 2E4F3E65CEDB11803E6C11F5 /* AppStore.swift */,
+ 229FC71FE30CBB8B6901D37D /* ContainerStatsViewModel.swift */,
+ D8659384B3D35AD4C5B7AD89 /* FileManagerViews.swift */,
+ C5D297BFBB5F71D96B680427 /* KeychainStore.swift */,
+ EACE1548A11B35CF896839C2 /* Models.swift */,
+ C38E12CE2A786EA26AD4AEF3 /* SystemViews.swift */,
+ BE1B181287ACC654B5978009 /* UnraidAPIClient.swift */,
+ DC62D2707014E5B2758E14EC /* UnraidManagerApp.swift */,
+ 0B190865C370858831B276B9 /* Views.swift */,
+ D077B38A10E83CDE3C8B06AC /* XFileManagerClient.swift */,
+ );
+ path = Sources;
+ sourceTree = "";
+ };
+ FAA375DC4B47E30F6EA7DB38 /* Resources */ = {
+ isa = PBXGroup;
+ children = (
+ F207AB118F3419E984923ED8 /* Assets.xcassets */,
+ );
+ path = Resources;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ A04AFA6B78692A02332AAC58 /* UnraidManagerIOS */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 1664B028BB0EB57D2976E1AB /* Build configuration list for PBXNativeTarget "UnraidManagerIOS" */;
+ buildPhases = (
+ 35E5EE44D8D86573DF79EC20 /* Sources */,
+ 7F4C999A9D41C7BC9284FAC4 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = UnraidManagerIOS;
+ packageProductDependencies = (
+ );
+ productName = UnraidManagerIOS;
+ productReference = 95311F871C995A50AF540A46 /* UnraidManagerIOS.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 92E3D1044BFC007DF53E751A /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 2600;
+ TargetAttributes = {
+ A04AFA6B78692A02332AAC58 = {
+ ProvisioningStyle = Automatic;
+ };
+ };
+ };
+ buildConfigurationList = AC9E3A5292C57B588F93CBED /* Build configuration list for PBXProject "UnraidManagerIOS" */;
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ Base,
+ en,
+ );
+ mainGroup = D3CDC7088A473AA0C5735471;
+ minimizedProjectReferenceProxies = 1;
+ preferredProjectObjectVersion = 77;
+ productRefGroup = 87BC9B7B9674E3BA96688163 /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ A04AFA6B78692A02332AAC58 /* UnraidManagerIOS */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 7F4C999A9D41C7BC9284FAC4 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ CE9923A73CD8354462AC421F /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 35E5EE44D8D86573DF79EC20 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 7328192F787F3AE363366F6E /* AppStore.swift in Sources */,
+ BC4FC81451EB2DF8D918C5BA /* ContainerStatsViewModel.swift in Sources */,
+ 581002999EF52536702B3013 /* FileManagerViews.swift in Sources */,
+ 3E72E76D8F4AD6BE7C0D9762 /* KeychainStore.swift in Sources */,
+ AF8E349692DC7732B45A5ECB /* Models.swift in Sources */,
+ 5EC822FE517E37FFC9752B79 /* SystemViews.swift in Sources */,
+ A5EF26900D5109A64234DDCF /* UnraidAPIClient.swift in Sources */,
+ CA74785BE8D28AEE3604F7BA /* UnraidManagerApp.swift in Sources */,
+ 94E16BE7003CB43447228088 /* Views.swift in Sources */,
+ 0088B9F2BD0E55838C4C8615 /* XFileManagerClient.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin XCBuildConfiguration section */
+ 3D174B55373854B42A007207 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 3;
+ INFOPLIST_FILE = UnraidManagerIOS/Resources/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.3.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.bigfeng.unraidmanager.ios;
+ PRODUCT_NAME = UnraidManager;
+ SDKROOT = iphoneos;
+ SUPPORTS_MACCATALYST = NO;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Release;
+ };
+ 983DB00C86EE5651E4E579E7 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ BC816475FA050D0C5BF522B6 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CODE_SIGN_IDENTITY = "iPhone Developer";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 3;
+ INFOPLIST_FILE = UnraidManagerIOS/Resources/Info.plist;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ MARKETING_VERSION = 0.3.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.bigfeng.unraidmanager.ios;
+ PRODUCT_NAME = UnraidManager;
+ SDKROOT = iphoneos;
+ SUPPORTS_MACCATALYST = NO;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ F09FB09CC1252B181B86D5E1 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ GCC_C_LANGUAGE_STANDARD = gnu11;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "$(inherited)",
+ "DEBUG=1",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 17.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = iphoneos;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_STRICT_CONCURRENCY = complete;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 1664B028BB0EB57D2976E1AB /* Build configuration list for PBXNativeTarget "UnraidManagerIOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ BC816475FA050D0C5BF522B6 /* Debug */,
+ 3D174B55373854B42A007207 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+ AC9E3A5292C57B588F93CBED /* Build configuration list for PBXProject "UnraidManagerIOS" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ F09FB09CC1252B181B86D5E1 /* Debug */,
+ 983DB00C86EE5651E4E579E7 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Debug;
+ };
+/* End XCConfigurationList section */
+ };
+ rootObject = 92E3D1044BFC007DF53E751A /* Project object */;
+}
diff --git a/ios/UnraidManagerIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/UnraidManagerIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 0000000..919434a
--- /dev/null
+++ b/ios/UnraidManagerIOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/ios/UnraidManagerIOS.xcodeproj/xcshareddata/xcschemes/UnraidManagerIOS.xcscheme b/ios/UnraidManagerIOS.xcodeproj/xcshareddata/xcschemes/UnraidManagerIOS.xcscheme
new file mode 100644
index 0000000..3296418
--- /dev/null
+++ b/ios/UnraidManagerIOS.xcodeproj/xcshareddata/xcschemes/UnraidManagerIOS.xcscheme
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/UnraidManagerIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/ios/UnraidManagerIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png
new file mode 100644
index 0000000..067db42
Binary files /dev/null and b/ios/UnraidManagerIOS/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ
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..4a89ca7
--- /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("未配置文件服务", systemImage: "folder.badge.questionmark", description: Text("请在设置中填写 FileBrowser 或 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("文件服务", 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..c2ba1ca
--- /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("FileBrowser / 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("FileBrowser / XFileManager(可选)") {
+ TextField("http://tower.local:10001", 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..15be2fc
--- /dev/null
+++ b/ios/UnraidManagerIOS/Sources/XFileManagerClient.swift
@@ -0,0 +1,234 @@
+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 enum Provider: Equatable {
+ case xFileManager
+ case fileBrowser
+ }
+
+ private let session = URLSession.shared
+ private var token: String?
+ private var provider: Provider?
+ private var sessionIdentity = ""
+
+ func list(configuration: ServerConfiguration, path: String) async throws -> [RemoteFileEntry] {
+ let safePath = try withinRoot(path, root: configuration.fileManagerRootPath)
+ let service = try await authenticate(configuration)
+ let data: Data
+ switch service.provider {
+ case .xFileManager:
+ 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")])
+ case .fileBrowser:
+ data = try await authorized(configuration: configuration, path: fileBrowserResourcePath(safePath, directory: true))
+ }
+ 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
+ let modified = seconds.map { Date(timeIntervalSince1970: $0 > 10_000_000_000 ? $0 / 1000 : $0) }
+ ?? (item["modified"] as? String).flatMap(parseISODate)
+ 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) ?? (item["isSymlink"] as? Bool) ?? false, sizeBytes: ((item["Size"] as? NSNumber) ?? (item["size"] as? NSNumber))?.int64Value ?? 0, modified: modified)
+ }.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)
+ let parent = try withinRoot(parent, root: configuration.fileManagerRootPath)
+ switch try await authenticate(configuration).provider {
+ case .xFileManager:
+ _ = try await authorizedJSON(configuration: configuration, path: "/api/files/mkdir", method: "POST", body: ["path": parent, "name": name])
+ case .fileBrowser:
+ _ = try await authorized(configuration: configuration, path: fileBrowserResourcePath(join(parent, name), directory: true), query: [URLQueryItem(name: "override", value: "false")], method: "POST")
+ }
+ }
+
+ func rename(configuration: ServerConfiguration, entryPath: String, newName: String) async throws {
+ try validateName(newName)
+ let source = try withinRoot(entryPath, root: configuration.fileManagerRootPath)
+ switch try await authenticate(configuration).provider {
+ case .xFileManager:
+ _ = try await authorizedJSON(configuration: configuration, path: "/api/files/rename", method: "POST", body: ["oldPath": source, "newName": newName])
+ case .fileBrowser:
+ let destination = join(parentPath(source), newName)
+ _ = try await authorized(configuration: configuration, path: fileBrowserResourcePath(source), query: [URLQueryItem(name: "action", value: "rename"), URLQueryItem(name: "destination", value: destination), URLQueryItem(name: "override", value: "false"), URLQueryItem(name: "rename", value: "false")], method: "PATCH")
+ }
+ }
+
+ 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("不能删除文件管理根目录") }
+ switch try await authenticate(configuration).provider {
+ case .xFileManager:
+ _ = try await authorizedJSON(configuration: configuration, path: "/api/files/delete", method: "POST", body: ["path": target])
+ case .fileBrowser:
+ _ = try await authorized(configuration: configuration, path: fileBrowserResourcePath(target), method: "DELETE")
+ }
+ }
+
+ func download(configuration: ServerConfiguration, entry: RemoteFileEntry) async throws -> URL {
+ let target = try withinRoot(entry.path, root: configuration.fileManagerRootPath)
+ let service = try await authenticate(configuration)
+ let data = try await authorized(configuration: configuration, path: service.provider == .fileBrowser ? fileBrowserRawPath(target) : "/api/files/download", query: service.provider == .fileBrowser ? [] : [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 service = try await authenticate(configuration)
+ let data = try await authorized(configuration: configuration, path: service.provider == .fileBrowser ? fileBrowserRawPath(target) : "/api/files/download", query: service.provider == .fileBrowser ? [URLQueryItem(name: "inline", value: "true")] : [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)
+ if try await authenticate(configuration).provider == .fileBrowser {
+ _ = try await authorizedUpload(configuration: configuration, path: fileBrowserResourcePath(join(parent, fileURL.lastPathComponent)), query: [URLQueryItem(name: "override", value: "false")], bodyURL: fileURL, contentType: "application/octet-stream")
+ return
+ }
+ 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 {
+ if try await authenticate(configuration).provider == .fileBrowser {
+ let data = try await authorizedJSON(configuration: configuration, path: fileBrowserSharePath(try withinRoot(entry.path, root: configuration.fileManagerRootPath)), query: [URLQueryItem(name: "expires", value: String(expireHours)), URLQueryItem(name: "unit", value: "hours")], method: "POST", body: ["password": "", "expires": String(expireHours), "unit": "hours"])
+ guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any], let hash = root["hash"] as? String, !hash.isEmpty, let base = URL(string: configuration.fileManagerURL), let url = URL(string: "/share/\(hash)", relativeTo: base)?.absoluteURL else { throw AppError.api("FileBrowser 没有返回分享链接") }
+ return RemoteShareLink(url: url, token: hash)
+ }
+ 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, query: [URLQueryItem] = [], method: String, body: [String: Any]) async throws -> Data {
+ try await authorized(configuration: configuration, path: path, query: query, method: method, body: JSONSerialization.data(withJSONObject: body), contentType: "application/json")
+ }
+
+ private func authorizedUpload(configuration: ServerConfiguration, path: String, query: [URLQueryItem] = [], bodyURL: URL, contentType: String, retry: Bool = true) async throws -> Data {
+ let access = try await authenticate(configuration)
+ var request = try request(configuration: configuration, path: path, query: query)
+ request.httpMethod = "POST"
+ authorize(&request, access: access)
+ 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 { clearSession(); return try await authorizedUpload(configuration: configuration, path: path, query: query, bodyURL: bodyURL, contentType: contentType, retry: false) }
+ guard (200...299).contains(http.statusCode) else { throw AppError.api("文件上传失败(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 access = try await authenticate(configuration)
+ var request = try request(configuration: configuration, path: path, query: query)
+ request.httpMethod = method
+ request.httpBody = body
+ authorize(&request, access: access)
+ 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 { clearSession(); 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("文件服务操作失败(HTTP \(http.statusCode))") }
+ return data
+ }
+
+ private func authenticate(_ configuration: ServerConfiguration) async throws -> (token: String, provider: Provider) {
+ let identity = "\(configuration.fileManagerURL)\0\(configuration.fileManagerUsername)\0\(configuration.fileManagerPassword)"
+ if identity == sessionIdentity, let token, let provider { return (token, provider) }
+ guard !configuration.fileManagerUsername.isEmpty, !configuration.fileManagerPassword.isEmpty else { throw AppError.api("请填写文件服务用户名和密码") }
+ let credentials: [String: Any] = ["username": configuration.fileManagerUsername, "password": configuration.fileManagerPassword, "recaptcha": ""]
+
+ if let result = try? await login(configuration: configuration, path: "/api/auth/login", credentials: credentials),
+ let root = (try? JSONSerialization.jsonObject(with: result.data)) as? [String: Any], root["success"] as? Bool == true,
+ let value = root["token"] as? String, !value.isEmpty {
+ token = value; provider = .xFileManager; sessionIdentity = identity
+ return (value, .xFileManager)
+ }
+
+ let result = try await login(configuration: configuration, path: "/api/login", credentials: credentials)
+ guard (200...299).contains(result.status), let value = String(data: result.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { throw AppError.api("FileBrowser 登录失败,请检查地址和凭据") }
+ token = value; provider = .fileBrowser; sessionIdentity = identity
+ return (value, .fileBrowser)
+ }
+
+ private func login(configuration: ServerConfiguration, path: String, credentials: [String: Any]) async throws -> (data: Data, status: Int) {
+ var request = try request(configuration: configuration, path: path)
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: credentials)
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else { throw AppError.invalidResponse }
+ return (data, http.statusCode)
+ }
+
+ private func authorize(_ request: inout URLRequest, access: (token: String, provider: Provider)) {
+ switch access.provider {
+ case .xFileManager: request.setValue("Bearer \(access.token)", forHTTPHeaderField: "Authorization")
+ case .fileBrowser: request.setValue(access.token, forHTTPHeaderField: "X-Auth")
+ }
+ }
+
+ private func clearSession() { token = nil; provider = nil; sessionIdentity = "" }
+
+ 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("请填写完整的文件服务地址") }
+ 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.3.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 parentPath(_ value: String) -> String { normalize(value).split(separator: "/").dropLast().joined(separator: "/") }
+ private func fileBrowserResourcePath(_ value: String, directory: Bool = false) -> String { "/api/resources/" + normalize(value) + (directory && !normalize(value).isEmpty ? "/" : "") }
+ private func fileBrowserRawPath(_ value: String) -> String { "/api/raw/" + normalize(value) }
+ private func fileBrowserSharePath(_ value: String) -> String { "/api/share/" + normalize(value) }
+ private func parseISODate(_ value: String) -> Date? { let formatter = ISO8601DateFormatter(); formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]; return formatter.date(from: value) ?? ISO8601DateFormatter().date(from: value) }
+ 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 0000000..7d91cc7
Binary files /dev/null and b/ios/dist/UnraidManager-0.2.0-unsigned.ipa differ
diff --git a/ios/dist/UnraidManager-0.3.0-unsigned.ipa b/ios/dist/UnraidManager-0.3.0-unsigned.ipa
new file mode 100644
index 0000000..a7c5598
Binary files /dev/null and b/ios/dist/UnraidManager-0.3.0-unsigned.ipa differ
diff --git a/ios/project.yml b/ios/project.yml
new file mode 100644
index 0000000..a86ac01
--- /dev/null
+++ b/ios/project.yml
@@ -0,0 +1,62 @@
+name: UnraidManagerIOS
+options:
+ bundleIdPrefix: com.bigfeng
+ deploymentTarget:
+ iOS: "17.0"
+ xcodeVersion: "26.0"
+ createIntermediateGroups: true
+settings:
+ base:
+ SWIFT_VERSION: "5.0"
+ SWIFT_STRICT_CONCURRENCY: complete
+ IPHONEOS_DEPLOYMENT_TARGET: "17.0"
+targets:
+ UnraidManagerIOS:
+ type: application
+ platform: iOS
+ deploymentTarget: "17.0"
+ sources:
+ - path: UnraidManagerIOS/Sources
+ - path: UnraidManagerIOS/Resources/Assets.xcassets
+ buildPhase: resources
+ info:
+ path: UnraidManagerIOS/Resources/Info.plist
+ properties:
+ CFBundleDisplayName: Unraid Manager
+ CFBundleShortVersionString: $(MARKETING_VERSION)
+ CFBundleVersion: $(CURRENT_PROJECT_VERSION)
+ LSRequiresIPhoneOS: true
+ NSAppTransportSecurity:
+ NSAllowsArbitraryLoads: true
+ NSAllowsLocalNetworking: true
+ NSLocalNetworkUsageDescription: 连接并管理您明确配置的局域网 Unraid 服务器。
+ UILaunchScreen: {}
+ UISupportedInterfaceOrientations:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ - UIInterfaceOrientationPortraitUpsideDown
+ UISupportedInterfaceOrientations~ipad:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationPortraitUpsideDown
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: com.bigfeng.unraidmanager.ios
+ PRODUCT_NAME: UnraidManager
+ MARKETING_VERSION: 0.3.0
+ CURRENT_PROJECT_VERSION: 3
+ ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
+ CODE_SIGN_STYLE: Automatic
+ SUPPORTS_MACCATALYST: NO
+ TARGETED_DEVICE_FAMILY: "1,2"
+schemes:
+ UnraidManagerIOS:
+ build:
+ targets:
+ UnraidManagerIOS: all
+ run:
+ config: Debug
+ archive:
+ config: Release