Commit Graph

67 Commits

Author SHA1 Message Date
Arkadiusz Fal
dca8e2fbe4 Let a row show the metadata it was handed 2026-09-07 07:30:00 +02:00
Arkadiusz Fal
5072fb0dce Start a session where the watch actually begins 2026-09-06 22:21:15 +02:00
Arkadiusz Fal
0e289ae920 Show a history from the account that recorded it 2026-09-06 16:23:28 +02:00
Arkadiusz Fal
7bcc711c40 Mirror a watch into the account it was watched under 2026-09-06 11:06:15 +02:00
Arkadiusz Fal
3dc129dcfa Let the banner speak only for what it answers
Standing the feed error down under the context banner was aimed at one
sentence and hit every one. The fallback to local reads asks for a Yattee
Server nobody configured, and that question the banner already answers —
but a local refresh that failed on the way out answers nothing, and it was
being swallowed too. With the banner up, an offline refresh left the last
feed on screen with no explanation and no retry: the state said error, the
view said nothing.

The decision now names the errors the banner covers. `yatteeServerRequired`
and `notAuthenticated` are the account the banner is already talking about
and stand down; a `networkError` is news the banner does not carry and
keeps its own message and its retry button. It lives on `FeedLoadState` as
`displayedError(contextBannerVisible:)` so the rule is one function rather
than a guard in a view property, and so it can be tested.
2026-09-05 23:49:10 +02:00
Arkadiusz Fal
6bf3942529 Send a creation where the button said it would go
Creating a playlist while an Invidious account was the selected context
wrote it to the device. No request left the app, the row never appeared in
the account's list, and the form had come up without its visibility picker
— the two symptoms of the same thing: the sheet took the local branch.

The destination was decided correctly and then read wrongly. The button
resolved the account and put it in a companion `@State`, and the sheet's
content closure — created by an earlier body evaluation — read that
property as it stood *then*, which was nil. Measured on device: the create
resolved the account and validity fine, and one millisecond later the sheet
rendered the local form while every live value around it still named the
account. Whether the stale read is corrected depends on whether anything
re-renders the view before the form is saved, which is why the same steps
against Piped had landed on the server.

The destination now travels with the presentation as
`PlaylistCreationTarget`, so the sheet is handed the account rather than
looking one up. One resolver answers for both the create affordances and
the row menus, and it is where the rule lives: a context reading an
account's data creates on that account or creates nothing at all — a
toolbar item can outlive the condition that put it there, and a write with
nowhere to go is not a write to somewhere else. A context that fell back to
local reads is the one case that does create locally, and it is honest:
those are the rows on screen, with the banner above them saying why.

Two more from the same pass:

- Subscriptions stopped giving two answers at once. Under a fallback the
  local feed asks for a Yattee Server nobody has configured, so "Add a
  subscriptions account in Settings" was stacking under a banner saying
  this account needs signing in again. The banner names the cause, so the
  feed error stands down while it is up.

- The playlists banner no longer lands on the search field. A navigation
  bar carrying a search field keeps its full height over content that
  cannot scroll, and the empty, loading and error states could not — they
  scroll now, which also gives pull to refresh somewhere to live.
2026-09-05 23:41:25 +02:00
Arkadiusz Fal
5abd314c88 Let an incoming context settle what a repair cannot argue with
A repair is a fallback, not a choice: it borrows the timestamp of the
selection it replaced so that a later choice still beats it, and it is
never published. But that borrowed rank also won arguments it had no
business winning. An incoming explicit context stamped at the same instant
that loses the `DataContext.tieBreakKey` tie was neither adopted — the
repair was not outranked — nor pushed back, because a repair pushes
nothing. Neither side moved, and the fallback stood against the server's
explicit value indefinitely. The tie is not theoretical: migrated contexts
are all stamped at the epoch, and a repair keeps the timestamp of whatever
it replaced, so two migrated-era values meet as equals.

A stored repair now never outranks an incoming context — it is replaced by
whatever arrived, older or tied, and nothing is ever enqueued for that.
Adopting is safe whatever the incoming value names: one naming an account a
tombstone dominates has already been turned back into a repair before the
comparison, so it stays unpublished and lands on the same target this
device's repair did.

Two more from the same trace:

- Staged records are applied by category, and contexts already come last so
  that a repair reads the accounts and the instance a dominated record left
  behind rather than falling to local past a signed-in sibling. That
  ordering was load-bearing and unremarked; it is now written down, along
  with what it does not cover — a context applied in an earlier batch than
  the account record that says where the deleted account lived still
  repairs without it, which the next explicit choice anywhere settles.

- An install that repaired its context before the repair bit existed reads
  back as having chosen it, so that one repair can still be published once.
  Accepted and documented at the load site: no shipped release ever
  persisted a data context, the window is interim builds only, and the next
  explicit choice heals it.
2026-09-05 23:30:40 +02:00
Arkadiusz Fal
ccf5c51460 Keep an automatic repair from ever reaching the server
A repair carries the timestamp of the selection it replaced, which is what
stops it outranking a deliberate choice made elsewhere — but it also makes
every ordering comparison read it as that choice. A fetch holding the
selected account's tombstone and an older server context applied them in
that order: the tombstone repaired the selection, and the older context
then lost to it, so the "the server holds the stale copy" path marked the
repair dirty and uploaded an automatic fallback under the deleted choice's
timestamp, over an explicit choice. The stored context now remembers
whether it is a repair, and a repair that wins an ordering pushes nothing
back: the server's context stands remotely, the fallback stays local, and
the next explicit choice on any device re-converges everyone. The bit is
persisted with the context, or a relaunch would forget and publish it on
the first stale record it read.

Three more from the same trace:

- The repair prefers a signed-in login on the same server, decided off
  statuses, and it commits — once the selection no longer names the deleted
  account there is nothing left for a later status to move. Foreground
  activation refreshes statuses ahead of it, but a deletion delivered by a
  silent push arrives outside that sequence, and a credential that reached
  this device over iCloud Keychain while it was in the background announces
  nothing. The refresh is now asked for by the repair itself, which closes
  every entry point rather than each fetch trigger having to remember one.

- A device that meets a tombstone before the account it kills cannot record
  which instance that account lived on, and the account record arriving
  afterwards was discarded — with the only copy of that fact still in it.
  Every later repair of a context naming it then fell to local past a
  usable sibling. The association is kept as the record is dropped.

