Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 12 additions & 5 deletions LiveActivityShared/BackendEndpoint.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down
6 changes: 3 additions & 3 deletions LiveActivityShared/MediaUserTokenStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Security
/// 这也是 `AGENTS.md`「不要拿 MusicKit 去补任务里的空缺」在这里的具体形态:
/// 缺的不是数据,是一个能取到数据的进程。
///
/// **为什么存钥匙串而不是 App Group 的 UserDefaults**:和 `PortalCredentialStore`
/// **为什么存钥匙串而不是 App Group 的 UserDefaults**:和 `GatewayCredentialStore`
/// 同一个理由——这是一份能代表用户 Apple Music 账号的凭据,UserDefaults 的 plist
/// 是明文、会进备份、也没有「解锁之后才可读」这种保护。
///
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
54 changes: 28 additions & 26 deletions ShareDownloadExtension/ShareViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`。
Expand Down Expand Up @@ -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
// 私人歌单没有令牌照样能下完,只是封面取不到。这不值得拦下提交,但也不该
// 一声不吭——「封面不对」正是这次报告里的另一半。
Expand Down Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
24 changes: 12 additions & 12 deletions amdl-ios.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading