From a2734c593df82ae1ee83b78158ea0b1874e9fae1 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 12:35:02 +0800 Subject: [PATCH 01/11] fix(share): hide shared URL before enqueue confirmation Signed-off-by: LYJW131 --- ShareDownloadExtension/ShareViewController.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ShareDownloadExtension/ShareViewController.swift b/ShareDownloadExtension/ShareViewController.swift index 27ed436..db5969d 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 // 私人歌单没有令牌照样能下完,只是封面取不到。这不值得拦下提交,但也不该 // 一声不吭——「封面不对」正是这次报告里的另一半。 From 6b408009dac94f436a0bfc89fbc090eef61f5ddd Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 12:53:48 +0800 Subject: [PATCH 02/11] feat(detail): segment the job progress bar by track MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail bar showed one fraction and the track list showed another; the bar could not say how many tracks were actually finished. It now carries both at once, like the web detail page's `.jd-bar`: - a solid segment for tracks that are done — (done + failed) / total, so it only moves a whole notch at a time and always lands on a tick; - a translucent segment at `DownloadDetail.progress`, which is the very number printed beside the bar, so the two cannot disagree. What it shows past the solid segment is the part-finished work of whatever is in flight; - per-track ticks, drawn in the page background so they cut the bar rather than draw a line on it. Skipped above 24 tracks, where they stop being legible — the same ceiling the web page uses. Failed tracks count toward the solid segment on purpose: they will never progress again, and leaving them outside it parks the bar short of the end where it reads as stuck. The two fractions are computed by the caller, not the view, because the two clients do not agree on what "progress" means and should not: iOS averages per-track fractions (`ItemProgress.fraction`, weighted from measurement), the web page uses finished-over-total. Nothing about the existing number, the bar's height, or the surrounding layout changed. Signed-off-by: LYJW131 Co-Authored-By: Claude Opus 5 Signed-off-by: LYJW131 --- amdl-ios/DownloadDetailSummaryView.swift | 32 ++++- amdl-ios/DownloadProgressViews.swift | 147 +++++++++++++++++++++++ 2 files changed, 177 insertions(+), 2 deletions(-) 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) +} From 97b4d0b05d7d14c5eb882f29c0a23e9e68c0b1b6 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 13:21:28 +0800 Subject: [PATCH 03/11] fix(detail): hold job progress at 99% until every track is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With enough tracks the last one's shortfall averages away: 199 of 200 done is 0.995, and printed to whole percent that is 100% — while the last track may not have moved a byte. The job then sits at "100%" for as long as the final track takes. `DownloadDetail.progress` now refuses to report full while any item is still in a live state, capping at 0.99. Terminal-only item sets are left alone, so a genuinely finished job still reads 1 and a job that ended with failures still reads whatever its average actually is. Capped on the value rather than at each display site because all five readers share this one number: the detail percentage, the segmented bar's translucent segment, and the Live Activity's lock screen and both Dynamic Island layouts (LiveActivityGateway assigns detail.progress straight through). Signed-off-by: LYJW131 Co-Authored-By: Claude Opus 5 Signed-off-by: LYJW131 --- amdl-ios/DownloadsAPI.swift | 8 +++++- amdl-iosTests/amdl_iosTests.swift | 46 +++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/amdl-ios/DownloadsAPI.swift b/amdl-ios/DownloadsAPI.swift index 10c02d3..3c69ebf 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 diff --git a/amdl-iosTests/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 6016863..6b762fd 100644 --- a/amdl-iosTests/amdl_iosTests.swift +++ b/amdl-iosTests/amdl_iosTests.swift @@ -167,6 +167,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") From deae57ee8eb4d6363c9dbf75d9bb705ec00a1c25 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 13:36:33 +0800 Subject: [PATCH 04/11] fix(artwork): let the overview row borrow the detail page's cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A job added from the share extension opens on `amdl://download/`, which pushes the detail page straight onto the navigation stack — the overview list never appears, so nothing ever requested its 256px cover. Backing out of the detail page therefore landed on a row holding the type placeholder until that fetch finished, even though the full-size cover was already decoded and in the cache. The two pages cache under different keys because they request different sizes, and `fallbackCacheKey` only ever resolved one way: detail borrowing the overview's small image. It is now symmetric — whichever size is missing borrows the other. The row shows the hero image on its first frame (the memory-cache lookup in `CachedAsyncImage.init` is synchronous), then swaps in the 256 without a fade, because the fade keys on nil-to-non-nil and the slot was never empty. Nothing new is downloaded: this reuses an image already in the cache rather than prefetching a second one ahead of time. `fallbackCacheKey` moved onto `JobArtworkLoader` so the direction it picks is testable, and the literal 256 became `overviewPixelSize` — it is part of a cache key, not just a request size. Private playlists are unaffected: both sizes share one unsized key, the two lookups return the same string, and the fallback stays nil rather than having a view borrow from itself. Signed-off-by: LYJW131 Co-Authored-By: Claude Opus 5 Signed-off-by: LYJW131 --- amdl-ios/DownloadArtworkView.swift | 29 ++++++++++++++---- amdl-iosTests/amdl_iosTests.swift | 47 ++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) 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-iosTests/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 6b762fd..88547f2 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 = """ { From c3e5e736bc9113c262656e73e58a89aa93367116 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 15:36:20 +0800 Subject: [PATCH 05/11] feat(push): open Emby from a finished album's notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway now puts `emby_deep_link` on a finished album's banner, after it has refreshed Emby and confirmed the album is actually there. Tapping the banner goes straight to that album. Every other case keeps the existing behaviour and they are not rare — a non-album job, no Emby configured, a scan that had not caught up, a name that did not match, or Emby not installed at all. `UIApplication.open` reports whether it could open the URL, so the last one falls back rather than dropping the tap on the floor, and `job_id` still rides along for it. Only the `emby` scheme is accepted. The value arrives inside a push payload, so taking it at face value would let anything that can push to this device name a URL for the app to open. Signed-off-by: LYJW131 Co-Authored-By: Claude Opus 5 Signed-off-by: LYJW131 --- amdl-ios/AppDelegate.swift | 31 ++++++++++++++++++++++++++++ amdl-iosTests/amdl_iosTests.swift | 34 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/amdl-ios/AppDelegate.swift b/amdl-ios/AppDelegate.swift index 4784128..bf30874 100644 --- a/amdl-ios/AppDelegate.swift +++ b/amdl-ios/AppDelegate.swift @@ -117,12 +117,43 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent ) async { let userInfo = response.notification.request.content.userInfo print("[Push] 用户点击通知: \(userInfo)") + + // 专辑下完之后网关会先刷新 Emby、拿到媒体 ID 再发这条推送,所以带链接 + // 就说明那张专辑此刻在 Emby 里确实点得开,直接过去。 + // + // 拿不到链接的情况都退回应用内详情页,而且这几种情况一点都不罕见:非专辑 + // 任务、没配 Emby、扫描还没跑到、或者匹配不上。装没装 Emby 也一样 —— open + // 的回调告诉我们打不开,再退回来。 + if let embyURL = Self.embyDeepLink(fromNotificationUserInfo: userInfo) { + let opened = await UIApplication.shared.open(embyURL) + if opened { return } + print("[Push] Emby 打不开(多半是没装),退回应用内详情页") + } + guard let jobID = Self.jobID(fromNotificationUserInfo: userInfo) else { return } // 只登记目标,导航由 ContentView 做:这个回调在冷启动时比根视图还早, // 直接推路径没人接得住。 PendingDownloadRoute.shared.route(toJob: jobID) } + /// 网关在专辑任务完成时放进 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`), /// 通知服务扩展换封面时也是照它分会话的,所以这是点击路由唯一的依据。 static func jobID(fromNotificationUserInfo userInfo: [AnyHashable: Any]) -> String? { diff --git a/amdl-iosTests/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 88547f2..874b10c 100644 --- a/amdl-iosTests/amdl_iosTests.swift +++ b/amdl-iosTests/amdl_iosTests.swift @@ -1091,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) From 63315d9fe5f931a2caf3c58cbfb60b9cc48dd0eb Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 16:53:55 +0800 Subject: [PATCH 06/11] fix(push): open Emby once the app is active, not from the tap callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deep link was on the push and the app parsed it, but tapping the banner just opened this app. `didReceive` runs before the root view exists on a cold launch — the app is not active yet, and iOS ignores `UIApplication.open` from there. `PendingDownloadRoute` already existed for exactly this reason: the same callback cannot set navigation state either, so it registers the target and the root view acts on it. The Emby URL now rides along and is opened from the same place, falling back to the in-app detail page when open() reports it could not (no Emby installed). Signed-off-by: LYJW131 Co-Authored-By: Claude Opus 5 Signed-off-by: LYJW131 --- amdl-ios/AppDelegate.swift | 22 +++++++--------------- amdl-ios/ContentView.swift | 26 +++++++++++++++++++++++--- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/amdl-ios/AppDelegate.swift b/amdl-ios/AppDelegate.swift index bf30874..e799e72 100644 --- a/amdl-ios/AppDelegate.swift +++ b/amdl-ios/AppDelegate.swift @@ -118,22 +118,14 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent let userInfo = response.notification.request.content.userInfo print("[Push] 用户点击通知: \(userInfo)") - // 专辑下完之后网关会先刷新 Emby、拿到媒体 ID 再发这条推送,所以带链接 - // 就说明那张专辑此刻在 Emby 里确实点得开,直接过去。 - // - // 拿不到链接的情况都退回应用内详情页,而且这几种情况一点都不罕见:非专辑 - // 任务、没配 Emby、扫描还没跑到、或者匹配不上。装没装 Emby 也一样 —— open - // 的回调告诉我们打不开,再退回来。 - if let embyURL = Self.embyDeepLink(fromNotificationUserInfo: userInfo) { - let opened = await UIApplication.shared.open(embyURL) - if opened { return } - print("[Push] Emby 打不开(多半是没装),退回应用内详情页") - } - 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` 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) + } } } From 74066aca1bbe94c1bdce636ced45e121be0b3025 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 18:13:07 +0800 Subject: [PATCH 07/11] feat(auth): send Apple's identity token straight to the gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit amdl-portal is gone, so the session it issued goes with it. PortalAuth.swift becomes GatewayAuth.swift: the credential is now the Apple identity token itself, sent as Authorization: Bearer to oauth2-proxy, which verifies it against Apple's JWKS and checks an email allow-list. Deleted along the way: the access/refresh pair, the exchange against POST /api/gw/auth/apple/native, the refresh single-flight actor (its whole reason for existing was that a concurrent refresh would rotate the token twice and get the family revoked as a replay — there is nothing to rotate now), the 401-refresh-retry, GET /api/gw/me, and the pending-approval state that every new account used to land in. ## The ten-minute token is back, and it is stated rather than hidden An Apple identity token lives ~10 minutes with no silent renewal, which is exactly what the portal was built to paper over. It is written down in GatewayCredential, in AGENTS.md, and in the gateway's own compose file, because the failure it produces — "I have to sign in again constantly" — reads as a bug unless you know it is a trade. Two consequences the code now has to be honest about: - isSignedIn checks the credential is still usable, not just present. The old one only asked "have you signed in", because an expired access token was refreshable. Keeping that would show a signed-in UI while every request 401s. - Expiry is parsed from the token's own `exp`, not received-time + 10 minutes. Ten minutes is measured, not promised. ## Cross-target contract ShareViewController hand-copies the Keychain decoder (extensions cannot see the main target). Its keys moved from accessToken to identityToken/expiresAt, and its service from …amdl.portal to …amdl.gateway; a test pins both, including that expiresAt encodes as a Double, since the extension decodes with a default JSONDecoder. Startup now purges two dead credentials: the plaintext identity token an old build left in UserDefaults, and the portal's 60-day refresh token. BackendEndpoint keeps its "portal" identifiers on purpose — the host name comes from the build setting AMDL_PORTAL_HOST, and renaming that key would silently empty the user's local Config/Portal.xcconfig. The file explains it. Verified: xcodebuild build and test both succeed on iPhone 17 (iOS 26.4.1). Signed-off-by: LYJW131 --- AGENTS.md | 44 +++ LiveActivityShared/BackendEndpoint.swift | 17 +- LiveActivityShared/MediaUserTokenStore.swift | 6 +- .../ShareViewController.swift | 47 +-- amdl-ios/AppleAuth.swift | 148 +++----- amdl-ios/ConfigAPI.swift | 12 +- amdl-ios/DebugView.swift | 15 +- amdl-ios/DownloadsAPI.swift | 22 +- amdl-ios/GatewayAuth.swift | 244 ++++++++++++ amdl-ios/JobActions.swift | 2 +- amdl-ios/LiveActivityGateway.swift | 6 +- amdl-ios/LogsAPI.swift | 2 +- amdl-ios/PortalAuth.swift | 349 ------------------ amdl-ios/amdl_iosApp.swift | 13 +- amdl-iosTests/amdl_iosTests.swift | 137 +++---- 15 files changed, 495 insertions(+), 569 deletions(-) create mode 100644 amdl-ios/GatewayAuth.swift delete mode 100644 amdl-ios/PortalAuth.swift diff --git a/AGENTS.md b/AGENTS.md index c500901..d8316df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,6 +38,50 @@ 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 expires in ten minutes + +`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. + +**An Apple identity token lives about ten minutes and there is no silent way to +mint another** — `getCredentialState` reports that the authorization still +stands, it does not issue a token. So the app re-prompts. That is a known, +accepted cost, not a bug to fix here: + +- `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. + +Expiry is read from the token's own `exp` claim, not "received + 10 min": ten +minutes is a measured value, not a contract. + +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 db5969d..0b8714f 100644 --- a/ShareDownloadExtension/ShareViewController.swift +++ b/ShareDownloadExtension/ShareViewController.swift @@ -271,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, @@ -298,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/AppleAuth.swift b/amdl-ios/AppleAuth.swift index 928a60a..85e3aa6 100644 --- a/amdl-ios/AppleAuth.swift +++ b/amdl-ios/AppleAuth.swift @@ -3,18 +3,17 @@ 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 只活约 10 分钟且无法静默续期"。 +/// 整套系统改回单用户设计时门户被删了,这条路也就跟着回到了直发 —— 连带那个 +/// 每隔十几分钟弹一次面板的代价。取舍的完整说明在 `GatewayCredential` 的注释里。 /// /// 留在 App Group 的 UserDefaults 里的只有用户 id 和邮箱——都是给界面显示"当前登录 -/// 的是谁"用的,不是凭据。**真正的凭据在 Keychain**(`PortalCredentialStore`)。 +/// 的是谁"用的,不是凭据。**真正的凭据在 Keychain**(`GatewayCredentialStore`)。 enum AppleAuthCredentialStore { private static let userIDKey = "appleUserID" private static let emailKey = "appleUserEmail" @@ -39,15 +38,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,6 +63,13 @@ enum AppleAuthCredentialStore { purgeLegacyIdentityToken() } + /// 启动时跑一次的清理:明文 plist 里的旧 token,以及门户时期那份 60 天的 + /// refresh token。两者签发方都已经不存在了。 + static func purgeRetiredCredentials() { + purgeLegacyIdentityToken() + GatewayCredentialStore.purgePortalCredentials() + } + /// 清掉旧版本留在明文 plist 里的 Apple identity token。 /// /// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了(10 分钟寿命), @@ -86,36 +93,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 +142,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 +157,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 +164,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 +197,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 +221,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..4cfe26b 100644 --- a/amdl-ios/ConfigAPI.swift +++ b/amdl-ios/ConfigAPI.swift @@ -199,7 +199,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 +218,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) } diff --git a/amdl-ios/DebugView.swift b/amdl-ios/DebugView.swift index aaf70a3..4030ef2 100644 --- a/amdl-ios/DebugView.swift +++ b/amdl-ios/DebugView.swift @@ -72,21 +72,22 @@ 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 本身,只活约十分钟,而且没有 + // 静默续期的办法。所以过期是**常态**而不是异常,界面必须直说 + // 一句,否则用户看到的只是"每隔一会儿就要重新登录一次",像是 + // 坏了。取舍的来龙去脉见 GatewayCredential 的注释。 + if !appleAuth.hasValidToken { Label { - Text("账号正在等待管理员批准。批准之后不用重新登录,直接就能用。") + Text("登录已过期,重新登录一次即可。Apple 的登录凭据只有约十分钟有效期,而且无法自动续期。") } icon: { - Image(systemName: "clock.badge.questionmark") + Image(systemName: "clock.badge.exclamationmark") } .font(.footnote) .foregroundStyle(.orange) diff --git a/amdl-ios/DownloadsAPI.swift b/amdl-ios/DownloadsAPI.swift index 3c69ebf..2179c49 100644 --- a/amdl-ios/DownloadsAPI.swift +++ b/amdl-ios/DownloadsAPI.swift @@ -946,7 +946,7 @@ enum DownloadsAPI { /// 归属和配额,`/api/v1/*` 是它对后端的镜像(形状逐字节兼容,所以下面那些 /// Codable 结构一个都不用改),`/api/gw/*` 是它自己的接口。 /// - /// 所有 /api 请求都要带门户签发的 Bearer 令牌,见 `PortalAuth.swift`。 + /// 所有 /api 请求都要带 Apple 的 identity token 作 Bearer,见 `GatewayAuth.swift`。 /// 仍然可以在「配置 → 调试」里改成别的地址。 /// /// 具体的值和存取都在 `BackendEndpoint` 里 —— 门户是唯一的源站,所以全 App @@ -1010,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 里的别的码——因为这里只对这两个码解码,别的码 @@ -1051,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) } @@ -1131,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) @@ -1144,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..c56a9f0 --- /dev/null +++ b/amdl-ios/GatewayAuth.swift @@ -0,0 +1,244 @@ +import Foundation +import Security + +/// 发给网关的凭据,**就是 Apple 自己签的那个 identity token**。 +/// +/// 网关(nginx + oauth2-proxy)开了 `--skip-jwt-bearer-tokens`,会拿 Apple 的公钥 +/// 直接验这个 token 的签名,再比一遍邮箱白名单。它不发自己的令牌、不存会话、 +/// 也不关心调用者是谁 —— 只回答"过,还是不过"。 +/// +/// ## 这里有一个已知的体验代价,不是 bug +/// +/// **Apple 的 identity token 实测只活约 10 分钟**,而且没有任何静默续期手段: +/// `getCredentialState` 只告诉你授权还在,不会签发新 token。所以 token 一过期, +/// 下一个请求就是 401,用户得重新弹一次系统登录面板。 +/// +/// 这正是 `amdl-portal` 当初存在的理由 —— 它用 identity token 换一对 +/// access/refresh(1 小时 / 60 天),App 因此能连着用两个月不弹面板。整套系统 +/// 改回单用户设计时门户被删掉了,这个代价就跟着回来了。 +/// +/// 要消掉它,只有让**服务端**签发长效令牌,而那无论写得多薄都是一层服务端会话。 +/// 那不是 App 侧能修的东西,也别在这里想办法绕 —— 唯一"能绕"的做法是把 token 存得 +/// 更久一点,而那只会让请求带着一个必定被拒的凭据出门。 +nonisolated struct GatewayCredential: Codable, Sendable { + let identityToken: String + /// 从 token 自己的 `exp` claim 解出来的到期时刻。 + /// + /// 解 JWT 而不是"收到时间 + 10 分钟":10 分钟是实测值不是契约,Apple 想改随时 + /// 可以改,而 `exp` 是这个 token 自己说的话。解不出来时按 10 分钟兜底。 + let expiresAt: Date + + /// 留 30 秒余量:请求在路上过期就是白跑一趟 401。 + var isUsable: Bool { expiresAt.timeIntervalSinceNow > 30 } + + init(identityToken: String, receivedAt: Date = Date()) { + self.identityToken = identityToken + 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/amdl_iosApp.swift b/amdl-ios/amdl_iosApp.swift index 012b017..bed5cb3 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/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 874b10c..969255a 100644 --- a/amdl-iosTests/amdl_iosTests.swift +++ b/amdl-iosTests/amdl_iosTests.swift @@ -1173,7 +1173,7 @@ struct amdl_iosTests { // MARK: - amdl-portal 会话(Milestone 7) @MainActor -struct PortalAuthTests { +struct GatewayAuthTests { /// 三条链路只剩一个可配置的地址。 /// @@ -1349,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") } } @@ -1553,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("重新")) } } From 0a4af113d01fecfa42bea3dbe771bce86ca87f8c Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 23:13:48 +0800 Subject: [PATCH 08/11] feat(shazam): refine result actions for version 3.3 Signed-off-by: LYJW131 --- amdl-ios.xcodeproj/project.pbxproj | 24 +-- amdl-ios/ShazamResultView.swift | 284 +++++++++++++++++++++++++---- 2 files changed, 258 insertions(+), 50 deletions(-) diff --git a/amdl-ios.xcodeproj/project.pbxproj b/amdl-ios.xcodeproj/project.pbxproj index 16350cf..a317ef8 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; 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.3; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.DownloadNotificationExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; 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)") From 081d4caefe1c75d95696cc94de60de121ee73f5e Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Fri, 31 Jul 2026 03:23:34 +0800 Subject: [PATCH 09/11] docs(auth): stop claiming the Apple token lasts ten minutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It lasts about a day. Reading `exp` off a real token on 2026-07-30 gave ~23.4 hours; the app's own "会话 剩余 N 分" line, which parses that claim, showed 1400+ minutes. The wrong number has a history worth keeping. This repo first said "about 24 hours", someone then "corrected" it to "about 10 minutes" and annotated the 24 hours as an observation error — and the correction was the error. It spread into four files and, worse, into a label the user reads on screen: "Apple 的登录凭据 只有约十分钟有效期". So the fix is not a better number. Nothing states a lifetime any more: - the on-screen line already shows real time remaining, parsed from `exp` - the warning text below it no longer names a duration - comments point at `exp` instead of asserting a value The 10-minute fallback in GatewayCredential.init stays, now labelled for what it is: a deliberately pessimistic floor for a token whose `exp` cannot be parsed, not an estimate of the real lifetime. This also right-sizes a product decision I had been describing wrongly. Dropping amdl-portal costs a sign-in roughly once a day, not one every ten minutes — still a regression against the portal's 60-day refresh, but nowhere near the one the old wording implied. No behaviour change: comments and one user-facing string. Signed-off-by: LYJW131 --- AGENTS.md | 21 ++++++++++++++------- amdl-ios/AppleAuth.swift | 7 ++++--- amdl-ios/DebugView.swift | 13 ++++++++----- amdl-ios/GatewayAuth.swift | 33 +++++++++++++++++++-------------- amdl-ios/amdl_iosApp.swift | 2 +- 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d8316df..bde230c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ 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 expires in ten minutes +## 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 @@ -47,10 +47,17 @@ 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. -**An Apple identity token lives about ten minutes and there is no silent way to -mint another** — `getCredentialState` reports that the authorization still -stands, it does not issue a token. So the app re-prompts. That is a known, -accepted cost, not a bug to fix here: +**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 @@ -64,8 +71,8 @@ accepted cost, not a bug to fix here: *usable*, not just present — otherwise the UI would claim you are signed in while every request 401s. -Expiry is read from the token's own `exp` claim, not "received + 10 min": ten -minutes is a measured value, not a contract. +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 diff --git a/amdl-ios/AppleAuth.swift b/amdl-ios/AppleAuth.swift index 85e3aa6..7bf2a37 100644 --- a/amdl-ios/AppleAuth.swift +++ b/amdl-ios/AppleAuth.swift @@ -8,7 +8,8 @@ import Foundation /// `--skip-jwt-bearer-tokens`,会用 Apple 公钥自己验签)。 /// /// 中间有一版不是这样:`amdl-portal` 用 identity token 换一对自己的 -/// access/refresh,为的是绕开"identity token 只活约 10 分钟且无法静默续期"。 +/// access/refresh,为的是绕开"identity token 无法静默续期"(当时以为它只活十分钟, +/// 实际约一天,见 `GatewayCredential`)。 /// 整套系统改回单用户设计时门户被删了,这条路也就跟着回到了直发 —— 连带那个 /// 每隔十几分钟弹一次面板的代价。取舍的完整说明在 `GatewayCredential` 的注释里。 /// @@ -72,7 +73,7 @@ enum AppleAuthCredentialStore { /// 清掉旧版本留在明文 plist 里的 Apple identity token。 /// - /// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了(10 分钟寿命), + /// 每次启动都跑一次,代价是两次 `removeObject`。它早就失效了, /// 所以这不是功能问题;但一份用户凭据留在会进备份的明文文件里,删掉才对。 static func purgeLegacyIdentityToken() { defaults?.removeObject(forKey: legacyTokenKey) @@ -146,7 +147,7 @@ enum AppleAuthError: LocalizedError { /// 那个 token 本身就是发给网关的凭据。 /// /// 中间有一版是两步 —— 第二步拿 identity token 去 `POST /api/gw/auth/apple/native` -/// 换门户的 access/refresh。那一步是为了绕开 identity token 只活十分钟这件事; +/// 换门户的 access/refresh。那一步是为了绕开 identity token 无法续期这件事; /// 门户删掉之后它没有了,代价见 `GatewayCredential`。 @MainActor @Observable diff --git a/amdl-ios/DebugView.swift b/amdl-ios/DebugView.swift index 4030ef2..7f1ea4c 100644 --- a/amdl-ios/DebugView.swift +++ b/amdl-ios/DebugView.swift @@ -79,13 +79,16 @@ struct DebugView: View { if appleAuth.isSignedIn { LabeledContent("账号", value: appleAuth.email ?? "已登录") LabeledContent("会话", value: appleTokenStatusText) - // 凭据是 Apple 的 identity token 本身,只活约十分钟,而且没有 - // 静默续期的办法。所以过期是**常态**而不是异常,界面必须直说 - // 一句,否则用户看到的只是"每隔一会儿就要重新登录一次",像是 - // 坏了。取舍的来龙去脉见 GatewayCredential 的注释。 + // 凭据是 Apple 的 identity token 本身,没有静默续期的办法,所以 + // 过期是**常态**而不是异常,界面要直说一句,否则用户看到的只是 + // "隔一阵就要重新登录",像是坏了。 + // + // 不要在这句话里写死时长。上面「会话」那行显示的是从 token 的 + // `exp` 解出来的真实剩余时间;写死数字正是之前出过的错——文案说 + // 十分钟,实际约一天。取舍见 GatewayCredential 的注释。 if !appleAuth.hasValidToken { Label { - Text("登录已过期,重新登录一次即可。Apple 的登录凭据只有约十分钟有效期,而且无法自动续期。") + Text("登录已过期,重新登录一次即可。Apple 的登录凭据不能自动续期,到期后需要手动登录。") } icon: { Image(systemName: "clock.badge.exclamationmark") } diff --git a/amdl-ios/GatewayAuth.swift b/amdl-ios/GatewayAuth.swift index c56a9f0..0d7184f 100644 --- a/amdl-ios/GatewayAuth.swift +++ b/amdl-ios/GatewayAuth.swift @@ -7,25 +7,29 @@ import Security /// 直接验这个 token 的签名,再比一遍邮箱白名单。它不发自己的令牌、不存会话、 /// 也不关心调用者是谁 —— 只回答"过,还是不过"。 /// -/// ## 这里有一个已知的体验代价,不是 bug +/// ## 有效期:读 token 自己说的,不要猜 /// -/// **Apple 的 identity token 实测只活约 10 分钟**,而且没有任何静默续期手段: -/// `getCredentialState` 只告诉你授权还在,不会签发新 token。所以 token 一过期, -/// 下一个请求就是 401,用户得重新弹一次系统登录面板。 +/// 到期时刻是从这个 JWT 的 `exp` claim 解出来的,不是按经验值估的。**这一点是有 +/// 来历的**:仓库里先写着「约 24 小时」,后来被"修正"成「约 10 分钟」并注明前者是 +/// 观察错误——而 2026-07-30 在真机上读出来的 `exp` 是 **约 23.4 小时**,也就是说 +/// 被改掉的那个才是对的,"修正"是错的,并且这个错在文档和界面文案里传播了一圈。 /// -/// 这正是 `amdl-portal` 当初存在的理由 —— 它用 identity token 换一对 -/// access/refresh(1 小时 / 60 天),App 因此能连着用两个月不弹面板。整套系统 -/// 改回单用户设计时门户被删掉了,这个代价就跟着回来了。 +/// 所以这里不写死任何数字。Apple 想改随时可以改,而 `exp` 是这个 token 自己说的话。 +/// +/// 实际代价:**大约一天重新登录一次**,因为没有静默续期手段 +/// (`getCredentialState` 只告诉你授权还在,不会签发新 token)。 +/// +/// 这比 `amdl-portal` 那一版(access 1 小时 / refresh 60 天、可连用两个月)仍然是 +/// 退步,但退得远没有"每十几分钟弹一次面板"那么严重——那个说法是基于上面那个错误 +/// 数字得出的。要不要为此再造一层服务端会话,是产品判断,请按一天一次来权衡。 /// -/// 要消掉它,只有让**服务端**签发长效令牌,而那无论写得多薄都是一层服务端会话。 -/// 那不是 App 侧能修的东西,也别在这里想办法绕 —— 唯一"能绕"的做法是把 token 存得 -/// 更久一点,而那只会让请求带着一个必定被拒的凭据出门。 nonisolated struct GatewayCredential: Codable, Sendable { let identityToken: String /// 从 token 自己的 `exp` claim 解出来的到期时刻。 /// - /// 解 JWT 而不是"收到时间 + 10 分钟":10 分钟是实测值不是契约,Apple 想改随时 - /// 可以改,而 `exp` 是这个 token 自己说的话。解不出来时按 10 分钟兜底。 + /// 解 JWT 而不是按经验值加一个偏移——见上面为什么。解不出来时按 10 分钟兜底, + /// 那**不是**对真实寿命的估计,而是刻意悲观:宁可早问一次,也不要带着一个已经 + /// 失效的凭据出门。 let expiresAt: Date /// 留 30 秒余量:请求在路上过期就是白跑一趟 401。 @@ -33,6 +37,7 @@ nonisolated struct GatewayCredential: Codable, Sendable { init(identityToken: String, receivedAt: Date = Date()) { self.identityToken = identityToken + // 兜底 10 分钟是刻意保守的下限,不是观测值;见 expiresAt 的注释。 self.expiresAt = Self.expiry(ofJWT: identityToken) ?? receivedAt.addingTimeInterval(600) } @@ -58,8 +63,8 @@ nonisolated struct GatewayCredential: Codable, Sendable { /// 凭据的持久化。 /// /// **放在 Keychain 而不是 App Group 的 UserDefaults**:UserDefaults 的 plist 是明文、 -/// 会进 iTunes/iCloud 备份、也没有"设备解锁后才可读"这种保护。这个 token 只活十分钟, -/// 危害确实比一份 60 天的 refresh token 小,但十分钟里它就是这套部署的通行证 —— +/// 会进 iTunes/iCloud 备份、也没有"设备解锁后才可读"这种保护。这个 token 大约活一天, +/// 危害比一份 60 天的 refresh token 小,但这一天里它就是这套部署的通行证 —— /// 而且更实际的理由是:早先版本正是把它明文存在 UserDefaults 里,那是个被专门修掉的 /// 问题,不该因为门户没了就退回去。`AppleAuthCredentialStore.purgeLegacyIdentityToken()` /// 每次启动还在清那份旧的。 diff --git a/amdl-ios/amdl_iosApp.swift b/amdl-ios/amdl_iosApp.swift index bed5cb3..9949dfe 100644 --- a/amdl-ios/amdl_iosApp.swift +++ b/amdl-ios/amdl_iosApp.swift @@ -33,7 +33,7 @@ struct amdl_iosApp: App { // // 这里以前问的是门户"我这个账号被批准了没有"。没有账号可问了, // 但**有一件事必须在用户动手之前知道**:手上这份 token 还能不能 - // 用。它只活约十分钟,所以"上次用还好好的"完全不说明问题,而 + // 用。它不能续期、大约一天就到期,所以"上次用还好好的"不说明问题,而 // 没有这一下,用户会在第一次提交下载失败时才发现要重新登录。 AppleAuthStore.shared.refreshFromStore() } From 3d1415f18935ed88fd701d7772f8228baee5d357 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Fri, 31 Jul 2026 12:04:26 +0800 Subject: [PATCH 10/11] feat(config): sync MusicKit token on app activation Co-authored-by: Codex Signed-off-by: LYJW131 --- amdl-ios/AppDelegate.swift | 11 ++-- amdl-ios/ConfigAPI.swift | 34 +++++++++++ amdl-ios/CreateDownloadIntent.swift | 60 ++++++++++++++++++ amdl-ios/DebugView.swift | 61 ++++++++++++++++++- amdl-ios/RadioView.swift | 11 ++-- .../MediaUserTokenSharingTests.swift | 37 +++++++++++ 6 files changed, 203 insertions(+), 11 deletions(-) diff --git a/amdl-ios/AppDelegate.swift b/amdl-ios/AppDelegate.swift index e799e72..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() } } diff --git a/amdl-ios/ConfigAPI.swift b/amdl-ios/ConfigAPI.swift index 4cfe26b..0ab71f8 100644 --- a/amdl-ios/ConfigAPI.swift +++ b/amdl-ios/ConfigAPI.swift @@ -188,6 +188,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 / @@ -239,6 +265,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/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 7f1ea4c..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() @@ -123,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() @@ -153,6 +175,10 @@ struct DebugView: View { .font(.footnote) .foregroundStyle(.red) } + } header: { + Text("Apple Music") + } footer: { + Text("开启后会立即同步一次;此后每次启动或回到 App,都获取最新 Music-User-Token 并写入后端配置文件。关闭不会清除后端已有令牌。") } Section("缓存") { @@ -164,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() @@ -209,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/RadioView.swift b/amdl-ios/RadioView.swift index cd465ee..b6743ea 100644 --- a/amdl-ios/RadioView.swift +++ b/amdl-ios/RadioView.swift @@ -481,13 +481,11 @@ private struct SimulatePage: View { // MARK: - 表单模型 /// 配置页的可编辑视图模型。字段均为非可选,缺省值取后端 Default(); -/// 从后端读到的值覆盖缺省,保存时回写为完整 RuntimeConfig。 +/// 从后端读到的值覆盖缺省,保存时回写可编辑配置。media-user-token 由启动同步 +/// 独立管理,不进入这份表单。 struct ConfigForm: Equatable { // catalog var albumTrackURLMode = "song" - /// App 里不再提供编辑入口,但仍随读取的值原样回写,避免保存时把后端上 - /// 已配置的 token 清空。 - var mediaUserToken = "" var signedModeHLSSource = "wrapper" // download @@ -538,7 +536,6 @@ struct ConfigForm: Equatable { 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 { @@ -604,7 +601,9 @@ struct ConfigForm: Equatable { RuntimeConfig( catalog: CatalogConfig( albumTrackURLMode: albumTrackURLMode, - mediaUserToken: mediaUserToken, + // token 由专门的启动同步请求管理。普通配置保存必须省略它,否则页面 + // 加载时缓存的旧值会在用户改别的设置时把刚同步的新值覆盖回去。 + mediaUserToken: nil, signedModeHLSSource: signedModeHLSSource ), download: DownloadConfig( 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() { From e998647a6517bfeee2b2ba898f76f4a26c72646b Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Fri, 31 Jul 2026 13:32:24 +0800 Subject: [PATCH 11/11] feat(config): add the library sync settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the backend's new library_sync runtime config: a toggle and a poll interval, under 后端 in the settings list. The backend polls the signed-in Apple Music library and submits the albums of newly added songs, so a song saved on the phone downloads on the NAS without a manual submit. Only send the section when the backend returned it. PUT /api/v1/config rejects unknown fields with 400, so an unconditional library_sync would make every settings save fail against a backend that predates the feature — and the failure would land on whichever setting the user actually came to change. Without backend support the row reads 后端不支持 and the toggle is disabled, rather than showing an inviting 已关闭 that silently discards the change. Interval is validated locally against the same 1..1440 range the backend enforces, and only when the section will actually be sent. Bump MARKETING_VERSION to 3.4. Signed-off-by: LYJW131 --- amdl-ios.xcodeproj/project.pbxproj | 24 ++++---- amdl-ios/ConfigAPI.swift | 22 +++++++ amdl-ios/RadioView.swift | 94 +++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 13 deletions(-) diff --git a/amdl-ios.xcodeproj/project.pbxproj b/amdl-ios.xcodeproj/project.pbxproj index a317ef8..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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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.3; + 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/ConfigAPI.swift b/amdl-ios/ConfigAPI.swift index 0ab71f8..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 { diff --git a/amdl-ios/RadioView.swift b/amdl-ios/RadioView.swift index b6743ea..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,6 +486,17 @@ 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(); @@ -530,6 +549,14 @@ 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() {} @@ -575,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) } @@ -594,6 +626,11 @@ struct ConfigForm: Equatable { return "模拟最大速度需 ≥ 最小速度。" } } + // 与后端 config.Validate 的区间一致,先在本地拦下,免得白跑一次 422。 + // 只在这一段会被发出去时才校验——不支持时它根本不进请求体。 + if librarySyncSupported && (librarySyncIntervalMinutes < 1 || librarySyncIntervalMinutes > 1440) { + return "资料库同步间隔需在 1–1440 分钟之间。" + } return nil } @@ -639,7 +676,14 @@ struct ConfigForm: Equatable { enabled: simulateEnabled, minSpeedKbps: simulateMinKbps, maxSpeedKbps: simulateMaxKbps - ) + ), + // 见 librarySyncSupported:旧后端会把整个请求以 400 拒掉。 + librarySync: librarySyncSupported + ? LibrarySyncConfig( + enabled: librarySyncEnabled, + intervalMinutes: librarySyncIntervalMinutes + ) + : nil ) } } @@ -696,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: - 选项常量 @@ -1035,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