- Removing an orphaned account deleted its credential before recording the
  deletion, so a crash in between brought the account back on the next
  launch as one nobody can sign into, owing no deletion to any device. The
  outbox goes first, here and in the sign-out and instance cascades; the
  scoped credential key needs only the account's ID, and the store is
  resolved through the instance before anything is removed.
2026-09-05 23:06:10 +02:00
Arkadiusz Fal
ca73b68ad9 Let activation know the accounts before it repairs the selection
Foreground activation started identity resolution, the deletion repair and
the CloudKit fetch as three things racing each other. With a tombstone for
the selected account already on disk and a sibling login on the same server
whose status had not been read back yet, the repair ran against `checking`
— which it will not repair onto — and landed on local. It commits, so the
status refresh arriving a moment later changed nothing: the selection no
longer named an account for a second repair to act on.

The steps now run as one sequence in both platform paths, because each one
is what makes the next correct: the identity says whose accounts these are,
the status refresh says which of them this device can use, the repair reads
those statuses, and the fetch goes last so a deletion it delivers repairs
against the same refreshed picture.
2026-09-05 22:27:21 +02:00
Arkadiusz Fal
f44c1d91c3 Refuse to repair a context that is not this identity's
Foreground activation repairs a selection whose account this device
knows was deleted. The check for whether that is safe was the identity
scope being resolved, which it already is in the window between an Apple
account change moving the namespace and the reload refilling memory —
so the repair weighed the previous identity's selection against the
previous identity's tombstones and announced the result to a user who
had not been shown an account yet. Fenced on the loaded namespace, the
same way saves are.

Three smaller things around the same theme:

- The destination rows forgot their membership answers on a credential
  or account change but not on an instance change, which adds a row to
  the picker while it is open. Unknown reads as unsubscribed, so that
  row offered to subscribe a channel the account already has.

- The subscriptions settings count applied whatever the fetch returned.
  A selection change is fenced by the service's provider generation, but
  the fallback to local is driven by availability and announces nothing,
  so the context is captured before the fetch and rechecked after it.

- Deleting a source from the browser runs the same cascade as deleting
  it from Settings, and was the one dialog left that did not say the
  logins go with it.
2026-09-04 23:59:27 +02:00
Arkadiusz Fal
fbeae586b0 Only a deletion the app can point at moves the selection
`correctIfContextRemoved` read a locally missing account as a deleted one
and reset the context with `setContext(.local)` — a new timestamp and a
pending upload. Now that foreground activation calls it, it ran before
the fetch that delivers the account record: a perfectly good remote
choice was reset and then published over everybody else's. It now asks
`AccountsManager` for a repair keyed on the deletion tombstone, which is
the same repair a deletion performs — a signed-in login on the same
server when there is one, the replaced choice's timestamp, nothing
uploaded. The toast names where the selection actually landed.

Adopting a synced context that named a deleted account repaired it but
kept whatever upload the previous choice still owed, so the sync engine
published the automatic fallback under that choice's timestamp — exactly
what not stamping it "now" exists to prevent. The adoption clears the
flag, and only on adoption: a local choice that outranks the repair keeps
its own upload.

The channel button's subscription lookup is fenced to the channel and the
round it was asked in, so an answer that lands after the user has walked
on — or after the account behind it changed — no longer writes itself
into the button on screen. Context and credential changes ask again. A
destination toggle is compared against the effective context rather than
the raw selection: the menu offers Local precisely when the selected
account cannot be written to, and that toggle has to be the one the
button then shows.

The destination rows forget their checkmarks when the logins change. The
answers describe credentials that have been replaced, and the tvOS picker
deliberately stays open across exactly that.

The subscriptions settings screen counts and exports what it is actually
reading from. The fetch behind both already followed the effective
context while the branch and the footer followed the selection, so an
unavailable account meant local data described as the server's.

The legacy `subscriptionAccount` migration refused to adopt a context
whose account had not synced yet and was never retried, leaving a fresh
install on local until the next launch. A synced account arriving now
retries it; the migration's own guards keep it a one-way move that never
overrides a choice made in the meantime.

The remaining negative-able Piped counts — likes, channel subscribers and
videos, playlist video counts — go through `PipedSentinel.count` like the
views and upload dates already did.
2026-09-04 23:56:39 +02:00
Arkadiusz Fal
69179c9fe9 Give every login a screen, including the ones with nowhere to go
An account whose source is gone was listed nowhere: `orphanedAccounts` had
no readers, and the credential behind it could not be reached at all —
`AppEnvironment.accountCredentialsManager(for:)` resolves the store from
the account's instance, and an orphan has none. The new accounts screen
lists them and removes them by account ID, asking every account-keyed
store because the backend is no longer knowable. The pre-revamp
instance-keyed item is deliberately left behind: identifying it needs the
instance too.

Deleting a source has always signed out and removed the accounts on it.
Both confirmations now say so, and name them.

The sign-in that files a credential against an account is now one
implementation instead of a private method on the source editor, so the
accounts screen and the new optional sign-in step after adding a source
attach a login exactly as the editor does. Extracted verbatim — the
identity-scope guard, the account re-resolve, the write-then-read-back
and the rollback are what keep a half-written login from looking real.

Signing out from the per-instance section now asks first, and both
sign-out paths run one cascade rather than two copies of it.
2026-09-04 23:35:51 +02:00
Arkadiusz Fal
0c5745e4fb Let a subscribe name the account it lands on
Subscribing went wherever the selected account happened to be, and the
button offering it read the local store first — so under a server account
it claimed a channel was subscribed when only the device knew about it.
The button now asks the active provider and nothing else, except under a
local context, where the local store is that provider and can answer
without a hop.

The channel menu gains a destination list: local, plus every account
whose backend does subscriptions, whose instance is switched on, and
which has a credential on this device. A row names its own target, so it
deliberately does not care whether the selected context can be written
to — that is precisely the case where naming somewhere else is the only
way to subscribe at all. The main button is disabled instead, with the
reason beside it. tvOS reaches the same list through its own button and a
full-screen picker, because a menu cannot be driven with a remote.

Membership is asked for only once the rows are on screen: an unwarmed
provider answers by fetching that account's entire subscription list, and
asking every destination up front is a burst nobody requested. Answers
carry the round they were asked under, and "not asked yet" is kept apart
from "asked, not subscribed" — a checkmark the user acts on must not be a
guess.

SubscriptionService grows the destination-parameterized subscribe,
unsubscribe and membership calls the menu writes through, with the
context-implicit ones left as wrappers over the current context. A
membership answer that outlived its login now says nothing rather than
"no". Building a provider moves behind SubscriptionProviderFactory, which
is what lets a test see which destination a write was routed to without a
server behind it.

