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