A deletion can reach a device before the account it deletes does — the
tombstone and the account are separate records, and iCloud Keychain runs
on its own timeline — and the credential cleanup was skipped whenever
the account was unknown here. The scoped key needs only the account ID,
so both credential managers are now asked to drop it; the unscoped
legacy item still needs the instance and is left alone.
Cached data is invalidated on the data context rather than on the legacy
selection it maps to. Two logins on the same instance collapse onto the
same legacy value, so switching between them cleared nothing and the
subscriptions feed kept showing the previous account's videos. Home
feeds are keyed by instance alone and had the same hole across accounts
and across Apple accounts; they are dropped with it.
The third source-deletion path now cascades to accounts like the other
two, and the Invidious private-playlist lookup resolves its session
through the account — a login created after the revamp writes only an
account-scoped credential, so the instance-keyed lookup missed it and
fell through to the public endpoint.
A login created after the revamp got a random account UUID that never
left the device, so its credential was keyed to an ID no other device
had and the login was unusable there. Migrated accounts were fine only
because their ID is deterministically the instance ID.
Accounts, the permanent legacy-migration markers, the permanent deletion
tombstones and the selected data context now ride the existing
CKSyncEngine as four record types, with the rules the category needs:
- Absence proves nothing. A fetch resumes from a change token and never
enumerates the zone, so deletions are records of their own and they
dominate the account wherever they are seen — including the engine's
retry-as-insert path, which is how a queued offline edit would
otherwise resurrect a deleted account.
- Fetched records are staged durably before each delegate event returns
and applied as a set, tombstones first, gated on the zone's own result
rather than didFetchChanges, which fires after failed fetches too.
- Nothing activates for an identity that has not bootstrapped: a device
left on the previous release consumed these record types as unknown
and advanced its token past them, so each identity fetches the zone
once from a nil token, outside the engine's own token.
- A disabled category defers records rather than discarding them; every
persisted artifact is namespaced by identity, and async work is
generation-checked so a fetch begun under one Apple account can never
land in another's state.
- Uploads are ordered: a marker never outruns the account it describes,
and a context naming an account is never published before that account
is server-confirmed.
Seventh review pass. No correctness findings this round; both are
availability.
The retry budget was only reset inside the tokenless branch of an identity
change, so an account that exhausted its four attempts left the next one
with none - its queued resolve() did no lookup at all and credentials
stayed unavailable until another foreground transition. The budget belongs
to the identity being resolved, so it now resets on every observed change.
ubiquityIdentityToken was archived with requiringSecureCoding: true, but
Foundation guarantees only NSCoding for it. Measured: a class adopting only
NSCoding does throw under secure archiving, and the `try?` there would have
silently produced no fingerprint - losing the scope cache, forcing a
CloudKit lookup every launch and leaving credentials unavailable offline.
This Mac's token happens to support secure coding, so nothing was failing
yet; the archive is only ever hashed, never decoded, so secure coding buys
nothing regardless. Also measured: the non-secure archive is deterministic,
which is what the fingerprint comparison depends on.
Note the digest differs between the two archiving modes, so any scope
cached before this change resolves once more against CloudKit and re-caches.
Third review pass.
adoptCurrentIdentity() treated a nil ubiquityIdentityToken as sign-out and
scoped credentials to "local". But Foundation also returns nil when iCloud
Drive is unavailable or switched off, and Yattee declares only the CloudKit
service with no ubiquity container, so the token tracks iCloud Drive state
rather than the account. In that configuration two different signed-in
Apple accounts both resolved to "local" and shared one credential
namespace - since Keychain items survive an account switch, account B could
then read account A's retained credential. That is the precise failure this
whole change exists to prevent.
A nil token now means unresolved, and CloudKit's account status settles it
during resolution: noAccount scopes to local, available proceeds to the
user-record lookup, anything else stays unresolved and retries. A scope
resolved without a token is not cached, because there is no key that could
be trusted to mean this account.
Adoption markers are now namespaced by Keychain service. Both credential
managers share one marker store and an instance UUID can outlive a change
of backend, so adopting an Invidious credential could suppress the Piped
offer for the same instance and point deletion at the wrong secret.
Writing the account-status branch also surfaced a Swift trap: an
unqualified `break` inside the switch left only the switch and fell through
to the user-record lookup, resolving a scope on an undetermined account
status. The loop is labelled now, and the test that caught it is included.
Yattee 2 keys credentials by instance UUID with no notion of which Apple
account they belong to, and reads them back with SynchronizableAny. Apple
documents that Keychain data may stay on device when a user signs out and
chooses to keep it, so whoever signs in next would be served the previous
user's credential - guaranteed for migrated accounts, since local instances
survive the switch and the migrated account UUID is deterministically the
instance UUID.
Credentials are now keyed "<identityScope>|<accountID>", where the scope is
the CloudKit user record (identical across a user's devices, so iCloud
Keychain sync still works). Usernames get the same scope and live beside the
credential, keeping login identifiers out of synced metadata.
The legacy unscoped item is never adopted automatically. A strict
kSecAttrSynchronizable == true query was the candidate discriminator for
"this secret belongs to the current Apple account", but measured on iPadOS
27.0 a retained item keeps the attribute and still matches that query with
no Apple account signed in at all - the flag records what was once requested,
not live iCloud Keychain membership. Reads therefore never fall back to the
legacy key; adoption is an explicit user action that copies the item,
verifies the scoped read-back, then records a marker bound to the identity
and a salted fingerprint of the secret. The legacy item is retained, because
deleting a synchronizable item propagates and would log out devices still on
the previous release mid-rollout. Deleting it later needs all three of: a
marker on this device, matching identity, and the item still holding the
adopted secret.
The instance-keyed path is untouched, so no shipping call site changes
behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth
belongs to the instance, not to a user account.
While the scope is unresolved (first launch under a new Apple account,
CloudKit unreachable) reads report "can't tell yet" via isScopeResolved
rather than "signed out", and writes are refused rather than guessing a
scope. Login state is cached per scope so one identity's cache can never
answer for another.
Also adds the debug-only harness the measurement was taken with, under
Advanced > Developer > Experiments.
With "Partially Watched Videos" set to Ask, opening a live stream showed
the resume sheet with a playback timestamp. A live position is relative
to the live edge, not a fixed timeline, so it is meaningless as a
playhead.
Live views are still recorded in history, but carry no resume position:
- WatchEntry gains a persisted isLive flag plus recordLiveWatch(), which
stores no position and clears state a drifting HLS duration may have
left behind. progress is always 0 and toVideo() propagates the flag so
history rows render the LIVE badge.
- from(video:) clamps duration, keeping Piped's -1 live sentinel out of
storage.
- DataManager routes live saves through recordLiveWatch() and clears the
flag on the non-live path (before updateProgress, so the auto-finish
check is not suppressed by the hard-0 live progress), letting an entry
heal once the stream becomes a VOD.
- PlayerService skips completion saves, always starts at the live edge,
and drops any startTime passed for a live video.
- The resume prompt, the "Continue at" button label, thumbnail progress
bars, "X remaining", Continue Watching and tvOS Top Shelf all exclude
live entries.
- CloudKit syncs isLive with the rest of the watch state: the conflict
resolver carries it with watchedSeconds/isFinished and the sync engine
applies it when merging into an existing entry. It is read leniently,
so the schema version stays at 2 and older clients keep parsing these
records.
Sideloading tools re-sign the app with a team that cannot register the
iCloud.stream.yattee.app container, and CKContainer(identifier:) fatally
traps when the entitlement is missing. Detect availability by parsing
the embedded provisioning profile, skip creating any CloudKit objects
when the entitlement is absent, and show an explanation in iCloud
settings instead of the sync toggle.
CloudKit record names carry a source scope suffix so the same channel
or video ID can exist under different sources, but the apply path
matched local entities by bare ID: incoming records could merge into an
entity from a different scope, and a remote deletion of one scope
deleted every local entity with that ID and cascaded scoped deletions
for all other scopes back to CloudKit.
- Match local entities on the full mapper-derived record name when
merging incoming records, materializing records for upload, and
applying remote deletions.
- Remote deletions now remove only the matching entity and no longer
queue CloudKit deletions (no echo, no cross-scope cascade).
- Insert dedupe for CloudKit-applied records is scope-aware, so a
same-ID record from a different source inserts instead of being
silently dropped. Remotely added bookmarks also update the
fast-lookup cache immediately.
- When conflict resolution during a fetch keeps newer local data, the
merge result is queued for upload; previously the server (and other
devices) kept the stale version unless the entity was edited again.
Strict timestamp comparisons prevent re-upload ping-pong between
devices.
The recent-channel resolver copied avatarURLString/providerName and the
recent-playlist resolver copied authorID/providerName - fields that do
not exist in the records the mapper writes - while missing the real
ones (thumbnailURLString, subscriberCount, isVerified, videoCount). As
a result, when the local side won a conflict, those fields silently
kept the older server values.
Also drop the notificationsEnabled preservation in the subscription
resolver: the subscription mapper never writes that field since
notification preferences moved to their own record type.
Register pending changes with the engine state instead of keeping
app-managed record arrays, and materialize records from local data at
send time in nextRecordZoneChangeBatch. This fixes several data-loss
paths:
- Pending changes now persist inside the engine state serialization,
so saves queued during the debounce window (or while offline) survive
app termination. Legacy record-name keys are migrated once.
- Transient CloudKit errors (zoneBusy, serviceUnavailable, rate limits,
network failures, limitExceeded, batchRequestFailed) re-register the
change for engine-scheduled retry instead of silently dropping it.
- Conflicts are resolved against the exact record that was sent; the
resolved record (carrying the server change tag) is cached and takes
precedence at the next send, and any newer local edit evicts it.
- Queue methods are now synchronous, removing a task-reordering race
where a fast delete/re-add could end up deleting the re-added entity.
- Records are built fresh at send time, so queued changes no longer
upload stale field snapshots, and batch size limits are handled by
the framework.
Previously CKSyncEngine was created with nil state serialization on
every launch, replaying the entire zone change history (hundreds of
merge/delete log lines and re-downloads each start). Now the saved
state is loaded so launches only fetch changes since the last session.
To make incremental sync safe:
- Clear persisted state once when the record schema version increases,
so records previously skipped as unsupported get re-delivered after
an app update.
- Handle remote zone deletion in fetchedDatabaseChanges by recreating
the zone and re-uploading local data, instead of leaving sync dead.
Account change and manual Refresh Sync still clear the state and force
a full fetch as before.
CloudKitRecordMapper was a standalone actor, so 'await recordMapper.toCKRecord'
from the @MainActor sync engine hopped off the main actor and read live
SwiftData @Model properties (e.g. LocalPlaylistItem.authorName) from the wrong
executor. SwiftData models are bound to the main ModelContext and are not
thread-safe, so this raced the backing store and crashed with EXC_BAD_ACCESS.
Make the mapper @MainActor (it must touch main-isolated models regardless) and
drop the now-redundant awaits. Fixes the crash for all synced model types.
Fixes an EXC_BAD_ACCESS seen in TestFlight build 261.
Pending deletes were lost across app restarts because
recoverPersistedPendingChanges() never reconstructed CKRecord.ID
objects from persisted record names. Additionally, incoming iCloud
records for deleted playlists were blindly applied, and orphaned
playlist items in CloudKit would recreate placeholder playlists.
- Rebuild pendingDeletes array from UserDefaults on recovery
- Guard applyRemoteRecord against records pending local deletion
- Skip deferred items whose parent playlist is pending deletion
- Queue all playlist item deletions when deleting a playlist
- Clean up placeholder playlists for pending-delete playlists