The unsubscribe-only screens are untouched: they act on the account being
browsed, which is the account whose list they are showing.
2026-09-04 23:18:04 +02:00
Arkadiusz Fal
3d87aeeaf1 Show the selected account's playlists on the playlists screen
The screen read local playlists and nothing else, so selecting an account
moved the subscriptions and the feed but left this list showing the
device's own playlists with no way to tell. It now follows the effective
context: local as before, or the account's list read live out of
RemotePlaylistsCache — never copied into state, because every change to a
server-side playlist goes through that cache and reading it is what makes
a rename or a delete show up.

Rows carry both the account's instance and the account's id, which have
to agree or UnifiedPlaylistDetailView drops ownership and the playlist
loses its edit and delete. Editing refetches first, exactly as the detail
view does: the form saves every field at once, and one started from a row
fetched minutes ago would put that row back over anything changed since.
Deleting from a server asks first; deleting a local playlist stays
unconfirmed as it always was.

Row identity, item mapping and the search filter move into
PlaylistListItem, where they can be tested — the transition id stays
separate from the row identity because it has to match what
PlaylistSource produces for the destination.

Local behaviour is unchanged throughout: the same rows, the same swipe
actions, the same unconfirmed delete, and the same synchronous read on
appear so the empty state never flashes.
2026-09-04 22:50:29 +02:00
Arkadiusz Fal
f4fcf97a27 Read Piped's -1 and its 200-with-an-error-body for what they are
Piped answers with "views": -1 and "uploaded": -1 wherever it has no
number, and playlist rows are full of them. Mapped straight through, a
row read "-0B views • 56 years ago" — -1 millisecond is a date in 1969.
Every Piped-to-Video conversion now runs both fields through
PipedSentinel, which reads a negative as an absent value; the epoch
milliseconds the field really is stay milliseconds.

Piped also refuses a write with HTTP 200 and {"error": "..."} — adding a
video it cannot fetch, or removing an index that moved, both come back
that way. Decoding straight into the success shape failed, and the toast
showed a decoding-error string instead of the server's own sentence. All
authenticated writes now read their body through decodeWriteResponse,
which checks the error shape first (a body carrying error may carry
message beside it) and throws APIError.serverError with the message, so
the existing toasts say what actually happened. HTTPClient's status-code
handling is untouched, so no other backend changes behaviour.
2026-09-04 19:31:47 +02:00
Arkadiusz Fal
afc016c5ce Repair a deleted account's context onto its sibling login
Deleting the selected account dropped the user to local data even when
the same server held another login they were signed in to — usually the
only other thing they could have meant. The repair now prefers a
signed-in account on the deleted one's instance, and the synced-tombstone
branch of applySyncedContext does the same, so a choice arriving from a
device that had not heard about the deletion lands there too.

The instance of a deleted account is recorded beside the tombstone: by
the time a context is repaired the account record itself is gone, and the
sibling cannot be found without it.

The repair keeps the replaced selection's timestamp, clears any queued
upload and is still never published. That is what makes picking by
status safe: statuses are device-local, so two devices can repair the
same deletion differently, and neither overwrites the other.
2026-09-03 23:58:42 +02:00
Arkadiusz Fal
cb858edd34 Serialize playlist mutations and re-resolve on credential change
Two changes to a server-side playlist could overlap: the second planned its
write from the snapshot the first had already invalidated, and on Piped —
where a row is a position — its preflight then passed over whatever had
shifted into the slot. Mutations now hold a per-playlist queue from the write
to the end of the refetch that reconciles it. Creation queues on the account's
list instead: it depends on no playlist's contents, and one queue per account
would only make unrelated changes wait.

The playlist detail view kept the authenticated snapshot in its own state, so
signing in as somebody else — or a change of Apple account — left the previous
login's rows, title and destructive affordances on screen. It now discards them
the moment either lands and loads again, which re-resolves ownership and falls
back to the public endpoint.

Also documents two residuals rather than pretending to close them: equal rows
are indistinguishable to a positional API, so verify-before-remove cannot
protect a particular occurrence of a repeated video; and the credential recheck
is a fence, not a lock, because Keychain state cannot be made atomic with a
remote send.
2026-09-03 20:43:21 +02:00
Arkadiusz Fal
b4855f5be9 Read a playlist back before writing over it
Ten review findings in the remote-playlist work, all of them the same
shape: something acted on what it remembered instead of what the server
says now.

- A confirmed-gone playlist is forgotten whole — snapshot, list entry,
  and both ordering fences — so a refresh that started before the delete
  can no longer republish it, and a list refetch that fails offline no
  longer leaves it on screen.
- `.accountsDidChange` discards account-scoped content when the account
  it belonged to is no longer the effective one, as the status
  notification already did.
- A tab that stops being offered no longer stays selected: the browse
  view moves to the first available one, which reloads by itself.
- A Handoff account id is accepted only when that account is on the
  instance the playlist lives on.
- Composite writes moved up into `AccountService`, over a transport whose
  every method is exactly one request: create-with-privacy, rename, and
  set-description. The credential is rechecked between each pair, so a
  sign-out between the two halves of a create or an edit can no longer
  send the second half under the previous user's secret.
- Editing a playlist no longer forces it private. Invidious serialises
  visibility as `isListed` — `privacy.public?`, so unlisted and private
  are indistinguishable — which the snapshot now carries; the picker
  starts there, and sends nothing at all unless it was moved. Invidious
  keeps the stored visibility when the PATCH omits it (measured), so an
  untouched picker leaves it exactly as it was.
- Tapping Edit on a server-side playlist awaits an authenticated refetch
  first and populates the form from it; a failed refetch shows an error
  and no sheet.
- Playlist rows are keyed by row rather than by video id, which two rows
  of the same video share.
- The add-to-playlist sheet offers only accounts whose instance is
  enabled and that hold a credential, alongside the write capability.
2026-09-02 23:59:31 +02:00
Arkadiusz Fal
24b43ac866 Aim a removal at the row the server counts
Remote playlist entries carried the display row number as their server
position, so a row the app could not parse shifted every removal after it
by one, and rows were paired back to entries by video id, which cannot
tell two copies of the same video apart.

- Piped entry positions are now the raw stream index; entries carry a
  separate displayRow for the row they are drawn as, filled in by both
  snapshot builders and used by the detail view's row map
- an emptied description clears a server-side playlist instead of being
  read as "leave it alone"
- a delete that reported failure forgets a playlist the refetch 404s on,
  and drops it from the account's cached list
