diff --git a/AGENTS.md b/AGENTS.md index c500901..bde230c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,57 @@ Concrete invariants worth knowing before you touch them: Prefer making a caller `async` over spawning an unstructured `Task`. +## Auth: the credential is Apple's own token, and it cannot be renewed + +`GatewayAuth.swift` is the whole of it. The app signs in natively, keeps the +Apple identity token, and sends **that token itself** as +`Authorization: Bearer` — the gatehouse (oauth2-proxy behind nginx) verifies it +against Apple's JWKS and checks an email allow-list. It issues nothing of its +own and does not report who the caller is, because there is one user and +nothing downstream has anywhere to put a name. + +**There is no silent way to mint another Apple identity token** — +`getCredentialState` reports that the authorization still stands, it does not +issue a token. So the app re-prompts when the current one expires. That is a +known, accepted cost, not a bug to fix here: + +**Do not write a lifetime into any comment or any string.** The repo said "about +24 hours", someone "corrected" it to "about 10 minutes" as an observation error, +and the 10 minutes then propagated into four documents and one user-facing +label. Reading `exp` off the real token on 2026-07-30 gave **~23.4 hours** — the +"correction" was the error. The code parses `exp` precisely so nobody has to +believe a number in a comment; leave it that way. + +- `amdl-portal` existed to remove it (identity token → its own access/refresh + pair, 1 hour / 60 days). The portal was deleted when the system went back to + single-user, and the cost came back with it. +- The only real fix is a **server** that mints a durable token, which is a + server-side session however thin you write it. Don't try to work around it in + the app — the one thing you could do here is keep the token longer, which just + sends a credential that is certain to be refused. +- So `GatewayHTTP` has no refresh and no 401 retry. A 401 throws + `needsSignIn` and the UI asks. `isSignedIn` checks the credential is still + *usable*, not just present — otherwise the UI would claim you are signed in + while every request 401s. + +The 10-minute fallback in `GatewayCredential.init` is a deliberately pessimistic +floor for an unparsable token, **not** an estimate of the real lifetime. + +The credential lives in the **Keychain** (`GatewayCredentialStore`), access +group = the App Group id, `AfterFirstUnlock` so the notification extension can +read it on a locked screen. `ShareViewController` hand-copies the decoder +because extensions can't see the main target — `identityToken` and `expiresAt` +are a **cross-target contract**, and a test pins them. + +Two things a startup path still cleans up: the plaintext identity token an old +build left in the App Group's UserDefaults, and the portal's 60-day refresh +token in the `com.lyjw131.amdl.portal` Keychain item. Both issuers are gone. + +**`BackendEndpoint`'s "portal" names are deliberate leftovers.** The host name +is injected at build time from `AMDL_PORTAL_HOST` (`Config/Portal.xcconfig`, +not in the repo), and renaming that key would silently empty a user's local +config — it compiles, installs, and never connects. The file says so at the top. + ## Don't reach for MusicKit to fill gaps in the job Animated album covers are the cautionary tale. `editorialVideo` is not available diff --git a/LiveActivityShared/BackendEndpoint.swift b/LiveActivityShared/BackendEndpoint.swift index c5cef87..22238ef 100644 --- a/LiveActivityShared/BackendEndpoint.swift +++ b/LiveActivityShared/BackendEndpoint.swift @@ -1,11 +1,18 @@ import Foundation -/// 门户地址的唯一事实来源。 +/// 服务器地址的唯一事实来源。 /// -/// `amdl-portal` 上线后,一个域名兜住了全部三条链路:`/api/v1/*`(后端镜像)、 -/// `/api/gw/*`(门户自己的接口)、`/apns/*`(剥掉前缀之后转给 `amdl-ios-gateway`)。 -/// 既然只有一个源站,可配置的就只该有**一个**主机地址:主 App、分享扩展和实时活动 -/// 网关都从这里取,`/apns` 前缀由 `gatewayBaseURLString` 派生,不再单独存一份。 +/// 一个域名兜住全部链路:`/api/v1/*`(下载核心)、`/apns/*`(剥掉前缀之后转给 +/// `amdl-ios-gateway`)、`/oauth2/*`(登录)。既然只有一个源站,可配置的就只该有 +/// **一个**主机地址:主 App、分享扩展和实时活动网关都从这里取,`/apns` 前缀由 +/// `gatewayBaseURLString` 派生,不再单独存一份。 +/// +/// **命名说明**:下面一堆标识符里的 "portal" 是历史名字 —— 这个域名曾经由 +/// `amdl-portal` 提供,现在是 nginx。它们没有跟着改,原因很具体:主机名在编译期 +/// 从构建设置 `AMDL_PORTAL_HOST` 注入(`Config/Portal.xcconfig`,不进仓库), +/// 改这个键名会让**用户本地那份配置静默失效** —— 编译得过、装得上、只是主机名变成 +/// 空串,永远连不上。为了内部命名一致而冒这个险不划算,所以留着,并在这里说清楚 +/// 它指的是什么。`/api/gw/*`(门户自己的接口)已经不存在了。 /// /// 放在 `LiveActivityShared/` 是为了让分享扩展也能引用同一份常量。以前三个文件 /// 各写一遍默认地址、靠人记得同步,而漏掉任何一个的后果都不一样地难查:主 App diff --git a/LiveActivityShared/MediaUserTokenStore.swift b/LiveActivityShared/MediaUserTokenStore.swift index 974c1e3..e5c4be4 100644 --- a/LiveActivityShared/MediaUserTokenStore.swift +++ b/LiveActivityShared/MediaUserTokenStore.swift @@ -9,7 +9,7 @@ import Security /// 这也是 `AGENTS.md`「不要拿 MusicKit 去补任务里的空缺」在这里的具体形态: /// 缺的不是数据,是一个能取到数据的进程。 /// -/// **为什么存钥匙串而不是 App Group 的 UserDefaults**:和 `PortalCredentialStore` +/// **为什么存钥匙串而不是 App Group 的 UserDefaults**:和 `GatewayCredentialStore` /// 同一个理由——这是一份能代表用户 Apple Music 账号的凭据,UserDefaults 的 plist /// 是明文、会进备份、也没有「解锁之后才可读」这种保护。 /// @@ -42,7 +42,7 @@ nonisolated struct SharedMediaUserToken: Codable, Sendable, Equatable { } nonisolated enum MediaUserTokenStore { - /// 与 `PortalCredentialStore.accessGroup` 是同一个组。这里引用 + /// 与 `GatewayCredentialStore.accessGroup` 是同一个组。这里引用 /// `BackendEndpoint.appGroupIdentifier`,因为这个文件本身就在 /// `LiveActivityShared/` 里,两边看得见同一个常量。 static let accessGroup = BackendEndpoint.appGroupIdentifier @@ -86,7 +86,7 @@ nonisolated enum MediaUserTokenStore { SharedMediaUserToken(value: trimmed, updatedAt: updatedAt) ) else { return } - // 先删后写,同 `PortalCredentialStore.save`:`SecItemUpdate` 在条目不存在时 + // 先删后写,同 `GatewayCredentialStore.save`:`SecItemUpdate` 在条目不存在时 // 返回 errSecItemNotFound,分两条路径只是多一个分支。 SecItemDelete(baseQuery as CFDictionary) var query = baseQuery diff --git a/ShareDownloadExtension/ShareViewController.swift b/ShareDownloadExtension/ShareViewController.swift index 27ed436..0b8714f 100644 --- a/ShareDownloadExtension/ShareViewController.swift +++ b/ShareDownloadExtension/ShareViewController.swift @@ -57,13 +57,13 @@ final class ShareViewController: UIViewController { iconView.tintColor = .systemBlue iconView.contentMode = .center - statusLabel.text = "正在创建下载任务" + statusLabel.text = "正在识别" statusLabel.font = .preferredFont(forTextStyle: .title3) statusLabel.adjustsFontForContentSizeCategory = true statusLabel.textAlignment = .center statusLabel.numberOfLines = 2 - messageLabel.text = "正在读取分享的链接" + messageLabel.text = "识别成功后会自动入队" messageLabel.font = .preferredFont(forTextStyle: .subheadline) messageLabel.adjustsFontForContentSizeCategory = true messageLabel.textColor = .secondaryLabel @@ -180,7 +180,6 @@ final class ShareViewController: UIViewController { do { let url = try await extractSharedURL() try Task.checkCancellation() - messageLabel.text = url.absoluteString // 主 App 每次提交都现取一次 media user token;扩展问不到 MusicKit, // 读的是主 App 抄进钥匙串的那份副本。见 `MediaUserTokenStore`。 @@ -224,7 +223,7 @@ final class ShareViewController: UIViewController { private func showCreatedState(missingArtworkToken: Bool) { iconView.image = UIImage(systemName: "checkmark.circle.fill") iconView.tintColor = .systemGreen - statusLabel.text = "任务已创建" + statusLabel.text = "已确认入队" statusLabel.textColor = .label // 私人歌单没有令牌照样能下完,只是封面取不到。这不值得拦下提交,但也不该 // 一声不吭——「封面不对」正是这次报告里的另一半。 @@ -272,25 +271,24 @@ final class ShareViewController: UIViewController { throw ShareSubmissionError.missingURL } - /// 主 App 换来的门户 access token,门户拿它做认证。 + /// 主 App 存下的 Apple identity token,网关拿它做认证。 /// - /// 从 App Group 的 UserDefaults 搬到了 **Keychain**:以前存的是 Apple 的 - /// identity token,10 分钟就废,明文放着风险有限;现在存的是门户会话, - /// refresh token 有 60 天寿命,不该躺在会进备份的明文 plist 里。 + /// 存在 **Keychain**,不在 App Group 的 UserDefaults 里。这个 token 只活约十 + /// 分钟,明文放着风险有限 —— 但把它明文写进会进备份的 plist 正是早先版本被专门 + /// 修掉的问题,门户没了不是退回去的理由。 /// - /// `PortalCredentialStore` 在主 App target 里,扩展够不着(共享的只有 - /// `LiveActivityShared/`),所以这里是它的一份手抄,**四个常量必须和它逐字 - /// 一致**(service / account / access group)。access group 已经改成引用 - /// `BackendEndpoint.appGroupIdentifier`,剩下三个还是字面量。 - /// access group 用的是 App Group id——iOS 允许这么用,所以扩展读得到,而且 - /// 不需要新增任何 entitlement。 + /// `GatewayCredentialStore` 在主 App target 里,扩展够不着(共享的只有 + /// `LiveActivityShared/`),所以这里是它的一份手抄,**三个常量必须和它逐字 + /// 一致**(service / account / access group)。access group 引用 + /// `BackendEndpoint.appGroupIdentifier`;iOS 允许拿 App Group id 当 keychain + /// access group,所以扩展读得到,而且不需要新增任何 entitlement。 /// - /// 扩展**不做刷新**:它是个一闪而过的浮层,转 token 是主 App 的事。access - /// token 过期时这里返回它、请求拿到 401,用户回主 App 打开一次就好了。 - private static func portalBearerToken() -> String? { + /// 扩展**不做任何续期**,主 App 也不做 —— 没有可续的东西。token 过期时这里 + /// 返回 nil、请求拿到 401,用户回主 App 重新登录一次。 + private static func gatewayBearerToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: "com.lyjw131.amdl.portal", + kSecAttrService as String: "com.lyjw131.amdl.gateway", kSecAttrAccount as String: "session", kSecAttrAccessGroup as String: BackendEndpoint.appGroupIdentifier, kSecReturnData as String: true, @@ -299,23 +297,27 @@ final class ShareViewController: UIViewController { var item: CFTypeRef? guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data, - let stored = try? JSONDecoder().decode(StoredCredentials.self, from: data), - !stored.accessToken.isEmpty + let stored = try? JSONDecoder().decode(StoredCredential.self, from: data), + !stored.identityToken.isEmpty, + // 过期的凭据不如不带:带着它去只会拿一个 401,不带至少让 401 的原因 + // 只有一个。判断和 `GatewayCredential.isUsable` 一致。 + stored.expiresAt.timeIntervalSinceNow > 30 else { return nil } - return stored.accessToken + return stored.identityToken } - /// `PortalCredentials` 的解码镜像。字段名必须一致。 - private struct StoredCredentials: Decodable { - let accessToken: String + /// `GatewayCredential` 的解码镜像。字段名必须一致。 + private struct StoredCredential: Decodable { + let identityToken: String + let expiresAt: Date } private static func authorized(_ request: inout URLRequest) { - guard let token = portalBearerToken() else { return } + guard let token = gatewayBearerToken() else { return } request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } - /// 门户地址从 `BackendEndpoint` 取,和主 App 是同一份代码、同一个 App Group + /// 后端地址从 `BackendEndpoint` 取,和主 App 是同一份代码、同一个 App Group /// 键。以前这里自己抄了一份默认地址和键名,主 App 改了地址而这边没跟上时, /// 分享面板会一直往旧域名提交。 private func backendBaseURL() throws -> URL { diff --git a/amdl-ios.xcodeproj/project.pbxproj b/amdl-ios.xcodeproj/project.pbxproj index 16350cf..d9e7932 100644 --- a/amdl-ios.xcodeproj/project.pbxproj +++ b/amdl-ios.xcodeproj/project.pbxproj @@ -573,7 +573,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -608,7 +608,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -754,7 +754,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_AMDLPortalHost = "$(AMDL_PORTAL_HOST)"; IPHONEOS_DEPLOYMENT_TARGET = 26.4; - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-iosTests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -777,7 +777,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_AMDLPortalHost = "$(AMDL_PORTAL_HOST)"; IPHONEOS_DEPLOYMENT_TARGET = 26.4; - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-iosTests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -798,7 +798,7 @@ DEVELOPMENT_TEAM = 2VTXNMR2GL; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_AMDLPortalHost = "$(AMDL_PORTAL_HOST)"; - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-iosUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -819,7 +819,7 @@ DEVELOPMENT_TEAM = 2VTXNMR2GL; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_AMDLPortalHost = "$(AMDL_PORTAL_HOST)"; - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-iosUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -850,7 +850,7 @@ "$(inherited)", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.DownloadLiveActivity"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -882,7 +882,7 @@ "$(inherited)", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.DownloadLiveActivity"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -913,7 +913,7 @@ "$(inherited)", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.ShareDownloadExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -944,7 +944,7 @@ "$(inherited)", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.ShareDownloadExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -975,7 +975,7 @@ "$(inherited)", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.DownloadNotificationExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -1006,7 +1006,7 @@ "$(inherited)", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2; + MARKETING_VERSION = 3.4; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.DownloadNotificationExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/amdl-ios/AppDelegate.swift b/amdl-ios/AppDelegate.swift index 4784128..00aa093 100644 --- a/amdl-ios/AppDelegate.swift +++ b/amdl-ios/AppDelegate.swift @@ -11,6 +11,7 @@ import UserNotifications final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { private var pushTokenTask: Task? + private var mediaUserTokenRefreshTask: Task? func application( _ application: UIApplication, @@ -52,10 +53,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent await DownloadLiveActivityManager.shared.reconcileWithGateway() } // 分享扩展问不到 MusicKit,只能读主 App 抄进钥匙串的那份 media user token。 - // 每次进前台刷新一次,「装上 App 之后只用分享面板」的用法才拿得到令牌。 - // 和上面那次对账分开起 Task:网关不通时它会卡住好几秒,令牌刷新不该陪等。 - Task { @MainActor in - await AppleMusicTokenService.refreshSharedToken() + // 每次进前台刷新一次;若用户开启了后端自动同步,同一次刷新还会把最新值写入 + // 后端 config.yaml。和上面那次对账分开起 Task:网关不通时它会卡住好几秒, + // 令牌刷新不该陪等。 + mediaUserTokenRefreshTask?.cancel() + mediaUserTokenRefreshTask = Task { @MainActor in + await AppleMusicTokenService.refreshForAppActivation() } } @@ -117,10 +120,33 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent ) async { let userInfo = response.notification.request.content.userInfo print("[Push] 用户点击通知: \(userInfo)") + guard let jobID = Self.jobID(fromNotificationUserInfo: userInfo) else { return } - // 只登记目标,导航由 ContentView 做:这个回调在冷启动时比根视图还早, - // 直接推路径没人接得住。 - PendingDownloadRoute.shared.route(toJob: jobID) + // 只登记目标,导航和「拉起 Emby」都由 ContentView 做:这个回调在冷启动时 + // 比根视图还早,那时本 App 自己都还没 active —— 直接推路径没人接得住, + // 直接 UIApplication.open 也会被系统忽略,通知只会把自己打开而已。 + PendingDownloadRoute.shared.route( + toJob: jobID, + emby: Self.embyDeepLink(fromNotificationUserInfo: userInfo) + ) + } + + /// 网关在专辑任务完成时放进 payload 的 `emby_deep_link` + /// (amdl-ios-gateway `alertPayload`),形如 + /// `emby://items?serverId=&itemId=`。 + /// + /// 只认 `emby` 这一个 scheme:这个值来自推送负载,照单全收就等于让任何能发到 + /// 这台设备的推送指定一个要打开的 URL。 + static func embyDeepLink(fromNotificationUserInfo userInfo: [AnyHashable: Any]) -> URL? { + guard let raw = userInfo["emby_deep_link"] as? String else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let url = URL(string: trimmed), + url.scheme?.lowercased() == "emby" + else { + return nil + } + return url } /// 网关的完成通知在 payload 顶层带 `job_id`(amdl-ios-gateway `alertPayload`), diff --git a/amdl-ios/AppleAuth.swift b/amdl-ios/AppleAuth.swift index 928a60a..7bf2a37 100644 --- a/amdl-ios/AppleAuth.swift +++ b/amdl-ios/AppleAuth.swift @@ -3,18 +3,18 @@ import Foundation /// 「通过 Apple 登录」的**身份**部分:这个设备上登录的是谁。 /// -/// 认证凭据本身不在这里了。以前 App 把 Apple 的 identity token 直接当 -/// `Authorization: Bearer` 发给 oauth2-proxy(它开了 `--skip-jwt-bearer-tokens`, -/// 会用 Apple 公钥自己验签)。换成 `amdl-portal` 之后不是这样:identity token 只 -/// 用来**换一次**门户自己的会话,之后所有请求带的是门户签发的 access token,见 -/// `PortalAuth.swift`。 +/// 认证凭据本身在 `GatewayAuth.swift`。凭据就是 Apple 的 identity token 本身, +/// 直接当 `Authorization: Bearer` 发给网关的 oauth2-proxy(它开了 +/// `--skip-jwt-bearer-tokens`,会用 Apple 公钥自己验签)。 /// -/// 这不是重构,是修一个体验缺陷:Apple 的 identity token 实测只活约 10 分钟, -/// 而且没有静默续期手段,所以旧方案下用户每隔十几分钟就得重新弹一次系统登录面板。 -/// 门户的 refresh token 是 60 天,App 因此可以连着用两个月不弹面板。 +/// 中间有一版不是这样:`amdl-portal` 用 identity token 换一对自己的 +/// access/refresh,为的是绕开"identity token 无法静默续期"(当时以为它只活十分钟, +/// 实际约一天,见 `GatewayCredential`)。 +/// 整套系统改回单用户设计时门户被删了,这条路也就跟着回到了直发 —— 连带那个 +/// 每隔十几分钟弹一次面板的代价。取舍的完整说明在 `GatewayCredential` 的注释里。 /// /// 留在 App Group 的 UserDefaults 里的只有用户 id 和邮箱——都是给界面显示"当前登录 -/// 的是谁"用的,不是凭据。**真正的凭据在 Keychain**(`PortalCredentialStore`)。 +/// 的是谁"用的,不是凭据。**真正的凭据在 Keychain**(`GatewayCredentialStore`)。 enum AppleAuthCredentialStore { private static let userIDKey = "appleUserID" private static let emailKey = "appleUserEmail" @@ -39,15 +39,16 @@ enum AppleAuthCredentialStore { set { defaults?.set(newValue, forKey: emailKey) } } - /// 门户会话的到期时刻,仅供界面显示。真正决定请求带不带凭据的是 - /// `PortalSession`,它在快过期时会自己续,所以这里过期了也不代表要重新登录。 + /// 凭据的到期时刻,供界面显示。**这次它是真的**:没有续期,过了就得重新登录, + /// 所以界面拿它倒计时是准的。门户时期它只是个装饰 —— 那时快过期会自动续。 static var expiresAt: Date? { - PortalCredentialStore.load()?.accessTokenExpiresAt + GatewayCredentialStore.load()?.expiresAt } - /// 手上有没有一份**能续**的门户会话。access token 过期无所谓——refresh 还在就 - /// 能续,而 refresh 有 60 天。 - static var hasPortalSession: Bool { PortalCredentialStore.load() != nil } + /// 手上有没有一份**还能用**的凭据。 + static var hasUsableCredential: Bool { + GatewayCredentialStore.load()?.isUsable ?? false + } static func store(userID: String, email: String?) { self.userID = userID @@ -63,9 +64,16 @@ enum AppleAuthCredentialStore { purgeLegacyIdentityToken() } + /// 启动时跑一次的清理:明文 plist 里的旧 token,以及门户时期那份 60 天的 + /// refresh token。两者签发方都已经不存在了。 + static func purgeRetiredCredentials() { + purgeLegacyIdentityToken() + GatewayCredentialStore.purgePortalCredentials() + } + /// 清掉旧版本留在明文 plist 里的 Apple identity token。 /// - /// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了(10 分钟寿命), + /// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了, /// 所以这不是功能问题;但一份用户凭据留在会进备份的明文文件里,删掉才对。 static func purgeLegacyIdentityToken() { defaults?.removeObject(forKey: legacyTokenKey) @@ -86,36 +94,32 @@ extension AppleAuthCredentialStore { } extension URLRequest { - /// 带上门户的认证头,**同步**版本:只读 Keychain 里现成的 access token, - /// 不做刷新。目标不是门户域名时什么都不加。 + /// 带上网关的认证头。目标不是网关域名时什么都不加。 /// - /// 需要刷新和 401 重试的请求走 `PortalHTTP.send`。这个同步版本留给两类调用方: - /// 封面图这种"401 了也就是少一张图"的请求,以及 WebSocket—— - /// `URLSessionWebSocketTask` 的握手头必须在创建任务时就定下来,没有异步的余地。 - mutating func authorizeWithPortal() { + /// 门户时期这里还有个"同步 / 异步"的区分:异步版本要能刷新 token,同步版本 + /// 只读现成的。**没有刷新之后两者是同一件事**,所以只剩这一个。 + mutating func authorizeForGateway() { guard AppleAuthCredentialStore.isGatewayHost(url?.host()) else { return } - setBearer(PortalCredentialStore.load()?.accessToken) + setBearer(GatewaySession.bearerToken()) } init(authorizedURL url: URL) { self.init(url: url) - authorizeWithPortal() + authorizeForGateway() } } extension URLSession { - /// WebSocket 也要过门户认证,所以不能用 `webSocketTask(with: URL)`—— + /// WebSocket 也要过网关认证,所以不能用 `webSocketTask(with: URL)`—— /// 那个重载没法带自定义头。 /// - /// **async 的原因**:握手头在建任务的那一刻就定死了,之后没有"401 了再刷一次 - /// 重试"的机会——`URLSessionWebSocketTask` 只会失败,调用方看到的是一次断线, - /// 然后重连、再断线。所以刷新必须发生在握手**之前**。App 在后台待过一小时 - /// 之后回到前台的第一次重连就是这条路径。 - func authorizedWebSocketTask(with url: URL) async -> URLSessionWebSocketTask { + /// 握手头在建任务的那一刻就定死了,之后没有补救机会——`URLSessionWebSocketTask` + /// 只会失败,调用方看到的是一次断线,然后重连、再断线。凭据过期时这条路径就是 + /// 这个样子,而且**没有办法在这一层修**:唯一能给出新 token 的是系统登录面板。 + /// 所以断线重连若持续失败,界面要引导用户重新登录,而不是继续重连。 + func authorizedWebSocketTask(with url: URL) -> URLSessionWebSocketTask { var request = URLRequest(url: url) - if AppleAuthCredentialStore.isGatewayHost(url.host()) { - request.setBearer(await PortalSession.shared.accessToken()) - } + request.authorizeForGateway() return webSocketTask(with: request) } } @@ -139,10 +143,12 @@ enum AppleAuthError: LocalizedError { /// 登录状态,供界面观察。 /// -/// 登录是**两步**,而且第二步才是重点:先让系统弹面板拿 Apple 的 identity token, -/// 再拿它去 `POST /api/gw/auth/apple/native` 换门户的会话。identity token 只活约 -/// 10 分钟且无法静默续期,门户的 refresh token 是 60 天并且每次刷新都轮换—— -/// 换取这一步就是 App 能连着用两个月不弹面板的全部原因。 +/// 登录是**一步**:让系统弹面板,把拿到的 identity token 存进 Keychain,完事。 +/// 那个 token 本身就是发给网关的凭据。 +/// +/// 中间有一版是两步 —— 第二步拿 identity token 去 `POST /api/gw/auth/apple/native` +/// 换门户的 access/refresh。那一步是为了绕开 identity token 无法续期这件事; +/// 门户删掉之后它没有了,代价见 `GatewayCredential`。 @MainActor @Observable final class AppleAuthStore { @@ -152,10 +158,6 @@ final class AppleAuthStore { private(set) var email: String? private(set) var expiresAt: Date? private(set) var isSigningIn = false - /// 账号还没被管理员批准。**每个新用户第一次登录看到的都是这个状态**,界面必须 - /// 说人话而不是弹一个 403。登录本身是成功的:门户给 pending 账号也发凭据, - /// 好让 App 能调 `/api/gw/me` 问出自己是 pending(DESIGN.md §6.2)。 - private(set) var isPendingApproval = false private var controllerBox: SignInController? @@ -163,15 +165,19 @@ final class AppleAuthStore { userID = AppleAuthCredentialStore.userID email = AppleAuthCredentialStore.email expiresAt = AppleAuthCredentialStore.expiresAt - // 顺手清掉旧版本明文存下的 Apple identity token。 - AppleAuthCredentialStore.purgeLegacyIdentityToken() + // 明文 plist 里的旧 token,以及门户那份 60 天的 refresh token。 + AppleAuthCredentialStore.purgeRetiredCredentials() } - /// 登录过且没有登出。access token 可能已过期,但 refresh 还在就不用管。 - var isSignedIn: Bool { userID != nil && AppleAuthCredentialStore.hasPortalSession } + /// 登录过且手上的凭据还没过期。 + /// + /// **和门户时期不是一个意思**:那时凭据过期只要 refresh 还在就能续,所以 + /// `isSignedIn` 只看"登录过没有"。现在过期就是真的要重新登录了,所以这里必须 + /// 把有效性一起算进去 —— 否则界面会一直显示已登录,而每个请求都是 401。 + var isSignedIn: Bool { userID != nil && AppleAuthCredentialStore.hasUsableCredential } - /// 手上有一份门户会话。 - var hasValidToken: Bool { AppleAuthCredentialStore.hasPortalSession } + /// 手上有一份还能用的凭据。 + var hasValidToken: Bool { AppleAuthCredentialStore.hasUsableCredential } func signIn() async throws { isSigningIn = true @@ -192,39 +198,22 @@ final class AppleAuthStore { throw AppleAuthError.missingIdentityToken } - // authorizationCode 门户目前收下但不用(DESIGN.md §6.2),照发即可—— - // 将来门户要用它去 Apple 查询 Apple ID 是否被撤销时,不需要 App 再发版。 - let authorizationCode = credential.authorizationCode.flatMap { String(data: $0, encoding: .utf8) } - _ = try await PortalSession.shared.exchange( - identityToken: identityToken, - authorizationCode: authorizationCode, - fullName: credential.fullName?.formatted() - ) + // credential.authorizationCode 不再发给任何人:它是用来在服务端跟 Apple + // 换 refresh token 的,而现在没有服务端会话可换。 + GatewaySession.store(identityToken: identityToken) AppleAuthCredentialStore.store(userID: credential.user, email: credential.email) userID = AppleAuthCredentialStore.userID email = AppleAuthCredentialStore.email expiresAt = AppleAuthCredentialStore.expiresAt - await refreshAccountStatus() } func signOut() { AppleAuthCredentialStore.clear() - Task { await PortalSession.shared.signOut() } + GatewaySession.signOut() userID = nil email = nil expiresAt = nil - isPendingApproval = false - } - - /// 问一次门户"我现在是什么状态"。 - /// - /// `GET /api/gw/me` 是 pending 账号**唯一**能调通的接口,所以它是 App 判断 - /// "登录成功但还不能用"的唯一途径——别的接口一律 403,从状态码上分不出 - /// "没批准"和"权限不够"。 - func refreshAccountStatus() async { - guard let status = await PortalAccount.fetchStatus() else { return } - isPendingApproval = status == "pending" } /// 令牌过期后刷新界面用:重新读一遍存储里的过期时间。 @@ -233,32 +222,6 @@ final class AppleAuthStore { email = AppleAuthCredentialStore.email expiresAt = AppleAuthCredentialStore.expiresAt } - - /// 请求侧发现账号还没批准时回调,把状态推给界面。 - func markPendingApproval() { - isPendingApproval = true - } -} - -/// `GET /api/gw/me` 的最小解码:这一版只需要账号状态。 -enum PortalAccount { - static func fetchStatus() async -> String? { - guard !DownloadsAPI.baseURLString.isEmpty, - var components = URLComponents(string: DownloadsAPI.baseURLString) - else { return nil } - components.path = "/api/gw/me" - guard let url = components.url else { return nil } - - struct Response: Decodable { - struct User: Decodable { let status: String } - let user: User - } - guard let (data, http) = try? await PortalHTTP.send(URLRequest(url: url)), - http.statusCode == 200, - let decoded = try? JSONDecoder().decode(Response.self, from: data) - else { return nil } - return decoded.user.status - } } /// 把 `ASAuthorizationController` 的 delegate 回调桥接成 async。 diff --git a/amdl-ios/ConfigAPI.swift b/amdl-ios/ConfigAPI.swift index de003c4..ec16c8b 100644 --- a/amdl-ios/ConfigAPI.swift +++ b/amdl-ios/ConfigAPI.swift @@ -72,6 +72,28 @@ struct RuntimeConfig: Codable, Sendable { var download: DownloadConfig? var logging: LoggingConfig? var simulate: SimulateConfig? + var librarySync: LibrarySyncConfig? + + enum CodingKeys: String, CodingKey { + case catalog, download, logging, simulate + case librarySync = "library_sync" + } +} + +/// 资料库监视器:后端轮询已登录 Apple Music 资料库,把新加入曲目所属的**专辑** +/// 当作普通下载任务提交。手机上收藏一首歌,NAS 那边就自动下整张专辑。 +/// +/// 依赖 `catalog.media_user_token` —— 个人资料库只有订阅令牌读得到。该字段为空时 +/// 监视器空转,原因见 `GET /api/v1/library-sync` 的 `last_error`。 +struct LibrarySyncConfig: Codable, Sendable { + var enabled: Bool? + /// 轮询间隔(分钟),后端限定 1...1440。 + var intervalMinutes: Int? + + enum CodingKeys: String, CodingKey { + case enabled + case intervalMinutes = "interval_minutes" + } } struct CatalogConfig: Codable, Sendable { @@ -188,6 +210,32 @@ enum ConfigAPI { return try await send(request) } + /// 只更新后端的全局 media-user-token fallback。 + /// + /// 这是自动同步路径使用的最小 patch;其余 catalog 键和整个 download/logging/ + /// simulate 段都必须省略,不能拿配置页可能已经过时的整份表单覆盖后端。 + static func updateMediaUserToken(_ token: String) async throws -> ConfigResponse { + let trimmed = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw MediaUserTokenConfigError.emptyToken + } + return try await updateConfig(mediaUserTokenPatch(trimmed)) + } + + /// 纯函数,既固定部分更新的 JSON 形状,也让测试不需要真的访问后端。 + static func mediaUserTokenPatch(_ token: String) -> RuntimeConfig { + RuntimeConfig( + catalog: CatalogConfig( + albumTrackURLMode: nil, + mediaUserToken: token, + signedModeHLSSource: nil + ), + download: nil, + logging: nil, + simulate: nil + ) + } + /// 探测后端是否处于「本地签名开发者 token」模式。 /// /// 后端没有直接暴露这个状态:catalog.apple_music_private_key_path / key_id / @@ -199,7 +247,7 @@ enum ConfigAPI { guard let url = try? makeURL(path: "/api/v1/developer-token") else { return false } var request = URLRequest(url: url) request.httpMethod = "GET" - guard let (_, httpResponse) = try? await PortalHTTP.send(request) else { + guard let (_, httpResponse) = try? await GatewayHTTP.send(request) else { return false } return httpResponse.statusCode == 200 @@ -218,10 +266,12 @@ enum ConfigAPI { } private static func send(_ request: URLRequest) async throws -> ConfigResponse { - // 走 PortalHTTP:它续 token、401 后重试一次,并把 403 的 pending_approval - // 翻成人话。这两个端点在门户策略表里是 **admin only**,所以普通用户会拿到 - // 403 forbidden——那是正确行为,不是 bug。 - let (data, httpResponse) = try await PortalHTTP.send(request) + // 走 GatewayHTTP:它负责带上凭据,并把 401 翻成"要重新登录"。 + // + // 这两个端点以前在门户策略表里是 **admin only**,普通用户拿 403 是正确 + // 行为。现在没有角色了,签了名就能改——这是进程级的配置,而进程是这一个 + // 人的。 + let (data, httpResponse) = try await GatewayHTTP.send(request) guard httpResponse.statusCode == 200 else { throw DownloadsAPI.serverError(status: httpResponse.statusCode, data: data) } @@ -237,6 +287,14 @@ enum ConfigAPI { } } +private enum MediaUserTokenConfigError: LocalizedError { + case emptyToken + + var errorDescription: String? { + "Music-User-Token 为空,未更新后端配置。" + } +} + private struct ConfigErrorResponse: Decodable { let error: String? let message: String? diff --git a/amdl-ios/ContentView.swift b/amdl-ios/ContentView.swift index 62c5577..a8b60de 100644 --- a/amdl-ios/ContentView.swift +++ b/amdl-ios/ContentView.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftData +import UIKit private enum AppTab: Hashable { case home @@ -28,15 +29,23 @@ final class PendingDownloadRoute { private(set) var jobID: String? + /// 专辑完成通知带的 Emby 深链,和 `jobID` 一起登记、一起等根视图。 + /// + /// 拉起别的 App 和改导航状态受同一条限制:`didReceive` 跑在本 App 还没 active + /// 的时候,那时 `UIApplication.open` 会被系统忽略,通知照样只是把自己打开。 + /// 所以这里也只登记,真正 open 由根视图在能动的时候做,打不开再退回 `jobID`。 + private(set) var embyURL: URL? + private init() {} - func route(toJob jobID: String) { + func route(toJob jobID: String, emby: URL? = nil) { self.jobID = jobID + self.embyURL = emby } /// 取走并清空,所以同一次点击只会导航一次。 func take() -> String? { - defer { jobID = nil } + defer { jobID = nil; embyURL = nil } return jobID } } @@ -68,8 +77,19 @@ struct ContentView: View { // `initial: true` 是冷启动那一半:点击早于本视图时值已经在里面了,光等 // 变化永远等不到。热启动走的是 `@Observable` 的变化通知。 .onChange(of: pendingRoute.jobID, initial: true) { _, _ in + // Emby 优先:这条通知说的专辑此刻在 Emby 里点得开,网关是先确认过 + // 才发的。打不开(多半是没装 Emby)再退回应用内详情页 —— 所以这里 + // 先把 URL 取走,`take()` 才不会连着它一起清掉。 + let emby = pendingRoute.embyURL guard let jobID = pendingRoute.take() else { return } - showDownloads(jobID: jobID) + guard let emby else { + showDownloads(jobID: jobID) + return + } + Task { + if await UIApplication.shared.open(emby) { return } + showDownloads(jobID: jobID) + } } } diff --git a/amdl-ios/CreateDownloadIntent.swift b/amdl-ios/CreateDownloadIntent.swift index d63d76d..bbfec3f 100644 --- a/amdl-ios/CreateDownloadIntent.swift +++ b/amdl-ios/CreateDownloadIntent.swift @@ -15,6 +15,15 @@ import AppIntents /// 令牌进入 App 的唯一那道门。 @MainActor enum AppleMusicTokenService { + /// 主 App 的本地偏好:是否在每次进入前台时把最新 media user token 写进后端。 + /// 开关不含敏感值,放 UserDefaults;令牌本身仍只进钥匙串和一次 HTTPS 请求。 + nonisolated static let syncToBackendOnActivationKey = + "syncMediaUserTokenToBackendOnActivation" + + static var syncToBackendOnActivation: Bool { + UserDefaults.standard.bool(forKey: syncToBackendOnActivationKey) + } + static func currentUserToken() async throws -> String? { guard MusicAuthorization.currentStatus == .authorized else { // 授权被撤销之后还留着旧副本,只会让分享扩展拿着一个必定被 Apple 拒掉 @@ -45,6 +54,57 @@ enum AppleMusicTokenService { static func refreshSharedToken() async { _ = try? await currentUserToken() } + + /// 忽略 MusicKit 缓存取得最新用户令牌,并用最小配置 patch 写入后端。 + /// + /// `ConfigAPI` 的成功响应若明确说 `persisted == false`,令牌只进了后端内存, + /// 不满足“同步到配置文件”,因此这里仍然报失败。旧后端不返回该字段时保持兼容。 + static func syncFreshUserTokenToBackend() async throws { + guard MusicAuthorization.currentStatus == .authorized else { + MediaUserTokenStore.clear() + throw MediaUserTokenBackendSyncError.notAuthorized + } + let tokens = try await freshTokens() + try Task.checkCancellation() + let response = try await ConfigAPI.updateMediaUserToken(tokens.user) + if response.persisted == false { + throw MediaUserTokenBackendSyncError.notPersisted(response.reloadError) + } + } + + /// App 进入前台的统一入口。开关关闭时保持原来的“只刷新分享扩展副本”行为; + /// 开启时 freshTokens() 同时刷新副本,所以不会向 MusicKit 重复取两次。 + static func refreshForAppActivation() async { + guard syncToBackendOnActivation else { + await refreshSharedToken() + return + } + do { + try await syncFreshUserTokenToBackend() + print("[Apple Music] 已把最新 media user token 同步到后端配置") + } catch { + // 不打印 token;启动同步失败不能阻塞 App 的其他前台恢复工作。 + print("[Apple Music] 自动同步 media user token 失败:\(error.localizedDescription)") + } + } +} + +private enum MediaUserTokenBackendSyncError: LocalizedError { + case notAuthorized + case notPersisted(String?) + + var errorDescription: String? { + switch self { + case .notAuthorized: + "Apple Music 未授权,无法获取 Music-User-Token。" + case let .notPersisted(reloadError): + if let reloadError, !reloadError.isEmpty { + "后端只更新了内存,未写入配置文件:\(reloadError)" + } else { + "后端只更新了内存,未写入配置文件。" + } + } + } } struct CreateDownloadIntent: AppIntent { diff --git a/amdl-ios/DebugView.swift b/amdl-ios/DebugView.swift index aaf70a3..a1cbe78 100644 --- a/amdl-ios/DebugView.swift +++ b/amdl-ios/DebugView.swift @@ -19,6 +19,10 @@ struct DebugView: View { @State private var musicUserToken = "" @State private var errorMessage: String? @State private var isRequestingAuthorization = false + @State private var isSyncingMediaUserToken = false + @State private var mediaUserTokenSyncMessage: String? + @AppStorage(AppleMusicTokenService.syncToBackendOnActivationKey) + private var syncMediaUserTokenToBackendOnActivation = false /// 抄给分享扩展的那份 media user token。扩展问不到 MusicKit,只能读这份副本, /// 所以「分享电台失败」第一个要看的就是它在不在、是什么时候写的。 @State private var sharedMediaUserToken = MediaUserTokenStore.load() @@ -72,21 +76,25 @@ struct DebugView: View { } header: { Text("门户") } footer: { - Text("整个 App 只有这一个地址:任务列表走 /api/v1,账号和配额走 /api/gw,实时活动走 /apns,都由它派生。修改后请重新启动 App,以向新地址注册实时活动 token。") + Text("整个 App 只有这一个地址:任务列表走 /api/v1,登录走 /oauth2,实时活动走 /apns,都由它派生。修改后请重新启动 App,以向新地址注册实时活动 token。") } Section { if appleAuth.isSignedIn { LabeledContent("账号", value: appleAuth.email ?? "已登录") LabeledContent("会话", value: appleTokenStatusText) - // 每个新账号第一次登录后都会停在这里等管理员点批准。这句话必须 - // 说清楚"登录是成功的、要等的是别人",否则用户只会看见后面每个 - // 请求都 403,然后以为是自己登录失败了。 - if appleAuth.isPendingApproval { + // 凭据是 Apple 的 identity token 本身,没有静默续期的办法,所以 + // 过期是**常态**而不是异常,界面要直说一句,否则用户看到的只是 + // "隔一阵就要重新登录",像是坏了。 + // + // 不要在这句话里写死时长。上面「会话」那行显示的是从 token 的 + // `exp` 解出来的真实剩余时间;写死数字正是之前出过的错——文案说 + // 十分钟,实际约一天。取舍见 GatewayCredential 的注释。 + if !appleAuth.hasValidToken { Label { - Text("账号正在等待管理员批准。批准之后不用重新登录,直接就能用。") + Text("登录已过期,重新登录一次即可。Apple 的登录凭据不能自动续期,到期后需要手动登录。") } icon: { - Image(systemName: "clock.badge.questionmark") + Image(systemName: "clock.badge.exclamationmark") } .font(.footnote) .foregroundStyle(.orange) @@ -119,10 +127,28 @@ struct DebugView: View { Text("「通过 Apple 登录」拿到的身份令牌只用来换一次门户会话,之后请求带的是门户签发的令牌:有效期 1 小时,过期自动续,续期凭证 60 天,所以正常情况下不需要再回到这里。令牌存在钥匙串里,只会发给门户域名,封面等第三方资源不会带上。") } - Section("Apple Music") { + Section { LabeledContent("授权状态", value: authorizationStatusText) LabeledContent("分享扩展副本", value: sharedMediaUserTokenStatusText) + Toggle( + "打开 App 时同步到后端", + isOn: $syncMediaUserTokenToBackendOnActivation + ) + .disabled(isSyncingMediaUserToken) + + if isSyncingMediaUserToken { + HStack { + Text("正在同步到后端") + Spacer() + ProgressView() + } + } else if let mediaUserTokenSyncMessage { + Label(mediaUserTokenSyncMessage, systemImage: "checkmark.circle.fill") + .font(.footnote) + .foregroundStyle(.green) + } + Button(action: authorizationButtonTapped) { if isRequestingAuthorization { ProgressView() @@ -149,6 +175,10 @@ struct DebugView: View { .font(.footnote) .foregroundStyle(.red) } + } header: { + Text("Apple Music") + } footer: { + Text("开启后会立即同步一次;此后每次启动或回到 App,都获取最新 Music-User-Token 并写入后端配置文件。关闭不会清除后端已有令牌。") } Section("缓存") { @@ -160,6 +190,11 @@ struct DebugView: View { .navigationTitle("调试") .navigationBarTitleDisplayMode(.inline) .onChange(of: backendBaseURL, initial: false, backendBaseURLChanged) + .onChange( + of: syncMediaUserTokenToBackendOnActivation, + initial: false, + syncMediaUserTokenSettingChanged + ) .onAppear { appleAuth.refreshFromStore() sharedMediaUserToken = MediaUserTokenStore.load() @@ -205,6 +240,34 @@ struct DebugView: View { } } + private func syncMediaUserTokenSettingChanged(_ oldValue: Bool, _ newValue: Bool) { + _ = oldValue + guard newValue else { + mediaUserTokenSyncMessage = nil + return + } + Task { + await syncMediaUserTokenNow() + } + } + + private func syncMediaUserTokenNow() async { + isSyncingMediaUserToken = true + mediaUserTokenSyncMessage = nil + errorMessage = nil + defer { + isSyncingMediaUserToken = false + sharedMediaUserToken = MediaUserTokenStore.load() + } + + do { + try await AppleMusicTokenService.syncFreshUserTokenToBackend() + mediaUserTokenSyncMessage = "已写入后端配置" + } catch { + errorMessage = "同步失败:\(error.localizedDescription)" + } + } + private func clearImageCache() { Task { await ImageCache.shared.clearAll() diff --git a/amdl-ios/DownloadArtworkView.swift b/amdl-ios/DownloadArtworkView.swift index d7f6e28..f0db2c3 100644 --- a/amdl-ios/DownloadArtworkView.swift +++ b/amdl-ios/DownloadArtworkView.swift @@ -8,6 +8,10 @@ import UIKit @MainActor enum JobArtworkLoader { + /// 概览列表那档尺寸。行里的槽位只有 56pt,但两个页面各存各的一份,所以它 + /// 同时是一个缓存 key 的组成部分 —— 改动它会让所有已缓存的列表封面失效。 + static let overviewPixelSize = 256 + /// 与详情页 Hero 封面一致:按显示原生像素的 2 倍请求并缓存大图。 static var heroPixelSize: Int { let windowScenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } @@ -29,6 +33,24 @@ enum JobArtworkLoader { return "" } + /// 本尺寸还没到位时可以先顶上的另一档尺寸的 key,没有可借的就是 nil。 + /// + /// **两个方向都要**: + /// + /// - 概览 → 详情:大图还在下,先用列表里那张小的,免得 zoom 转场结束后短暂 + /// 或永久露出任务类型占位图。 + /// - 详情 → 概览:分享拓展和完成通知都是 `amdl://download/` 深链,直接把 + /// 详情页压进导航栈,概览列表一次都没出现过 —— 那张 256 于是从来没人取过。 + /// 退回列表时若不借详情页已经下好的大图,就得从占位图重新等一次。 + /// + /// 私人歌单两边共用一个不带尺寸的 key,两次算出来是同一个,这里返回 nil, + /// 不会自己借自己。 + static func fallbackCacheKey(for job: Job, pixelSize: Int) -> String? { + let otherPixelSize = pixelSize == overviewPixelSize ? heroPixelSize : overviewPixelSize + let otherKey = cacheKey(for: job, pixelSize: otherPixelSize) + return otherKey == cacheKey(for: job, pixelSize: pixelSize) ? nil : otherKey + } + static func prefetch(job: Job, pixelSize: Int) async { let primaryURL = job.artworkURL(pixelSize: pixelSize) if let request = PrivatePlaylistArtworkStore.request(for: job, pixelSize: pixelSize) { @@ -49,7 +71,7 @@ enum JobArtworkLoader { /// 其他任务直接使用后端的 artwork_url。 struct JobArtworkView: View { let job: Job - var pixelSize: Int = 256 + var pixelSize: Int = JobArtworkLoader.overviewPixelSize @State private var fallbackURL: URL? @State private var artworkRevision = 0 @@ -70,11 +92,8 @@ struct JobArtworkView: View { JobArtworkLoader.cacheKey(for: job, pixelSize: pixelSize) } - /// 详情大图尚未准备好时先复用概览封面的缓存,避免 zoom 动画结束后 - /// 短暂或永久露出任务类型占位图。 private var fallbackCacheKey: String? { - let overviewKey = JobArtworkLoader.cacheKey(for: job, pixelSize: 256) - return overviewKey == cacheKey ? nil : overviewKey + JobArtworkLoader.fallbackCacheKey(for: job, pixelSize: pixelSize) } var body: some View { diff --git a/amdl-ios/DownloadDetailSummaryView.swift b/amdl-ios/DownloadDetailSummaryView.swift index 004099f..2b0383b 100644 --- a/amdl-ios/DownloadDetailSummaryView.swift +++ b/amdl-ios/DownloadDetailSummaryView.swift @@ -104,6 +104,31 @@ struct DownloadDetailSummaryView: View { job.status == .completed ? 1 : progress } + /// 实心段:已经彻底结束的曲目占比。只整格跳,所以永远落在刻度上。 + /// + /// 用 `done + failed` 而不是只用 `done`:失败的那首不会再有进展了,把它留在 + /// 实心段外面,条会停在那里再也走不到头,看着像卡住。 + private var segmentedDone: Double { + // 一首歌没什么好分段的,整根条就是它自己的进度,和以前逐像素一致。 + guard job.totalItems > 1 else { return barProgress } + guard job.status != .completed else { return 1 } + return min(1, Double(job.doneItems + job.failedItems) / Double(job.totalItems)) + } + + /// 半透明段:详情页那个百分比本身。旁边的大数字就是这一段的尖端。 + /// + /// 取 max 是因为失败的曲目在均值里只按它死掉时的零头计,均值可能反而落在 + /// 实心段后面 —— 那种时候不画这一段,而不是画一段倒退的。 + private var segmentedLive: Double { + guard job.totalItems > 1 else { return 0 } + return max(segmentedDone, barProgress) + } + + /// 刻度格数。曲目多到刻度糊成一片就不画了,24 是 Web 端量出来的同一个上限。 + private var segmentedTicks: Int { + (2...24).contains(job.totalItems) ? job.totalItems : 0 + } + var body: some View { VStack(spacing: 12) { JobArtworkView(job: job, pixelSize: JobArtworkLoader.heroPixelSize) @@ -149,9 +174,12 @@ struct DownloadDetailSummaryView: View { } VStack(spacing: 6) { - ThinProgressBar( - progress: barProgress, + SegmentedProgressBar( + done: segmentedDone, + live: segmentedLive, + ticks: segmentedTicks, tint: progressBarTint, + tickColor: palette?.background ?? Color(.systemBackground), height: 5 ) diff --git a/amdl-ios/DownloadProgressViews.swift b/amdl-ios/DownloadProgressViews.swift index f3234da..c3f9387 100644 --- a/amdl-ios/DownloadProgressViews.swift +++ b/amdl-ios/DownloadProgressViews.swift @@ -30,6 +30,91 @@ struct ThinProgressBar: View { } } +/// 分段进度条:一根条同时说两件事 —— 下完了几首,和整体走到哪了。 +/// +/// 和 Web 端详情页的 `.jd-bar` 是同一套画法:实心段 + 半透明段 + 每首一格的刻度。 +/// 但两段各自代表什么是这边自己定的,因为两端的「进度」本来就不是一个数: +/// +/// - **实心段** = 已结束的曲目 ÷ 总数。它只会整格整格地跳,所以永远落在刻度上, +/// 一眼数得出「14 首下完了 7 首」。 +/// - **半透明段** = 详情页那个百分比本身(`DownloadDetail.progress`,所有曲目 +/// 进度的平均)。于是旁边那个大数字就是这一段的尖端,条和数字不可能各说各的 —— +/// 而它超出实心段的那一截,正好就是同时在下的那几首各自的零头。 +/// - **刻度** = 曲目分隔线。多到画出来只剩一片糊的时候就不画。 +/// +/// 单曲进度怎么折算成 0..1 不归这里管,那是 `ItemProgress.fraction` 的事。 +/// 这个视图只管怎么画,两个比例都由调用方算好传进来。 +struct SegmentedProgressBar: View { + /// 实心段的位置,0...1。 + let done: Double + /// 半透明段的位置,0...1。小于等于 `done` 时不画。 + let live: Double + /// 刻度格数(曲目数)。0 表示不画刻度。 + var ticks: Int = 0 + let tint: Color + /// 刻度线的颜色。刻度是把条**切开**,所以给页面底色,不是在条上描一道线。 + var tickColor: Color = .clear + var height: CGFloat = 4 + + private var clampedDone: Double { + min(max(done, 0), 1) + } + + private var clampedLive: Double { + min(max(live, 0), 1) + } + + var body: some View { + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule() + .fill(tint.opacity(0.15)) + + // 先画淡的再画实的:两段都从最左边起算,实心段直接盖在半透明段上, + // 露出来的那一截就是「正在下、还没下完」的部分。 + if clampedLive > clampedDone { + Capsule() + .fill(tint.opacity(0.38)) + .frame(width: geometry.size.width * clampedLive) + } + + Capsule() + .fill(tint) + .frame(width: geometry.size.width * clampedDone) + } + // 刻度压在最上面,实心段和半透明段一起被切开。 + .overlay { + if ticks >= 2 { + SegmentTicks(count: ticks, lineWidth: 1.5) + .fill(tickColor) + } + } + } + .frame(height: height) + .animation(.smooth, value: clampedDone) + .animation(.smooth, value: clampedLive) + } +} + +/// 把一根条等分成 `count` 格的分隔线。线画在每一格的右边缘,最后一格那条正好 +/// 落在条的外沿上、被裁掉,所以看得见的是 `count - 1` 条。 +private struct SegmentTicks: Shape { + let count: Int + let lineWidth: CGFloat + + func path(in rect: CGRect) -> Path { + var path = Path() + guard count >= 2 else { return path } + for index in 1.. some View) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + bar() + } + } + + return VStack(alignment: .leading, spacing: 22) { + row("11 首 · 下完 3 首,第 4 首下到一半") { + SegmentedProgressBar( + done: 3.0 / 11, + live: 3.5 / 11, + ticks: 11, + tint: .orange, + tickColor: background, + height: 5 + ) + } + row("11 首 · 同时在下好几首") { + SegmentedProgressBar( + done: 3.0 / 11, + live: 6.2 / 11, + ticks: 11, + tint: .orange, + tickColor: background, + height: 5 + ) + } + row("11 首 · 全部完成") { + SegmentedProgressBar( + done: 1, live: 1, ticks: 11, + tint: .green, tickColor: background, height: 5 + ) + } + row("40 首 · 超过刻度上限,不画分隔线") { + SegmentedProgressBar( + done: 12.0 / 40, + live: 15.4 / 40, + ticks: 0, + tint: .orange, + tickColor: background, + height: 5 + ) + } + row("单曲 · 不分段,和以前一样") { + SegmentedProgressBar( + done: 0.62, live: 0, ticks: 0, + tint: .orange, tickColor: background, height: 5 + ) + } + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(background) +} diff --git a/amdl-ios/DownloadsAPI.swift b/amdl-ios/DownloadsAPI.swift index 10c02d3..2179c49 100644 --- a/amdl-ios/DownloadsAPI.swift +++ b/amdl-ios/DownloadsAPI.swift @@ -609,7 +609,13 @@ struct DownloadDetail: Codable { var progress: Double { guard !items.isEmpty else { return job.progress } let total = items.reduce(0) { $0 + $1.clampedProgress } - return total / Double(items.count) + let average = total / Double(items.count) + // 还有曲目没走完就绝不报到 100%。曲目一多,最后那一首的零头在均值里就摊得 + // 看不见了:200 首下完 199 首是 0.995,四舍五入到整数正好是 100% —— 而这 + // 时最后一首可能才刚开始。封顶封在**数值**上而不是各个显示点上,是因为 + // 详情页的百分比、分段条的半透明段、灵动岛和锁屏读的都是这一个数。 + guard items.contains(where: { $0.status.isActive }) else { return average } + return min(average, 0.99) } /// 合并刷新快照时保留已解析出的稳定展示信息。下载状态、进度、错误和 hook @@ -940,7 +946,7 @@ enum DownloadsAPI { /// 归属和配额,`/api/v1/*` 是它对后端的镜像(形状逐字节兼容,所以下面那些 /// Codable 结构一个都不用改),`/api/gw/*` 是它自己的接口。 /// - /// 所有 /api 请求都要带门户签发的 Bearer 令牌,见 `PortalAuth.swift`。 + /// 所有 /api 请求都要带 Apple 的 identity token 作 Bearer,见 `GatewayAuth.swift`。 /// 仍然可以在「配置 → 调试」里改成别的地址。 /// /// 具体的值和存取都在 `BackendEndpoint` 里 —— 门户是唯一的源站,所以全 App @@ -1004,7 +1010,7 @@ enum DownloadsAPI { ) ) - let (data, httpResponse) = try await PortalHTTP.send(request) + let (data, httpResponse) = try await GatewayHTTP.send(request) // 202 和 422 都要解 body:**422 才是配额被拒时唯一带着逐条原因的响应**。 // 门户把每个 URL 的拒绝理由塞在 `results[].status/error` 里,整批被拒时 // 它必须答 422 而不是 4xx 里的别的码——因为这里只对这两个码解码,别的码 @@ -1045,7 +1051,7 @@ enum DownloadsAPI { var request = URLRequest(url: url) request.httpMethod = method - let (data, httpResponse) = try await PortalHTTP.send(request) + let (data, httpResponse) = try await GatewayHTTP.send(request) guard httpResponse.statusCode == expecting else { throw serverError(status: httpResponse.statusCode, data: data) } @@ -1125,11 +1131,10 @@ enum DownloadsAPI { return url } - /// 通过 `PortalHTTP` 而不是 `URLSession.shared` 直接发:那一层负责在发之前续 - /// 快过期的 access token,并在 401 之后刷新一次、重试一次。它还会把 403 的 - /// `pending_approval` / `suspended` 翻成人话抛出来。 + /// 通过 `GatewayHTTP` 而不是 `URLSession.shared` 直接发:那一层负责在发之前 + /// 把凭据带上,并把 401 翻成 `needsSignIn`。 private static func fetchData(from url: URL) async throws -> Data { - let (data, httpResponse) = try await PortalHTTP.send(URLRequest(url: url)) + let (data, httpResponse) = try await GatewayHTTP.send(URLRequest(url: url)) guard httpResponse.statusCode == 200 else { throw serverError(status: httpResponse.statusCode, data: data) @@ -1138,13 +1143,12 @@ enum DownloadsAPI { return data } - /// 把镜像面的错误体翻成一个能给用户看的错误。 + /// 把错误体翻成一个能给用户看的错误。 /// - /// `/api/v1/*` 的错误保持 amdl-backend 的 `{"error":...}` 形状,所以 - /// `pending_approval` 是从 `error` 字段里读出来的,不是 problem+json 的 `code`。 - /// 两边的值域是同一张表(DESIGN.md §6.3),一个客户端只需要一份码表。 + /// `/api/v1/*` 就是 amdl-backend 自己的 `{"error":...}`,网关的 401 特意也用 + /// 同一个形状,所以一份解码器管两边。 static func serverError(status: Int, data: Data) -> Error { - let body = PortalErrorBody.decode(from: data) + let body = GatewayErrorBody.decode(from: data) if let mapped = body?.authError(status: status) { return mapped } diff --git a/amdl-ios/GatewayAuth.swift b/amdl-ios/GatewayAuth.swift new file mode 100644 index 0000000..0d7184f --- /dev/null +++ b/amdl-ios/GatewayAuth.swift @@ -0,0 +1,249 @@ +import Foundation +import Security + +/// 发给网关的凭据,**就是 Apple 自己签的那个 identity token**。 +/// +/// 网关(nginx + oauth2-proxy)开了 `--skip-jwt-bearer-tokens`,会拿 Apple 的公钥 +/// 直接验这个 token 的签名,再比一遍邮箱白名单。它不发自己的令牌、不存会话、 +/// 也不关心调用者是谁 —— 只回答"过,还是不过"。 +/// +/// ## 有效期:读 token 自己说的,不要猜 +/// +/// 到期时刻是从这个 JWT 的 `exp` claim 解出来的,不是按经验值估的。**这一点是有 +/// 来历的**:仓库里先写着「约 24 小时」,后来被"修正"成「约 10 分钟」并注明前者是 +/// 观察错误——而 2026-07-30 在真机上读出来的 `exp` 是 **约 23.4 小时**,也就是说 +/// 被改掉的那个才是对的,"修正"是错的,并且这个错在文档和界面文案里传播了一圈。 +/// +/// 所以这里不写死任何数字。Apple 想改随时可以改,而 `exp` 是这个 token 自己说的话。 +/// +/// 实际代价:**大约一天重新登录一次**,因为没有静默续期手段 +/// (`getCredentialState` 只告诉你授权还在,不会签发新 token)。 +/// +/// 这比 `amdl-portal` 那一版(access 1 小时 / refresh 60 天、可连用两个月)仍然是 +/// 退步,但退得远没有"每十几分钟弹一次面板"那么严重——那个说法是基于上面那个错误 +/// 数字得出的。要不要为此再造一层服务端会话,是产品判断,请按一天一次来权衡。 +/// +nonisolated struct GatewayCredential: Codable, Sendable { + let identityToken: String + /// 从 token 自己的 `exp` claim 解出来的到期时刻。 + /// + /// 解 JWT 而不是按经验值加一个偏移——见上面为什么。解不出来时按 10 分钟兜底, + /// 那**不是**对真实寿命的估计,而是刻意悲观:宁可早问一次,也不要带着一个已经 + /// 失效的凭据出门。 + let expiresAt: Date + + /// 留 30 秒余量:请求在路上过期就是白跑一趟 401。 + var isUsable: Bool { expiresAt.timeIntervalSinceNow > 30 } + + init(identityToken: String, receivedAt: Date = Date()) { + self.identityToken = identityToken + // 兜底 10 分钟是刻意保守的下限,不是观测值;见 expiresAt 的注释。 + self.expiresAt = Self.expiry(ofJWT: identityToken) ?? receivedAt.addingTimeInterval(600) + } + + /// 读 JWT payload 里的 `exp`。不校验签名 —— 校验是网关的事,这里只是想知道 + /// 什么时候该重新登录,读错了最坏也就是早问或晚问一次。 + static func expiry(ofJWT token: String) -> Date? { + let parts = token.split(separator: ".") + guard parts.count == 3 else { return nil } + var base64 = String(parts[1]) + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + // base64url 去掉了 padding,Data(base64Encoded:) 要求补回来。 + let remainder = base64.count % 4 + if remainder > 0 { base64 += String(repeating: "=", count: 4 - remainder) } + guard let data = Data(base64Encoded: base64), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let exp = json["exp"] as? Double + else { return nil } + return Date(timeIntervalSince1970: exp) + } +} + +/// 凭据的持久化。 +/// +/// **放在 Keychain 而不是 App Group 的 UserDefaults**:UserDefaults 的 plist 是明文、 +/// 会进 iTunes/iCloud 备份、也没有"设备解锁后才可读"这种保护。这个 token 大约活一天, +/// 危害比一份 60 天的 refresh token 小,但这一天里它就是这套部署的通行证 —— +/// 而且更实际的理由是:早先版本正是把它明文存在 UserDefaults 里,那是个被专门修掉的 +/// 问题,不该因为门户没了就退回去。`AppleAuthCredentialStore.purgeLegacyIdentityToken()` +/// 每次启动还在清那份旧的。 +/// +/// **`kSecAttrAccessGroup` 用的是 App Group id**。iOS 允许把 App Group 直接当作 +/// keychain access group 用,所以主 App、分享扩展、通知扩展共享凭据**不需要新增 +/// 任何 entitlement** —— 四个 target 的 `.entitlements` 里已经都有 +/// `group.com.lyjw131.amdl.amdl-ios` 了。改 entitlement 要重新配 provisioning, +/// 而这里不用。 +/// +/// **`kSecAttrAccessibleAfterFirstUnlock`**:通知服务扩展会在锁屏状态下被唤起去下载 +/// 封面,那时它得能读到凭据。`WhenUnlocked` 会让锁屏推送的附件下载静默失败。 +nonisolated enum GatewayCredentialStore { + /// 与 `DownloadsAPI.appGroupIdentifier` 相同。这里写字面量而不是引用它,是因为 + /// 分享扩展没有共享源码目录,两边都得各写一份,写死才能一眼看出必须一致。 + static let accessGroup = "group.com.lyjw131.amdl.amdl-ios" + private static let service = "com.lyjw131.amdl.gateway" + private static let account = "session" + + private static var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + ] + } + + static func load() -> GatewayCredential? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data + else { return nil } + return try? JSONDecoder().decode(GatewayCredential.self, from: data) + } + + static func save(_ credential: GatewayCredential) { + guard let data = try? JSONEncoder().encode(credential) else { return } + // 先删后写。SecItemUpdate 在条目不存在时返回 errSecItemNotFound,两条路径 + // 分开写只会多一个分支,收益是零。 + SecItemDelete(baseQuery as CFDictionary) + var query = baseQuery + query[kSecValueData as String] = data + query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + SecItemAdd(query as CFDictionary, nil) + } + + static func clear() { + SecItemDelete(baseQuery as CFDictionary) + } + + /// 清掉门户时期那份 access/refresh 凭据。 + /// + /// 它存在另一个 keychain service(`com.lyjw131.amdl.portal`)下,所以不会和上面 + /// 这份打架 —— 但里面躺着一个 60 天寿命的 refresh token,而签发它的服务已经不存在。 + /// 留着没有任何用处,删掉是对的。每次启动跑一次,代价是一次 `SecItemDelete`。 + static func purgePortalCredentials() { + SecItemDelete([ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "com.lyjw131.amdl.portal", + kSecAttrAccount as String: "session", + kSecAttrAccessGroup as String: accessGroup, + ] as CFDictionary) + } +} + +nonisolated enum GatewayAuthError: LocalizedError { + /// 需要重新走一次 Apple 登录:没有凭据,或者手上这份已经过期了。 + case needsSignIn + case invalidBaseURL + case invalidResponse + case server(status: Int, message: String?) + + var errorDescription: String? { + switch self { + case .needsSignIn: + "登录已过期,请重新「通过 Apple 登录」。" + case .invalidBaseURL: + "服务器地址无效,请到「配置」页检查" + case .invalidResponse: + "服务器返回了无法解析的数据" + case let .server(status, message): + message ?? "服务器错误 (\(status))" + } + } +} + +/// 错误响应的解码。 +/// +/// 后端用 `{"error": "..."}`,网关的 401 特意也用同一个形状(`unauthenticated`), +/// 所以一个解码器管两边。 +/// +/// 以前这里还要处理 problem+json,以及 `pending_approval` / `suspended` / +/// `forbidden` 三个门户专有的码 —— 那些都是门户对**账号**的判断,而账号这个概念 +/// 已经没有了。 +nonisolated struct GatewayErrorBody: Decodable { + let error: String? + let message: String? + + /// 机器可读的错误码。 + var resolvedCode: String? { error } + /// 给人看的那句话。 + var resolvedMessage: String? { message } + + static func decode(from data: Data) -> GatewayErrorBody? { + try? JSONDecoder().decode(GatewayErrorBody.self, from: data) + } + + /// 把错误码映射成需要特别措辞的错误。返回 nil 表示交给调用方按普通服务端错误处理。 + /// + /// 只剩一条了。以前这里还有 `pending_approval` 和 `suspended`,两个都是门户对 + /// 账号的判断。`unauthenticated` 留着是因为它有一个**动作**跟着 —— 重新登录 —— + /// 而其它错误只能报出来。 + func authError(status: Int) -> GatewayAuthError? { + if status == 401 || resolvedCode == "unauthenticated" { return .needsSignIn } + return nil + } +} + +/// 当前凭据的持有者。 +/// +/// 比它取代的 `PortalSession` 简单得多。那个 actor 存在的全部理由是**刷新的单飞** +/// —— 并发的 401 如果各自去刷新,第一个换走 refresh token 之后其余的拿旧 token 去刷, +/// 门户判定为重放攻击、吊销整个令牌家族,用户被踢回登录面板。**这里没有刷新这回事**, +/// 所以没有可竞争的东西:读一份 Keychain 里的 token,过期了就是过期了。 +nonisolated enum GatewaySession { + /// 当前可用的 token;过期或没有就返回 nil,让请求裸奔去拿 401,由调用方提示登录。 + /// 这里不抛错,是因为封面图之类的请求本来就不需要凭据。 + static func bearerToken() -> String? { + guard let credential = GatewayCredentialStore.load(), credential.isUsable else { return nil } + return credential.identityToken + } + + static func store(identityToken: String) { + GatewayCredentialStore.save(GatewayCredential(identityToken: identityToken)) + } + + static func signOut() { + GatewayCredentialStore.clear() + } +} + +/// 所有走网关的请求的唯一出口。 +/// +/// 它做一件 `URLSession.shared.data(for:)` 不会做的事:发之前把凭据带上。 +/// +/// 以前它还做第二件事 —— 收到 401 之后刷新一次再重试一次。**现在没有可刷的东西**, +/// 401 就是 401:token 过期了,只能重新弹面板。所以这里直接抛 `needsSignIn`, +/// 让界面去问,而不是静默重试一个注定失败的请求。 +/// +/// 封面图那类请求不必走这里:它们打的多半是 Apple CDN,401 了也就是少一张图。 +enum GatewayHTTP { + static func send(_ request: URLRequest, using session: URLSession = .shared) async throws -> (Data, HTTPURLResponse) { + var request = request + let isGateway = AppleAuthCredentialStore.isGatewayHost(request.url?.host()) + if isGateway { + request.setBearer(GatewaySession.bearerToken()) + } + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { throw GatewayAuthError.invalidResponse } + + if isGateway, http.statusCode == 401 { + throw GatewayAuthError.needsSignIn + } + return (data, http) + } +} + +nonisolated extension URLRequest { + mutating func setBearer(_ token: String?) { + guard let token, !token.isEmpty else { + setValue(nil, forHTTPHeaderField: "Authorization") + return + } + setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } +} diff --git a/amdl-ios/JobActions.swift b/amdl-ios/JobActions.swift index 127f4ee..6adcc9e 100644 --- a/amdl-ios/JobActions.swift +++ b/amdl-ios/JobActions.swift @@ -120,7 +120,7 @@ enum JobActionOutcome: Equatable, Sendable { /// 任务管理动作失败时给用户看的错误。 /// /// 为什么不直接用 `DownloadsAPIError.server`:`/api/v1/*` 的错误体是后端原本的 -/// `{"error": "..."}`,而 `PortalErrorBody.resolvedMessage` 读的是 +/// `{"error": "..."}`,而 `GatewayErrorBody.resolvedMessage` 读的是 /// `detail/message/title` —— 这三个字段在这个形状里一个都没有,于是 /// `errorDescription` 每次都退化成「服务器错误 (409)」。就算把 `error` 直接当消息 /// 显示也不行,那里装的可能是 `sql: no rows in result set`(后端 `GET` 404 的原文)。 diff --git a/amdl-ios/LiveActivityGateway.swift b/amdl-ios/LiveActivityGateway.swift index dfe0a41..078aa33 100644 --- a/amdl-ios/LiveActivityGateway.swift +++ b/amdl-ios/LiveActivityGateway.swift @@ -103,7 +103,7 @@ enum LiveActivityGatewayAPI { var request = URLRequest(url: url) request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.setValue("no-cache", forHTTPHeaderField: "Cache-Control") - let (data, httpResponse) = try await PortalHTTP.send(request) + let (data, httpResponse) = try await GatewayHTTP.send(request) guard (200..<300).contains(httpResponse.statusCode) else { throw LiveActivityGatewayError.server(httpResponse.statusCode) } @@ -119,10 +119,10 @@ enum LiveActivityGatewayAPI { request.timeoutInterval = 10 request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(body) - // 必须走 PortalHTTP:门户是这条路唯一的入口(`/apns/*` 转发给 + // 必须走 GatewayHTTP:门户是这条路唯一的入口(`/apns/*` 转发给 // amdl-ios-gateway),而且它靠请求上的会话来断言这台设备属于谁—— // 没有凭据就注册不上,注册不上就再也收不到实时活动,而且**没有任何报错**。 - let (_, httpResponse) = try await PortalHTTP.send(request) + let (_, httpResponse) = try await GatewayHTTP.send(request) guard (200..<300).contains(httpResponse.statusCode) else { throw LiveActivityGatewayError.server(httpResponse.statusCode) } diff --git a/amdl-ios/LogsAPI.swift b/amdl-ios/LogsAPI.swift index 65150e5..9805fd5 100644 --- a/amdl-ios/LogsAPI.swift +++ b/amdl-ios/LogsAPI.swift @@ -225,7 +225,7 @@ enum LogsAPI { // 门户策略表把 /api/v1/logs 标成 admin only(它带着每个租户的输入 URL), // 所以普通用户在这里拿到 403 forbidden 是设计如此。 - let (data, httpResponse) = try await PortalHTTP.send(request) + let (data, httpResponse) = try await GatewayHTTP.send(request) guard httpResponse.statusCode == 200 else { throw DownloadsAPI.serverError(status: httpResponse.statusCode, data: data) } diff --git a/amdl-ios/PortalAuth.swift b/amdl-ios/PortalAuth.swift deleted file mode 100644 index 7a28547..0000000 --- a/amdl-ios/PortalAuth.swift +++ /dev/null @@ -1,349 +0,0 @@ -import Foundation -import Security - -/// 门户(`amdl-portal`)签发的会话凭据。 -/// -/// 为什么需要这一层:Apple 原生登录给的 identity token **实测只活约 10 分钟** -/// (早先注释写的 24 小时是观察错误,见 §6.2),而且没有任何静默续期手段—— -/// `getCredentialState` 只告诉你授权还在,不会给新 token。以前 App 直接把它当 -/// Bearer 发给 oauth2-proxy,代价就是每隔十几分钟必须重新弹一次系统登录面板。 -/// -/// 现在换成:identity token 只用一次,换成门户自己的 access/refresh 对, -/// access 1 小时、refresh 60 天且每次刷新都轮换。App 因此可以连着用两个月不弹面板。 -nonisolated struct PortalCredentials: Codable, Sendable { - let accessToken: String - let refreshToken: String - /// access token 的到期时刻。由 `expires_in` 加上收到响应的时间算出——服务端 - /// 只给相对秒数,本地时钟偏移会让绝对时间不准,但刷新是由 401 兜底的, - /// 这个时间只用来**提前**刷新,早一点晚一点都不会造成故障。 - let accessTokenExpiresAt: Date - - /// 留 60 秒余量:请求在路上过期会白跑一趟 401。 - var isAccessTokenUsable: Bool { accessTokenExpiresAt.timeIntervalSinceNow > 60 } -} - -/// 门户凭据的持久化。 -/// -/// **放在 Keychain 而不是 App Group 的 UserDefaults**:refresh token 有 60 天寿命, -/// 拿到它等于拿到这个账号两个月的访问权,而 UserDefaults 的 plist 是明文、会进 -/// iTunes/iCloud 备份、也没有"设备解锁后才可读"这种保护。旧的 identity token 存在 -/// UserDefaults 里问题还小一些——它十分钟就废了。 -/// -/// **`kSecAttrAccessGroup` 用的是 App Group id**。iOS 允许把 App Group 直接当作 -/// keychain access group 用,所以主 App、分享扩展、通知扩展共享凭据**不需要新增 -/// 任何 entitlement**——四个 target 的 `.entitlements` 里已经都有 -/// `group.com.lyjw131.amdl.amdl-ios` 了。这一点很重要:改 entitlement 要重新配 -/// provisioning,而这里不用。 -/// -/// **`kSecAttrAccessibleAfterFirstUnlock`**:通知服务扩展会在锁屏状态下被唤起去下载 -/// 封面,那时它得能读到凭据。`WhenUnlocked` 会让锁屏推送的附件下载静默失败。 -nonisolated enum PortalCredentialStore { - /// 与 `DownloadsAPI.appGroupIdentifier` 相同。这里写字面量而不是引用它,是因为 - /// 分享扩展没有共享源码目录,两边都得各写一份,写死才能一眼看出必须一致。 - static let accessGroup = "group.com.lyjw131.amdl.amdl-ios" - private static let service = "com.lyjw131.amdl.portal" - private static let account = "session" - - private static var baseQuery: [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrAccessGroup as String: accessGroup, - ] - } - - static func load() -> PortalCredentials? { - var query = baseQuery - query[kSecReturnData as String] = true - query[kSecMatchLimit as String] = kSecMatchLimitOne - - var item: CFTypeRef? - guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, - let data = item as? Data - else { return nil } - return try? JSONDecoder().decode(PortalCredentials.self, from: data) - } - - static func save(_ credentials: PortalCredentials) { - guard let data = try? JSONEncoder().encode(credentials) else { return } - // 先删后写。SecItemUpdate 在条目不存在时返回 errSecItemNotFound,两条路径 - // 分开写只会多一个分支,收益是零。 - SecItemDelete(baseQuery as CFDictionary) - var query = baseQuery - query[kSecValueData as String] = data - query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock - SecItemAdd(query as CFDictionary, nil) - } - - static func clear() { - SecItemDelete(baseQuery as CFDictionary) - } -} - -/// 门户返回的错误码。三个 API 面共用同一张表(DESIGN.md §6.3): -/// `/api/gw/*` 放在 problem+json 的 `code` 里,`/api/v1/*` 放在 `{"error": ...}` 里。 -nonisolated enum PortalErrorCode { - static let unauthenticated = "unauthenticated" - static let pendingApproval = "pending_approval" - static let suspended = "suspended" - static let forbidden = "forbidden" -} - -nonisolated enum PortalAuthError: LocalizedError { - /// 账号已登录但还没被管理员批准。**每个新用户第一次进来看到的都是这个**, - /// 所以它必须有一句人话,而不是"服务器错误 (403)"。 - case pendingApproval - case suspended - /// 需要重新走一次 Apple 登录:没有凭据,或者 refresh token 也失效了。 - case needsSignIn - case invalidBaseURL - case invalidResponse - case server(status: Int, message: String?) - - var errorDescription: String? { - switch self { - case .pendingApproval: - "账号正在等待管理员批准。批准之后不用重新登录,直接就能用。" - case .suspended: - "这个账号已被停用,请联系管理员。" - case .needsSignIn: - "登录已过期,请重新「通过 Apple 登录」。" - case .invalidBaseURL: - "服务器地址无效,请到「配置」页检查" - case .invalidResponse: - "服务器返回了无法解析的数据" - case let .server(status, message): - message ?? "服务器错误 (\(status))" - } - } -} - -/// 门户错误响应的两种形状。`/api/gw/*` 是 RFC 9457 的 problem+json,`/api/v1/*` -/// 是后端原本的 `{"error":...}`——两边的**机器可读值是同一张表**,所以解码成一个 -/// 类型,谁有值用谁的。 -nonisolated struct PortalErrorBody: Decodable { - let code: String? - let error: String? - let detail: String? - let message: String? - let title: String? - - /// 机器可读的错误码,两种形状取其一。 - var resolvedCode: String? { code ?? error } - /// 给人看的那句话。 - var resolvedMessage: String? { detail ?? message ?? title } - - static func decode(from data: Data) -> PortalErrorBody? { - try? JSONDecoder().decode(PortalErrorBody.self, from: data) - } - - /// 把错误码映射成有意义的错误。返回 nil 表示这不是一个需要特别措辞的状态。 - func authError(status: Int) -> PortalAuthError? { - switch resolvedCode { - case PortalErrorCode.pendingApproval: .pendingApproval - case PortalErrorCode.suspended: .suspended - case PortalErrorCode.unauthenticated: .needsSignIn - default: nil - } - } -} - -/// 令牌的换取与刷新。 -/// -/// 单独做成 actor 是为了**刷新的单飞**:App 启动时会并发发好几个请求(列表、 -/// 配置、设备注册),access token 过期时它们会同时拿到 401。如果每个都各自去刷新, -/// 第一个换走 refresh token 之后,其余的拿着已经轮换掉的旧 token 去刷——门户把 -/// 这判定为重放攻击,会**吊销整个令牌家族**(DESIGN.md §6.2),用户被踢回登录面板。 -/// 所以刷新必须全局只有一个在飞,其余的等它。 -actor PortalSession { - static let shared = PortalSession() - - private var refreshTask: Task? - - /// 用 Apple 的 identity token 换门户的会话。只在用户刚点完系统登录面板时调用一次。 - func exchange(identityToken: String, authorizationCode: String?, fullName: String?) async throws -> PortalCredentials { - struct Body: Encodable { - let identityToken: String - let authorizationCode: String? - let fullName: String? - - enum CodingKeys: String, CodingKey { - case identityToken = "identity_token" - case authorizationCode = "authorization_code" - case fullName = "full_name" - } - } - let credentials = try await post( - path: "/api/gw/auth/apple/native", - body: Body( - identityToken: identityToken, - authorizationCode: authorizationCode, - fullName: fullName - ) - ) - PortalCredentialStore.save(credentials) - return credentials - } - - /// 当前可用的 access token;快到期就先刷新。没有凭据时返回 nil,让请求裸奔去拿 - /// 401,由调用方提示登录——这里不抛错,是因为封面图之类的请求本来就不需要凭据。 - func accessToken() async -> String? { - guard let credentials = PortalCredentialStore.load() else { return nil } - if credentials.isAccessTokenUsable { - return credentials.accessToken - } - return try? await refresh(using: credentials.refreshToken).accessToken - } - - /// 收到 401 之后刷新一次。返回新的 access token,或者 nil 表示真的得重新登录了。 - func refreshAfterUnauthorized(usedToken: String?) async -> String? { - // 别人可能已经刷过了:如果存着的 token 和刚才用的那个不是同一个,直接用新的, - // 不要再消耗一次 refresh。并发 401 的常态就是这一条分支。 - if let current = PortalCredentialStore.load(), current.isAccessTokenUsable, - current.accessToken != usedToken { - return current.accessToken - } - guard let refreshToken = PortalCredentialStore.load()?.refreshToken else { return nil } - return try? await refresh(using: refreshToken).accessToken - } - - func signOut() { - refreshTask?.cancel() - refreshTask = nil - PortalCredentialStore.clear() - } - - /// 单飞的刷新。同一时刻只有一个 refresh 请求在飞,其余 await 同一个 Task。 - private func refresh(using refreshToken: String) async throws -> PortalCredentials { - if let inFlight = refreshTask { - return try await inFlight.value - } - let task = Task { [weak self] in - struct Body: Encodable { - let refreshToken: String - enum CodingKeys: String, CodingKey { case refreshToken = "refresh_token" } - } - guard let self else { throw PortalAuthError.needsSignIn } - do { - let credentials = try await self.post( - path: "/api/gw/auth/refresh", - body: Body(refreshToken: refreshToken) - ) - PortalCredentialStore.save(credentials) - return credentials - } catch PortalAuthError.needsSignIn { - // refresh token 也不认了(过期、被吊销、或者重放检测触发)。 - // 清掉,让界面回到"请登录",而不是留着一份注定 401 的凭据反复重试。 - PortalCredentialStore.clear() - throw PortalAuthError.needsSignIn - } - } - refreshTask = task - defer { refreshTask = nil } - return try await task.value - } - - /// 认证端点的 POST。这两个端点**本身不带 Authorization**——它们就是用来拿凭据的。 - private func post(path: String, body: Body) async throws -> PortalCredentials { - // baseURLString 存在 App Group 的 UserDefaults 里,属于主 actor 的状态, - // 所以这里显式跳一次;剩下的网络和 Keychain 工作留在本 actor 上。 - let baseURLString = await MainActor.run { DownloadsAPI.baseURLString } - guard !baseURLString.isEmpty, - var components = URLComponents(string: baseURLString) - else { throw PortalAuthError.invalidBaseURL } - components.path = path - guard let url = components.url else { throw PortalAuthError.invalidBaseURL } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.timeoutInterval = 15 - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try JSONEncoder().encode(body) - - let (data, response) = try await URLSession.shared.data(for: request) - guard let http = response as? HTTPURLResponse else { throw PortalAuthError.invalidResponse } - guard (200..<300).contains(http.statusCode) else { - let body = PortalErrorBody.decode(from: data) - if http.statusCode == 401 { throw PortalAuthError.needsSignIn } - if let mapped = body?.authError(status: http.statusCode) { throw mapped } - throw PortalAuthError.server(status: http.statusCode, message: body?.resolvedMessage) - } - - guard let pair = try? JSONDecoder().decode(PortalTokenPair.self, from: data) else { - throw PortalAuthError.invalidResponse - } - return PortalCredentials( - accessToken: pair.accessToken, - refreshToken: pair.refreshToken, - accessTokenExpiresAt: Date().addingTimeInterval(TimeInterval(pair.expiresIn)) - ) - } -} - -/// 所有走门户的请求的唯一出口。 -/// -/// 它做两件 `URLSession.shared.data(for:)` 不会做的事: -/// -/// 1. **发之前**确保 access token 是新鲜的(快过期就先刷)。 -/// 2. **收到 401 之后**刷新一次并重试一次。只重试一次——如果刷新过的 token 还是 -/// 401,那就是真的需要重新登录了,再试下去只会把 refresh 家族折腾没。 -/// -/// 封面图那类请求不必走这里:它们打的多半是 Apple CDN,401 了也就是少一张图。 -enum PortalHTTP { - static func send(_ request: URLRequest, using session: URLSession = .shared) async throws -> (Data, HTTPURLResponse) { - var request = request - let isPortal = AppleAuthCredentialStore.isGatewayHost(request.url?.host()) - var usedToken: String? - if isPortal { - usedToken = await PortalSession.shared.accessToken() - request.setBearer(usedToken) - } - - var (data, response) = try await session.data(for: request) - guard var http = response as? HTTPURLResponse else { throw PortalAuthError.invalidResponse } - - if isPortal, http.statusCode == 401 { - guard let refreshed = await PortalSession.shared.refreshAfterUnauthorized(usedToken: usedToken) else { - throw PortalAuthError.needsSignIn - } - request.setBearer(refreshed) - (data, response) = try await session.data(for: request) - guard let retried = response as? HTTPURLResponse else { throw PortalAuthError.invalidResponse } - http = retried - if http.statusCode == 401 { throw PortalAuthError.needsSignIn } - } - - // 403 的两个原因都需要一句人话,而且都不是"重试就好":pending 要等管理员, - // suspended 要找管理员。放在这里而不是每个调用方各判一次。 - if http.statusCode == 403, - let mapped = PortalErrorBody.decode(from: data)?.authError(status: http.statusCode) { - throw mapped - } - return (data, http) - } -} - -nonisolated extension URLRequest { - mutating func setBearer(_ token: String?) { - guard let token, !token.isEmpty else { - setValue(nil, forHTTPHeaderField: "Authorization") - return - } - setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } -} - -/// `POST /api/gw/auth/apple/native` 和 `/api/gw/auth/refresh` 的响应体 -/// (门户的 `auth.TokenPair`,DESIGN.md §6.2)。 -nonisolated struct PortalTokenPair: Decodable { - let accessToken: String - let refreshToken: String - let expiresIn: Int - - enum CodingKeys: String, CodingKey { - case accessToken = "access_token" - case refreshToken = "refresh_token" - case expiresIn = "expires_in" - } -} diff --git a/amdl-ios/RadioView.swift b/amdl-ios/RadioView.swift index cd465ee..95e167f 100644 --- a/amdl-ios/RadioView.swift +++ b/amdl-ios/RadioView.swift @@ -144,6 +144,14 @@ struct RadioView: View { ) { LogStreamView(configStore: store) } + SettingsRow( + title: "资料库同步", + systemImage: "music.note.house.fill", + tint: .pink, + value: store.form.librarySyncSummary + ) { + LibrarySyncPage(store: store) + } SettingsRow( title: "模拟模式", systemImage: "testtube.2", @@ -478,16 +486,25 @@ private struct SimulatePage: View { } } +private struct LibrarySyncPage: View { + @Bindable var store: ConfigStore + var body: some View { + Form { LibrarySyncSection(form: $store.form) } + .navigationTitle("资料库同步") + .navigationBarTitleDisplayMode(.inline) + .configSaveStatusToolbar(store) + .animation(.snappy, value: store.form.librarySyncEnabled) + } +} + // MARK: - 表单模型 /// 配置页的可编辑视图模型。字段均为非可选,缺省值取后端 Default(); -/// 从后端读到的值覆盖缺省,保存时回写为完整 RuntimeConfig。 +/// 从后端读到的值覆盖缺省,保存时回写可编辑配置。media-user-token 由启动同步 +/// 独立管理,不进入这份表单。 struct ConfigForm: Equatable { // catalog var albumTrackURLMode = "song" - /// App 里不再提供编辑入口,但仍随读取的值原样回写,避免保存时把后端上 - /// 已配置的 token 清空。 - var mediaUserToken = "" var signedModeHLSSource = "wrapper" // download @@ -532,13 +549,20 @@ struct ConfigForm: Equatable { var simulateEnabled = false var simulateMinKbps = 512 var simulateMaxKbps = 4096 + var librarySyncEnabled = false + var librarySyncIntervalMinutes = 15 + /// 后端这次 GET 是否返回了 `library_sync` 段。 + /// + /// 保存时据此决定要不要带上该段:后端的 PUT 会**拒绝未知字段并返回 400**, + /// 所以对着还没有这个功能的旧后端无条件发送,会让每一次保存设置都失败—— + /// 而且失败的是用户当时真正想改的那项。 + private(set) var librarySyncSupported = false init() {} init(config: RuntimeConfig) { if let c = config.catalog { albumTrackURLMode = c.albumTrackURLMode ?? albumTrackURLMode - mediaUserToken = c.mediaUserToken ?? mediaUserToken signedModeHLSSource = c.signedModeHLSSource ?? signedModeHLSSource } if let d = config.download { @@ -578,6 +602,11 @@ struct ConfigForm: Equatable { simulateMinKbps = s.minSpeedKbps ?? simulateMinKbps simulateMaxKbps = s.maxSpeedKbps ?? simulateMaxKbps } + if let ls = config.librarySync { + librarySyncSupported = true + librarySyncEnabled = ls.enabled ?? librarySyncEnabled + librarySyncIntervalMinutes = ls.intervalMinutes ?? librarySyncIntervalMinutes + } } var usesALAC: Bool { qualityPriority.contains(.alac) } @@ -597,6 +626,11 @@ struct ConfigForm: Equatable { return "模拟最大速度需 ≥ 最小速度。" } } + // 与后端 config.Validate 的区间一致,先在本地拦下,免得白跑一次 422。 + // 只在这一段会被发出去时才校验——不支持时它根本不进请求体。 + if librarySyncSupported && (librarySyncIntervalMinutes < 1 || librarySyncIntervalMinutes > 1440) { + return "资料库同步间隔需在 1–1440 分钟之间。" + } return nil } @@ -604,7 +638,9 @@ struct ConfigForm: Equatable { RuntimeConfig( catalog: CatalogConfig( albumTrackURLMode: albumTrackURLMode, - mediaUserToken: mediaUserToken, + // token 由专门的启动同步请求管理。普通配置保存必须省略它,否则页面 + // 加载时缓存的旧值会在用户改别的设置时把刚同步的新值覆盖回去。 + mediaUserToken: nil, signedModeHLSSource: signedModeHLSSource ), download: DownloadConfig( @@ -640,7 +676,14 @@ struct ConfigForm: Equatable { enabled: simulateEnabled, minSpeedKbps: simulateMinKbps, maxSpeedKbps: simulateMaxKbps - ) + ), + // 见 librarySyncSupported:旧后端会把整个请求以 400 拒掉。 + librarySync: librarySyncSupported + ? LibrarySyncConfig( + enabled: librarySyncEnabled, + intervalMinutes: librarySyncIntervalMinutes + ) + : nil ) } } @@ -697,6 +740,17 @@ private extension ConfigForm { var simulateSummary: String { simulateEnabled ? "已开启 · \(simulateMinKbps)–\(simulateMaxKbps) KB/s" : "已关闭" } + + var librarySyncSummary: String { + // 不支持时必须说出来:否则「已关闭」看起来像一个可以打开的开关, + // 而实际上改了也不会被保存。 + guard librarySyncSupported else { return "后端不支持" } + guard librarySyncEnabled else { return "已关闭" } + if librarySyncIntervalMinutes % 60 == 0 { + return "每 \(librarySyncIntervalMinutes / 60) 小时" + } + return "每 \(librarySyncIntervalMinutes) 分钟" + } } // MARK: - 选项常量 @@ -1036,6 +1090,43 @@ private struct PathsSection: View { } } +private struct LibrarySyncSection: View { + @Binding var form: ConfigForm + + var body: some View { + Section { + Toggle("资料库同步", isOn: $form.librarySyncEnabled) + .disabled(!form.librarySyncSupported) + } footer: { + if form.librarySyncSupported { + Text("后端定期检查 Apple Music 资料库,把新加入曲目所属的整张专辑加入下载队列。开启前需要先在「媒体用户令牌」里填好订阅令牌,否则读不到个人资料库。") + } else { + Text("当前后端没有这个功能,升级后端后可用。") + } + } + + if form.librarySyncSupported && form.librarySyncEnabled { + Section { + LabeledContent("检查间隔") { + HStack(spacing: 4) { + TextField("15", value: $form.librarySyncIntervalMinutes, format: .number) + .keyboardType(.numberPad) + .multilineTextAlignment(.trailing) + .monospacedDigit() + .frame(maxWidth: 96) + Text("分钟") + .foregroundStyle(.secondary) + } + } + } header: { + Text("轮询") + } footer: { + Text("取值 1–1440 分钟。一次检查通常只有一个网络请求,间隔短不会明显增加开销。首次开启只记录当前资料库状态,不会补下已有内容。") + } + } + } +} + private struct SimulateSection: View { @Binding var form: ConfigForm diff --git a/amdl-ios/ShazamResultView.swift b/amdl-ios/ShazamResultView.swift index 8f378a0..64e53b5 100644 --- a/amdl-ios/ShazamResultView.swift +++ b/amdl-ios/ShazamResultView.swift @@ -4,7 +4,8 @@ // // The identified-track screen shown after a successful Shazam match: a blurred // album-art backdrop, an artwork hero, track metadata, and the primary actions -// (open in Apple Music, add to library, view on Shazam, share, Shazam again). +// (enqueue download, add to library, share, Shazam again). The artwork, title, +// and artist link directly to their matching Apple Music pages. // import SwiftUI @@ -22,13 +23,48 @@ struct ShazamResultView: View { case added } + private enum DownloadState: Equatable { + case idle + case submitting + case submitted + } + + private struct AppleMusicDestinations: Equatable { + var album: URL? + var song: URL? + var artist: URL? + } + + private static let successGradient = LinearGradient( + colors: [ + Color(red: 0.20, green: 0.66, blue: 0.53), + Color(red: 0.08, green: 0.42, blue: 0.35) + ], + startPoint: .leading, + endPoint: .trailing + ) + @Environment(\.openURL) private var openURL @State private var artwork: UIImage? + @State private var appleMusicDestinations = AppleMusicDestinations() + @State private var downloadState: DownloadState = .idle @State private var libraryState: LibraryAddState = .idle @State private var appear = false private var shareURL: URL? { item.appleMusicURL ?? item.webURL } + private var albumURL: URL? { + appleMusicDestinations.album ?? Self.albumURL(from: item.appleMusicURL) + } + private var songURL: URL? { + appleMusicDestinations.song ?? item.appleMusicURL + } + private var artistURL: URL? { + appleMusicDestinations.artist ?? Self.artistSearchURL( + artist: item.artist, + songURL: item.appleMusicURL + ) + } var body: some View { ZStack { @@ -58,6 +94,9 @@ struct ShazamResultView: View { .task(id: item.artworkURL) { await loadArtwork() } + .task(id: item.appleMusicID) { + await loadAppleMusicDestinations() + } .onAppear { withAnimation(.easeOut(duration: 0.45)) { appear = true @@ -104,7 +143,23 @@ struct ShazamResultView: View { } } + @ViewBuilder private var artworkHero: some View { + if let albumURL { + Button { + openURL(albumURL) + } label: { + artworkHeroContent + .contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) + } + .buttonStyle(PressableButtonStyle()) + .accessibilityLabel("在 Apple Music 中打开专辑") + } else { + artworkHeroContent + } + } + + private var artworkHeroContent: some View { RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(.ultraThinMaterial) .overlay { @@ -130,17 +185,10 @@ struct ShazamResultView: View { private var trackInfo: some View { VStack(spacing: 6) { - Text(item.title ?? "未知歌曲") - .font(.title2.bold()) - .multilineTextAlignment(.center) - .lineLimit(2) + songTitle if let artist = item.artist { - Text(artist) - .font(.title3) - .foregroundStyle(.white.opacity(0.75)) - .multilineTextAlignment(.center) - .lineLimit(1) + artistName(artist) } if let genre = item.genres.first { @@ -155,15 +203,57 @@ struct ShazamResultView: View { } } + @ViewBuilder + private var songTitle: some View { + let title = Text(item.title ?? "未知歌曲") + .font(.title2.bold()) + .multilineTextAlignment(.center) + .lineLimit(2) + + if let songURL { + Button { + openURL(songURL) + } label: { + title.contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("在 Apple Music 中打开歌曲") + } else { + title + } + } + + @ViewBuilder + private func artistName(_ artist: String) -> some View { + let label = Text(artist) + .font(.title3) + .foregroundStyle(.white.opacity(0.75)) + .multilineTextAlignment(.center) + .lineLimit(1) + + if let artistURL { + Button { + openURL(artistURL) + } label: { + label.contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityHint("在 Apple Music 中打开艺人页") + } else { + label + } + } + private var actions: some View { VStack(spacing: 12) { if let appleMusicURL = item.appleMusicURL { Button { - openURL(appleMusicURL) + submitDownload(appleMusicURL) } label: { - capsuleLabel("在 Apple Music 中打开", systemImage: "music.note", filled: true) + downloadLabel } .buttonStyle(PressableButtonStyle()) + .disabled(downloadState != .idle) } if let appleMusicID = item.appleMusicID { @@ -177,22 +267,54 @@ struct ShazamResultView: View { } Button(action: onRestart) { - capsuleLabel("再次识曲", systemImage: "waveform", filled: false) + capsuleLabel("再次识曲", systemImage: "waveform") } .buttonStyle(PressableButtonStyle()) - if let webURL = item.webURL { - Button { - openURL(webURL) - } label: { - Text("在 Shazam 中查看") - .font(.subheadline.weight(.medium)) - .foregroundStyle(.white.opacity(0.7)) - .padding(.vertical, 4) + Text("识别能力来自 Shazam") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.white.opacity(0.7)) + .padding(.vertical, 4) + } + } + + private var downloadLabel: some View { + Group { + switch downloadState { + case .idle: + Label("下载", systemImage: "arrow.down.circle.fill") + .transition(.opacity.combined(with: .scale(scale: 0.97))) + case .submitting: + HStack(spacing: 8) { + ProgressView().tint(.white) + Text("正在加入队列…") } - .buttonStyle(.plain) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + case .submitted: + Label("已加入下载队列", systemImage: "checkmark") + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + } + .font(.headline) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 15) + .background { + ZStack { + Capsule().fill( + LinearGradient( + colors: [ShazamStyle.buttonTop, ShazamStyle.buttonBottom], + startPoint: .leading, + endPoint: .trailing + ) + ) + + Capsule() + .fill(Self.successGradient) + .opacity(downloadState == .submitted ? 1 : 0) } } + .animation(.easeInOut(duration: 0.55), value: downloadState) } private var addToLibraryLabel: some View { @@ -200,41 +322,41 @@ struct ShazamResultView: View { switch libraryState { case .idle: Label("加入资料库", systemImage: "plus") + .transition(.opacity.combined(with: .scale(scale: 0.97))) case .adding: HStack(spacing: 8) { ProgressView().tint(.white) Text("正在加入…") } + .transition(.opacity.combined(with: .scale(scale: 0.97))) case .added: Label("已加入资料库", systemImage: "checkmark") + .transition(.opacity.combined(with: .scale(scale: 0.97))) } } .font(.headline) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 15) - .background(.white.opacity(0.16), in: Capsule()) + .background { + ZStack { + Capsule().fill(.white.opacity(0.16)) + + Capsule() + .fill(Self.successGradient) + .opacity(libraryState == .added ? 1 : 0) + } + } + .animation(.easeInOut(duration: 0.55), value: libraryState) } - private func capsuleLabel(_ title: String, systemImage: String, filled: Bool) -> some View { + private func capsuleLabel(_ title: String, systemImage: String) -> some View { Label(title, systemImage: systemImage) .font(.headline) .foregroundStyle(.white) .frame(maxWidth: .infinity) .padding(.vertical, 15) - .background { - if filled { - Capsule().fill( - LinearGradient( - colors: [ShazamStyle.buttonTop, ShazamStyle.buttonBottom], - startPoint: .leading, - endPoint: .trailing - ) - ) - } else { - Capsule().fill(.white.opacity(0.16)) - } - } + .background(.white.opacity(0.16), in: Capsule()) } // MARK: - Data @@ -253,6 +375,93 @@ struct ShazamResultView: View { } } + private func loadAppleMusicDestinations() async { + appleMusicDestinations = AppleMusicDestinations() + guard let appleMusicID = item.appleMusicID else { return } + + do { + let request = MusicCatalogResourceRequest( + matching: \.id, + equalTo: MusicItemID(appleMusicID) + ) + let response = try await request.response() + guard let song = response.items.first else { return } + let resolvedSong = try await song.with(.albums, .artists) + guard !Task.isCancelled else { return } + + appleMusicDestinations = AppleMusicDestinations( + album: resolvedSong.albums?.first?.url, + song: resolvedSong.url, + artist: resolvedSong.artistURL ?? resolvedSong.artists?.first?.url + ) + } catch { + // The Shazam song URL and local Apple Music search remain usable + // when catalog relationships cannot be loaded. + } + } + + /// Apple Music commonly represents a song as its album URL plus an `i` + /// query item. Removing that item yields the exact album page without a + /// catalog request. Direct `/song/` URLs are not rewritten. + private static func albumURL(from songURL: URL?) -> URL? { + guard let songURL, + var components = URLComponents(url: songURL, resolvingAgainstBaseURL: false), + components.path.split(separator: "/").contains("album") else { + return nil + } + components.queryItems = components.queryItems?.filter { $0.name != "i" } + components.fragment = nil + return components.url + } + + /// Until MusicKit returns the canonical primary-artist URL, keep the name + /// tappable via Apple Music search in the same storefront as the song. + private static func artistSearchURL(artist: String?, songURL: URL?) -> URL? { + guard let artist = artist?.trimmingCharacters(in: .whitespacesAndNewlines), + !artist.isEmpty else { + return nil + } + let storefront = songURL?.path.split(separator: "/").first.map(String.init) ?? "us" + var components = URLComponents() + components.scheme = "https" + components.host = "music.apple.com" + components.path = "/\(storefront)/search" + components.queryItems = [URLQueryItem(name: "term", value: artist)] + return components.url + } + + private func submitDownload(_ appleMusicURL: URL) { + downloadState = .submitting + + Task { + do { + let mediaUserToken = try await AppleMusicTokenService.currentUserToken() + let response = try await DownloadsAPI.createDownload( + input: appleMusicURL.absoluteString, + mediaUserToken: mediaUserToken + ) + + if response.accepted > 0 { + downloadState = .submitted + return + } + if response.firstExistingJobID != nil { + downloadState = .submitted + return + } + + downloadState = .idle + SWAlertManager.shared.show( + .error, + message: response.firstError ?? "任务未被后端接受" + ) + } catch { + downloadState = .idle + SWAlertManager.shared.show(.error, message: error.localizedDescription) + } + } + } + private func addToLibrary(appleMusicID: String) { libraryState = .adding @@ -276,7 +485,6 @@ struct ShazamResultView: View { try await MusicLibrary.shared.add(song) libraryState = .added - SWAlertManager.shared.show(.success, message: "已加入资料库") } catch { libraryState = .idle SWAlertManager.shared.show(.error, message: "加入资料库失败:\(error.localizedDescription)") diff --git a/amdl-ios/amdl_iosApp.swift b/amdl-ios/amdl_iosApp.swift index 012b017..9949dfe 100644 --- a/amdl-ios/amdl_iosApp.swift +++ b/amdl-ios/amdl_iosApp.swift @@ -29,12 +29,13 @@ struct amdl_iosApp: App { WindowGroup { ContentView() .task { - // 启动时问一次门户"我现在是什么状态"。`GET /api/gw/me` 是 - // pending 账号唯一调得通的接口,所以这是 App 在用户动手之前 - // 就能知道"登录成功了但还没被批准"的唯一途径——否则用户要等到 - // 第一次提交下载失败,才从一个 403 里去猜发生了什么。 - guard AppleAuthStore.shared.isSignedIn else { return } - await AppleAuthStore.shared.refreshAccountStatus() + // 启动时对一遍界面和存储:凭据可能在 App 没运行的时候过期了。 + // + // 这里以前问的是门户"我这个账号被批准了没有"。没有账号可问了, + // 但**有一件事必须在用户动手之前知道**:手上这份 token 还能不能 + // 用。它不能续期、大约一天就到期,所以"上次用还好好的"不说明问题,而 + // 没有这一下,用户会在第一次提交下载失败时才发现要重新登录。 + AppleAuthStore.shared.refreshFromStore() } } .modelContainer(sharedModelContainer) diff --git a/amdl-iosTests/MediaUserTokenSharingTests.swift b/amdl-iosTests/MediaUserTokenSharingTests.swift index 38f2e4b..92b68fa 100644 --- a/amdl-iosTests/MediaUserTokenSharingTests.swift +++ b/amdl-iosTests/MediaUserTokenSharingTests.swift @@ -117,6 +117,43 @@ import Foundation #expect(overrides["media_user_token"] as? String == "user-token") } + // MARK: - 后端配置自动同步 + + @Test @MainActor func backendSyncPatchOnlyContainsMediaUserToken() throws { + let body = try JSONEncoder().encode( + ConfigAPI.mediaUserTokenPatch("user-token") + ) + let json = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + #expect(json.keys.sorted() == ["catalog"]) + + let catalog = try #require(json["catalog"] as? [String: Any]) + #expect(catalog.keys.sorted() == ["media_user_token"]) + #expect(catalog["media_user_token"] as? String == "user-token") + } + + @Test @MainActor func regularConfigSaveNeverOverwritesMediaUserToken() throws { + let remote = RuntimeConfig( + catalog: CatalogConfig( + albumTrackURLMode: "song", + mediaUserToken: "remote-token", + signedModeHLSSource: "wrapper" + ), + download: nil, + logging: nil, + simulate: nil + ) + let body = try JSONEncoder().encode( + ConfigForm(config: remote).toRuntimeConfig() + ) + let json = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + let catalog = try #require(json["catalog"] as? [String: Any]) + #expect(catalog["media_user_token"] == nil) + } + // MARK: - 新旧判定 @Test func freshTokenIsUsable() { diff --git a/amdl-iosTests/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 6016863..969255a 100644 --- a/amdl-iosTests/amdl_iosTests.swift +++ b/amdl-iosTests/amdl_iosTests.swift @@ -75,6 +75,53 @@ struct amdl_iosTests { try assert(small.imageCacheKey != large.imageCacheKey, "template image cache should retain size") } + /// 概览和详情各存各的尺寸,所以谁先拿到图,另一边都得能先借来顶上。 + /// + /// 借的方向以前只有一个:详情借概览。分享拓展和完成通知走 `amdl://download/` + /// 深链直接进详情页,概览列表压根没出现过,那张 256 从来没人取过 —— 退回列表 + /// 时没得借,就得从占位图重新等一次。 + @Test @MainActor func artworkFallsBackBetweenOverviewAndHeroSizes() throws { + let job = try DownloadsAPI.decodeDownloadDetail(from: """ + { + "job": { + "id": "job_art", "input": "https://music.apple.com/cn/album/example/1", "type": "album", + "force": false, "status": "running", "total_items": 1, "done_items": 0, "failed_items": 0, + "artwork_url": "https://is1-ssl.mzstatic.com/image/thumb/x/{w}x{h}bb.jpg", + "created_at": "2026-07-30T00:00:00Z", "updated_at": "2026-07-30T00:00:00Z" + }, + "items": [] + } + """.data(using: .utf8)!).job + + let overview = JobArtworkLoader.overviewPixelSize + let hero = JobArtworkLoader.heroPixelSize + try assert(hero != overview, "hero and overview must be different sizes for this to matter") + + let overviewKey = JobArtworkLoader.cacheKey(for: job, pixelSize: overview) + let heroKey = JobArtworkLoader.cacheKey(for: job, pixelSize: hero) + try assert(overviewKey != heroKey, "each size caches separately") + + // 详情 → 概览:这一条以前是 nil,正是深链进来后退回列表要等图的原因。 + try assert( + JobArtworkLoader.fallbackCacheKey(for: job, pixelSize: overview) == heroKey, + "overview borrows the hero image" + ) + // 概览 → 详情:原有方向,不能改坏。 + try assert( + JobArtworkLoader.fallbackCacheKey(for: job, pixelSize: hero) == overviewKey, + "hero borrows the overview image" + ) + + // 私人歌单两档尺寸共用一个 key,没有另一份可借,不能自己借自己。 + let privateJob = privatePlaylistJob( + artworkURL: "https://example-bucket.s3.amazonaws.com/cover.jpg?X-Amz-Expires=86400" + ) + try assert( + JobArtworkLoader.fallbackCacheKey(for: privateJob, pixelSize: overview) == nil, + "a shared cache key has nothing to borrow" + ) + } + @Test func downloadDetailDecodesJobItems() throws { let json = """ { @@ -167,6 +214,52 @@ struct amdl_iosTests { try assert(abs(detail.progress - (firstItemFraction + 1 + 1) / 3) < 1e-9, "detail progress") } + /// 曲目一多,最后一首的零头在均值里就摊得看不见了:200 首下完 199 首是 0.995, + /// 显示成整数正好是 100% —— 而这时最后一首可能一个字节都还没下。只要还有曲目 + /// 没走完,这个数就得停在 99%。 + @Test func detailProgressStaysBelowFullUntilEveryTrackIsDone() throws { + func detail(completedTracks: Int, lastTrack status: String) throws -> DownloadDetail { + let full = #"{"download": 1, "decrypt": 1, "resolved": true, "remuxed": true, "verified": true, "tagged": true, "saved": true}"# + let untouched = #"{"download": 0, "decrypt": 0, "resolved": false, "remuxed": false, "verified": false, "tagged": false, "saved": false}"# + let stamps = #""created_at": "2026-07-30T00:00:00Z", "updated_at": "2026-07-30T00:00:00Z""# + + let done = (0.. 0.9, "199/200 is still nearly done") + try assert(almost.progress <= 0.99, "199/200 must not round up to 100%") + + // 最后一首也走完了才允许报满。 + let finished = try detail(completedTracks: 199, lastTrack: "completed") + try assert(finished.progress == 1, "every track done reads as 100%") + } + @Test func trackDurationSummaryFormatsAdaptiveUnits() throws { // 无时长(旧后端或未解析)不产生底注片段。 try assert(TrackDurationSummary.totalDurationText(totalMilliseconds: 0) == nil, "zero → nil") @@ -998,6 +1091,40 @@ struct amdl_iosTests { ) } + /// 通知里的 Emby 链接来自推送负载,所以它是外部输入。只认 emby 这一个 + /// scheme —— 照单全收就等于让任何能发到这台设备的推送指定一个要打开的 URL。 + @Test func embyDeepLinkAcceptsOnlyTheEmbyScheme() throws { + let good = try #require(AppDelegate.embyDeepLink( + fromNotificationUserInfo: ["emby_deep_link": "emby://items?serverId=srv-1&itemId=item-42"] + )) + try assert(good.scheme == "emby", "emby scheme is accepted") + try assert(good.absoluteString.contains("itemId=item-42"), "item id survives") + + for rejected in [ + "https://evil.example/steal", + "javascript:alert(1)", + "amdl://download/job_1", + " ", + "", + ] { + try assert( + AppDelegate.embyDeepLink(fromNotificationUserInfo: ["emby_deep_link": rejected]) == nil, + "rejects \(rejected)" + ) + } + + // 没有这个键就是常态:非专辑任务、没配 Emby、扫描没跟上都走这一支。 + try assert( + AppDelegate.embyDeepLink(fromNotificationUserInfo: ["job_id": "job_1"]) == nil, + "absent key is not an error" + ) + // 退回路由的依据必须还在。 + try assert( + AppDelegate.jobID(fromNotificationUserInfo: ["job_id": "job_1"]) == "job_1", + "job_id still routes in-app" + ) + } + private func assert(_ condition: Bool, _ message: String) throws { if !condition { throw TestFailure(message: message) @@ -1046,7 +1173,7 @@ struct amdl_iosTests { // MARK: - amdl-portal 会话(Milestone 7) @MainActor -struct PortalAuthTests { +struct GatewayAuthTests { /// 三条链路只剩一个可配置的地址。 /// @@ -1222,84 +1349,97 @@ struct PortalAuthTests { #expect(!AppleAuthCredentialStore.isGatewayHost("\(host).evil.example")) } - /// 两种错误体形状、同一张码表(DESIGN.md §6.3)。 - /// - /// `/api/gw/*` 是 problem+json,机器码在 `code`;`/api/v1/*` 保持后端的 - /// `{"error":...}`。客户端只该有一份码表,所以两种都得解得出同一个结论。 - @Test func pendingApprovalIsRecognisedInBothErrorShapes() throws { - let problemJSON = Data(#""" - {"type":"about:blank","title":"Forbidden","status":403, - "detail":"this account is awaiting approval","code":"pending_approval"} - """#.utf8) - let mirrorJSON = Data(#"{"error":"pending_approval"}"#.utf8) - - for body in [problemJSON, mirrorJSON] { - let decoded = try #require(PortalErrorBody.decode(from: body)) - #expect(decoded.resolvedCode == "pending_approval") - guard case .pendingApproval = try #require(decoded.authError(status: 403)) else { - Issue.record("403 pending_approval 没有被识别出来") - return - } + /// 401 是唯一还带着"下一步该做什么"的拒绝,所以它必须被认出来,而不是变成 + /// 一句「服务器错误 (401)」。网关的 401 body 特意用后端那个 `{"error":...}` + /// 形状,就是为了让客户端只需要一份解码器。 + @Test func unauthenticatedIsRecognisedFromTheErrorBody() throws { + let decoded = try #require( + GatewayErrorBody.decode(from: Data(#"{"error":"unauthenticated"}"#.utf8)) + ) + #expect(decoded.resolvedCode == "unauthenticated") + guard case .needsSignIn = try #require(decoded.authError(status: 401)) else { + Issue.record("401 unauthenticated 没有被识别出来") + return } } - /// 「等待批准」必须是一句人话。**每个新用户第一次进来看到的就是它**:门户给 - /// pending 账号也发凭据(好让 App 能调 /api/gw/me 问出自己的状态),别的接口 - /// 一律 403 —— 如果这里只剩「服务器错误 (403)」,用户唯一能得出的结论是登录坏了。 - @Test func pendingApprovalHasAComprehensibleMessage() throws { - let message = try #require(PortalAuthError.pendingApproval.errorDescription) - #expect(message.contains("批准")) - #expect(!message.contains("403")) - #expect(!message.contains("error")) - - // 停用和登录过期同理:都要说清楚下一步该做什么。 - #expect(try #require(PortalAuthError.suspended.errorDescription).contains("停用")) - #expect(try #require(PortalAuthError.needsSignIn.errorDescription).contains("登录")) - } - /// 未知的错误码不许被当成认证问题吞掉——那会把一个真的服务器故障显示成 /// 「请重新登录」,然后用户反复登录也没用。 @Test func unknownCodesAreNotTreatedAsAuthErrors() throws { - let decoded = try #require(PortalErrorBody.decode(from: Data(#"{"error":"queue_full"}"#.utf8))) + let decoded = try #require( + GatewayErrorBody.decode(from: Data(#"{"error":"queue_full"}"#.utf8)) + ) #expect(decoded.authError(status: 422) == nil) } - /// access token 的可用性判断留了余量:卡着到期时刻发出去的请求会在路上过期, - /// 白跑一趟 401。 - @Test func accessTokenNeedsHeadroomBeforeExpiry() { - let almostExpired = PortalCredentials( - accessToken: "a", refreshToken: "r", - accessTokenExpiresAt: Date().addingTimeInterval(30) - ) - let fresh = PortalCredentials( - accessToken: "a", refreshToken: "r", - accessTokenExpiresAt: Date().addingTimeInterval(3600) - ) - #expect(!almostExpired.isAccessTokenUsable) - #expect(fresh.isAccessTokenUsable) + /// 「登录已过期」必须是一句人话,而且要说清楚下一步。**用户会经常看到它** —— + /// Apple 的 identity token 只活约十分钟且无法静默续期,所以过期是常态。 + @Test func needsSignInHasAComprehensibleMessage() throws { + let message = try #require(GatewayAuthError.needsSignIn.errorDescription) + #expect(message.contains("登录")) + #expect(!message.contains("401")) + #expect(!message.contains("error")) } - /// 凭据的编码形状是**跨 target 的契约**:`PortalCredentialStore` 在主 App - /// target 里,分享扩展够不着(共享的只有 `LiveActivityShared/`),它自己手抄 - /// 了一份解码器,只认 `accessToken` 这个键。改字段名会让分享面板静默地不带令牌。 - @Test func storedCredentialsKeepTheKeyNamesTheExtensionReads() throws { - let encoded = try JSONEncoder().encode(PortalCredentials( - accessToken: "token-value", refreshToken: "refresh-value", - accessTokenExpiresAt: Date() - )) + /// 凭据的到期时刻是从 token 自己的 `exp` claim 解出来的,不是"收到时间 + 十分钟"。 + /// 十分钟是实测值不是契约;Apple 改了寿命而这里还按十分钟算,就会带着一个已经 + /// 失效的凭据出门。 + @Test func expiryComesFromTheTokenNotTheClock() throws { + // exp = 2026-07-30T12:00:00Z。header 和签名都是占位符:这里只解 payload, + // 验签是网关的事。 + let exp = 1_785_585_600.0 + let payload = try JSONSerialization.data(withJSONObject: ["exp": exp, "aud": "com.example.app"]) + let base64url = payload.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + let token = "eyJhbGciOiJSUzI1NiJ9.\(base64url).signature" + + let parsed = try #require(GatewayCredential.expiry(ofJWT: token)) + #expect(abs(parsed.timeIntervalSince1970 - exp) < 1) + } + + /// 解不出 `exp` 时按十分钟兜底,而不是当成"永不过期"。一个不会过期的凭据会让 + /// App 永远不提示重新登录,而每个请求都 401。 + @Test func unparsableTokenFallsBackToTenMinutes() { + let received = Date() + for junk in ["", "not-a-jwt", "a.b", "a.!!!.c"] { + let credential = GatewayCredential(identityToken: junk, receivedAt: received) + #expect(abs(credential.expiresAt.timeIntervalSince(received) - 600) < 1) + } + } + + /// 可用性判断留了余量:卡着到期时刻发出去的请求会在路上过期,白跑一趟 401。 + @Test func credentialNeedsHeadroomBeforeExpiry() { + // 直接构造,绕开 init 里的 JWT 解析 —— 这里测的是余量,不是解析。 + let almostExpired = GatewayCredential(identityToken: "x", receivedAt: Date().addingTimeInterval(-590)) + let fresh = GatewayCredential(identityToken: "x", receivedAt: Date()) + #expect(!almostExpired.isUsable) + #expect(fresh.isUsable) + } + + /// 凭据的编码形状是**跨 target 的契约**:`GatewayCredentialStore` 在主 App + /// target 里,分享扩展够不着(共享的只有 `LiveActivityShared/`),它自己手抄了 + /// 一份解码器,只认 `identityToken` 和 `expiresAt` 这两个键。改字段名会让分享 + /// 面板静默地不带令牌 —— 提交会 401,而分享面板是个一闪而过的浮层,最难查。 + @Test func storedCredentialKeepsTheKeyNamesTheExtensionReads() throws { + let encoded = try JSONEncoder().encode(GatewayCredential(identityToken: "token-value")) let object = try #require( try JSONSerialization.jsonObject(with: encoded) as? [String: Any] ) - #expect(object["accessToken"] as? String == "token-value") - #expect(object["refreshToken"] as? String == "refresh-value") + #expect(object["identityToken"] as? String == "token-value") + #expect(object["expiresAt"] != nil) + // 扩展用默认的 JSONDecoder 解 `expiresAt`,所以编码策略必须是默认的 + // .deferredToDate(自参考日期起的秒数),不能是 ISO8601 字符串。 + #expect(object["expiresAt"] is Double) } /// Keychain 的 access group 用的是 App Group id。四个 target 的 entitlements /// 里已经都有它,所以共享凭据**不需要新增任何 entitlement**——这一点值得钉住, /// 因为改 entitlement 要重新配 provisioning。 @Test func keychainAccessGroupIsTheExistingAppGroup() { - #expect(PortalCredentialStore.accessGroup == DownloadsAPI.appGroupIdentifier) - #expect(PortalCredentialStore.accessGroup == "group.com.lyjw131.amdl.amdl-ios") + #expect(GatewayCredentialStore.accessGroup == DownloadsAPI.appGroupIdentifier) + #expect(GatewayCredentialStore.accessGroup == "group.com.lyjw131.amdl.amdl-ios") } } @@ -1426,12 +1566,12 @@ struct JobActionTests { /// 认证类错误本来就有人话,不许被动作层的措辞盖掉。 @Test func authErrorsPassThroughTheActionMapper() throws { - let mapped = JobActionError.mapping(PortalAuthError.pendingApproval, action: .delete) - guard case PortalAuthError.pendingApproval = mapped else { + let mapped = JobActionError.mapping(GatewayAuthError.needsSignIn, action: .delete) + guard case GatewayAuthError.needsSignIn = mapped else { Issue.record("认证错误被动作层改写了:\(mapped)") return } - #expect(mapped.localizedDescription.contains("等待管理员批准")) + #expect(mapped.localizedDescription.contains("重新")) } }