Fetched account records are staged under the identity the fetch began
under, not whichever is current when they arrive — the engine's fetch
spans several delegate events and the Apple account can move between
them, which would have filed one identity's accounts under another's.
Subscription fetches are fenced against context switches, not only
against a provider being discarded. Providers are deliberately kept
across a switch — each is one account's cache — so an earlier account's
fetch could finish afterwards and put its channels on screen while
unsubscribes went to the account now selected.
The subscriptions view reloads when the *effective* context moves, which
a credential arriving, expiring or being rejected does without the
stored context changing at all.
Feed requests report what they learn about the account, like every other
authenticated call: a credential that expired during a feed-only refresh
kept looking signed in, and the fallback never engaged. The
classification behind that verdict now exists once, on AccountsManager,
rather than in each provider.
Two devices editing an account in the same instant — or migrating one,
since every migrated account is stamped at the epoch — each kept its own
label and considered the matter closed. Equal timestamps are now broken
by the label, on the incoming side and in the conflict resolver alike,
the same rule the context already uses.
Cached feeds were thrown away on every ordinary launch: recomputing
statuses posts a notification, and the observer ran before the value it
compares against had been seeded. The seed now happens first.
Cache ownership is checked once at launch and compared against a
recorded owner afterwards. An unresolved identity at launch means the
owner cannot be established at all — a switch to an unseen Apple account
while offline looks exactly like that — so the caches are discarded;
mid-session it is a transient, and with iCloud Drive off it happens on
every activation, so it no longer clears anything.
Whether a Keychain item syncs is fixed when it is written, so a login
stored while sync was off stayed on this device while its account record
travelled, leaving every other device with an account whose credential
never arrives. Turning sync on rewrites them.
Reseeding a deleted zone marks the account records dirty even while the
category is off, so re-enabling it restores them instead of finding an
empty outbox.
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.
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.
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