- the browse view discards account-scoped content when a status change
  moves which account the instance resolves to
- every mutation re-reads the credential immediately before it is sent,
  which matters most after Piped's verify-before-remove fetch
- Handoff carries the owning account id, so a Piped playlist keeps its
  ownership on the receiving device
2026-09-02 23:56:59 +02:00
Arkadiusz Fal
f0ebe9b612 Reach a playlist through the account that owns it
The app could show a server-side playlist and nothing else. Editing was
never the missing part on its own: nothing on screen knew whose playlist
it was looking at, and without an owner there is no credential to fetch it
with, no row identity to remove by, and nobody to send a change to.

So the destination carries the account. Only Invidious ids hint at
ownership — the IVPL prefix — and that heuristic stays for links that
arrive from elsewhere; a Piped playlist looks exactly like any other, so
the places that already know whose list a row came out of now say so. An
owned playlist is then fetched authenticated, which is both the only way
to see a private one and the only way to learn what the server calls its
rows. A playlist read from the public endpoint has no row identities at
all, and that is precisely why nothing about it may be changed: the edit
affordances appear over the authenticated snapshot or not at all.

Rows are paired with entries by walking the two lists together rather
than by index. Entries are a subsequence — a row the server declined to
name is still displayed, it just can never be a target — and matching on
the video alone would be wrong too, since a playlist may hold the same
video twice and each copy is its own row.

Every change goes through the cache, so the refetch that reconciles it
cannot be skipped, and what the view shows afterwards is only ever what
came back. That includes the failures: a removal aimed at a row that has
since moved says so specifically, over a list that has already been
refreshed.

Adding a video now offers every account that can be written to, on every
instance rather than the selected one alone — this is not a browsing
decision. Those rows never claim the video is already there: the list
fetch does not carry a playlist's videos, so membership is unknowable
from here, and a duplicate is the server's business. Capabilities decide
throughout, never the instance type, which is what finally lets a Piped
login list and edit its playlists in the browse tab beside an Invidious
one — and what keeps the visibility picker away from a backend that has
no such setting.

Handoff leaves the account out of its payload deliberately. It assembles
its userInfo key by key, so the added value changes nothing about the
coding either way, but an account is only usable on a device holding its
credential — the receiving device resolves ownership for itself.
2026-09-02 23:44:07 +02:00
Arkadiusz Fal
b2741bbb32 Let an account change the playlists it owns
Reading a server-side playlist was all the app could do: the write half of
both APIs was missing, and the only thing that knew which credential to use
was the subscription layer. So playlists arrive here as an account concern —
hand AccountService an account and it finds the instance, reads the secret,
refuses what the backend cannot do, and records what the answer implies
about the login.

Capabilities decide, never the instance type: a privacy setting sent to a
backend that has none is dropped rather than refused, because the playlist
is still perfectly creatable there. Every call captures the credential it
used and re-reads it before recording an outcome, so a late 401 cannot
reject the login the user just fixed, nor a late success clear a rejection
it never earned.

Removal is where the backends stop being alike. Invidious names the row
itself with an indexId that survives refetches, so it removes directly.
Piped removes by position, and a position is only true of the fetch that
minted it — so the playlist is refetched and the position checked to still
hold the same video before anything is deleted. That narrows the window; it
cannot close it, and only the refetch afterwards shows what really happened.

The cache exists so that refetch cannot be forgotten. Every mutation goes
through it and republishes what it touched — on failure as much as on
success, because a write that reported failure may still have landed — and
nothing is ever edited optimistically. It is in memory alone: the data
belongs to a login rather than to this device, and a stored Piped entry id
would be aimed at whatever occupies that slot next launch. A per-account
generation fences results that come back after the account was cleared,
since clearing has never cancelled the request that was filling it.

Along the way, clients minted per instance sent no User-Agent at all —
instances that whitelist the Yattee prefix answered them with a 401 the
shared client never earned. The factory now bakes the app's User-Agent in
at construction, where no request can outrun it.
2026-09-02 23:29:13 +02:00
Arkadiusz Fal
de4ee9a728 Give remote playlist rows a name of their own
Playlist fetches threw away the only handle a row could be removed by:
Invidious hands out an index/indexId pair per entry and both were dropped
on conversion, so nothing downstream could say which row it meant.

Both authenticated fetches now also return a snapshot — the playlist plus
its addressable entries. Invidious entries carry the server's indexId and
survive refetches; Piped has no such token, so its entries are addressed
by position and are only valid inside the snapshot that minted them. The
entry id carries the playlist id in both cases, so position 3 of one
playlist can never be mistaken for position 3 of another.

