From f124088ddd4162a2635b47271f3443ac9c13e6dd Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 10:51:39 +0800 Subject: [PATCH 1/6] fix(artwork): stop the cover slot's size depending on what is in it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slot was a ZStack in which nothing claimed space: the placeholder is built on a GeometryReader and the image renders Color.clear until a UIImage is decoded, and both are fully flexible. The list row escaped only because its caller happens to pin a 56pt frame; the detail hero, which has an aspectRatio and no frame, got its size negotiated from whatever the content currently was. The placeholder is now a Rectangle that sizes the slot and the cover moved into an .overlay, which is sized by its parent and never contributes to it. Color.clear, a thumbnail, a 1620px cover and nothing at all now all produce the identical slot. Ordering is unchanged — placeholder underneath, image on top, Color.clear until the bytes are actually decoded, so the slot still never flashes white — and the 0.25s fade and its reduceMotion guard are untouched. Signed-off-by: LYJW131 --- amdl-ios/DownloadArtworkView.swift | 46 +++++++++++++++++++----------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/amdl-ios/DownloadArtworkView.swift b/amdl-ios/DownloadArtworkView.swift index 4559c99..d7f6e28 100644 --- a/amdl-ios/DownloadArtworkView.swift +++ b/amdl-ios/DownloadArtworkView.swift @@ -78,23 +78,35 @@ struct JobArtworkView: View { } var body: some View { - ZStack { - // 占位一直垫在最底下,`CachedAsyncImage` 在真正解出 UIImage 之前画的是 - // Color.clear —— 这样封面槽位从头到尾都有东西,不会先闪一下白底。 - JobArtworkPlaceholder(job: job) - - // 即便 fallbackURL 还没解析出来,只要本地缓存里有这个 key 的图, - // CachedAsyncImage 就会优先加载缓存,不会用过期 URL 覆盖当前画面。 - CachedAsyncImage( - url: privateRequest == nil ? primaryURL : fallbackURL, - cacheKey: cacheKey, - fallbackCacheKey: fallbackCacheKey - ) - .id("\(cacheKey)|\(artworkRevision)") - } - .task(id: "\(job.id)|\(job.artworkURL ?? "")|\(pixelSize)") { - await loadPrivatePlaylistArtwork() - } + // 槽位大小只跟父视图给的建议有关,跟里面装的是什么无关。 + // + // 以前这里是 ZStack 直接摞占位图和 `CachedAsyncImage`,两层谁都不声明尺寸: + // 图解出来之前画的是 Color.clear,解出来之后理想尺寸又变成封面的原始像素, + // 于是槽位多大是跟内容协商出来的。列表行只是碰巧写死了 56×56 才一直稳, + // 详情页 Hero 只约束了比例不约束大小,换图、`.id` 重建、压根没有封面,都可能 + // 让它跟着动一下。现在由占位图(一个 Shape,尺寸只认建议)撑出正方形槽位, + // 封面放进 overlay —— overlay 不参与布局,谁都顶不动它。 + // + // 顺带保住原来的层次:占位一直垫在最底下,`CachedAsyncImage` 在真正解出 + // UIImage 之前画的是 Color.clear,封面槽位从头到尾都有东西,不会闪白底。 + JobArtworkPlaceholder(job: job) + .aspectRatio(1, contentMode: .fit) + .overlay { + // 即便 fallbackURL 还没解析出来,只要本地缓存里有这个 key 的图, + // CachedAsyncImage 就会优先加载缓存,不会用过期 URL 覆盖当前画面。 + CachedAsyncImage( + url: privateRequest == nil ? primaryURL : fallbackURL, + cacheKey: cacheKey, + fallbackCacheKey: fallbackCacheKey + ) + .id("\(cacheKey)|\(artworkRevision)") + } + // scaledToFill 出来的图可能比槽位大一圈,就地裁掉溢出的部分;圆角仍然 + // 由调用方给(列表 12pt、详情页 10pt),这里只负责不画到槽位外面去。 + .clipped() + .task(id: "\(job.id)|\(job.artworkURL ?? "")|\(pixelSize)") { + await loadPrivatePlaylistArtwork() + } } private func loadPrivatePlaylistArtwork() async { From 47071c397b050f059ed9eca044eb9cd8d091ecf0 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 10:51:39 +0800 Subject: [PATCH 2/6] fix(detail): don't interpolate the layout when metadata arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner reported the cover shrinking and then growing on a job detail page. Measured from a screen recording: the artwork took 13 distinct geometries during the transition, its bottom edge moving 1187 → 1145 → 1187 device px, and everything under it slid through 24 positions — 56pt of continuous travel over about 0.4s. The cause was not the artwork and not the image load. `.animation( .easeInOut(duration: 0.5), value: palette)` covers the whole content subtree, and the palette resolves in the *same* state update that brings the title, the track count and the item list. SwiftUI saw one view whose height went from A to B and interpolated it, geometry included. Giving that content an identity keyed on whether metadata has arrived, with an opacity transition, makes the layout settle instantly while the two versions cross-fade in place. Measured after: one geometry for the artwork in every frame, and the block below it holds exactly two positions — the old one fading out where it was, the new one fading in where it belongs. **No layout value changed.** No frame, padding, spacing, height, line limit or alignment was added, removed or altered; the two identities render the same view with the same arguments. The cross-fade is deliberately kept — it was an explicit request — and `.animation(..., value: palette)` is untouched. Side effect worth knowing: the identity change rebuilds the subtree once at resolution, so scroll returns to top. The waiting page has nothing to scroll, and a job that is already resolved when opened gets no transition at all. Signed-off-by: LYJW131 --- amdl-ios/DownloadDetailView.swift | 118 +++++++++++++++++++++--------- 1 file changed, 82 insertions(+), 36 deletions(-) diff --git a/amdl-ios/DownloadDetailView.swift b/amdl-ios/DownloadDetailView.swift index 0850fcf..e599777 100644 --- a/amdl-ios/DownloadDetailView.swift +++ b/amdl-ios/DownloadDetailView.swift @@ -34,9 +34,8 @@ struct DownloadDetailView: View { @State private var lastEventID: Int64 = 0 @State private var presentedQualityDetails: AudioQualityPresentation.Details? @State private var isShowingInfo = false + @State private var isShowingRealtimeSpeed = false @State private var speedTracker = TaskSpeedTracker() - @AppStorage("downloadDetail.showsRealtimeSpeed") - private var showsRealtimeSpeed = false private var job: Job? { detail?.job ?? initialJob } @@ -53,10 +52,6 @@ struct DownloadDetailView: View { detail?.hooks ?? [] } - private var speedPresentation: TaskSpeedPresentation? { - showsRealtimeSpeed ? speedTracker.presentation : nil - } - private var appleMusicURL: URL? { guard let input = job?.input, let url = URL(string: input), @@ -93,31 +88,37 @@ struct DownloadDetailView: View { return !job.status.isActive && !hooks.contains { $0.isActive } } + /// 后端解析 input 之前,任务上只有一条链接:没有标题、没有曲目数、没有封面 + /// 配色,也没有逐曲清单。解析前后**是两份内容**,不是同一份内容长高了。 + /// + /// 进了终态就不再算「等解析」:解析失败的任务永远等不到标题,它得照常显示自己 + /// 的错误,而不是停在另一份内容上。 + private var isAwaitingMetadata: Bool { + guard let job else { return true } + guard job.status.isActive else { return false } + let hasTitle = !(job.title ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .isEmpty + return !hasTitle && job.totalItems == 0 + } + var body: some View { ZStack { - if job?.type == .song { - DownloadSongDetailContent( - job: job, - items: items, - progress: progress, - speed: speedPresentation, - errorMessage: errorMessage, - palette: palette, - presentedQualityDetails: $presentedQualityDetails - ) - } else { - DownloadTrackListView( - job: job, - items: items, - progress: progress, - speed: speedPresentation, - isLoading: isLoading, - hasLoadedDetail: detail != nil, - errorMessage: errorMessage, - palette: palette, - presentedQualityDetails: $presentedQualityDetails - ) - } + detailContent + // 元数据到达那一刻,标题从链接换成真标题(一行变两行)、`0/0` 变成 + // `0/48`、「暂无轨道明细」变成整张曲目表 —— 而封面配色也正好在同一次 + // 更新里从无到有。于是下面那条 `.animation(value: palette)` 把这次 + // 内容替换一并接管了:SwiftUI 找不到「两份内容」,只看见同一个视图的 + // 高度从 A 变成 B,就去补间这个差值。封面以下的一切在那半秒里连续 + // 滑动,两套版式还同时挂在屏幕上(`已完成 0/0` 和 `已完成 0/48` 相隔 + // 一行地一起显影)。 + // + // 给两种状态各自的身份 + 纯不透明度过渡之后,就没有任何几何量可补间 + // 了:旧的那份在原地淡出,新的那份从第一帧起就待在自己的最终位置上 + // 淡入。ZStack 让两份重叠共存、谁都不参与对方的布局,所以淡入淡出还 + // 在,版式本身一个数都没动。 + .id(isAwaitingMetadata) + .transition(.opacity) } .background { if let palette { @@ -140,7 +141,9 @@ struct DownloadDetailView: View { job: job, taskURL: appleMusicURL, runner: actionRunner, - showsRealtimeSpeed: $showsRealtimeSpeed, + showRealtimeSpeed: { + isShowingRealtimeSpeed = true + }, showInfo: { isShowingInfo = true }, onOutcome: handle(outcome:) ) @@ -164,6 +167,14 @@ struct DownloadDetailView: View { DownloadDetailInfoView(job: job, items: items, hooks: hooks) } } + .sheet(isPresented: $isShowingRealtimeSpeed) { + DownloadTaskSpeedView(speed: speedTracker.presentation) + } + .onChange(of: job?.status.isActive) { _, isActive in + guard isActive != true else { return } + isShowingRealtimeSpeed = false + speedTracker.reset() + } .alert(item: $presentedQualityDetails) { details in Alert( title: Text(details.title), @@ -175,6 +186,33 @@ struct DownloadDetailView: View { .swAlert() } + /// 页面主体。原样从 `body` 里搬出来,只为让 `.id` / `.transition` 有个落点; + /// 视图本身和它们收到的参数一个字都没改。 + @ViewBuilder + private var detailContent: some View { + if job?.type == .song { + DownloadSongDetailContent( + job: job, + items: items, + progress: progress, + errorMessage: errorMessage, + palette: palette, + presentedQualityDetails: $presentedQualityDetails + ) + } else { + DownloadTrackListView( + job: job, + items: items, + progress: progress, + isLoading: isLoading, + hasLoadedDetail: detail != nil, + errorMessage: errorMessage, + palette: palette, + presentedQualityDetails: $presentedQualityDetails + ) + } + } + /// 动作成功之后详情页要做的事。 private func handle(outcome: JobActionOutcome) { switch outcome { @@ -229,7 +267,7 @@ struct DownloadDetailView: View { cached.job.preservePresentationMetadata(from: initialJob) } detail = cached - speedTracker.update(with: cached.items) + updateSpeedTracker(with: cached.items) lastEventID = max(lastEventID, cached.lastEventID ?? 0) } let hasCache = detail != nil @@ -268,7 +306,7 @@ struct DownloadDetailView: View { lastEventID = event.id guard detail?.apply(event) == true else { continue } if let detail { - speedTracker.update(with: detail.items) + updateSpeedTracker(with: detail.items) } if event.requiresDetailSnapshotRefresh { @@ -321,7 +359,7 @@ struct DownloadDetailView: View { snapshot.job.preservePresentationMetadata(from: initialJob) } detail = snapshot - speedTracker.update(with: snapshot.items) + updateSpeedTracker(with: snapshot.items) await DownloadLiveActivityManager.shared.refreshFromDetail(snapshot) guard !Task.isCancelled else { return } lastEventID = max(lastEventID, snapshotEventID) @@ -348,13 +386,19 @@ struct DownloadDetailView: View { } } } + + /// 只在当前详情页生命周期内保留一小段趋势。关闭弹窗后继续更新这份临时内存, + /// 再次打开可以直接续上;不会写入 UserDefaults、数据库或磁盘。 + private func updateSpeedTracker(with items: [JobItem]) { + speedTracker.update(with: items) + } } private struct DownloadDetailLinkActions: View { let job: Job let taskURL: URL? let runner: JobActionRunner - @Binding var showsRealtimeSpeed: Bool + let showRealtimeSpeed: () -> Void let showInfo: () -> Void let onOutcome: (JobActionOutcome) -> Void @@ -382,8 +426,10 @@ private struct DownloadDetailLinkActions: View { Label("详细信息", systemImage: "info.circle") } - Toggle(isOn: $showsRealtimeSpeed) { - Label("实时速度显示", systemImage: "chart.xyaxis.line") + if job.status.isActive { + Button(action: showRealtimeSpeed) { + Label("实时速度显示", systemImage: "chart.xyaxis.line") + } } // 可用动作跟着 job.status 走,而 job 是详情页那份被事件流实时更新的 From e217892cfca28eeaae5c8c02cc583f915bd8f800 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 10:51:39 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(detail):=20=E6=98=BE=E7=A4=BA=E6=AD=A3?= =?UTF-8?q?=E5=9C=A8=E4=B8=8B=E8=BD=BD=EF=BC=8F=E8=A7=A3=E5=AF=86=E7=9A=84?= =?UTF-8?q?=E6=9B=B2=E7=9B=AE=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TaskSpeedTracker 现在除速率外还统计处于下载中与解密中的曲目数,摘要页 把它们显示在对应的速率旁边——单看速率无法区分「一首在快速传输」和 「多首各自很慢」,而这两种情况的处理方式完全不同。 这部分是既有的在途改动,本次随修复一并发布。 Signed-off-by: LYJW131 --- amdl-ios/DownloadDetailSummaryView.swift | 297 +++++++++++++++++------ amdl-ios/DownloadTrackListView.swift | 2 - amdl-ios/DownloadsAPI.swift | 16 +- amdl-ios/TaskSpeedTracker.swift | 114 +++++++-- amdl-iosTests/amdl_iosTests.swift | 41 ++++ 5 files changed, 377 insertions(+), 93 deletions(-) diff --git a/amdl-ios/DownloadDetailSummaryView.swift b/amdl-ios/DownloadDetailSummaryView.swift index 381cbde..004099f 100644 --- a/amdl-ios/DownloadDetailSummaryView.swift +++ b/amdl-ios/DownloadDetailSummaryView.swift @@ -9,7 +9,6 @@ struct DownloadSongDetailContent: View { let job: Job? let items: [JobItem] let progress: Double - var speed: TaskSpeedPresentation? let errorMessage: String? let palette: DownloadDetailPalette? @Binding var presentedQualityDetails: AudioQualityPresentation.Details? @@ -30,7 +29,6 @@ struct DownloadSongDetailContent: View { job: job, items: items, progress: progress, - speed: speed, albumTracksOmitSubtitles: false, palette: palette, presentedQualityDetails: $presentedQualityDetails @@ -49,7 +47,6 @@ struct DownloadDetailSummaryView: View { let job: Job let items: [JobItem] let progress: Double - var speed: TaskSpeedPresentation? let albumTracksOmitSubtitles: Bool let palette: DownloadDetailPalette? @Binding var presentedQualityDetails: AudioQualityPresentation.Details? @@ -170,12 +167,6 @@ struct DownloadDetailSummaryView: View { .font(.caption.monospacedDigit()) .foregroundStyle(palette?.secondaryText ?? Color.secondary) - if let speed, job.status.isActive { - TaskSpeedReadout( - speed: speed, - color: palette?.secondaryText ?? Color.secondary - ) - } } .padding(.horizontal, 24) .padding(.top, 8) @@ -284,6 +275,23 @@ struct DownloadDetailSummaryView: View { } } +struct DownloadTaskSpeedView: View { + let speed: TaskSpeedPresentation + + var body: some View { + NavigationStack { + ScrollView { + TaskSpeedReadout(speed: speed, color: .secondary) + .padding(20) + } + .navigationTitle("实时速度") + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } +} + private struct TaskSpeedReadout: View { let speed: TaskSpeedPresentation let color: Color @@ -292,90 +300,243 @@ private struct TaskSpeedReadout: View { private let decryptColor = Color.orange var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 22) { HStack(spacing: 16) { - Label( - "下载 \(TransferSpeedFormat.string(bytesPerSecond: speed.downloadBytesPerSecond))", - systemImage: "arrow.down" - ) - .foregroundStyle(downloadColor) - .accessibilityLabel( - "下载速度 \(TransferSpeedFormat.string(bytesPerSecond: speed.downloadBytesPerSecond))" + speedMetric( + title: "下载", + value: TransferSpeedFormat.string( + bytesPerSecond: speed.downloadBytesPerSecond + ), + countLabel: "正在下载曲目数", + count: speed.downloadingItemCount, + systemImage: "arrow.down", + tint: downloadColor ) - Label( - "解密 \(TransferSpeedFormat.string(bytesPerSecond: speed.decryptBytesPerSecond))", - systemImage: "lock.open" - ) - .foregroundStyle(decryptColor) - .accessibilityLabel( - "解密速度 \(TransferSpeedFormat.string(bytesPerSecond: speed.decryptBytesPerSecond))" + Divider() + .frame(height: 36) + + speedMetric( + title: "解密", + value: TransferSpeedFormat.string( + bytesPerSecond: speed.decryptBytesPerSecond + ), + countLabel: "正在解密曲目数", + count: speed.decryptingItemCount, + systemImage: "lock.open", + tint: decryptColor ) } - TaskSpeedTrendChart( - points: speed.history, - downloadColor: downloadColor, - decryptColor: decryptColor - ) - .frame(height: 44) - .accessibilityLabel("下载与解密速度变化趋势") - .accessibilityValue( - "下载 \(TransferSpeedFormat.string(bytesPerSecond: speed.downloadBytesPerSecond))," - + "解密 \(TransferSpeedFormat.string(bytesPerSecond: speed.decryptBytesPerSecond))" + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("最近速度变化") + .font(.caption) + .fontWeight(.medium) + + Spacer() + + Text("MB/s") + .font(.caption2.monospacedDigit()) + } + .foregroundStyle(color) + + SWTaskSpeedLineChart( + points: speed.history, + downloadColor: downloadColor, + decryptColor: decryptColor, + axisColor: color ) + .frame(height: 190) + .accessibilityLabel("下载与解密速度折线趋势") + .accessibilityValue( + "下载 \(TransferSpeedFormat.string(bytesPerSecond: speed.downloadBytesPerSecond))," + + "解密 \(TransferSpeedFormat.string(bytesPerSecond: speed.decryptBytesPerSecond))" + ) + } } .font(.caption.monospacedDigit()) .foregroundStyle(color) - .padding(.top, 2) + } + + private func speedMetric( + title: String, + value: String, + countLabel: String, + count: Int, + systemImage: String, + tint: Color + ) -> some View { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.caption.bold()) + .frame(width: 26, height: 26) + .background(tint.opacity(0.12), in: Circle()) + + Text(title) + .fontWeight(.semibold) + .fixedSize(horizontal: true, vertical: false) + + Text(value) + .fontWeight(.semibold) + .contentTransition(.numericText()) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.75) + + HStack(spacing: 4) { + Text(countLabel) + Text(count, format: .number) + .fontWeight(.semibold) + .contentTransition(.numericText()) + } + .font(.caption2.monospacedDigit()) + .foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(title)速度 \(value),\(countLabel) \(count)") } } -private struct TaskSpeedTrendChart: View { +/// 取自 ShipSwift 多序列折线图配方的紧凑任务趋势版本:下载和解密共用纵轴, +/// 分别使用蓝、橙两条折线展示最近一段时间的速度变化。 +private struct SWTaskSpeedLineChart: View { let points: [TaskSpeedPoint] let downloadColor: Color let decryptColor: Color + let axisColor: Color + + private var maximumMBps: Double { + let largest = points.reduce(0.0) { + max( + $0, + max( + $1.downloadBytesPerSecond / 1_000_000, + $1.decryptBytesPerSecond / 1_000_000 + ) + ) + } + guard largest > 0 else { return 1 } + let magnitude = pow(10, floor(log10(largest))) + let normalized = largest / magnitude + let rounded: Double + switch normalized { + case ...1: + rounded = 1 + case ...2: + rounded = 2 + case ...5: + rounded = 5 + default: + rounded = 10 + } + return rounded * magnitude + } var body: some View { - Canvas { context, size in - let downloadValues = points.map(\.downloadBytesPerSecond) - let decryptValues = points.map(\.decryptBytesPerSecond) - guard points.count >= 2 else { - return + HStack(spacing: 6) { + VStack(alignment: .trailing, spacing: 0) { + Text(axisLabel(maximumMBps)) + Spacer() + Text(axisLabel(maximumMBps / 2)) + Spacer() + Text("0.0") } - let maximum = max(downloadValues.max() ?? 0, decryptValues.max() ?? 0) - guard maximum > 0 else { return } - - let horizontalStep = size.width / CGFloat(points.count - 1) - let topInset: CGFloat = 2 - let chartHeight = max(size.height - topInset - 2, 1) - - func line(for values: [Double]) -> Path { - let chartPoints = values.enumerated().map { index, value in - CGPoint( - x: CGFloat(index) * horizontalStep, - y: topInset + chartHeight * (1 - value / maximum) + .font(.caption2.monospacedDigit()) + .foregroundStyle(axisColor.opacity(0.8)) + .frame(width: 38) + + Canvas { context, size in + for fraction in [0.0, 0.5, 1.0] { + let y = size.height * (1 - fraction) + var gridLine = Path() + gridLine.move(to: CGPoint(x: 0, y: y)) + gridLine.addLine(to: CGPoint(x: size.width, y: y)) + context.stroke( + gridLine, + with: .color(axisColor.opacity(0.14)), + lineWidth: 0.5 ) } - var path = Path() - path.move(to: chartPoints[0]) - for point in chartPoints.dropFirst() { - path.addLine(to: point) - } - return path + + guard !points.isEmpty else { return } + drawLine( + values: points.map(\.downloadBytesPerSecond), + maximum: maximumMBps, + size: size, + color: downloadColor, + context: &context + ) + drawLine( + values: points.map(\.decryptBytesPerSecond), + maximum: maximumMBps, + size: size, + color: decryptColor, + context: &context + ) } + } + .animation(.easeOut(duration: 0.2), value: points) + } - context.stroke( - line(for: downloadValues), - with: .color(downloadColor), - style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round) - ) - context.stroke( - line(for: decryptValues), - with: .color(decryptColor), - style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round) + private func axisLabel(_ value: Double) -> String { + String( + format: "%.1f", + locale: Locale(identifier: "en_US_POSIX"), + value + ) + } + + private func drawLine( + values: [Double], + maximum: Double, + size: CGSize, + color: Color, + context: inout GraphicsContext + ) { + guard !values.isEmpty, maximum > 0 else { return } + let step = values.count > 1 + ? (size.width - 8) / CGFloat(values.count - 1) + : 0 + let chartPoint: (Int, Double) -> CGPoint = { index, bytesPerSecond in + let megabytesPerSecond = bytesPerSecond / 1_000_000 + return CGPoint( + x: values.count > 1 ? 4 + CGFloat(index) * step : size.width / 2, + y: 4 + (size.height - 8) + * (1 - CGFloat(min(megabytesPerSecond / maximum, 1))) ) } + + var path = Path() + for (index, value) in values.enumerated() { + let point = chartPoint(index, value) + if index == 0 { + path.move(to: point) + } else { + path.addLine(to: point) + } + } + context.stroke( + path, + with: .color(color), + style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round) + ) + + guard let lastValue = values.last else { return } + let lastPoint = chartPoint(values.count - 1, lastValue) + context.fill( + Path(ellipseIn: CGRect( + x: lastPoint.x - 2, + y: lastPoint.y - 2, + width: 4, + height: 4 + )), + with: .color(color) + ) } } diff --git a/amdl-ios/DownloadTrackListView.swift b/amdl-ios/DownloadTrackListView.swift index 812eb77..61827ae 100644 --- a/amdl-ios/DownloadTrackListView.swift +++ b/amdl-ios/DownloadTrackListView.swift @@ -27,7 +27,6 @@ struct DownloadTrackListView: View { let job: Job? let items: [JobItem] let progress: Double - var speed: TaskSpeedPresentation? let isLoading: Bool let hasLoadedDetail: Bool let errorMessage: String? @@ -85,7 +84,6 @@ struct DownloadTrackListView: View { job: job, items: items, progress: progress, - speed: speed, albumTracksOmitSubtitles: albumTracksOmitSubtitles, palette: palette, presentedQualityDetails: $presentedQualityDetails diff --git a/amdl-ios/DownloadsAPI.swift b/amdl-ios/DownloadsAPI.swift index 4637416..10c02d3 100644 --- a/amdl-ios/DownloadsAPI.swift +++ b/amdl-ios/DownloadsAPI.swift @@ -239,7 +239,9 @@ struct Job: Codable, Identifiable { enum JobItemStatus: String, Codable { case queued case resolving + case waitingDownload = "waiting_download" case downloading + case waitingDecrypt = "waiting_decrypt" case decrypting case remuxing case tagging @@ -251,7 +253,8 @@ enum JobItemStatus: String, Codable { var isActive: Bool { switch self { - case .queued, .resolving, .downloading, .decrypting, .remuxing, .tagging, .saving: + case .queued, .resolving, .waitingDownload, .downloading, + .waitingDecrypt, .decrypting, .remuxing, .tagging, .saving: true case .completed, .failed, .skippedExisting, .cancelled: false @@ -264,6 +267,8 @@ enum JobItemStatus: String, Codable { "clock" case .resolving: "magnifyingglass" + case .waitingDownload, .waitingDecrypt: + "hourglass" case .downloading: "arrow.down.circle.fill" case .decrypting: @@ -289,6 +294,8 @@ enum JobItemStatus: String, Codable { .secondary case .resolving, .downloading, .decrypting, .remuxing, .tagging, .saving: .blue + case .waitingDownload, .waitingDecrypt: + .secondary case .completed, .skippedExisting: .green case .failed: @@ -304,8 +311,12 @@ enum JobItemStatus: String, Codable { "排队中" case .resolving: "解析中" + case .waitingDownload: + "等待下载" case .downloading: "下载中" + case .waitingDecrypt: + "等待解密" case .decrypting: "解密中" case .remuxing: @@ -494,6 +505,9 @@ struct JobItem: Codable, Identifiable { if status == .failed, let error, !error.isEmpty { return error } + if status == .waitingDownload || status == .waitingDecrypt { + return status.text + } // 后端快照在终态仍保留最后一条阶段消息(如 "ALAC download completed"), // 刷新后会把本地化的「已完成」顶掉;status_message 只在活跃阶段展示。 if status.isActive, let statusMessage, !statusMessage.isEmpty { diff --git a/amdl-ios/TaskSpeedTracker.swift b/amdl-ios/TaskSpeedTracker.swift index 8d15e1c..1668163 100644 --- a/amdl-ios/TaskSpeedTracker.swift +++ b/amdl-ios/TaskSpeedTracker.swift @@ -17,6 +17,8 @@ struct TaskSpeedPoint: Identifiable, Equatable { struct TaskSpeedPresentation: Equatable { let downloadBytesPerSecond: Double let decryptBytesPerSecond: Double + let downloadingItemCount: Int + let decryptingItemCount: Int let history: [TaskSpeedPoint] } @@ -29,12 +31,16 @@ struct TaskSpeedPresentation: Equatable { struct TaskSpeedTracker { private(set) var downloadBytesPerSecond: Double = 0 private(set) var decryptBytesPerSecond: Double = 0 + private(set) var downloadingItemCount = 0 + private(set) var decryptingItemCount = 0 private(set) var history: [TaskSpeedPoint] = [] var presentation: TaskSpeedPresentation { TaskSpeedPresentation( downloadBytesPerSecond: downloadBytesPerSecond, decryptBytesPerSecond: decryptBytesPerSecond, + downloadingItemCount: downloadingItemCount, + decryptingItemCount: decryptingItemCount, history: history ) } @@ -67,14 +73,26 @@ struct TaskSpeedTracker { private var decryptSamples: [String: [Sample]] = [:] private var downloadSpeeds: [String: Double] = [:] private var decryptSpeeds: [String: Double] = [:] + private var downloadActivityTimes: [String: Date] = [:] + private var decryptActivityTimes: [String: Date] = [:] + private var hasObservedExplicitWaitingState = false private var nextHistoryID = 0 private var lastHistoryTime: Date? mutating func update(with items: [JobItem]) { + downloadingItemCount = 0 + decryptingItemCount = 0 guard let referenceTime = items.map(\.updatedAt).max() else { return } + if items.contains(where: { + $0.status == .waitingDownload || $0.status == .waitingDecrypt + }) { + hasObservedExplicitWaitingState = true + } let fallbackBitrate = TrackSizeEstimator.representativeBitrate(for: items) var downloadingIDs: Set = [] var decryptingIDs: Set = [] + var incompleteDownloadIDs: Set = [] + var incompleteDecryptIDs: Set = [] for item in items { guard let totalBytes = TrackSizeEstimator.estimatedBytes( @@ -87,22 +105,38 @@ struct TaskSpeedTracker { switch item.status { case .downloading: downloadingIDs.insert(item.id) - Self.ingest( + let progress = Self.clamped(item.progress.download) + if progress < 1 { + incompleteDownloadIDs.insert(item.id) + } + if Self.ingest( id: item.id, - bytes: Self.clamped(item.progress.download) * totalBytes, + bytes: progress * totalBytes, time: item.updatedAt, samples: &downloadSamples, speeds: &downloadSpeeds - ) + ), progress < 1 { + downloadActivityTimes[item.id] = item.updatedAt + } else if progress >= 1 { + downloadActivityTimes[item.id] = nil + } case .decrypting: decryptingIDs.insert(item.id) - Self.ingest( + let progress = Self.clamped(item.progress.decrypt) + if progress < 1 { + incompleteDecryptIDs.insert(item.id) + } + if Self.ingest( id: item.id, - bytes: Self.clamped(item.progress.decrypt) * totalBytes, + bytes: progress * totalBytes, time: item.updatedAt, samples: &decryptSamples, speeds: &decryptSpeeds - ) + ), progress < 1 { + decryptActivityTimes[item.id] = item.updatedAt + } else if progress >= 1 { + decryptActivityTimes[item.id] = nil + } default: break } @@ -113,6 +147,12 @@ struct TaskSpeedTracker { downloadSpeeds = downloadSpeeds.filter { downloadingIDs.contains($0.key) } decryptSamples = decryptSamples.filter { decryptingIDs.contains($0.key) } decryptSpeeds = decryptSpeeds.filter { decryptingIDs.contains($0.key) } + downloadActivityTimes = downloadActivityTimes.filter { + incompleteDownloadIDs.contains($0.key) + } + decryptActivityTimes = decryptActivityTimes.filter { + incompleteDecryptIDs.contains($0.key) + } downloadBytesPerSecond = Self.total( of: downloadSpeeds, @@ -124,17 +164,39 @@ struct TaskSpeedTracker { samples: decryptSamples, referenceTime: referenceTime ) + if hasObservedExplicitWaitingState { + // 新后端只会在拿到相应全局许可后进入 downloading/decrypting; + // waiting_* 明确表示排队,所以这里可以直接得到真实占用槽位数。 + downloadingItemCount = items.count { $0.status == .downloading } + decryptingItemCount = items.count { $0.status == .decrypting } + } else { + // 兼容旧后端:它会在信号量前就把所有曲目标为 downloading。只把最近 + // 确实发生过阶段进度增长的曲目算作活跃,避免把整张歌单当作并发数。 + downloadingItemCount = Self.activeCount( + activityTimes: downloadActivityTimes, + referenceTime: referenceTime + ) + decryptingItemCount = Self.activeCount( + activityTimes: decryptActivityTimes, + referenceTime: referenceTime + ) + } recordHistory(at: referenceTime) } mutating func reset() { downloadBytesPerSecond = 0 decryptBytesPerSecond = 0 + downloadingItemCount = 0 + decryptingItemCount = 0 history.removeAll() downloadSamples.removeAll() decryptSamples.removeAll() downloadSpeeds.removeAll() decryptSpeeds.removeAll() + downloadActivityTimes.removeAll() + decryptActivityTimes.removeAll() + hasObservedExplicitWaitingState = false nextHistoryID = 0 lastHistoryTime = nil } @@ -143,27 +205,29 @@ struct TaskSpeedTracker { min(max(value, 0), 1) } + @discardableResult private static func ingest( id: String, bytes: Double, time: Date, samples: inout [String: [Sample]], speeds: inout [String: Double] - ) { + ) -> Bool { let current = Sample(bytes: bytes, time: time) guard var timeline = samples[id], let previous = timeline.last else { samples[id] = [current] - return + return false } let sincePrevious = time.timeIntervalSince(previous.time) - guard sincePrevious >= 0 else { return } + guard sincePrevious >= 0 else { return false } if bytes < previous.bytes || sincePrevious > Self.staleWindow { // 重试会把阶段进度清零;旧速度不能跨重试沿用。 speeds[id] = nil samples[id] = [current] - return + return false } + let madeProgress = bytes > previous.bytes if sincePrevious == 0 { timeline[timeline.count - 1] = current @@ -178,15 +242,16 @@ struct TaskSpeedTracker { } samples[id] = timeline - guard let baseline = timeline.first else { return } + guard let baseline = timeline.first else { return madeProgress } let elapsed = time.timeIntervalSince(baseline.time) - guard elapsed >= Self.minimumSampleWindow else { return } + guard elapsed >= Self.minimumSampleWindow else { return madeProgress } let delta = bytes - baseline.bytes - guard delta >= 0 else { return } + guard delta >= 0 else { return madeProgress } let windowed = delta / elapsed speeds[id] = speeds[id].map { $0 + Self.smoothing * (windowed - $0) } ?? windowed + return madeProgress } private static func total( @@ -203,6 +268,15 @@ struct TaskSpeedTracker { } } + private static func activeCount( + activityTimes: [String: Date], + referenceTime: Date + ) -> Int { + activityTimes.values.count { + referenceTime.timeIntervalSince($0) <= staleWindow + } + } + private mutating func recordHistory(at time: Date) { if let lastHistoryTime { guard time >= lastHistoryTime else { return } @@ -234,15 +308,11 @@ struct TaskSpeedTracker { } enum TransferSpeedFormat { - private static let formatter: ByteCountFormatter = { - let formatter = ByteCountFormatter() - formatter.countStyle = .file - formatter.allowsNonnumericFormatting = false - return formatter - }() - static func string(bytesPerSecond: Double) -> String { - let value = formatter.string(fromByteCount: Int64(max(bytesPerSecond, 0))) - return "\(value)/s" + String( + format: "%.1f MB/s", + locale: Locale(identifier: "en_US_POSIX"), + max(bytesPerSecond, 0) / 1_000_000 + ) } } diff --git a/amdl-iosTests/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 268f5d8..416c70a 100644 --- a/amdl-iosTests/amdl_iosTests.swift +++ b/amdl-iosTests/amdl_iosTests.swift @@ -226,6 +226,8 @@ struct amdl_iosTests { ) var tracker = TaskSpeedTracker() tracker.update(with: [downloading, decrypting]) + try assert(tracker.presentation.downloadingItemCount == 0, "waiting download is not active") + try assert(tracker.presentation.decryptingItemCount == 0, "waiting decrypt is not active") downloading.progress.download = 0.2 downloading.updatedAt = try #require( @@ -244,6 +246,8 @@ struct amdl_iosTests { abs(tracker.presentation.decryptBytesPerSecond - 1_120_000) < 0.001, "aggregate decrypt speed" ) + try assert(tracker.presentation.downloadingItemCount == 1, "downloading item count") + try assert(tracker.presentation.decryptingItemCount == 1, "decrypting item count") try assert(tracker.presentation.history.count == 2, "trend keeps both samples") try assert( tracker.presentation.history.last?.downloadBytesPerSecond @@ -257,6 +261,43 @@ struct amdl_iosTests { ) } + @Test func transferSpeedFormatUsesFixedMegabytesAndOneDecimalPlace() { + #expect(TransferSpeedFormat.string(bytesPerSecond: 560_000) == "0.6 MB/s") + #expect(TransferSpeedFormat.string(bytesPerSecond: 12_340_000) == "12.3 MB/s") + } + + @Test func taskSpeedTrackerUsesExplicitWaitingStatesForConcurrency() throws { + let waiting = try speedItem( + id: "waiting", + status: "waiting_download", + download: 0, + decrypt: 0, + updatedAt: "2026-07-29T00:00:00Z" + ) + let downloading = try speedItem( + id: "downloading", + status: "downloading", + download: 0, + decrypt: 0, + updatedAt: "2026-07-29T00:00:00Z" + ) + let decrypting = try speedItem( + id: "decrypting", + status: "decrypting", + download: 1, + decrypt: 0, + updatedAt: "2026-07-29T00:00:00Z" + ) + var tracker = TaskSpeedTracker() + tracker.update(with: [waiting, downloading, decrypting]) + + #expect(waiting.status == .waitingDownload) + #expect(waiting.status.isActive) + #expect(waiting.status.text == "等待下载") + #expect(tracker.presentation.downloadingItemCount == 1) + #expect(tracker.presentation.decryptingItemCount == 1) + } + @Test func taskSpeedTrackerRejectsSubsecondPercentBurstSpikes() throws { var item = try speedItem( id: "burst", From fc61477e10713c53c30e1c3f912e63dc33171320 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 10:52:02 +0800 Subject: [PATCH 4/6] chore(release): 3.2 Signed-off-by: LYJW131 --- amdl-ios.xcodeproj/project.pbxproj | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/amdl-ios.xcodeproj/project.pbxproj b/amdl-ios.xcodeproj/project.pbxproj index 66988f5..16350cf 100644 --- a/amdl-ios.xcodeproj/project.pbxproj +++ b/amdl-ios.xcodeproj/project.pbxproj @@ -573,7 +573,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; 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.1; + MARKETING_VERSION = 3.2; PRODUCT_BUNDLE_IDENTIFIER = "com.lyjw131.amdl.amdl-ios.DownloadNotificationExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; From 62f3858e7babefe2a22f6cab4c5609d58d9ae9c3 Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 10:57:33 +0800 Subject: [PATCH 5/6] fix(build): a base configuration reference must resolve in a fresh clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Config/Portal.xcconfig` was wired in as the project-level base configuration and then added to .gitignore, so a clean checkout had a dangling reference. I assumed at the time that Xcode would warn and carry on. It does not — xcodebuild reports error: Unable to open base configuration reference file and fails outright. That is why CI has been red on every run since the compile-time host injection landed, and why the failure looked like a review-bot problem rather than a build one. The base file is now committed and deliberately empty, with an optional `#include? "Portal.local.xcconfig"` for the real host. A fresh clone resolves the reference and builds; a developer keeps their own host in the gitignored local file; and the release workflow keeps passing AMDL_PORTAL_HOST on the xcodebuild command line, which overrides both. An empty host was already a supported state — BackendEndpoint returns "" and the app asks the user for an address — so a clone that has neither override still produces a working build rather than one silently pointed at nothing. Verified by building a checkout with Portal.local.xcconfig absent, which is exactly what CI sees. Signed-off-by: LYJW131 --- .gitignore | 6 ++++-- Config/Portal.xcconfig | 12 ++++++++++++ Config/Portal.xcconfig.example | 3 --- 3 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 Config/Portal.xcconfig delete mode 100644 Config/Portal.xcconfig.example diff --git a/.gitignore b/.gitignore index 5cbd693..6f5de28 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,7 @@ build/ .agents/ skills-lock.json -# Local portal host — see Config/Portal.xcconfig.example -Config/Portal.xcconfig +# Local portal host. Config/Portal.xcconfig IS committed (empty) so the +# project's base configuration reference resolves in a fresh clone; it +# optionally includes this file, which holds your real host. +Config/Portal.local.xcconfig diff --git a/Config/Portal.xcconfig b/Config/Portal.xcconfig new file mode 100644 index 0000000..da18367 --- /dev/null +++ b/Config/Portal.xcconfig @@ -0,0 +1,12 @@ +// Portal host, injected at compile time. Committed and intentionally empty: +// a base configuration reference must resolve in a fresh clone or xcodebuild +// fails outright with "Unable to open base configuration reference file" — +// it is an error, not a warning, which is how CI stayed red. +// +// Your own host goes in Portal.local.xcconfig (gitignored, optional include +// below). CI passes AMDL_PORTAL_HOST on the xcodebuild command line instead. +// Empty is a supported state: BackendEndpoint returns "" and the app asks the +// user for an address. +AMDL_PORTAL_HOST = + +#include? "Portal.local.xcconfig" diff --git a/Config/Portal.xcconfig.example b/Config/Portal.xcconfig.example deleted file mode 100644 index 33fc9c3..0000000 --- a/Config/Portal.xcconfig.example +++ /dev/null @@ -1,3 +0,0 @@ -// Your portal host, e.g. amdl.example.com — scheme is added in code. -// Copy to Portal.xcconfig (gitignored) and fill in. -AMDL_PORTAL_HOST = From 152ed006ad55e6534af80715fdf7512b8bbd6fbe Mon Sep 17 00:00:00 2001 From: LYJW131 Date: Thu, 30 Jul 2026 11:07:53 +0800 Subject: [PATCH 6/6] test(portal): stop two tests asserting a property of the build config `everyCallSiteReadsTheOneSetting` and `bearerGoesOnlyToThePortalHost` both read the ambient `baseURLString`, which comes from AMDL_PORTAL_HOST and is injected at compile time. A developer's checkout has a host, so they pass; a fresh clone and CI do not, so `apnsURLString(from: "")` returns "" and fails `hasSuffix("/apns")`, and `URLComponents(string: "")?.host` is nil and fails a `#require`. They now check the derivation against an explicit host and keep the ambient assertions behind a guard, so they describe the logic in every environment and the configured case where there is one to describe. This is the same correction `legacyGatewayURLMigration` already needed for the same reason; these two were missed. Verified by running the suite against a checkout with no local override, which is exactly what CI sees. Signed-off-by: LYJW131 --- amdl-iosTests/amdl_iosTests.swift | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/amdl-iosTests/amdl_iosTests.swift b/amdl-iosTests/amdl_iosTests.swift index 416c70a..6016863 100644 --- a/amdl-iosTests/amdl_iosTests.swift +++ b/amdl-iosTests/amdl_iosTests.swift @@ -1111,9 +1111,23 @@ struct PortalAuthTests { let base = BackendEndpoint.baseURLString let gateway = BackendEndpoint.apnsURLString(from: base) + // Holds whatever the host is, including empty: the point of this test is + // that there is one setting, not what it contains. #expect(DownloadsAPI.baseURLString == base) #expect(LiveActivityGatewayAPI.baseURLString == gateway) - #expect(gateway.hasSuffix("/apns")) + + // The `/apns` derivation is checked against an explicit host rather than + // the ambient one. AMDL_PORTAL_HOST is injected at compile time and is + // empty in a fresh clone and on CI, and a test that silently asserts a + // property of the build configuration is worse than no test — that is + // exactly how these two started failing only on CI. + let derived = BackendEndpoint.apnsURLString(from: "https://amdl.example.com") + #expect(derived == "https://amdl.example.com/apns") + #expect(derived.hasSuffix("/apns")) + #expect(BackendEndpoint.apnsURLString(from: "https://amdl.example.com/apns") == derived) + #expect(BackendEndpoint.apnsURLString(from: "") == "") + + guard !gateway.isEmpty else { return } #expect(LiveActivityGatewayAPI.makeURL(path: "/v1/devices/abc/push-token")?.absoluteString == gateway + "/v1/devices/abc/push-token") #expect(LiveActivityGatewayAPI.makeURL(path: "/health")?.absoluteString @@ -1191,15 +1205,21 @@ struct PortalAuthTests { /// 断言恰好成立。那正是这次要消掉的分叉,所以断言改成钉住策略本身:**只信任 /// 配置里的那一个 host,且必须精确匹配**。 @Test func bearerGoesOnlyToThePortalHost() throws { - let host = try #require(URLComponents(string: DownloadsAPI.baseURLString)?.host) + // These hold with no host configured at all, which is the state of a + // fresh clone and of CI: AMDL_PORTAL_HOST is injected at compile time. + #expect(!AppleAuthCredentialStore.isGatewayHost("is5-ssl.mzstatic.com")) + #expect(!AppleAuthCredentialStore.isGatewayHost(nil)) + #expect(!AppleAuthCredentialStore.isGatewayHost("")) + + // The rest describes a configured build. Returning early rather than + // asserting against an empty host keeps the test honest about what it + // can see, instead of failing only on CI for the wrong reason. + guard let host = URLComponents(string: DownloadsAPI.baseURLString)?.host else { return } #expect(AppleAuthCredentialStore.isGatewayHost(host)) #expect(AppleAuthCredentialStore.isGatewayHost(host.uppercased())) // 网关走同一个 host,所以派生出来的地址不会引入第二个受信任域名。 #expect(URLComponents(string: LiveActivityGatewayAPI.baseURLString)?.host == host) - #expect(!AppleAuthCredentialStore.isGatewayHost("is5-ssl.mzstatic.com")) #expect(!AppleAuthCredentialStore.isGatewayHost("\(host).evil.example")) - #expect(!AppleAuthCredentialStore.isGatewayHost(nil)) - #expect(!AppleAuthCredentialStore.isGatewayHost("")) } /// 两种错误体形状、同一张码表(DESIGN.md §6.3)。