Tags: AMDL-Web/ios
Tags
docs(auth): stop claiming the Apple token lasts ten minutes 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 <lyjw2007@gmail.com>
feat(auth): send Apple's identity token straight to the gateway 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 <lyjw2007@gmail.com>
fix(push): open Emby once the app is active, not from the tap callback 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 <lyjw2007@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: LYJW131 <lyjw2007@gmail.com>
feat(push): open Emby from a finished album's notification 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 <lyjw2007@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: LYJW131 <lyjw2007@gmail.com>
fix(artwork): let the overview row borrow the detail page's cover A job added from the share extension opens on `amdl://download/<id>`, 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 <lyjw2007@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: LYJW131 <lyjw2007@gmail.com>
fix(detail): hold job progress at 99% until every track is done 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 <lyjw2007@gmail.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: LYJW131 <lyjw2007@gmail.com>
feat(detail): segment the job progress bar by track
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 <lyjw2007@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
PreviousNext