A row needs both an indexId and an index to be a target: rows missing
either are still displayed but never appear in entries, so no removal can
be aimed at a row the server refuses to name. Page-overlap dedupe stays
keyed on index and never on videoId — the same video may sit in a
playlist twice, and each copy is its own removable row.
2026-09-02 19:44:12 +02:00
Arkadiusz Fal
9b5d2b001f Probe before rejecting a credential
Invidious answers a revoked session with 403, not 401, so the failure
reached recordServerOutcome as a plain HTTP error and fell through the
switch: an account whose session died server-side kept reporting
signedIn forever, and credentialRejected was unreachable on the
commonest backend. The roadmap's rule cuts the other way too — a raw
401/403 alone must never reject, because the failure may be about the
operation (someone else's playlist) rather than the login.

recordServerOutcome now treats .unauthorized and HTTP 401/403 alike as
suspicion, not verdict: after the existing basic-auth-proxy exclusion,
it hands the account to a new CredentialProbeService, which re-asks the
server with a harmless authenticated read — Invidious's auth
subscriptions endpoint with the same SID, Piped's subscriptions with
the same token. Only a probe-level 401/403 marks the account
credentialRejected; success means the original failure was
operation-specific and the account stays signedIn; an unreachable
probe records exactly that, and anything ambiguous leaves the status
alone.

The probe applies its verdict only if the account still exists and
still holds the credential it tested — a replacement or an identity
flip mid-flight drops the answer, and a dropped answer starts no
cooldown, so the login the user just fixed is examined without delay.
One probe per account may be in flight; an answered probe stands for a
cooldown before the next suspicion re-asks.

With rejection finally reachable, the existing machinery engages on
its own: the validators fall back to local data, server mutations
pause, and a successful call or re-sign-in lifts the mark.
2026-09-01 23:56:26 +02:00
Arkadiusz Fal
5a77e93558 Quarantine the unresolved account namespace
The accounts.unresolved.* UserDefaults namespace was found on a real
device holding a stale, contradictory snapshot: a tombstoned account
listed live, context reset to local, every sync outbox still pending.
With iCloud Drive off the identity scope dips to unresolved on every
foreground activation, so that snapshot was loaded — and rewritten —
in an entirely ordinary configuration.

Close it off on four fronts:

- Gate the unprompted writers. reconcileLegacyAccounts() and
  migrateLegacyContextIfNeeded() now no-op while the identity scope is
  unresolved; reloadForCurrentIdentity() re-runs both once a scope
  resolves, so nothing is lost, and nothing synthesizes accounts or
  queues uploads under a namespace that answers to no one.

- Fence the reload gap. Between a scope change and this manager's
  observer reloading, namespace already names the new identity while
  memory still holds the old snapshot. loadedNamespace records what
  memory belongs to; save(), the stored-context flag, and every
  coordinator path that builds, enqueues, applies or confirms
  account-category records refuses when it mismatches. Staged
  downloads stay durable and re-apply after the reload.

- Tie confirmations and conflict retries to the identity that sent
  them. Every outbound account-category record carries an
  identityNamespace field; didSave honours only confirmations whose
  record carries the current identity's stamp, conflict resolutions
  inherit the local record's stamp and are dropped when foreign, and
  a skipped confirmation re-registers the durable outbox so nothing
  strands. The abandoning conflict branches re-register it too.

- Purge the stale blob. Once a scope resolves, the unresolved
  namespace keys and the unresolved credential-fingerprint store are
  removed; the activation-time validators no longer correct the
  context or rewrite the legacy selection from unresolved state.

Hardened through four adversarial review rounds; the last found
nothing.
2026-09-01 23:55:47 +02:00
Arkadiusz Fal
abd0f8ddbb Let only the adopting account retire its marker
Found on the iPad: after signing a second login into an instance, its
migrated account could no longer retire the unscoped legacy item when it
was deleted, and the instance was left still claiming a pre-revamp login.

Storing a credential retires the adoption marker, but it matched on
service, instance and identity only. An instance holds several logins, so
a sibling sign-in silently withdrew the migrated account's claim on a
secret it had nothing to do with. After that the legacy item outlived the
account that adopted it, and could be adopted a second time.

Erring that way costs nothing — an item that is not deleted is an item
nobody is logged out of — but the marker already carries the account it
was written for, and deletion authority already checks it. Retiring it
now matches on the same thing.
2026-09-01 23:52:59 +02:00
Arkadiusz Fal
4a1378f8d6 Close the two ways an adopted secret could still be uploaded
Review of the silent migration found two paths that defeat the point of
writing the adopted copy device-local.

The marker is written after the copy it describes, so a launch that ends
in between leaves an adopted credential nothing claims — and adoption
never retries, because the key is no longer empty. Enabling iCloud sync
then rewrites it as synchronizable. The resync now also recognizes the
value: a scoped credential identical to the unscoped item it could only
have come from is an adopted copy whether or not a marker says so.

The opposite failure is a marker that outlives what it described.
Signing in again replaces the credential with one the user proved they
hold, but the marker stayed, so enabling sync afterwards skipped the
account and left every other device showing a login it never receives.
Storing a credential now retires the marker.

Also corrects four comments that still describe adoption as something
the user is asked about, and states plainly what the legacy-deletion
conditions do and do not prove now that no one is asked.
2026-08-31 23:59:37 +02:00
Arkadiusz Fal
53adfd2d66 Migrate a saved login without asking about it
The pre-revamp release reads its unscoped Keychain item with
kSecAttrSynchronizableAny and no identity in the key, so a credential
retained across an Apple Account change is already served to whoever
holds the device. Asking before reusing it prevents nothing that is not
already happening, and it cost every upgrading user a trip into the
instance editor to find a button they had no reason to look for.

What silent adoption could genuinely add is re-filing that secret as
synchronizable under the identity signed in now, which uploads a login
of unproven provenance into that person's iCloud Keychain and spreads it
to their devices. So the copy is written device-local instead, and the
migration runs where the status is computed. The real owner loses
nothing: the item it was copied from syncs, so each of their devices
finds it and adopts its own copy, and adoption still never deletes it.

Two things keep that closed. Turning iCloud sync on rewrites stored
credentials to follow the setting, which would undo the whole point, so
adopted ones are left out of it. And only the account the instance's
legacy login became may inherit the secret — the old item is keyed by
instance, so a second login the user added themselves would otherwise
match it.
2026-08-31 23:59:36 +02:00
Arkadiusz Fal
6a2b3250ce Remember credential and instance deletions across launches
The credential digests are persisted. A login replaced on another device
while this one was not running left the Apple account, the context and
the status all unchanged, so nothing noticed on the next launch that the
cached feed belonged to whoever was signed in before.

Deleting an instance is remembered too. Its accounts may still be in
transit — CloudKit and the instance list travel separately — and one
arriving afterwards was installed as a permanent orphan, credential and
account record intact, with no screen yet that could remove it. Late
arrivals are now deleted like the rest.

The source editor and the instance browser recompute their signed-in
state when a status changes, not only when the account list does: a
credential arriving over iCloud Keychain moves nothing else, and the
authenticated imports and the Feed tab stayed hidden until the view was
rebuilt.
2026-08-28 22:54:37 +02:00
Arkadiusz Fal
055a8514d5 Expire the unreachable state visibly, and address the right scope
"Could not be reached" now lifts by mutating the status rather than by
having the getter compare the clock. Nothing re-reads a value because
time has passed, so the read fallback stayed in place and no request was
ever made to discover that the server had recovered.

The identity-scope notification names the scope it is about, and
everything namespaced by identity observes its own. A second scope
resolving would otherwise make every account list in the process reload
and drop its statuses — which is also what made the new test fail.

A disk load of the feed waits for any purge already asked for. The clear
runs in its own task, so a load starting afterwards could still reach
the file first and restore what the purge was about to delete.

The comment on orphaned accounts no longer promises a removal action
that does not exist yet — that screen is Phase 3 — and says what happens
until then.
2026-08-27 23:41:09 +02:00
Arkadiusz Fal
46db43c9b5 Let temporary account states recover on their own
"Could not be reached" now expires after a minute. It stops reads from
going to the server at all, so nothing could ever discover that the
server came back — and a timeout during an outage produces no
connectivity change to react to either. The credential is untouched
throughout; the next request settles it either way.

A rejection is remembered against the credential that earned it. Signing
in again on another device replaces that secret under the same key, and
preserving the rejection regardless left the newly synced credential
unusable with no way to prove otherwise.

An account that syncs ahead of its credential — the ordinary case on a
new device, since iCloud Keychain delivers on its own schedule and
announces nothing — is re-checked for a couple of minutes instead of
sitting at "sign in required" until something unrelated happens. It
gives up rather than polling forever: on tvOS the wait is permanent by
design.

The feed cache no longer restores a purged account's videos from disk if
the load was already in flight when the purge happened, and no longer
leaves its loading flag set afterwards — which blocked the replacement
refresh and left the new account on an empty feed.
2026-08-27 22:20:53 +02:00
Arkadiusz Fal
e094770047 Fence the last two caches and undo failed logins properly
Home feeds and subscription providers join the subscriptions feed in
dropping results that belong to a login the app has since moved off.
Clearing a cache does not cancel what is already awaiting the server,
and an Apple-account switch discarded providers without invalidating the
fetches holding them.

A sign-in whose credential the Keychain refused to store is now rolled
back rather than deleted. Creating an account also queues the permanent
marker that stops every other device synthesizing an account for that
instance's pre-revamp login; leaving it behind would suppress a still
valid legacy login everywhere on the strength of an account nobody has.
An account the server already confirmed is still deleted properly,
tombstone and all.

Caches with no recorded owner are discarded even when the identity
cannot be resolved at all — that is the same first launch after
upgrading, only offline.

An account whose credential is missing no longer hides a signed-in
sibling on the same instance, which made the whole instance report
itself as logged out. And connectivity returning recomputes statuses, so
an account marked unreachable retries instead of waiting for a
foreground transition it has no way to cause.
2026-08-27 20:45:54 +02:00
Arkadiusz Fal
cc0fd3e6f4 Fence stale feed results and stop guessing at unknown account state
Signing in again keeps the account ID and the context, so a request
already in flight under the previous credential could land afterwards
and write that user's private feed back into the cache and onto disk.
Feed results now carry the account they were requested for and are
dropped if it has moved.

An account with no recorded status was reported as "sign in required".
While the identity scope is unresolved nothing can read the Keychain
under the right scope, so that is the absence of an answer, not one — on
a tokenless or first-seen Apple account it told users with working
logins to sign in again and dropped reads to local data. It is now a
state of its own.

Transport failures mark the account unreachable instead of leaving it
"signed in", which is what the documented local read fallback keys on.
Neither that nor a rejection is overwritten by the Keychain-based
refresh any more: only the server can clear what the server said.

Disabling sync deliberately does not rewrite Keychain items to local.
That means deleting them, and deleting a synchronizable item propagates
to every device — "stop syncing this login" would sign the user out
elsewhere, which is not what the toggle says. The reasoning is recorded
at the call site.
2026-08-26 23:55:50 +02:00
Arkadiusz Fal
b37ca40d37 Settle account ties and stop discarding caches on every launch
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.
2026-08-26 23:53:29 +02:00
Arkadiusz Fal
ae2fee2d4c Make a second login on an instance reachable and orderable
An additional login could be created but never selected: the legacy
picker lists one entry per instance and always resolved to whichever
account signed in first. Account rows gain a "Use This Account" action,
and the subscriptions view reloads on the data context rather than on
the legacy setting — two logins on the same instance map onto the same
legacy value, so switching between them changed nothing on screen while
writes went to the other account.

Two devices migrating different legacy selections produced contexts with
identical epoch timestamps, and each refused the other's: a permanent,
silent divergence that both sides considered settled. Equal timestamps
are now broken by the selected account's ID, on the incoming side and in
the conflict resolver alike — a record that wins the upload must not be
refused on arrival.

Credential cleanup falls back to both managers whenever the backend
cannot be resolved, not only when the account is unknown here: an
instance deletion can arrive before the tombstone, and the credential
was then left in the Keychain.
2026-08-26 22:59:52 +02:00
Arkadiusz Fal
923e13565f Stop a fresh login from synthesizing a phantom migrated account
Signing in wrote `usesAccountLogin`, which syncs through key-value
storage independently of the account records. A device receiving that
flag before the account synthesized a migrated account for a login that
was never legacy — and the marker arriving later deliberately retracts
nothing, so the credential-less duplicate was permanent and uploaded.
The flag is pre-revamp state that migration only reads; accounts
describe logins now. `needsCredentials` reads the account list for any
instance that has one, so the badge it drives is unaffected.

A tombstone now dominates contexts as well as accounts: an offline
device can select an account another device deleted and publish that
choice with a newer timestamp, which would have left every device
pointing at an account that is never coming back.

Cached private feeds survived an Apple-account switch two ways. A scope
this device has seen before resolves while `CredentialIdentityScope` is
still being constructed, so the change notification fires before any
observer exists — startup now compares the persisted cache owner. And a
purge asked for before the disk cache is read was undone by the load,
which now carries it.

The need to re-bootstrap after dropping records for want of a resolved
identity is persisted. The engine may already have moved its token past
those records, so a launch that lost the in-memory flag would neither
replay nor bootstrap them.
2026-08-26 08:06:52 +02:00
Arkadiusz Fal
ff78cc0121 Make synced accounts usable the moment they arrive
A fresh device never adopted the synced context. The local value is a
default stamped when the manager is built, so it is always newer than a
choice made on another device, and ordering against it discarded that
choice. With no local decision to protect, the first synced context is
simply adopted.

An account arriving mid-session sat at "sign in required" until the app
was relaunched: nothing recomputed its status once the metadata — or its
instance — turned up, even when iCloud Keychain had already delivered
the credential. Both arrivals now recompute statuses and re-run the
legacy mirror, which was similarly stuck whenever the instance had not
arrived yet.

Signing in again keeps the same account and the same context, so nothing
downstream saw a change — but the credential may now belong to somebody
else, and their subscriptions and feed stayed on screen. The provider
and the account's caches are discarded on every credential replacement.

The deletion repair also cancels a context upload the deleted choice had
queued; sending it would have published the fallback under that choice's
timestamp, which not stamping it "now" exists to prevent.

Piped subscribe/unsubscribe and Invidious unsubscribe now report their
outcome like every other authenticated call, so a credential rejected by
one of them stops claiming to be signed in. Removing the last account
updates the source screen, which kept offering authenticated imports
that could only fail.
2026-08-25 23:59:38 +02:00
Arkadiusz Fal
f3e03e5790 Stop automatic context repairs from outranking real choices
Correcting away from a deleted account was stamped with the current time
and published. A device hearing about the deletion late would therefore
sink an explicit choice another device made in between, and push its own
automatic fallback over it. The repair now keeps the timestamp of the
selection it replaces and is never uploaded: every device performs the
same repair when it sees the deletion, so they converge without anyone
writing anything.

The tombstone is queued before the removal can persist. Deleting the
selected account also corrects the context, and that wrote storage first
— a crash in the gap left the account gone here and alive everywhere
else, with nothing owed to the server.

An identity reload replaces the context too, so it announces one. The
legacy selection and the feed cache, which is not keyed by account,
would otherwise keep serving the previous Apple account's data; the
cache is now cleared unconditionally on an identity change, and the
legacy selection falls back to local when the new namespace's context
cannot be mapped yet.

The legacy validator no longer corrects a selection whose account still
exists. It predates account-scoped credentials and reads "not signed in"
as "not available", which is now routinely temporary — an unresolved
identity scope, or a credential iCloud Keychain has yet to deliver — and
correcting on that desynchronized it from the data context, which is
preserved.

Background refresh routes on the effective context, so a disabled
instance no longer receives background requests.
2026-08-25 23:57:45 +02:00
Arkadiusz Fal
d366982ad8 Close account lifecycle gaps found in review
A synced context change had no effect on the pre-revamp
`subscriptionAccount` setting several views still read, nor on the feed
cache, which is global rather than account-keyed — so a selection made
on another device left this one showing the previous account's
subscriptions and videos. Both now follow every context change,
whoever made it.

Instances replaced wholesale by iCloud bypassed `saveInstances()` and so
posted no change notification. A legacy authenticated instance arriving
that way never reached account migration: no account row, and no action
to adopt its saved login, until an unrelated edit or a relaunch.

Deleting an instance now deletes the accounts on it, while the instance
is still resolvable — afterwards the account's credential manager cannot
be determined from its type and the credential would be stranded. Only
an explicit deletion cascades; an instance vanishing from iCloud leaves
its accounts orphaned, because absence is not a deletion.

A context whose instance has been switched off is temporarily
unavailable rather than valid. The account stays signed in, so nothing
about the account said the server had been turned off, and requests kept
going to it while the switcher already hid it.
2026-08-25 23:57:43 +02:00
Arkadiusz Fal
0c003c71cc Sync accounts through CloudKit records
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.
2026-08-25 23:56:58 +02:00
Arkadiusz Fal
bef119f70e Resolve contexts and credentials through real accounts
Round two of review on the accounts layer.

The settings picker mapped its legacy instance-keyed selection straight
onto DataContext(legacy:), which turns an instance UUID into an account
UUID. That identity only holds for migrated accounts - a login created
after the revamp has a random ID - so selecting a freshly added account
named one that does not exist and left the provider nil. Selections now
resolve through AccountsManager to an actual account on that instance,
preferring a signed-in one. The picker still lists one entry per instance
and so cannot show a second login; the Phase 3 switcher replaces it.

BackgroundFeedRefresher was missed in the previous sweep because it
resolves credentials differently: it routed on the legacy setting and
looked up "first enabled Invidious instance" with an instance-keyed
credential. After any new-style login that silently found nothing and
skipped the refresh. It now routes on the selected context and takes the
credential from that account.

Subscription reads used the raw stored context, so an account waiting on
its instance or credential rendered empty instead of falling back to local
data as the tri-state design specifies. Reads go through the validator's
effective context; writes deliberately do not, so a subscribe fails
visibly rather than landing in local data the user did not choose.

Account statuses are cleared when the identity namespace reloads, but
nothing recomputed them once a deferred scope resolution succeeded - a
synced account kept showing "sign in required" until relaunch. The scope
change now triggers a refresh, deferred by one turn so it cannot depend on
observer registration order relative to the store's own reload.
2026-08-24 23:59:46 +02:00
Arkadiusz Fal
2fded2e1dd Fix account defects found in review
Five findings against the accounts layer, all real.

A fresh login created a random-UUID account and then flipped
usesAccountLogin, which posts instancesDidChange and re-ran legacy
reconciliation immediately. That guard only looked for an account whose ID
equalled the instance ID, so it synthesized a second, credential-less
account alongside the real one. Signing in now marks the instance migrated,
and reconciliation treats any account on an instance as evidence its login
is already represented.

save() set hasStoredContext, so the save inside reconcileLegacyAccounts
marked the still-default .local context as explicitly chosen and the
legacy-context migration that ran immediately after did nothing. Upgrading
users with a selected server account silently lost it. Only setContext,
applySyncedContext and the migration itself record a choice now.

An Apple Account change re-resolved the credential scope but never told
AccountsManager to reload its identity-namespaced storage - the only reload
was a one-time launch task - so the previous identity's accounts, context
and outbox stayed live under the new one, as did cached per-account
subscription providers. The scope now posts when its resolved value
changes; the accounts store reloads and the provider cache is dropped.

Fourteen call sites still read the pre-revamp instance-keyed Keychain item,
which a new login no longer writes: reopening the source screen showed the
account as signed out, and feed, browse, home and import operations treated
it as unauthenticated. They now resolve the credential through the account
layer, preferring the selected context's account and otherwise any
signed-in account on that instance.

Signing in while the identity scope was still unresolved created the
account in a temporary namespace that reloadForCurrentIdentity later
discards, while credential storage silently refused the write and the UI
reported success. Login is gated on a resolved scope and verifies the
credential actually stored before reporting success, rolling back a
newly-created account if it did not.
2026-08-24 23:57:44 +02:00
Arkadiusz Fal
66f721e3ac Add Account model, AccountsManager and multi-login UI
Yattee 2 could hold at most one login per instance: a Keychain item keyed by
the instance UUID and a Bool on the instance. This adds the account layer
the rest of the revamp builds on.

DataContext (.local or .account) replaces SubscriptionAccount as the thing
that selects a data set. The legacy value maps onto it as an identity
mapping, because migrated accounts deliberately reuse their instance UUID.
Contexts carry an app-managed changedAt, and a migrated one is stamped at
the epoch so any explicit choice on any device outranks it whatever order
they arrive in.

AccountCapabilities makes what a backend supports a single switch in
InstanceType.accountCapabilities, verified against each API surface -
Invidious has history and playlist privacy, Piped has neither. Call sites
ask for capabilities instead of switching on instance type, which is the
seam the planned per-user Yattee Server accounts land on.

AccountsManager owns accounts and the selected context, stored per identity
scope so a switch of Apple Account cannot show or upload the previous one's
list. Legacy single-logins become accounts through a repeatable
reconciliation rather than a one-shot at launch, because instances arrive
over iCloud whenever they arrive; a permanent marker set is what makes
re-running safe, since usesAccountLogin stays true on the instance forever
and would otherwise recreate a deliberately deleted account. A dirty-flag
outbox records what still needs uploading so an offline change survives a
crash. The legacy subscriptionAccount selection is migrated one way,
reading local defaults first and then the raw KVS value - the previous
release published that key to iCloud but never read it back, so on a fresh
install it exists only there.

DataContextValidator replaces SubscriptionAccountValidator, which corrected
any selection it could not immediately verify. That was safe when a context
was just "an instance I am logged into", but with credentials arriving
asynchronously it would discard the user's choice during a slow Keychain
sync. Validity is now tri-state: anything that might still resolve keeps
the selection and falls back to local for reads only, with server mutations
disabled rather than silently retargeted at local data; only an actual
deletion corrects the stored context.

EditSourceView's single log in/out pair becomes a list of accounts with
per-row status, sign-in-again and sign-out-and-remove, plus the explicit
"use saved login from this device" action - the one-tap adoption that
replaced automatic adoption after the Keychain provenance measurement. On
tvOS a credential-less account offers a local sign-in instead, since Apple
never syncs app Keychain items there and the state will not resolve on its
own.

InstanceLoginView now returns the identifier alongside the credential so
the caller can file it in the Keychain beside the secret, keeping login
identifiers out of synced metadata.
2026-08-24 23:52:01 +02:00
Arkadiusz Fal
e7476bec9b Archive the identity token by its guaranteed coding contract
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.
2026-08-24 23:30:24 +02:00
Arkadiusz Fal
00a298b09f Order tokenless scope resolution with an identity generation
Sixth review pass, both findings in the tokenless path the fourth pass
introduced.

The staleness guard compared fingerprints, which is useless when there is
no ubiquity token: nil before the switch and nil after it, so a user-record
lookup started under account A was accepted as current after CKAccountChanged
had already announced B. The scope then read as resolved, the pending-resolve
replay was skipped because it only runs while unresolved, and B was left
pointed at A's credential namespace. Every async path now captures a
generation counter that every observed account change bumps, and refuses to
apply a result across it.

Foreground revalidation also kept serving the old tokenless scope across
both awaits, and indefinitely when the account status came back
undetermined - so a switch that happened while the app was suspended could
hand A's retained credentials to B. The scope is now dropped before the
confirmation rather than compared after it, which also let the bespoke
revalidation logic collapse into resolve(), where the signed-out,
signed-in and undetermined cases are already handled. A tokenless setup
briefly reports "can't tell yet" on each activation; the tri-state UI
covers that, and the alternative traded a real cross-account disclosure for
the convenience.
2026-08-24 19:59:25 +02:00
Arkadiusz Fal
d2e9264119 Revalidate a tokenless credential scope on account change
Fourth review pass, on a hole the previous commit opened.

Making a nil ubiquity token mean "unresolved" fixed the shared-local-scope
collision, but left a scope resolved from CloudKit alone with nothing local
to watch: with iCloud Drive off the token stays nil across a switch from
account A to B, so NSUbiquityIdentityDidChange never fires and the scope
kept pointing at A - and its retained credentials with it - until relaunch.

Two additions. CKAccountChanged is now observed alongside the ubiquity
notification, and a tokenless scope is dropped outright when either fires
because there is no fingerprint to re-derive it from. Foreground activation
also revalidates a tokenless scope against CloudKit, covering a switch
whose notification never arrived; it compares before replacing so the
common case never blanks out credentials, and an unreachable account leaves
the scope standing rather than flapping on every transient failure.

A token-derived scope is untouched by all of this - it changes only via the
notification, and a test pins that down.
2026-08-23 23:59:56 +02:00
Arkadiusz Fal
313403cc1e Stop a missing ubiquity token from meaning "signed out"
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.
2026-08-23 23:56:31 +02:00
Arkadiusz Fal
f5e88df403 Stop adoption rollback from revoking a synced credential
Two more issues from a second review pass.

Adoption verified its write by reading the key back and deleted it on any
mismatch. But a mismatch can also mean iCloud Keychain delivered a real
credential for that key between the add and the read - and since the delete
spans both synchronizable forms, it would propagate and revoke that
credential on every device. A readable value that is not ours is now left
strictly alone; only an unreadable key (the add reported success but
produced nothing usable) is rolled back so the offer can be retried.

An identity switch arriving while a resolution was in flight was dropped:
the observer's resolve() returned early on the isResolving guard, and the
in-flight one then bailed on its fingerprint check without starting a
lookup for the new identity, leaving credentials disabled until a
foreground transition or relaunch. Requests that arrive mid-resolution are
now replayed once the running one finishes.

Adds a fingerprint provider and an injectable backoff so identity
resolution is testable without a real Apple account or real seconds, plus
tests for retry, foreground recovery, mid-flight switching, signed-out
scoping and the cached-scope fast path.
2026-08-23 23:56:28 +02:00
Arkadiusz Fal
c4053d5100 Close identity and adoption holes in credential scoping
Four issues from an external review of the previous commit.

The scope was resolved once at startup, so signing out or switching Apple
account while the app kept running left it pinned to the old identity -
reads and writes would then reach the previous account's retained
credentials, which is the exact leak scoping exists to prevent.
CredentialIdentityScope now observes NSUbiquityIdentityDidChange and
re-resolves.

Legacy deletion authority checked only the instance and the identity, not
the account. On an instance with several accounts, deleting any of them
satisfied the marker lookup and could retire the legacy item another
account had adopted, logging out devices still on the previous release.
The marker's accountID is now required to match.

A first-time resolution that failed (launched offline, CloudKit
unreachable) was never retried, disabling credentials for the rest of the
process. resolve() now retries with backoff, and resolveIfNeeded() runs on
foreground activation.

Adoption re-checked nothing at execution time, so a scoped credential
delivered by iCloud Keychain between rendering the offer and tapping it
would be overwritten with the older legacy secret. Adoption now re-verifies
eligibility and writes with a non-replacing add, leaving a duplicate
strictly alone.

Adds regression tests for the sibling-account deletion and the adoption
race.
2026-08-23 23:39:06 +02:00
Arkadiusz Fal
9fd0a71035 Add identity-scoped, account-keyed credential storage
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.
2026-08-23 21:20:30 +02:00
Phil-Bastian Berndt
d46002e99a Chunk stateless feed requests to support more than 500 subscriptions (#967) 2026-08-23 12:22:07 +02:00