commit 07223f2e9473157d1f102606047d9276f9ff224e Author: ngfchl Date: Wed Jul 15 18:28:53 2026 +0800 实现现代化云盘工作区与文件管理功能 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b367b0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.DS_Store + +# Xcode user and build data +xcuserdata/ +*.xcuserstate +DerivedData/ +build/ + +# Swift package and local tooling artifacts +.swiftpm/ +.build/ + +# Local signing and environment files +*.mobileprovision +.env diff --git a/guangya_mac.xcodeproj/project.pbxproj b/guangya_mac.xcodeproj/project.pbxproj new file mode 100644 index 0000000..2423df5 --- /dev/null +++ b/guangya_mac.xcodeproj/project.pbxproj @@ -0,0 +1,357 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 8C0E0B3E2C40000100A10001 /* guangya_macApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B362C40000100A10001 /* guangya_macApp.swift */; }; + 8C0E0B3F2C40000100A10001 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B372C40000100A10001 /* ContentView.swift */; }; + 8C0E0B402C40000100A10001 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B382C40000100A10001 /* Assets.xcassets */; }; + 8C0E0B412C40000100A10001 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B3A2C40000100A10001 /* Preview Assets.xcassets */; }; + 8C0E0B522C40000100A10001 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B422C40000100A10001 /* Models.swift */; }; + 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */; }; + 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B442C40000100A10001 /* AppModel.swift */; }; + 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 8C0E0B332C40000100A10001 /* guangya_mac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = guangya_mac.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 8C0E0B362C40000100A10001 /* guangya_macApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = guangya_macApp.swift; sourceTree = ""; }; + 8C0E0B372C40000100A10001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 8C0E0B382C40000100A10001 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 8C0E0B3A2C40000100A10001 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + 8C0E0B422C40000100A10001 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuangyaAPI.swift; sourceTree = ""; }; + 8C0E0B442C40000100A10001 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + 8C0E0B452C40000100A10001 /* 光鸭.svg */ = {isa = PBXFileReference; lastKnownFileType = image.svg; path = "光鸭.svg"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 8C0E0B302C40000100A10001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 8C0E0B292C40000100A10001 = { + isa = PBXGroup; + children = ( + 8C0E0B352C40000100A10001 /* guangya_mac */, + 8C0E0B342C40000100A10001 /* Products */, + ); + sourceTree = ""; + }; + 8C0E0B342C40000100A10001 /* Products */ = { + isa = PBXGroup; + children = ( + 8C0E0B332C40000100A10001 /* guangya_mac.app */, + ); + name = Products; + sourceTree = ""; + }; + 8C0E0B352C40000100A10001 /* guangya_mac */ = { + isa = PBXGroup; + children = ( + 8C0E0B362C40000100A10001 /* guangya_macApp.swift */, + 8C0E0B372C40000100A10001 /* ContentView.swift */, + 8C0E0B422C40000100A10001 /* Models.swift */, + 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */, + 8C0E0B442C40000100A10001 /* AppModel.swift */, + 8C0E0B452C40000100A10001 /* 光鸭.svg */, + 8C0E0B382C40000100A10001 /* Assets.xcassets */, + 8C0E0B392C40000100A10001 /* Preview Content */, + ); + path = guangya_mac; + sourceTree = ""; + }; + 8C0E0B392C40000100A10001 /* Preview Content */ = { + isa = PBXGroup; + children = ( + 8C0E0B3A2C40000100A10001 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 8C0E0B322C40000100A10001 /* guangya_mac */ = { + isa = PBXNativeTarget; + buildConfigurationList = 8C0E0B4B2C40000100A10001 /* Build configuration list for PBXNativeTarget "guangya_mac" */; + buildPhases = ( + 8C0E0B2F2C40000100A10001 /* Sources */, + 8C0E0B302C40000100A10001 /* Frameworks */, + 8C0E0B312C40000100A10001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = guangya_mac; + productName = guangya_mac; + productReference = 8C0E0B332C40000100A10001 /* guangya_mac.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 8C0E0B2A2C40000100A10001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; + TargetAttributes = { + 8C0E0B322C40000100A10001 = { + CreatedOnToolsVersion = 26.3; + }; + }; + }; + buildConfigurationList = 8C0E0B2D2C40000100A10001 /* Build configuration list for PBXProject "guangya_mac" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 8C0E0B292C40000100A10001; + productRefGroup = 8C0E0B342C40000100A10001 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 8C0E0B322C40000100A10001 /* guangya_mac */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 8C0E0B312C40000100A10001 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8C0E0B402C40000100A10001 /* Assets.xcassets in Resources */, + 8C0E0B412C40000100A10001 /* Preview Assets.xcassets in Resources */, + 8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 8C0E0B2F2C40000100A10001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8C0E0B3E2C40000100A10001 /* guangya_macApp.swift in Sources */, + 8C0E0B3F2C40000100A10001 /* ContentView.swift in Sources */, + 8C0E0B522C40000100A10001 /* Models.swift in Sources */, + 8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */, + 8C0E0B542C40000100A10001 /* AppModel.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 8C0E0B492C40000100A10001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = guangya_mac; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 8C0E0B4A2C40000100A10001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = ""; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = guangya_mac; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 8C0E0B4C2C40000100A10001 /* 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++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 8C0E0B4D2C40000100A10001 /* 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++20"; + 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; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 8C0E0B2D2C40000100A10001 /* Build configuration list for PBXProject "guangya_mac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8C0E0B4C2C40000100A10001 /* Debug */, + 8C0E0B4D2C40000100A10001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 8C0E0B4B2C40000100A10001 /* Build configuration list for PBXNativeTarget "guangya_mac" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 8C0E0B492C40000100A10001 /* Debug */, + 8C0E0B4A2C40000100A10001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 8C0E0B2A2C40000100A10001 /* Project object */; +} diff --git a/guangya_mac/AppModel.swift b/guangya_mac/AppModel.swift new file mode 100644 index 0000000..46a21a7 --- /dev/null +++ b/guangya_mac/AppModel.swift @@ -0,0 +1,479 @@ +import AppKit +import Combine +import Foundation + +@MainActor +final class AppModel: ObservableObject { + @Published var isSignedIn = false + @Published var user = UserProfile(json: .object([:])) + @Published var section: WorkspaceSection = .files + @Published var files: [CloudFile] = [] + @Published var folderPath: [FolderPath] = [] + @Published var isLoadingFiles = false + @Published var isLoadingFolderSizes = false + @Published var duplicateGroups: [DuplicateGroup] = [] + @Published var similarFolders: [CloudFile] = [] + @Published var detail: JSONValue? + @Published var lastActionMessage = "" + @Published var tmdbAPIKey = UserDefaults.standard.string(forKey: "guangya.tmdbAPIKey") ?? "" + @Published var tmdbProxyHost = UserDefaults.standard.string(forKey: "guangya.tmdbProxyHost") ?? "" + @Published var tmdbProxyPort = UserDefaults.standard.string(forKey: "guangya.tmdbProxyPort") ?? "" + @Published var isAnalyzing = false + @Published var isBusy = false + @Published var statusMessage = "" + @Published var errorMessage = "" + @Published var phoneNumber = "+86 " + @Published var verificationCode = "" + @Published var captchaToken = "" + @Published var verificationID = "" + @Published var qrPayload = "" + @Published var qrToken = "" + @Published var qrStatus = "等待生成二维码" + @Published var codeCountdown = 0 + + let api: GuangyaAPI + private var qrPollingTask: Task? + private var countdownTask: Task? + + init() { + let defaults = UserDefaults.standard + let access = defaults.string(forKey: "guangya.accessToken") ?? "" + let refresh = defaults.string(forKey: "guangya.refreshToken") + api = GuangyaAPI(accessToken: access, refreshToken: refresh) + if !access.isEmpty { + isSignedIn = true + Task { await loadAccount() } + } + } + + func sendVerificationCode() async { + guard phoneNumber.filter({ $0.isNumber }).count >= 8 else { errorMessage = "请输入有效的手机号"; return } + await perform("正在发送验证码…") { [self] in + let initResult = try await api.loginSMSInit(phoneNumber: phoneNumber) + guard let captcha = initResult.firstString(["captcha_token", "captchaToken"]) else { + if initResult.firstString(["url", "verify_url", "verifyUrl"]) != nil { throw GuangyaAPIError.http(status: 403, message: "需要完成验证码验证后再发送短信") } + throw GuangyaAPIError.missingField("captcha_token") + } + captchaToken = captcha + let result = try await api.loginSMSSend(phoneNumber: phoneNumber, captchaToken: captcha) + guard let id = result.firstString(["verification_id", "verificationId", "id"]) else { throw GuangyaAPIError.missingField("verification_id") } + verificationID = id + startCountdown() + statusMessage = "验证码已发送" + } + } + + func verifyAndSignIn() async { + guard !verificationID.isEmpty, !captchaToken.isEmpty else { errorMessage = "请先发送验证码"; return } + guard verificationCode.count >= 4 else { errorMessage = "请输入短信验证码"; return } + await perform("正在登录…") { [self] in + let verified = try await api.loginSMSVerify(verificationID: verificationID, verificationCode: verificationCode) + guard let token = verified.firstString(["verification_token", "verificationToken"]) else { throw GuangyaAPIError.missingField("verification_token") } + _ = try await api.loginSMSSignIn(code: verificationCode, verificationToken: token, username: phoneNumber, captchaToken: captchaToken) + completeLogin() + } + } + + func startQRLogin() async { + qrPollingTask?.cancel() + qrPayload = "" + qrToken = "" + qrStatus = "正在生成二维码…" + await perform("正在生成二维码…") { [self] in + let result = try await api.loginQRInit() + qrToken = result.firstStringDeep(["device_code", "deviceCode", "qr_token", "qrToken", "token", "id"]) ?? "" + qrPayload = result.firstStringDeep(["verification_uri_complete", "verificationUriComplete", "qr_url", "qrUrl", "url", "verification_uri", "verificationUri", "qrcode", "qrCode", "code"]) ?? qrToken + guard !qrPayload.isEmpty else { throw GuangyaAPIError.missingField("二维码内容") } + qrStatus = "请使用光鸭 App 扫码" + let expiresIn = result.firstIntDeep(["expires_in", "expiresIn"]) ?? 120 + let interval = max(2, result.firstIntDeep(["interval", "pollInterval", "poll_interval"]) ?? 3) + startQRPolling(expiresIn: expiresIn, interval: interval) + } + } + + func stopQRLogin() { qrPollingTask?.cancel(); qrPollingTask = nil } + + func refresh() async { + guard isSignedIn else { return } + await perform("正在刷新…") { [self] in await loadFiles() } + } + + func openFolder(_ file: CloudFile) async { + guard file.isDirectory, !isLoadingFiles else { return } + folderPath.append(FolderPath(id: file.id, name: file.name)) + await loadFiles() + } + + func navigateToRoot() async { + guard !folderPath.isEmpty, !isLoadingFiles else { return } + folderPath.removeAll() + await loadFiles() + } + + func navigateToFolder(at index: Int) async { + guard folderPath.indices.contains(index), !isLoadingFiles else { return } + folderPath.removeSubrange((index + 1)..= 1024 * 1024 } : detailed + let groups: [String: [(String, CloudFile)]] = Dictionary(grouping: eligible.compactMap { file in file.gcid.map { ($0, file) } }, by: { $0.0 }) + duplicateGroups = groups.compactMap { key, values in + guard values.count > 1 else { return nil } + return DuplicateGroup(id: key, files: values.map { $0.1 }) + }.sorted { $0.files.count > $1.files.count } + lastActionMessage = duplicateGroups.isEmpty ? "没有发现重复文件" : "发现 \(duplicateGroups.count) 组重复文件" + } + + func copyTransferJSON(for file: CloudFile) { + let payload: [String: Any] = ["name": file.name, "fileId": file.id, "size": file.size ?? 0, "gcid": file.gcid ?? ""] + guard let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]), let text = String(data: data, encoding: .utf8) else { return } + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + let panel = NSSavePanel() + panel.nameFieldStringValue = "\(file.name).transfer.json" + panel.allowedContentTypes = [.json] + if panel.runModal() == .OK, let url = panel.url { try? data.write(to: url, options: .atomic); lastActionMessage = "秒传 JSON 已保存并复制" } + else { lastActionMessage = "秒传 JSON 已复制" } + } + + func saveTMDBSettings(key: String, proxyHost: String, proxyPort: String) { + tmdbAPIKey = key.trimmingCharacters(in: .whitespacesAndNewlines) + tmdbProxyHost = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines) + tmdbProxyPort = proxyPort.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(tmdbAPIKey, forKey: "guangya.tmdbAPIKey") + UserDefaults.standard.set(tmdbProxyHost, forKey: "guangya.tmdbProxyHost") + UserDefaults.standard.set(tmdbProxyPort, forKey: "guangya.tmdbProxyPort") + lastActionMessage = tmdbAPIKey.isEmpty ? "TMDB 配置已清除" : "TMDB 配置已保存" + } + + func recognizeTMDB(_ file: CloudFile, organize: Bool = false) async { + guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return } + await perform(organize ? "正在识别并整理…" : "正在识别…") { [self] in + let match = try await tmdbMatch(for: file.name) + if organize { + let created = try await api.fsCreateDir(name: match.title, parentID: folderPath.last?.id) + guard let folderID = created.firstStringDeep(["fileId", "id", "resId"]) else { throw GuangyaAPIError.missingField("整理文件夹 ID") } + _ = try await api.fsMove(fileIDs: [file.id], parentID: folderID) + let metadata = ["tmdbId": match.id, "title": match.title, "mediaType": match.mediaType, "releaseDate": match.releaseDate, "overview": match.overview] as [String: Any] + let metadataURL = FileManager.default.temporaryDirectory.appendingPathComponent("tmdb-\(match.id).json") + let data = try JSONSerialization.data(withJSONObject: metadata, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: metadataURL, options: .atomic) + defer { try? FileManager.default.removeItem(at: metadataURL) } + _ = try await api.fileUpload(url: metadataURL, parentID: folderID, contentType: "application/json") + lastActionMessage = "已整理到「\(match.title)」并上传刮削信息" + await loadFiles() + } else { + lastActionMessage = "识别结果:\(match.title)\(match.releaseDate.isEmpty ? "" : "(\(match.releaseDate.prefix(4)))")" + } + } + } + + func recognizeTMDBFolder(_ folder: CloudFile) async { + guard !tmdbAPIKey.isEmpty else { lastActionMessage = "请先在工作区设置中保存 TMDB API Key"; return } + await perform("正在扫描文件夹并识别…") { [self] in + var pending = [folder.id] + var media: [CloudFile] = [] + while let parentID = pending.popLast() { + let items = extractFiles(from: try await api.fsFiles(parentID: parentID)) + for item in items { + if item.isDirectory { pending.append(item.id) } else { media.append(item) } + } + } + var matches: [String] = [] + for file in media { + if let match = try? await tmdbMatch(for: file.name) { matches.append(match.title) } + } + lastActionMessage = matches.isEmpty ? "当前文件夹及子文件夹没有识别结果" : "识别到 \(matches.count) 个媒体文件:\n" + matches.prefix(8).joined(separator: "、") + } + } + + func findSimilarFolders() { + let folders = files.filter(\.isDirectory) + let groups = Dictionary(grouping: folders, by: { normalizedFolderName($0.name) }) + similarFolders = groups.values.filter { $0.count > 1 }.flatMap { $0 } + lastActionMessage = similarFolders.isEmpty ? "当前目录没有相似文件夹" : "找到 \(similarFolders.count) 个相似文件夹" + } + + func logout() { + stopQRLogin() + UserDefaults.standard.removeObject(forKey: "guangya.accessToken") + UserDefaults.standard.removeObject(forKey: "guangya.refreshToken") + isSignedIn = false + files = [] + folderPath = [] + statusMessage = "" + } + + private func completeLogin() { + let defaults = UserDefaults.standard + defaults.set(api.accessToken, forKey: "guangya.accessToken") + if let refresh = api.refreshTokenValue { defaults.set(refresh, forKey: "guangya.refreshToken") } + isSignedIn = true + Task { await loadAccount() } + } + + private func loadAccount() async { + await api.prepareSession() + isLoadingFiles = true + defer { isLoadingFiles = false } + async let userResult: JSONValue? = try? await api.userInfo() + async let fileResult: JSONValue? = try? await filesResponse() + if let result = await userResult { + user = UserProfile(json: result) + } else { + statusMessage = "登录状态已恢复" + } + if let result = await fileResult { + files = extractFiles(from: result) + await enrichPageDetails() + } else { + errorMessage = "文件列表加载失败,请点击刷新重试" + } + } + + private func loadFiles() async { + guard !isLoadingFiles else { return } + isLoadingFiles = true + defer { isLoadingFiles = false } + do { + let result = try await filesResponse() + files = extractFiles(from: result) + await enrichPageDetails() + if files.isEmpty { statusMessage = "当前文件夹暂无文件" } + else if statusMessage == "当前文件夹暂无文件" { statusMessage = "" } + } catch { errorMessage = error.localizedDescription } + } + + private func filesResponse() async throws -> JSONValue { + switch section { + case .files: return try await api.fsFiles(parentID: folderPath.last?.id) + case .photos: return try await api.fsImageList() + case .videos: return try await api.fsVideoList() + case .documents: return try await api.fsDocumentList() + case .recycle: return try await api.fsRecycleFiles() + case .cloud, .shares: return .object([:]) + } + } + + private func tmdbMatch(for name: String) async throws -> TMDBMatch { + let query = name.replacingOccurrences(of: "\\.[^.]+$", with: "", options: .regularExpression).replacingOccurrences(of: "[._()\\[\\]{}-]+", with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines) + let response = try await api.tmdbSearch(query: query, apiKey: tmdbAPIKey, proxyHost: tmdbProxyHost, proxyPort: tmdbProxyPort) + guard let matches = response["results"]?.arrayValue?.compactMap(TMDBMatch.init), let match = matches.first else { throw GuangyaAPIError.missingField("TMDB 识别结果") } + return match + } + + private func normalizedFolderName(_ value: String) -> String { + value.lowercased().replacingOccurrences(of: "[\\s._()\\[\\]{}-]+", with: "", options: .regularExpression) + } + + private func enrichPageDetails() async { + let page = files + guard !page.isEmpty else { return } + isLoadingFolderSizes = true + defer { isLoadingFolderSizes = false } + let api = self.api + let details = await withTaskGroup(of: (String, Int64?, String?, Int64?).self, returning: [(String, Int64?, String?, Int64?)].self) { group in + for file in page { + group.addTask { + let detail = try? await api.fsDetail(fileID: file.id) + return (file.id, detail?.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]), detail?.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]), detail?.firstInt64Deep(["utime", "ctime"])) + } + } + var result: [(String, Int64?, String?, Int64?)] = [] + for await item in group { result.append(item) } + return result + } + var updatedFiles = files + for index in updatedFiles.indices { + guard let item = details.first(where: { $0.0 == updatedFiles[index].id }) else { continue } + if let size = item.1 { updatedFiles[index].size = size } + if let gcid = item.2 { updatedFiles[index].gcid = gcid } + if let epoch = item.3 { updatedFiles[index].modifiedAt = Self.formatDate(epoch) } + } + files = updatedFiles + } + + private static func formatDate(_ epoch: Int64) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch))) + } + + private func extractFiles(from value: JSONValue) -> [CloudFile] { + var result: [CloudFile] = [] + func visit(_ value: JSONValue) { + if let file = CloudFile(json: value), !result.contains(where: { $0.id == file.id }) { result.append(file) } + if let object = value.objectValue { object.values.forEach(visit) } + if let array = value.arrayValue { array.forEach(visit) } + } + visit(value) + return result + } + + private func startQRPolling(expiresIn: Int, interval: Int) { + qrPollingTask = Task { [weak self] in + guard let self else { return } + let deadline = Date().addingTimeInterval(TimeInterval(expiresIn)) + while !Task.isCancelled && !self.qrToken.isEmpty && Date() < deadline { + try? await Task.sleep(for: .seconds(interval)) + guard !Task.isCancelled else { return } + do { + let result = try await self.api.loginQRPoll(token: self.qrToken) + if self.api.accessToken.isEmpty == false { + self.qrStatus = "扫码成功,正在进入网盘…" + self.completeLogin() + return + } + self.qrStatus = result.firstStringDeep(["message", "msg", "statusText"]) ?? "等待扫码" + } catch let error as GuangyaAPIError where error.isDeviceAuthorizationPending { + self.qrStatus = "等待扫码" + } catch { + self.qrStatus = "二维码状态查询失败" + self.errorMessage = error.localizedDescription + return + } + } + if !Task.isCancelled && Date() >= deadline { self.qrStatus = "二维码已过期,请刷新" } + } + } + + private func startCountdown() { + countdownTask?.cancel() + codeCountdown = 60 + countdownTask = Task { [weak self] in + while let self, self.codeCountdown > 0, !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + self.codeCountdown -= 1 + } + } + } + + private func perform(_ message: String, operation: @escaping @MainActor () async throws -> Void) async { + isBusy = true; errorMessage = ""; statusMessage = message + do { try await operation(); if errorMessage.isEmpty { statusMessage = "" } } + catch { errorMessage = error.localizedDescription } + isBusy = false + } +} diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/Contents.json b/guangya_mac/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..df59d15 --- /dev/null +++ b/guangya_mac/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "idiom" : "mac", + "filename" : "icon_16x16.png", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "filename" : "icon_16x16@2x.png", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "filename" : "icon_32x32.png", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "filename" : "icon_32x32@2x.png", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "filename" : "icon_128x128.png", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "filename" : "icon_128x128@2x.png", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "filename" : "icon_256x256.png", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "filename" : "icon_256x256@2x.png", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "filename" : "icon_512x512.png", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "filename" : "icon_512x512@2x.png", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 0000000..344c928 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 0000000..2a39a47 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 0000000..04444a8 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 0000000..1bd8b62 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 0000000..2a39a47 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 0000000..193d050 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 0000000..1bd8b62 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 0000000..5cb4cd0 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 0000000..193d050 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 0000000..c59bdb6 Binary files /dev/null and b/guangya_mac/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/guangya_mac/Assets.xcassets/Contents.json b/guangya_mac/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/guangya_mac/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/guangya_mac/ContentView.swift b/guangya_mac/ContentView.swift new file mode 100644 index 0000000..fe157ba --- /dev/null +++ b/guangya_mac/ContentView.swift @@ -0,0 +1,812 @@ +import AppKit +import CoreImage.CIFilterBuiltins +import SwiftUI +import UniformTypeIdentifiers + +struct ContentView: View { + @StateObject private var model = AppModel() + + var body: some View { + Group { + if model.isSignedIn { + WorkspaceView(model: model) + } else { + LoginView(model: model) + } + } + .alert("请求失败", isPresented: Binding(get: { !model.errorMessage.isEmpty }, set: { if !$0 { model.errorMessage = "" } })) { + Button("确定", role: .cancel) { model.errorMessage = "" } + } message: { + Text(model.errorMessage) + } + } +} + +private struct LiquidGlassModifier: ViewModifier { + let cornerRadius: CGFloat + + @ViewBuilder + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } else { + content.background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + } +} + +private extension View { + func liquidGlass(cornerRadius: CGFloat = 16) -> some View { + modifier(LiquidGlassModifier(cornerRadius: cornerRadius)) + } +} + +private struct LoginView: View { + @ObservedObject var model: AppModel + let autoStartQR: Bool + @State private var mode = 0 + + init(model: AppModel, autoStartQR: Bool = true) { + self.model = model + self.autoStartQR = autoStartQR + } + + var body: some View { + ZStack { + Color(nsColor: .windowBackgroundColor).ignoresSafeArea() + HStack(spacing: 0) { + LoginBrandPanel() + LoginFormPanel(model: model, mode: $mode) + } + .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.black.opacity(0.08), lineWidth: 1) + } + .shadow(color: .black.opacity(0.18), radius: 30, y: 14) + .padding(28) + } + .frame(minWidth: 900, minHeight: 620) + .task { + guard autoStartQR, model.qrPayload.isEmpty, !model.isBusy else { return } + await model.startQRLogin() + } + } +} + +private struct LoginBrandPanel: View { + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 12) { + BrandMark(size: 46) + VStack(alignment: .leading, spacing: 2) { + Text("光鸭云盘").font(.headline) + Text("GUANGYA CLOUD").font(.system(size: 9, weight: .semibold, design: .rounded)).tracking(1.2).foregroundStyle(.white.opacity(0.62)) + } + } + Spacer() + Text("文件,").font(.system(size: 42, weight: .bold, design: .rounded)) + Text("在你身边。").font(.system(size: 42, weight: .bold, design: .rounded)) + Text("轻松管理每一份重要内容。") + .font(.title3) + .foregroundStyle(.white.opacity(0.76)) + .padding(.top, 14) + VStack(alignment: .leading, spacing: 14) { + BrandFeature(icon: "square.stack.3d.up.fill", title: "云端文件集中管理") + BrandFeature(icon: "arrow.triangle.2.circlepath", title: "随时访问你的内容") + BrandFeature(icon: "lock.shield.fill", title: "安全连接,安心使用") + } + .padding(.top, 38) + Spacer() + HStack(spacing: 7) { + Circle().fill(Color.green).frame(width: 7, height: 7) + Text("服务正常运行").font(.caption).foregroundStyle(.white.opacity(0.72)) + } + } + .foregroundStyle(.white) + .padding(42) + .frame(width: 370) + .frame(maxHeight: .infinity, alignment: .leading) + .background(Color(red: 0.95, green: 0.30, blue: 0.055)) + } +} + +private struct BrandFeature: View { + let icon: String + let title: String + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon).font(.callout.weight(.semibold)).frame(width: 20) + Text(title).font(.callout).foregroundStyle(.white.opacity(0.86)) + } + } +} + +private struct LoginFormPanel: View { + @ObservedObject var model: AppModel + @Binding var mode: Int + + var body: some View { + VStack(spacing: 0) { + Spacer() + VStack(alignment: .leading, spacing: 22) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 7) { + Text("欢迎回来").font(.system(size: 31, weight: .bold, design: .rounded)) + Text("登录后开始管理你的云端文件").font(.callout).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "person.crop.circle.badge.checkmark") + .font(.title2) + .foregroundStyle(Color.orange) + } + Picker("登录方式", selection: $mode) { + Label("二维码登录", systemImage: "qrcode").tag(0) + Label("手机号登录", systemImage: "iphone").tag(1) + } + .pickerStyle(.segmented) + Group { + if mode == 0 { + QRLoginPanel(model: model) + } else { + SMSLoginPanel(model: model) + } + } + .frame(maxWidth: .infinity) + } + .padding(32) + .frame(width: 450) + .liquidGlass(cornerRadius: 18) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.black.opacity(0.06), lineWidth: 1) + } + Spacer() + HStack(spacing: 5) { + Image(systemName: "checkmark.seal.fill") + Text("光鸭云盘安全登录") + } + .font(.caption) + .foregroundStyle(.tertiary) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .underPageBackgroundColor)) + } +} + +private struct QRLoginPanel: View { + @ObservedObject var model: AppModel + + var body: some View { + VStack(spacing: 16) { + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(Color.orange.opacity(0.08)) + .frame(width: 232, height: 232) + if model.qrPayload.isEmpty { + VStack(spacing: 13) { + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 58, weight: .light)) + .foregroundStyle(Color.orange.opacity(0.78)) + Text("准备登录") + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + } + } else { + QRCodeImage(payload: model.qrPayload) + .frame(width: 190, height: 190) + .padding(14) + .background(.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 15, y: 7) + } + } + HStack(spacing: 8) { + Circle().fill(model.qrPayload.isEmpty ? Color.secondary : Color.green).frame(width: 7, height: 7) + Text(model.qrStatus).font(.callout).foregroundStyle(.secondary) + } + Button { + Task { await model.startQRLogin() } + } label: { + HStack(spacing: 8) { + if model.isBusy { ProgressView().controlSize(.small) } + Image(systemName: model.qrPayload.isEmpty ? "qrcode" : "arrow.clockwise") + Text(model.qrPayload.isEmpty ? "生成登录二维码" : "刷新二维码") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.orange) + .disabled(model.isBusy) + Text("请使用光鸭 App 扫码登录") + .font(.caption) + .foregroundStyle(.tertiary) + } + } +} + +private struct SMSLoginPanel: View { + @ObservedObject var model: AppModel + @FocusState private var focusedField: Field? + + private enum Field { case phone, code } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + LoginFieldLabel(title: "手机号", icon: "phone.fill") + TextField("+86 13800138000", text: $model.phoneNumber) + .textFieldStyle(.plain) + .focused($focusedField, equals: .phone) + .padding(.horizontal, 13) + .frame(height: 40) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(focusedField == .phone ? Color.orange : Color.black.opacity(0.12), lineWidth: 1) } + .textContentType(.telephoneNumber) + LoginFieldLabel(title: "短信验证码", icon: "number.square.fill") + HStack(spacing: 10) { + TextField("请输入验证码", text: $model.verificationCode) + .textFieldStyle(.plain) + .focused($focusedField, equals: .code) + .padding(.horizontal, 13) + .frame(height: 40) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { RoundedRectangle(cornerRadius: 8).stroke(focusedField == .code ? Color.orange : Color.black.opacity(0.12), lineWidth: 1) } + Button { + Task { await model.sendVerificationCode() } + } label: { + Text(model.codeCountdown > 0 ? "\(model.codeCountdown)s" : "获取验证码") + .frame(width: 94, height: 40) + } + .buttonStyle(.bordered) + .disabled(model.isBusy || model.codeCountdown > 0) + } + Button { + Task { await model.verifyAndSignIn() } + } label: { + HStack(spacing: 8) { + if model.isBusy { ProgressView().controlSize(.small) } + Text("登录") + Image(systemName: "arrow.right") + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.orange) + .disabled(model.isBusy) + if !model.statusMessage.isEmpty { + Label(model.statusMessage, systemImage: "checkmark.circle.fill") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +private struct LoginFieldLabel: View { + let title: String + let icon: String + var body: some View { + Label(title, systemImage: icon).font(.caption.weight(.semibold)).foregroundStyle(.secondary) + } +} + +private struct WorkspaceView: View { + @ObservedObject var model: AppModel + @State private var selectedFileID: String? + @State private var renameFile: CloudFile? + @State private var isCreatingFolder = false + @State private var isImporting = false + @State private var isShowingSettings = false + @State private var isShowingDuplicates = false + @State private var isShowingTools = false + @State private var folderName = "" + + private var selectedFile: CloudFile? { model.files.first { $0.id == selectedFileID } } + + var body: some View { + NavigationSplitView { + WorkspaceSidebar(model: model, isShowingTools: $isShowingTools) + } content: { + FilesBrowser(model: model, selectedFileID: $selectedFileID, renameFile: $renameFile, isCreatingFolder: $isCreatingFolder, isImporting: $isImporting, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates, folderName: $folderName) + } detail: { + FileInspector(model: model, file: selectedFile) + } + .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 270) + .frame(minWidth: 1180, minHeight: 700) + .background(Color(nsColor: .underPageBackgroundColor)) + .sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } } + .sheet(isPresented: $isCreatingFolder) { + CreateFolderSheet(name: $folderName) { name in + isCreatingFolder = false + folderName = "" + Task { await model.createFolder(name: name) } + } onCancel: { isCreatingFolder = false } + } + .sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) } + .sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) } + .sheet(isPresented: $isShowingTools) { ToolsPanel(model: model, isShowingSettings: $isShowingSettings, isShowingDuplicates: $isShowingDuplicates) } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in + if case .success(let urls) = result, let url = urls.first { Task { await model.upload(url: url) } } + } + .alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) { + Button("确定", role: .cancel) { model.lastActionMessage = "" } + } message: { Text(model.lastActionMessage) } + .onChange(of: model.section) { _, _ in Task { await model.refresh() } } + } +} + +private struct WorkspaceSidebar: View { + @ObservedObject var model: AppModel + @Binding var isShowingTools: Bool + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { + BrandMark(size: 32) + VStack(alignment: .leading, spacing: 1) { Text("光鸭云盘").font(.headline); Text("CLOUD WORKSPACE").font(.system(size: 8, weight: .semibold, design: .rounded)).tracking(1).foregroundStyle(.secondary) } + Spacer() + }.padding(.horizontal, 18).padding(.vertical, 20) + List(selection: $model.section) { + Section("文件空间") { ForEach([WorkspaceSection.files, .photos, .videos, .documents]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } + Section("工作区") { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } + }.listStyle(.sidebar).scrollContentBackground(.hidden) + Divider() + VStack(alignment: .leading, spacing: 8) { + Button { isShowingTools = true } label: { + Label("工具", systemImage: "slider.horizontal.3") + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + }.buttonStyle(.plain) + Text("清理、去重、相似文件夹和工作区设置").font(.caption2).foregroundStyle(.tertiary).fixedSize(horizontal: false, vertical: true) + }.padding(.horizontal, 18).padding(.vertical, 14) + Divider() + HStack(spacing: 10) { + Circle().fill(Color.orange.opacity(0.18)).frame(width: 32, height: 32).overlay { Text(String(model.user.name.prefix(1))).font(.headline).foregroundStyle(.orange) } + VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.medium)).lineLimit(1); Text(model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + Spacer() + Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录") + }.padding(14) + } + .padding(8) + .liquidGlass(cornerRadius: 18) + .padding(8) + } +} + +private struct FilesBrowser: View { + @ObservedObject var model: AppModel + @Binding var selectedFileID: String? + @Binding var renameFile: CloudFile? + @Binding var isCreatingFolder: Bool + @Binding var isImporting: Bool + @Binding var isShowingSettings: Bool + @Binding var isShowingDuplicates: Bool + @Binding var folderName: String + @State private var searchText = "" + private var filteredFiles: [CloudFile] { searchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } + + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 14) { + VStack(alignment: .leading, spacing: 6) { + Text(model.section.rawValue).font(.system(size: 24, weight: .bold, design: .rounded)) + if model.section == .files && !model.folderPath.isEmpty { + HStack(spacing: 5) { + Button("全部文件") { Task { await model.navigateToRoot() } }.buttonStyle(.link) + ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in + Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) + Button(folder.name) { Task { await model.navigateToFolder(at: index) } }.buttonStyle(.link).lineLimit(1) + } + }.font(.caption) + } else { + Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.subheadline).foregroundStyle(.secondary) + } + } + Spacer() + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("搜索文件", text: $searchText).textFieldStyle(.plain).frame(width: 130) + }.padding(.horizontal, 11).padding(.vertical, 9).liquidGlass(cornerRadius: 11) + Button { isImporting = true } label: { Label("上传", systemImage: "arrow.up.circle") }.buttonStyle(.borderedProminent).tint(.orange) + Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") }.buttonStyle(.bordered).help("新建文件夹") + Button { Task { await model.refresh() } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新").disabled(model.isBusy || model.isLoadingFiles) + Menu { + Button { Task { await model.cleanEmptyFolders() } } label: { Label("清理空文件夹", systemImage: "sparkles") } + Button { Task { await model.scanDuplicates(); isShowingDuplicates = true } } label: { Label("扫描重复文件", systemImage: "square.on.square") } + Button { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true } } label: { Label("扫描重复文件(排除小文件)", systemImage: "square.on.square.fill") } + Button { model.findSimilarFolders() } label: { Label("查询相似文件夹", systemImage: "rectangle.3.group") } + Divider() + Button { isShowingSettings = true } label: { Label("工作区设置", systemImage: "gearshape") } + } label: { Image(systemName: "ellipsis.circle") }.menuStyle(.borderlessButton).help("更多工具") + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .liquidGlass(cornerRadius: 16) + .padding(.horizontal, 12) + .padding(.top, 14) + .padding(.bottom, 12) + if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section) } + else if model.isLoadingFiles && model.files.isEmpty { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } + else if filteredFiles.isEmpty { EmptyFilesView(model: model) } + else { + List(selection: $selectedFileID) { + ForEach(filteredFiles) { file in + BrowserRow(file: file) { + selectedFileID = file.id + Task { await model.open(file) } + } onSelect: { selectedFileID = file.id } + .tag(file.id) + .contextMenu { fileMenu(file) } + .draggable(file.id) + .dropDestination(for: String.self) { values, _ in + guard file.isDirectory else { return false } + let ids = values.filter { $0 != file.id } + guard !ids.isEmpty else { return false } + Task { await model.move(fileIDs: ids, to: file.id) } + return true + } + } + }.listStyle(.inset(alternatesRowBackgrounds: true)) + } + } + .background(Color(nsColor: .underPageBackgroundColor)) + } + + @ViewBuilder private func fileMenu(_ file: CloudFile) -> some View { + Button { Task { await model.open(file) } } label: { Label("打开", systemImage: "arrow.up.right.square") } + if !file.isDirectory { Button { Task { await model.download(file) } } label: { Label("下载", systemImage: "arrow.down.circle") } } + Button { Task { await model.share(file) } } label: { Label("分享", systemImage: "square.and.arrow.up") } + Menu("复制到") { + Button("当前文件夹") { Task { await model.copy(file, to: model.folderPath.last?.id) } } + Button("根目录") { Task { await model.copy(file, to: nil) } } + } + Menu("移动到") { + Button("当前文件夹") { Task { await model.move(file, to: model.folderPath.last?.id) } } + Button("根目录") { Task { await model.move(file, to: nil) } } + } + Divider() + Button { renameFile = file } label: { Label("重命名", systemImage: "pencil") } + Button { Task { await model.showDetails(file) } } label: { Label("查看详情", systemImage: "info.circle") } + if file.isDirectory { + Button { Task { await model.recognizeTMDBFolder(file) } } label: { Label("TMDB 识别当前文件夹及子文件夹", systemImage: "film") } + } else { + Button { Task { await model.recognizeTMDB(file) } } label: { Label("TMDB 识别", systemImage: "film") } + Button { Task { await model.recognizeTMDB(file, organize: true) } } label: { Label("TMDB 识别并整理", systemImage: "film.stack") } + } + Button { model.copyTransferJSON(for: file) } label: { Label("复制秒传 JSON", systemImage: "doc.on.doc") } + Divider() + if model.section == .recycle { Button { Task { await model.restore(file) } } label: { Label("恢复", systemImage: "arrow.uturn.backward") } } + Button(role: .destructive) { Task { await model.delete(file) } } label: { Label("删除", systemImage: "trash") } + } +} + +private struct BrowserRow: View { + let file: CloudFile + let onOpen: () -> Void + let onSelect: () -> Void + var body: some View { + Button(action: file.isDirectory ? onOpen : onSelect) { + HStack(spacing: 12) { + Image(systemName: file.icon).font(.title3).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(file.name).font(.body).lineLimit(1) + HStack(spacing: 6) { Text(file.typeName); if file.isDirectory && file.size != nil { Text("·"); Text(file.formattedSize) } }.font(.caption).foregroundStyle(.secondary) + } + Spacer() + Text(file.formattedSize).font(.callout).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing) + Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout).foregroundStyle(.secondary).frame(width: 130, alignment: .trailing) + if file.isDirectory { Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) } + }.padding(.vertical, 8).frame(maxWidth: .infinity, alignment: .leading) + }.buttonStyle(.plain) + } +} + +private struct FileInspector: View { + @ObservedObject var model: AppModel + let file: CloudFile? + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let file { + VStack(alignment: .leading, spacing: 16) { + HStack { Image(systemName: file.icon).font(.system(size: 30)).foregroundStyle(file.isDirectory ? .orange : .accentColor); Spacer(); Button { Task { await model.showDetails(file) } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.plain).help("刷新详情") } + Text(file.name).font(.title3.weight(.semibold)).lineLimit(2) + HStack { Label(file.typeName, systemImage: file.isDirectory ? "folder" : "doc"); Spacer(); Text(file.formattedSize).foregroundStyle(.secondary) }.font(.caption) + Divider() + VStack(alignment: .leading, spacing: 10) { + InspectorField(title: "文件 ID", value: file.id) + InspectorField(title: "GCID", value: file.gcid ?? "未获取") + InspectorField(title: "修改时间", value: file.modifiedAt.isEmpty ? "--" : file.modifiedAt) + } + HStack(spacing: 8) { Button { Task { await model.open(file) } } label: { Label(file.isDirectory ? "打开文件夹" : "下载", systemImage: file.isDirectory ? "folder" : "arrow.down.circle") }.buttonStyle(.borderedProminent).tint(.orange); Button { Task { await model.share(file) } } label: { Image(systemName: "square.and.arrow.up") }.buttonStyle(.bordered) } + if let detail = model.detail { Divider(); Text("接口详情").font(.caption.weight(.semibold)); ScrollView { Text(jsonText(detail)).font(.system(.caption, design: .monospaced)).textSelection(.enabled) }.frame(maxHeight: 220) } + Spacer() + }.padding(22) + } else { + VStack(spacing: 12) { Image(systemName: "sidebar.right").font(.system(size: 32, weight: .light)).foregroundStyle(.tertiary); Text("选择一个文件").font(.headline); Text("详情和操作会显示在这里").font(.caption).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .frame(minWidth: 250, idealWidth: 290) + .padding(8) + .liquidGlass(cornerRadius: 18) + .padding(8) + .onChange(of: file?.id) { _, _ in model.detail = nil } + } +} + +private struct InspectorField: View { let title: String; let value: String; var body: some View { VStack(alignment: .leading, spacing: 3) { Text(title.uppercased()).font(.system(size: 9, weight: .semibold)).foregroundStyle(.tertiary); Text(value).font(.caption).lineLimit(2).textSelection(.enabled) } } } + +private struct CreateFolderSheet: View { + @Binding var name: String + let onCreate: (String) -> Void + let onCancel: () -> Void + var body: some View { VStack(alignment: .leading, spacing: 16) { Text("新建文件夹").font(.title3.bold()); TextField("文件夹名称", text: $name).textFieldStyle(.roundedBorder); HStack { Spacer(); Button("取消", action: onCancel); Button("创建") { onCreate(name) }.buttonStyle(.borderedProminent).tint(.orange) } }.padding(24).frame(width: 340) } +} + +private struct RenameSheet: View { + let file: CloudFile + let onRename: (String) -> Void + @Environment(\.dismiss) private var dismiss + @State private var name: String + init(file: CloudFile, onRename: @escaping (String) -> Void) { self.file = file; self.onRename = onRename; _name = State(initialValue: file.name) } + var body: some View { VStack(alignment: .leading, spacing: 16) { Text("重命名").font(.title3.bold()); TextField("名称", text: $name).textFieldStyle(.roundedBorder); HStack { Spacer(); Button("取消") { dismiss() }; Button("保存") { onRename(name); dismiss() }.buttonStyle(.borderedProminent).tint(.orange) } }.padding(24).frame(width: 340) } +} + +private struct WorkspaceSettings: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + @State private var key = "" + @State private var proxyHost = "" + @State private var proxyPort = "" + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image(systemName: "gearshape.2.fill").font(.title2).foregroundStyle(.orange) + Text("工作区设置").font(.title3.bold()) + } + Text("TMDB 识别").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + SecureField("本地记住的 API Key", text: $key).textFieldStyle(.roundedBorder) + Text("HTTP 代理").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + HStack(spacing: 8) { + TextField("地址,例如 127.0.0.1", text: $proxyHost).textFieldStyle(.roundedBorder) + TextField("端口", text: $proxyPort).textFieldStyle(.roundedBorder).frame(width: 88) + } + Text("代理仅用于 TMDB 请求,配置保存在本机。留空表示直连。") + .font(.caption).foregroundStyle(.secondary) + HStack { Spacer(); Button("取消") { dismiss() }; Button("保存") { model.saveTMDBSettings(key: key, proxyHost: proxyHost, proxyPort: proxyPort); dismiss() }.buttonStyle(.borderedProminent).tint(.orange) } + } + .padding(24) + .frame(width: 500) + .liquidGlass(cornerRadius: 20) + .onAppear { key = model.tmdbAPIKey; proxyHost = model.tmdbProxyHost; proxyPort = model.tmdbProxyPort } + } +} + +private struct ToolsPanel: View { + @ObservedObject var model: AppModel + @Binding var isShowingSettings: Bool + @Binding var isShowingDuplicates: Bool + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { Text("工作区工具").font(.title3.bold()); Spacer(); Button("关闭") { dismiss() } } + ToolAction(title: "清理空文件夹", detail: "递归扫描当前云盘并删除空目录", icon: "sparkles") { Task { await model.cleanEmptyFolders(); dismiss() } } + ToolAction(title: "扫描重复文件", detail: "按 GCID 查找重复资源", icon: "square.on.square") { Task { await model.scanDuplicates(); isShowingDuplicates = true; dismiss() } } + ToolAction(title: "排除小文件扫描", detail: "忽略小于 1 MB 的文件", icon: "square.on.square.fill") { Task { await model.scanDuplicates(excludingSmallFiles: true); isShowingDuplicates = true; dismiss() } } + ToolAction(title: "查询相似文件夹", detail: "按标准化名称查找相似目录", icon: "rectangle.3.group") { model.findSimilarFolders(); dismiss() } + Divider() + Button { isShowingSettings = true; dismiss() } label: { Label("工作区设置", systemImage: "gearshape") }.buttonStyle(.bordered) + } + .padding(22) + .frame(width: 420) + .liquidGlass(cornerRadius: 20) + } +} + +private struct ToolAction: View { + let title: String + let detail: String + let icon: String + let action: () -> Void + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon).font(.title3).foregroundStyle(.orange).frame(width: 28) + VStack(alignment: .leading, spacing: 3) { Text(title).font(.body.weight(.medium)); Text(detail).font(.caption).foregroundStyle(.secondary) } + Spacer() + Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary) + }.contentShape(Rectangle()) + }.buttonStyle(.plain) + } +} + +private struct DuplicateResults: View { + @ObservedObject var model: AppModel + @Environment(\.dismiss) private var dismiss + var body: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text("重复文件").font(.title3.bold()) + Spacer() + Button("关闭") { dismiss() } + } + if model.isAnalyzing { + ProgressView("正在按 GCID 扫描…") + } else if model.duplicateGroups.isEmpty { + Text("没有发现重复文件").foregroundStyle(.secondary) + } else { + List(model.duplicateGroups) { group in + VStack(alignment: .leading, spacing: 6) { + Text("GCID \(group.id)").font(.caption.monospaced()).foregroundStyle(.secondary) + ForEach(group.files) { file in + HStack { + Image(systemName: file.icon) + Text(file.name).lineLimit(1) + Spacer() + Text(file.formattedSize).foregroundStyle(.secondary) + } + } + } + } + .frame(minHeight: 260) + } + } + .padding(20) + .frame(width: 560, height: 430) + } +} + +private func jsonText(_ value: JSONValue) -> String { guard let data = try? JSONEncoder().encode(value), let object = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys]), let text = String(data: pretty, encoding: .utf8) else { return "--" }; return text } + +private struct LegacyWorkspaceView: View { + @ObservedObject var model: AppModel + var body: some View { + NavigationSplitView { LegacyWorkspaceSidebar(model: model) } detail: { LegacyFilesView(model: model) } + .navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 270).frame(minWidth: 980, minHeight: 620) + .onChange(of: model.section) { _, _ in Task { await model.refresh() } } + } +} + +private struct LegacyWorkspaceSidebar: View { + @ObservedObject var model: AppModel + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 10) { BrandMark(size: 30); Text("光鸭云盘").font(.headline); Spacer() }.padding(.horizontal, 16).padding(.vertical, 18) + List(selection: $model.section) { + Section("文件空间") { ForEach([WorkspaceSection.files, .photos, .videos, .documents]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } + Section("更多") { ForEach([WorkspaceSection.cloud, .shares, .recycle]) { section in Label(section.rawValue, systemImage: section.icon).tag(section) } } + }.listStyle(.sidebar) + Divider() + HStack(spacing: 10) { + Circle().fill(Color.orange.opacity(0.18)).frame(width: 32, height: 32).overlay { Text(String(model.user.name.prefix(1))).font(.headline).foregroundStyle(.orange) } + VStack(alignment: .leading, spacing: 2) { Text(model.user.name).font(.callout.weight(.medium)).lineLimit(1); Text(model.user.phone).font(.caption).foregroundStyle(.secondary).lineLimit(1) } + Spacer() + Button { model.logout() } label: { Image(systemName: "rectangle.portrait.and.arrow.right") }.buttonStyle(.plain).help("退出登录") + }.padding(14) + }.background(Color(nsColor: .controlBackgroundColor)) + } +} + +private struct LegacyFilesView: View { + @ObservedObject var model: AppModel + @State private var searchText = "" + @State private var isImporting = false + @State private var isCreatingFolder = false + @State private var folderName = "" + private var filteredFiles: [CloudFile] { searchText.isEmpty ? model.files : model.files.filter { $0.name.localizedCaseInsensitiveContains(searchText) } } + var body: some View { + VStack(spacing: 0) { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text(model.section.rawValue).font(.system(size: 26, weight: .bold)) + if model.section == .files && !model.folderPath.isEmpty { + HStack(spacing: 5) { + Button("全部文件") { Task { await model.navigateToRoot() } }.buttonStyle(.link) + ForEach(Array(model.folderPath.enumerated()), id: \.element.id) { index, folder in + Image(systemName: "chevron.right").font(.caption2).foregroundStyle(.tertiary) + Button(folder.name) { Task { await model.navigateToFolder(at: index) } }.buttonStyle(.link).lineLimit(1) + } + }.font(.caption) + } else { + Text(model.section == .files ? "全部文件" : "按类型整理的文件").font(.subheadline).foregroundStyle(.secondary) + } + } + Spacer() + TextField("搜索文件", text: $searchText).textFieldStyle(.roundedBorder).frame(width: 190) + Button { isImporting = true } label: { Image(systemName: "arrow.up") }.buttonStyle(.bordered).help("上传文件") + Button { isCreatingFolder = true } label: { Image(systemName: "folder.badge.plus") }.buttonStyle(.bordered).help("新建文件夹") + Button { Task { await model.refresh() } } label: { Image(systemName: "arrow.clockwise") }.buttonStyle(.bordered).help("刷新") + .disabled(model.isBusy || model.isLoadingFiles) + }.padding(.horizontal, 28).padding(.vertical, 24) + Divider() + if model.section == .cloud || model.section == .shares { EmptySectionView(section: model.section) } + else if model.isLoadingFiles && model.files.isEmpty { VStack(spacing: 12) { ProgressView(); Text("正在加载文件…").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } + else if filteredFiles.isEmpty { EmptyFilesView(model: model) } + else { + List { + ForEach(filteredFiles) { file in + Group { + if file.isDirectory { + Button { Task { await model.openFolder(file) } } label: { LegacyFileRow(file: file) } + .buttonStyle(.plain) + } else { + LegacyFileRow(file: file) + } + } + .contextMenu { Button(role: .destructive) { Task { await model.delete(file) } } label: { Label("删除", systemImage: "trash") } } + } + }.listStyle(.inset) + } + } + .fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in + if case .success(let urls) = result, let url = urls.first { Task { await model.upload(url: url) } } + } + .sheet(isPresented: $isCreatingFolder) { + VStack(alignment: .leading, spacing: 16) { + Text("新建文件夹").font(.title3.bold()); TextField("文件夹名称", text: $folderName).textFieldStyle(.roundedBorder) + HStack { Spacer(); Button("取消") { isCreatingFolder = false }; Button("创建") { isCreatingFolder = false; Task { await model.createFolder(name: folderName); folderName = "" } }.buttonStyle(.borderedProminent) } + }.padding(24).frame(width: 330) + } + } +} + +private struct LegacyFileRow: View { + let file: CloudFile + var body: some View { + HStack(spacing: 14) { + Image(systemName: file.icon).font(.title3).foregroundStyle(file.isDirectory ? .orange : .accentColor).frame(width: 28) + VStack(alignment: .leading, spacing: 3) { + Text(file.name).font(.body).lineLimit(1) + HStack(spacing: 6) { + Text(file.typeName) + if file.isDirectory && file.size != nil { + Text("·") + Text(file.formattedSize) + } + }.font(.caption).foregroundStyle(.secondary) + } + Spacer(); Text(file.formattedSize).font(.callout).foregroundStyle(.secondary).frame(width: 90, alignment: .trailing); Text(file.modifiedAt.isEmpty ? "--" : file.modifiedAt).font(.callout).foregroundStyle(.secondary).frame(width: 140, alignment: .trailing) + }.padding(.vertical, 8).frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct EmptyFilesView: View { + @ObservedObject var model: AppModel + var body: some View { VStack(spacing: 12) { Image(systemName: model.section.icon).font(.system(size: 46, weight: .light)).foregroundStyle(.tertiary); Text("这里还没有文件").font(.title3.weight(.medium)); Text("上传文件或创建文件夹开始使用").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } +} + +private struct EmptySectionView: View { + let section: WorkspaceSection + var body: some View { VStack(spacing: 12) { Image(systemName: section.icon).font(.system(size: 46, weight: .light)).foregroundStyle(.tertiary); Text(section == .cloud ? "云下载" : "我的分享").font(.title3.weight(.medium)); Text("接口已就绪,登录后可以在这里管理内容").foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) } +} + +private struct BrandMark: View { + let size: CGFloat + var body: some View { + if let path = Bundle.main.path(forResource: "光鸭", ofType: "svg"), let image = NSImage(contentsOfFile: path) { Image(nsImage: image).resizable().aspectRatio(contentMode: .fit).frame(width: size, height: size) } + else { RoundedRectangle(cornerRadius: size * 0.22).fill(Color.orange).frame(width: size, height: size).overlay { Image(systemName: "bird.fill").foregroundStyle(.white) } } + } +} + +private struct QRCodeImage: View { + let payload: String + var body: some View { if let image = makeImage() { Image(nsImage: image).interpolation(.none).resizable().scaledToFit() } else { Image(systemName: "qrcode").font(.largeTitle) } } + private func makeImage() -> NSImage? { + let filter = CIFilter.qrCodeGenerator(); filter.message = Data(payload.utf8); filter.correctionLevel = "M" + guard let output = filter.outputImage else { return nil } + let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10)); let context = CIContext() + guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else { return nil } + return NSImage(cgImage: cgImage, size: NSSize(width: 210, height: 210)) + } +} + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + LoginView(model: AppModel(), autoStartQR: false) + .previewDisplayName("登录页") + } +} diff --git a/guangya_mac/GuangyaAPI.swift b/guangya_mac/GuangyaAPI.swift new file mode 100644 index 0000000..bd36b27 --- /dev/null +++ b/guangya_mac/GuangyaAPI.swift @@ -0,0 +1,377 @@ +import CryptoKit +import CFNetwork +import Foundation + +enum GuangyaAPIError: LocalizedError { + case invalidResponse + case http(status: Int, message: String) + case missingField(String) + case noRefreshToken + + var isDeviceAuthorizationPending: Bool { + guard case .http(let status, let message) = self, status == 400 else { return false } + let normalized = message.lowercased() + return normalized.contains("authorization_pending") || normalized.contains("slow_down") + } + + var errorDescription: String? { + switch self { + case .invalidResponse: return "服务器返回了无法识别的数据" + case .http(let status, let message): return "请求失败(\(status)):\(message)" + case .missingField(let field): return "响应缺少字段:\(field)" + case .noRefreshToken: return "没有可用的刷新令牌" + } + } +} + +final class GuangyaAPI: @unchecked Sendable { + static let accountBase = URL(string: "https://account.guangyapan.com")! + static let apiBase = URL(string: "https://api.guangyapan.com")! + static let clientID = "aMe-8VSlkrbQXpUR" + + private let session: URLSession + private(set) var accessToken: String + private(set) var refreshTokenValue: String? + private var tokenExpiresAt: Date? + let deviceID: String + + init(accessToken: String = "", refreshToken: String? = nil, deviceID: String = GuangyaAPI.generateDeviceID()) { + self.accessToken = accessToken + self.refreshTokenValue = refreshToken + self.deviceID = deviceID + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 30 + configuration.waitsForConnectivity = false + configuration.httpAdditionalHeaders = [ + "Accept": "application/json, text/plain, */*", + "Content-Type": "application/json", + "did": deviceID, + "dt": "4", + "Origin": "https://www.guangyapan.com", + "Referer": "https://www.guangyapan.com/", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36" + ] + session = URLSession(configuration: configuration) + } + + func updateTokens(from value: JSONValue) -> AuthTokens? { + guard let access = value.firstStringDeep(["access_token", "accessToken"]) else { return nil } + accessToken = access + refreshTokenValue = value.firstStringDeep(["refresh_token", "refreshToken"]) ?? refreshTokenValue + let expires = value.firstIntDeep(["expires_in", "expiresIn"]).map(Double.init) + tokenExpiresAt = expires.map { Date().addingTimeInterval($0) } + return AuthTokens(accessToken: access, refreshToken: refreshTokenValue, expiresIn: expires) + } + + func prepareSession() async { + guard !accessToken.isEmpty, refreshTokenValue != nil, tokenExpiresAt == nil else { return } + _ = try? await refreshToken() + } + + // MARK: Authentication + + func loginSMSInit(phoneNumber: String, captchaToken: String? = nil) async throws -> JSONValue { + var body: [String: JSONValue] = [ + "client_id": .string(Self.clientID), + "action": .string("POST:/v1/auth/verification"), + "device_id": .string(deviceID), + "meta": .object(["phone_number": .string(phoneNumber)]) + ] + if let captchaToken { body["captcha_token"] = .string(captchaToken) } + return try await accountRequest("/v1/shield/captcha/init", body: .object(body)) + } + + func loginSMSSend(phoneNumber: String, captchaToken: String, target: String = "ANY") async throws -> JSONValue { + try await accountRequest("/v1/auth/verification", body: .object([ + "phone_number": .string(phoneNumber), "target": .string(target), "client_id": .string(Self.clientID) + ]), extraHeaders: ["x-captcha-token": captchaToken]) + } + + func loginSMSVerify(verificationID: String, verificationCode: String) async throws -> JSONValue { + try await accountRequest("/v1/auth/verification/verify", body: .object([ + "verification_id": .string(verificationID), "verification_code": .string(verificationCode), "client_id": .string(Self.clientID) + ])) + } + + func loginSMSSignIn(code: String, verificationToken: String, username: String, captchaToken: String) async throws -> JSONValue { + let result = try await accountRequest("/v1/auth/signin", body: .object([ + "verification_code": .string(code), "verification_token": .string(verificationToken), "username": .string(username), "client_id": .string(Self.clientID) + ]), extraHeaders: ["x-captcha-token": captchaToken]) + _ = updateTokens(from: result) + return result + } + + /// OAuth device authorization flow used by the web client QR login. + func loginQRInit() async throws -> JSONValue { + try await accountRequest("/v1/auth/device/code", body: .object([ + "scope": .string("user"), "client_id": .string(Self.clientID) + ])) + } + + func loginQRPoll(token: String) async throws -> JSONValue { + let result = try await accountRequest("/v1/auth/token", body: .object([ + "grant_type": .string("urn:ietf:params:oauth:grant-type:device_code"), + "device_code": .string(token), + "client_id": .string(Self.clientID) + ])) + _ = updateTokens(from: result) + return result + } + + func refreshToken(_ token: String? = nil) async throws -> JSONValue { + guard let token = token ?? refreshTokenValue else { throw GuangyaAPIError.noRefreshToken } + let result = try await accountRequest("/v1/auth/token", body: .object([ + "client_id": .string(Self.clientID), "grant_type": .string("refresh_token"), "refresh_token": .string(token) + ]), extraHeaders: ["x-action": "401"]) + _ = updateTokens(from: result) + return result + } + + func userInfo() async throws -> JSONValue { + try await accountRequest("/v1/user/me", body: nil, authenticated: true) + } + + // MARK: Cloud download, file system, and sharing + + func cloudTaskList(page: Int = 0, pageSize: Int = 50, status: [Int] = [0, 1, 3, 4]) async throws -> JSONValue { + try await apiRequest("/nd.bizcloudcollection.s/v1/list_task", body: object(("page", page), ("pageSize", pageSize), ("status", status))) + } + + func cloudResolveURL(_ url: String) async throws -> JSONValue { + try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_res", body: object(("url", url))) + } + + func cloudResolveTorrent(data: Data, filename: String = "file.torrent") async throws -> JSONValue { + try await apiRequest("/nd.bizcloudcollection.s/v1/resolve_torrent", method: "POST", multipart: [("torrent", filename, "application/octet-stream", data)]) + } + + func cloudCreateTask(url: String, parentID: String? = nil) async throws -> JSONValue { + try await apiRequest("/nd.bizcloudcollection.s/v1/create_task", body: object(("url", url), ("parentId", parentID ?? ""))) + } + + func downloadURL(fileID: String) async throws -> JSONValue { + try await apiRequest("/nd.bizuserres.s/v1/get_res_download_url", body: object(("fileId", fileID))) + } + + func taskStatus(taskID: String) async throws -> JSONValue { + try await apiRequest("/nd.bizuserres.s/v1/get_task_status", body: object(("taskId", taskID))) + } + + func fsFiles(parentID: String? = nil, page: Int = 0, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0, fileTypes: [Int]? = nil, resType: Int? = nil, dirType: Int? = nil, needPlayRecord: Bool = false) async throws -> JSONValue { + var body: [String: JSONValue] = ["parentId": .string(parentID ?? ""), "page": .number(Double(page)), "pageSize": .number(Double(pageSize)), "orderBy": .number(Double(orderBy)), "sortType": .number(Double(sortType))] + if let fileTypes { body["fileTypes"] = .array(fileTypes.map { .number(Double($0)) }) } + if let resType { body["resType"] = .number(Double(resType)) } + if let dirType { body["dirType"] = .number(Double(dirType)) } + if needPlayRecord { body["needPlayRecord"] = .bool(true) } + return try await apiRequest("/userres/v1/file/get_file_list", body: .object(body)) + } + + func fsImageList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [1], resType: 1) } + func fsVideoList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [2], resType: 1) } + func fsDocumentList() async throws -> JSONValue { try await fsFiles(parentID: "*", orderBy: 3, sortType: 1, fileTypes: [4], resType: 1) } + func fsRecycleFiles() async throws -> JSONValue { try await fsFiles(orderBy: 10, dirType: 4) } + + func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue { + var body = ["dirName": JSONValue.string(name), "parentId": .string(parentID ?? "")] + if failIfNameExist { body["failIfNameExist"] = .bool(true) } + return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body)) + } + func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) } + func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", fileIDs), ("parentId", parentID ?? ""))) } + func fsDelete(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/delete_file", body: object(("fileIds", fileIDs))) } + func fsRecycle(fileIDs: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/recycle_file", body: object(("fileIds", fileIDs))) } + func fsClearRecycleBin() async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/clear_recycle_bin") } + func fsRename(fileID: String, newName: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/rename", body: object(("fileId", fileID), ("newName", newName))) } + func fsDetail(fileID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_file_detail", body: object(("fileId", fileID))) } + + func shareCreate(fileIDs: [String], title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { + try await apiRequest("/nd.bizuserres.s/v1/share_file", body: object(("fileIds", fileIDs), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) + } + func shareUserList(page: Int = 0, pageSize: Int = 50) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_list", body: object(("page", page), ("pageSize", pageSize), ("orderType", 1), ("sortType", 1))) } + func shareUpdate(shareID: String, title: String, validateDuration: Int = 0, shareType: Int = 1, code: String = "", autoFillCode: Bool = true, trafficLimit: String = "0", maxRestoreCount: Int = 0, downloadType: Int = 1) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/update_share", body: object(("id", shareID), ("title", title), ("validateDuration", validateDuration), ("shareType", shareType), ("code", code), ("autoFillCode", autoFillCode), ("trafficLimit", trafficLimit), ("maxRestoreCount", maxRestoreCount), ("downloadType", downloadType))) } + func shareDelete(ids: [String]) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/delete_share", body: object(("ids", ids))) } + func shareRestore(accessToken: String, fileIDs: [String], parentID: String = "") async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/restore_share", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("parentId", parentID))) } + func shareDownloadURL(fileID: String, accessToken: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_download_url", body: object(("fileId", fileID), ("accessToken", accessToken))) } + func shareFilesSize(accessToken: String, fileIDs: [String], download: Bool = true) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/get_share_files_size", body: object(("accessToken", accessToken), ("fileIds", fileIDs), ("download", download))) } + func shareSummary(shareID: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_summary", body: object(("shareId", shareID))) } + func shareAccessToken(shareID: String, code: String) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_access_token", body: object(("shareId", shareID), ("code", code))) } + func shareFilesList(accessToken: String, parentID: String = "", page: Int = 1, pageSize: Int = 50, orderBy: Int = 0, sortType: Int = 0) async throws -> JSONValue { try await publicPost("/nd.bizuserres.s/v1/get_share_page_files_list", body: object(("accessToken", accessToken), ("parentId", parentID), ("page", page), ("pageSize", pageSize), ("orderBy", orderBy), ("sortType", sortType))) } + + // MARK: Upload + + func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue { + var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))] + if let md5 { res["md5"] = .string(md5) } + return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": .string(parentID ?? "")])) + } + func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) } + func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID))) } + + func tmdbSearch(query: String, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue { + var components = URLComponents(string: "https://api.themoviedb.org/3/search/multi")! + components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "include_adult", value: "false")] + var request = URLRequest(url: components.url!) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 15 + configuration.timeoutIntervalForResource = 30 + configuration.waitsForConnectivity = false + let normalizedHost = proxyHost + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "^https?://", with: "", options: .regularExpression) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + if !normalizedHost.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) { + configuration.connectionProxyDictionary = [ + kCFNetworkProxiesHTTPEnable as String: true, + kCFNetworkProxiesHTTPProxy as String: normalizedHost, + kCFNetworkProxiesHTTPPort as String: port, + kCFNetworkProxiesHTTPSEnable as String: true, + kCFNetworkProxiesHTTPSProxy as String: normalizedHost, + kCFNetworkProxiesHTTPSPort as String: port + ] + } + let tmdbSession = URLSession(configuration: configuration) + let (data, response) = try await tmdbSession.data(for: request) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") } + return try JSONDecoder().decode(JSONValue.self, from: data) + } + + func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue { + let values = try Data(contentsOf: url) + let name = url.lastPathComponent + if values.count < 1024 * 1024 { + let digest = Data(Insecure.MD5.hash(data: values)).base64EncodedString() + let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID, md5: digest) + guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") } + return try await uploadInfo(taskID: taskID) + } + let token = try await uploadToken(name: name, fileSize: Int64(values.count), parentID: parentID) + guard let taskID = token["data"]?.firstString(["taskId", "task_id"]) else { throw GuangyaAPIError.missingField("taskId") } + let canFlash = try await checkCanFlashUpload(taskID: taskID, gcid: Self.calculateGCID(data: values)) + if canFlash["data"]?["canFlashUpload"]?.boolValue == true { return try await uploadInfo(taskID: taskID) } + _ = try await cdnUpload(data: values, tokenData: token["data"] ?? .null, contentType: contentType, chunkSize: chunkSize) + return try await uploadInfo(taskID: taskID) + } + + // MARK: Low-level requests and OSS signing + + private func accountRequest(_ path: String, method: String = "POST", body: JSONValue?, extraHeaders: [String: String] = [:], authenticated: Bool = false) async throws -> JSONValue { + var headers = accountHeaders() + headers.merge(extraHeaders) { _, new in new } + if authenticated || !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" } + return try await request(Self.accountBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, refreshOnUnauthorized: false) + } + + private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil) async throws -> JSONValue { + if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { _ = try? await refreshToken() } + var headers = ["traceparent": Self.generateTraceparent()] + if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" } + return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart) + } + + private func publicPost(_ path: String, body: JSONValue) async throws -> JSONValue { + try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false) + } + + private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true) async throws -> JSONValue { + var request = URLRequest(url: url) + request.httpMethod = method + headers.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + if let multipart { + let boundary = "Boundary-\(UUID().uuidString)" + request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") + request.httpBody = Self.multipartBody(parts: multipart, boundary: boundary) + } else if let body { request.httpBody = try JSONEncoder().encode(body) } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse } + if http.statusCode == 401, refreshOnUnauthorized, refreshTokenValue != nil { + _ = try? await refreshToken() + return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false) + } + guard (200..<300).contains(http.statusCode) else { + let message: String + if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { + message = decoded.firstStringDeep(["message", "msg", "error_description", "error"]) ?? String(data: data, encoding: .utf8) ?? "未知错误" + } else { + message = String(data: data, encoding: .utf8) ?? "未知错误" + } + throw GuangyaAPIError.http(status: http.statusCode, message: message) + } + if data.isEmpty { return .object([:]) } + if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) { + if let code = decoded.firstIntDeep(["code"]), code != 0 { + throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误") + } + return decoded + } + return .string(String(data: data, encoding: .utf8) ?? "") + } + + private func accountHeaders() -> [String: String] { + ["accept": "*/*", "content-type": "application/json", "origin": "https://www.guangyapan.com", "referer": "https://www.guangyapan.com/", "x-client-id": Self.clientID, "x-client-version": "0.0.1", "x-device-id": deviceID, "x-device-model": "chrome%2F147.0.0.0", "x-device-name": "PC-Chrome", "x-device-sign": "wdi10.\(deviceID)\(Self.randomHex(16))", "x-net-work-type": "NONE", "x-os-version": "MacIntel", "x-platform-version": "1", "x-protocol-version": "301", "x-provider-name": "NONE", "x-sdk-version": "9.0.2"] + } + + private func cdnUpload(data: Data, tokenData: JSONValue, contentType: String, chunkSize: Int) async throws -> String { + guard let creds = tokenData["creds"], let key = creds.firstString(["accessKeyID", "accessKeyId"]), let secret = creds.firstString(["secretAccessKey"]), let sessionToken = creds.firstString(["sessionToken"]), let endpoint = tokenData.firstString(["fullEndPoint", "fullEndpoint"]), let bucket = tokenData.firstString(["bucketName"]), let objectPath = tokenData.firstString(["objectPath"]) else { throw GuangyaAPIError.missingField("OSS token data") } + let objectURL = "\(endpoint)/\(objectPath)" + let uploadResponse = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, subResources: ["uploads": ""]) + guard let uploadID = Self.xmlValue(uploadResponse.body, tag: "UploadId") else { throw GuangyaAPIError.missingField("UploadId") } + var parts: [(Int, String)] = [] + var offset = 0 + var number = 1 + while offset < data.count { + let end = min(offset + chunkSize, data.count) + let chunk = data.subdata(in: offset.." + parts.map { "\($0.0)\"\($0.1)\"" }.joined() + "" + let result = try await ossRequest(method: "POST", url: objectURL, bucket: bucket, objectKey: objectPath, accessKeyID: key, secret: secret, sessionToken: sessionToken, content: Data(xml.utf8), contentType: "application/xml", contentMD5: Data(Insecure.MD5.hash(data: Data(xml.utf8))).base64EncodedString(), subResources: ["uploadId": uploadID]) + return Self.xmlValue(result.body, tag: "ETag") ?? "" + } + + private func ossRequest(method: String, url: String, bucket: String, objectKey: String, accessKeyID: String, secret: String, sessionToken: String, content: Data = Data(), contentType: String = "", contentMD5: String = "", subResources: [String: String] = [:]) async throws -> (body: String, headers: [String: String]) { + let date = Self.httpDate() + var canonical = [method.uppercased(), contentMD5, contentType, date, "x-oss-date:\(date)", "x-oss-security-token:\(sessionToken.trimmingCharacters(in: .whitespacesAndNewlines))", "/\(bucket)/\(objectKey)"] + if !subResources.isEmpty { canonical[6] += "?" + subResources.keys.sorted().map { key in let value = subResources[key] ?? ""; return value.isEmpty ? key : "\(key)=\(value)" }.joined(separator: "&") } + let signature = Data(HMAC.authenticationCode(for: Data(canonical.joined(separator: "\n").utf8), using: SymmetricKey(data: Data(secret.utf8)))).base64EncodedString() + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = method + request.httpBody = content + request.setValue("OSS \(accessKeyID):\(signature)", forHTTPHeaderField: "Authorization") + request.setValue(date, forHTTPHeaderField: "x-oss-date") + request.setValue(sessionToken, forHTTPHeaderField: "x-oss-security-token") + if !contentType.isEmpty { request.setValue(contentType, forHTTPHeaderField: "Content-Type") } + if !contentMD5.isEmpty { request.setValue(contentMD5, forHTTPHeaderField: "Content-MD5") } + subResources.forEach { request.url = URL(string: "\(request.url!.absoluteString)\($0.key == subResources.keys.sorted().first ? "?" : "&")\($0.key)=\($0.value)") } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse } + guard (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: http.statusCode, message: String(data: data, encoding: .utf8) ?? "OSS 请求失败") } + return (String(data: data, encoding: .utf8) ?? "", http.allHeaderFields.reduce(into: [:]) { result, item in result[String(describing: item.key).lowercased()] = String(describing: item.value) }) + } + + private static func multipartBody(parts: [(String, String, String, Data)], boundary: String) -> Data { + var body = Data() + for (name, filename, contentType, data) in parts { + body.append(Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"\(name)\"; filename=\"\(filename)\"\r\nContent-Type: \(contentType)\r\n\r\n".utf8)); body.append(data); body.append(Data("\r\n".utf8)) + } + body.append(Data("--\(boundary)--\r\n".utf8)); return body + } + + private static func xmlValue(_ xml: String, tag: String) -> String? { let pattern = "<\(tag)>(.*?)"; guard let range = xml.range(of: pattern, options: .regularExpression) else { return nil }; let value = String(xml[range]); return value.replacingOccurrences(of: "<\(tag)>", with: "").replacingOccurrences(of: "", with: "") } + private func object(_ values: [String: Any]) -> JSONValue { .object(values.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) }) } + private func object(_ values: (String, Any)...) -> JSONValue { object(values.reduce(into: [:]) { $0[$1.0] = JSONValue(any: $1.1) }) } + private static func generateDeviceID() -> String { Insecure.MD5.hash(data: Data((0..<16).map { _ in UInt8.random(in: 0...255) })).map { String(format: "%02x", $0) }.joined() } + private static func randomHex(_ count: Int) -> String { (0.. String { "00-\(randomHex(16))-\(randomHex(8))-01" } + private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) } + private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset.. JSONValue { + .object(values.reduce(into: [:]) { result, item in result[item.key] = JSONValue(any: item.value) }) + } + + init(any value: Any) { + switch value { + case let value as String: self = .string(value) + case let value as Int: self = .number(Double(value)) + case let value as Int64: self = .number(Double(value)) + case let value as Double: self = .number(value) + case let value as Bool: self = .bool(value) + case let value as [Any]: self = .array(value.map(JSONValue.init(any:))) + case let value as [String: Any]: self = .object(value.reduce(into: [:]) { $0[$1.key] = JSONValue(any: $1.value) }) + default: self = .null + } + } + + var objectValue: [String: JSONValue]? { if case .object(let value) = self { return value }; return nil } + var arrayValue: [JSONValue]? { if case .array(let value) = self { return value }; return nil } + var stringValue: String? { if case .string(let value) = self { return value }; return nil } + var doubleValue: Double? { if case .number(let value) = self { return value }; return nil } + var intValue: Int? { doubleValue.map(Int.init) } + var int64Value: Int64? { doubleValue.map(Int64.init) } + + subscript(_ key: String) -> JSONValue? { objectValue?[key] } + + func firstString(_ keys: [String]) -> String? { + for key in keys where self[key]?.stringValue != nil { return self[key]?.stringValue } + return nil + } + + // API responses are not consistent about whether payload fields are nested in `data`. + func firstStringDeep(_ keys: [String]) -> String? { + if let value = firstString(keys) { return value } + guard let object = objectValue else { return nil } + let preferredKeys = ["data", "result", "user", "profile", "payload"] + for key in preferredKeys { + if let value = object[key]?.firstStringDeep(keys) { return value } + } + for (key, value) in object where !preferredKeys.contains(key) { + if let found = value.firstStringDeep(keys) { return found } + } + return nil + } + + func firstInt(_ keys: [String]) -> Int? { + for key in keys where self[key]?.intValue != nil { return self[key]?.intValue } + return nil + } + + func firstIntDeep(_ keys: [String]) -> Int? { + if let value = firstInt(keys) { return value } + guard let object = objectValue else { return nil } + let preferredKeys = ["data", "result", "user", "profile", "payload"] + for key in preferredKeys { + if let value = object[key]?.firstIntDeep(keys) { return value } + } + for (key, value) in object where !preferredKeys.contains(key) { + if let found = value.firstIntDeep(keys) { return found } + } + return nil + } + + func firstInt64Deep(_ keys: [String]) -> Int64? { + if let value = keys.compactMap({ self[$0]?.int64Value }).first { return value } + guard let object = objectValue else { return nil } + let preferredKeys = ["data", "result", "user", "profile", "payload"] + for key in preferredKeys { + if let value = object[key]?.firstInt64Deep(keys) { return value } + } + for (key, value) in object where !preferredKeys.contains(key) { + if let found = value.firstInt64Deep(keys) { return found } + } + return nil + } +} + +private func formatFileDate(_ epoch: Int64) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "zh_CN") + formatter.dateFormat = "yyyy-MM-dd HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: TimeInterval(epoch))) +} + +struct CloudFile: Identifiable, Hashable, Sendable { + let id: String + let name: String + let isDirectory: Bool + var size: Int64? + var gcid: String? + var modifiedAt: String + let fileType: Int + + var icon: String { + if isDirectory { return "folder.fill" } + switch fileType { + case 1: return "photo.fill" + case 2: return "play.rectangle.fill" + case 3: return "music.note" + case 4: return "doc.text.fill" + case 5, 9: return "archivebox.fill" + default: return "doc.fill" + } + } + + var typeName: String { + if isDirectory { return "文件夹" } + return [1: "图片", 2: "视频", 3: "音频", 4: "文档", 5: "压缩包", 9: "BT种子"][fileType] ?? "文件" + } + + var formattedSize: String { + guard let size else { return "--" } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: size) + } + + init?(json: JSONValue) { + guard let object = json.objectValue, + let name = json.firstString(["name", "fileName", "resName"]) else { return nil } + id = json.firstString(["fileId", "id", "resId"]) ?? UUID().uuidString + self.name = name + let resourceType = json.firstInt(["resType"]) + let type = json.firstInt(["fileType", "type"]) ?? 0 + if let explicitDirectory = json.firstInt(["isDir", "dir", "directoryType"]) { + isDirectory = explicitDirectory == 1 + } else if let resourceType { + // dirType == 1 is also present on ordinary resources; resType == 2 identifies folders. + isDirectory = resourceType == 2 + } else { + isDirectory = type == 0 && (object["dirName"] != nil || object["children"] != nil) + } + size = json.firstInt64Deep(["size", "fileSize", "resSize", "totalSize", "dirSize", "folderSize"]) + gcid = json.firstStringDeep(["gcid", "gcId", "gcidValue", "hash"]) + modifiedAt = json.firstString(["updateTime", "updatedAt", "modifyTime", "createTime"]) ?? json.firstInt64Deep(["utime", "ctime"]).map(formatFileDate) ?? "" + fileType = type + } +} + +struct DuplicateGroup: Identifiable, Sendable { + let id: String + let files: [CloudFile] +} + +struct TMDBMatch: Identifiable, Sendable { + let id: Int + let title: String + let mediaType: String + let releaseDate: String + let overview: String + + init?(json: JSONValue) { + guard let id = json.firstIntDeep(["id"]), let title = json.firstStringDeep(["title", "name"]) else { return nil } + self.id = id + self.title = title + mediaType = json.firstStringDeep(["media_type"]) ?? "movie" + releaseDate = json.firstStringDeep(["release_date", "first_air_date"]) ?? "" + overview = json.firstStringDeep(["overview"]) ?? "" + } +} + +struct FolderPath: Identifiable, Hashable, Sendable { + let id: String + let name: String +} + +struct UserProfile: Sendable { + let name: String + let phone: String + let avatarURL: URL? + + init(json: JSONValue) { + name = json.firstStringDeep(["nickname", "name", "username", "phone_number"]) ?? "光鸭用户" + phone = json.firstStringDeep(["phone", "phoneNumber", "phone_number", "username"]) ?? "" + avatarURL = json.firstStringDeep(["avatar", "avatarUrl", "avatar_url"]).flatMap(URL.init(string:)) + } +} + +struct AuthTokens: Sendable { + let accessToken: String + let refreshToken: String? + let expiresIn: Double? +} + +enum WorkspaceSection: String, CaseIterable, Identifiable { + case files = "我的文件" + case photos = "图片" + case videos = "视频" + case documents = "文档" + case cloud = "云下载" + case shares = "我的分享" + case recycle = "回收站" + + var id: String { rawValue } + var icon: String { + switch self { + case .files: return "folder" + case .photos: return "photo" + case .videos: return "play.rectangle" + case .documents: return "doc.text" + case .cloud: return "arrow.down.circle" + case .shares: return "square.and.arrow.up" + case .recycle: return "trash" + } + } +} diff --git a/guangya_mac/Preview Content/Preview Assets.xcassets/Contents.json b/guangya_mac/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/guangya_mac/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/guangya_mac/guangya_macApp.swift b/guangya_mac/guangya_macApp.swift new file mode 100644 index 0000000..767817b --- /dev/null +++ b/guangya_mac/guangya_macApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct guangya_macApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/guangya_mac/光鸭.svg b/guangya_mac/光鸭.svg new file mode 100644 index 0000000..280a27f --- /dev/null +++ b/guangya_mac/光鸭.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + +