Commit Graph

424 Commits

Author SHA1 Message Date
Arkadiusz Fal
d41c6938f2 Let go of a playlist before deleting it
Deleting a local playlist from its own detail view took the app down every
time: the model is gone the moment the context saves, dismissal is animated
so the view renders at least once more, and the summary line under the
title reads the playlist's total duration. Reading a deleted model traps in
SwiftData with "context is missing", which is a fatal error rather than a
nil.

The state is cleared before the deletion now, so the renders that follow
have nothing to reach for. Everything else the view shows about a local
playlist is a copy — the rows are `Video` values built at load time — so
letting the model go costs the dismissal nothing.

Predates the accounts revamp; the same three lines are on `main`.
2026-09-05 23:38:06 +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
175b98097a Put the account switcher where the data is, and retire the old setting
Switching accounts was buried in Subscriptions settings behind a picker
and a confirmation dialog, and it wrote two stores that could disagree.
One shared switcher now sits in the Subscriptions and Playlists
toolbars, the macOS sidebar footer and the Settings root, and every path
goes through AppEnvironment.switchDataContext(to:) so a switch behaves
the same wherever it is made. Switching is one tap: it is reversible, so
there is nothing to confirm.

Rows come from DataContextValidator.availableContexts, name themselves
through displayName(for:), and carry a status suffix when the account is
not signed in — plus the instance host when two accounts share a name.
tvOS gets the same rows as a full-screen picker.

Subscriptions also explains itself when the selected account cannot
answer: a thin banner names the cause and says the data below is local.
This is the first consumer of the validator's unavailability, and
correctIfContextRemoved() now runs on foreground activation, where
SubscriptionAccountValidator used to auto-correct any selection it could
not immediately verify.

With the switcher reading the context directly, the legacy
subscriptionAccount setting has no readers left: the setting, its
validator, the mirror that kept it in step and the feed cache's
instance-keyed fallbacks all go. The cache invalidation the mirror also
carried stays, under its own name. AccountsManager still reads the raw
legacy key for the one-way context migration.
2026-09-03 23:59:32 +02:00
Arkadiusz Fal
c2df30dc6e Keep playlist affordances where an empty list took them
Three things live testing found, all of them where a playlist list or a
playlist is empty.

The picker said "This playlist has no videos" when there were no
playlists to pick — copy about a playlist's contents standing in for
copy about the list of them. Each section now says it has none, scoped
to where it is: the device, or that account. An account whose fetch came
back empty said nothing at all and left a bare gap.

The browse view's Playlists tab fetched into its own state, so a
playlist created through the picker — which goes through the shared
cache — never appeared, and nothing refetched on coming back to the tab.
It reads the cache now, and selecting the tab always refetches with the
rows already there kept on screen. The cache is keyed by account, which
is the discard on an account change the private copy needed a generation
for.

Removing the last video from a playlist took away the whole action row,
so an owned server-side playlist could no longer be renamed or deleted
from the app at all. The menu stays; only what needs videos to act on —
play, download, save to library — goes.
2026-09-03 23:16:24 +02:00
Arkadiusz Fal
29958606f6 Let only the newest playlist load land 2026-09-03 21:12:19 +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
2ba9f57d4c Let the account name the server its playlist lives on
An account opened over Handoff arrives without an instance, and a Piped
playlist id looks like any other, so the instance was guessed from the id
and the guess landed on Invidious — where the ownership check then threw
away the one account that could read the playlist.

Leaving the detail view is also the honest answer when the reconciling
refetch finds nothing left: the playlist is gone, whether a delete that
reported a failure landed anyway or the account was cleared.
2026-09-03 18:27:39 +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
b3c4216013 Let every request reach an instance the same way
A handful of call sites built their own InvidiousAPI or PipedAPI over the
shared HTTP client, which quietly opted them out of everything an instance
may require of a request: the reverse-proxy basic auth, the certificate
policy, and the User-Agent some instances whitelist and answer 401 without.
The feed on an instance behind a proxy could load in one place and fail in
another for no reason the user could see. They now all go through the
service that already configures a client per instance.

Login was the site that had noticed. It reproduced the basic-auth handling
itself, including the header Invidious' login needs passed explicitly
because that POST runs on its own session to read the SID out of a cookie.
That logic moves into the service verbatim rather than being dropped: the
view now asks for a credential and is told nothing about how it was
obtained.

Paging is the one thing the service could not already express. Invidious
feeds have pages and Piped feeds do not, so the paged variant says so in
its return value instead of leaving each caller to know.

The browse view's subscriptions arrive as channels now rather than as raw
Invidious rows, so the avatar enrichment works over channels — a fetched
or cached avatar still wins over whatever the listing carried, which is
the precedence the conversion applied before.
2026-09-02 23:53:33 +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
1e92db25f0 Name the login a row is actually holding
Signing in again as somebody else left the row naming the previous
person while the app had already switched to serving the new one's data.
The identifier lives in the Keychain beside the secret, so nothing
SwiftUI watches moves: same account, same context, and the status was
already signed-in.

The credential change is announced, so the section listens for it and
re-reads what it displays. On one instance the identifier is the only
thing telling two logins apart, which makes naming the wrong one worse
than showing no name at all.
2026-09-01 23:38:50 +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
35b7d313c9 Clear the import progress with the listing it describes
Replacing the list left the spinner and progress set against a row that
was going away, so the incoming listing rendered them against whichever
row carried that ID — another account's playlist, shown as importing,
until the abandoned import next noticed and cleared them itself.
2026-08-31 23:59:29 +02:00
Arkadiusz Fal
19c65e8f4b Carry the login itself, and clean up after any failed read
Two things were conflated. Reloading the list bumped the generation, and
the import treated that as its login having changed — so a routine
refresh aborted a healthy import and told the user their account had
moved. The listing and the login are separate facts: the login that
listed a row is now carried with it, and the generation decides only
what may be marked imported.

The local playlist is also created before it is read, so anything
stopping the read left it behind empty — a login that moved, but equally
a server that did not answer. The import reports whether it completed,
and an incomplete one takes its empty playlist with it. A playlist that
genuinely had nothing to add keeps it.
2026-08-31 23:17:44 +02:00
Arkadiusz Fal
24dd530cbc Do not mark a row imported once its listing is gone
The last write is followed by a pause, and an empty remote playlist
never reaches a check at all, so the list could be replaced before the
import reported itself finished. Marking it then attached to whichever
row carries that ID now — on another account, a different playlist
shown as already imported.

Clearing the in-progress state stays unconditional: it describes a row
that is no longer there either way.
2026-08-31 22:55:56 +02:00
Arkadiusz Fal
e767bb9a22 Take the listing at selection, not at import
Capturing it where the import starts was too late for the one case that
needed it. The merge dialog waits on the user; a reload landing while it
is open replaces the list and re-bases the credential, and confirming
afterwards captured the new generation and passed every check — reading
a row that no longer exists under whoever now owns the account.

The listing is recorded where the row is chosen and carried to the
import, so what answers the dialog is the list that asked the question.
2026-08-31 21:50:00 +02:00
Arkadiusz Fal
8a97c6da56 Tie an import to the listing it was chosen from
The credential baseline was recorded before the list it describes had
arrived, and cleared alongside it. A reload landing while an import was
still running therefore moved the baseline under it: the import passed
its check against a login that had never listed the row it was working
on, and a batch resumed through rows that no longer existed. A failed
reload left the same wrong baseline standing with the old rows still on
screen.

The baseline is now recorded where the rows are, so a failed load leaves
the previous login in place, and a generation names the listing itself —
captured by the batch and by each import, so replacing the list stops
what was chosen from it rather than re-authorising it.
2026-08-30 23:30:57 +02:00
Arkadiusz Fal
26cc4aba25 Bind the playlist import to the login that listed it
Checking that some credential exists was the wrong question. Everything
on screen was read with one particular login, and a replacement is a
different person's account on the same server — its playlist IDs mean
something else entirely, so importing "the same" playlist under it
fetches whatever that ID happens to name there. The credential the list
was loaded with is now recorded, and every import is checked against it
rather than against whatever is current.

The two checks around creating the local playlist are a suspension
apart, so the login can still go between them. Nothing has been read at
that point, and an empty playlist named after somebody else's is not
worth keeping.
2026-08-30 23:23:36 +02:00
Arkadiusz Fal
13b1fe9c0f Close the two ways a merge outlived its login
The merge dialog waits on the user, so the login can go between choosing
a playlist and confirming it. Reading it then returned quietly: the row
stayed in its importing state with nothing running, and a batch went on
to the next playlist. It now reports what happened and clears up after
itself, like every other way an import stops.

The merge that dialog starts is detached from it, so dismissing the
dialog was never the merge finishing — a batch resumed alongside it and
could begin the next playlist under whoever replaced the login. It is
waited for now, which is what makes the batch sequential.
2026-08-30 23:21:36 +02:00
Arkadiusz Fal
a9ef6ca0c5 Stop the whole batch when one import loses its login
Importing every playlist creates each local one before reading it, so
aborting a single import left the loop free to carry on: an empty
playlist for each remaining title, or the rest read under whoever
replaced the login. The batch now ends where the first abort happened,
and nothing is created for a login already known to be gone.
2026-08-30 22:35:01 +02:00
Arkadiusz Fal
00bab2ae63 Stop a playlist import that outlived its login
Videos are read with one account's credential and written one at a time
into a local playlist, which is not account-scoped: it belongs to
whoever uses the device and syncs to iCloud. The sheet can be dismissed
and somebody else signed in on the same server while the import is still
running — nothing cancels it — and the rest of one person's private
playlist would land in the other's library.

The credential is checked before every write rather than only at the
start. What was already written stays: it was imported under the login
that asked for it.
2026-08-29 23:56:18 +02:00
Arkadiusz Fal
395b8eb083 Refuse a login whose Apple identity moved under it
Storing checked that the identity scope was resolved, but not that it
was still the one the login was made under. Credentials are filed per
identity, and the round trip leaves room for the scope to move — the
account obtained here would then be written into somebody else's
namespace and synced to their Keychain.

Only a move between two named identities counts. Unresolved at either
end is left to the storing side, which already refuses to write under a
scope it cannot name; rejecting it here as well would turn an ordinary
sign-in that began before iCloud settled — or a tokenless scope
revalidating on activation, which it does every time — into a failed
login.
2026-08-29 23:50:47 +02:00
Arkadiusz Fal
361f19b762 Fence the instance browser's first load, not only its next page
Pagination carried the login it was requested for, but the load that
fills the tab in the first place did not. Its subscriptions, feed and
playlist requests all resolve well after the credential behind them can
have been replaced, and cancelling the previous task does not settle it:
a request that already came back races the cancellation and installs the
previous person's private videos over the purge that was meant to remove
them. Popular and trending stay unfenced — they are unauthenticated, and
discarding them on a change of login would cost a fetch for nothing.

Discarding the account-scoped content now also forgets how far the feed
had been read. The high-water mark belongs to the feed that was thrown
away; left behind, it gates pagination against a page the next login
never loaded, and that login cannot ask for a second page until its
first exceeds a count it knows nothing about.
2026-08-29 20:21:08 +02:00
Arkadiusz Fal
360b1ec0cb Fence the last in-flight paths across an account change
A home-feed disk load that was already suspended when the context or
Apple account moved no longer installs what it brings back: the
in-memory cache is empty by then, so the purge finds nothing to drop and
the previous login's private feeds would quietly reappear.

Instance-browser pagination carries the login it was requested for, so a
page still in flight cannot be appended to the next account's feed, and
the pagination state is reset with the content it belongs to.

The stateless-server refresh stops before touching anything when its
result is stale, rather than only rejecting the videos and going on to
set loading state and start polling for subscriptions nobody is showing.

Signing in re-resolves its target first. The round trip leaves room for
another device to delete the account, and the credential would have been
filed under one that no longer exists — reported as success. A
provisional account is rolled back when its backend cannot be resolved
at all, which is what a vanished instance looks like.
2026-08-28 23:59:48 +02:00
Arkadiusz Fal
9a93b0347e Tell the views when a credential is replaced
Signing in again as a different person on the same server changes
neither the account ID, the context, nor usually the status — so nothing
the views observe moved, and the previous user's subscriptions and feed
stayed on screen while actions went out under the new credential. There
is now a notification for exactly that, and the subscriptions view and
the instance browser discard and reload on it.

An account deleted after its instance disappeared retires its legacy
Keychain item too. Identifying that item needs the instance, but
checking it does not: the adoption marker is specific to service,
account, scope and the exact secret, so asking both managers retires it
if either owns it and does nothing otherwise.
2026-08-28 23:58:20 +02:00
Arkadiusz Fal
1c0a3e8e9a Keep one login's verdicts and content off another's
A feed failure recorded its verdict before checking whether anyone was
still waiting for it, and the verdict resolves the *currently* selected
account — so a 401 from a credential the app had already moved off could
mark its replacement, or a different account, as rejected indefinitely.

The instance browser drops its feed, subscriptions and playlists when
the selected login changes. Nothing else there would notice: the
instance, the tab and the "already has data" shortcut are all unchanged
between two accounts on the same server.

Caches are no longer discarded while the identity is unresolved. With
iCloud Drive off the scope drops to unresolved on every activation and
comes back the same, and the context reads as local in between — which
looked like a move and threw the caches away several times a day.

Local is always in the account picker again. Its feed needs a Yattee
Server and reports that itself; with correction no longer moving away
from local, leaving it out of the list made it unreachable once left.
And the subscriptions view reloads when an instance is switched off,
which moves reads to local without touching the context or any status.
2026-08-28 23:24:06 +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
e1ebb71511 Bind results to the account and identity they were requested for
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.
2026-08-28 19:58:27 +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
b07af6c153 Separate account status from the account list, and stop stranding accounts
Recomputing statuses reads the Keychain, which cannot know that a server
rejected a credential or failed to answer — and it ran in reaction to
the same notification those verdicts were announced with, so it erased
them as they were recorded. Status changes now have their own
notification. With that separation `unreachable` no longer has to be
preserved to survive: it is transient, and preserving it stopped every
read from reaching the server, so nothing would ever discover that the
network had come back.

A fetch already running when its provider is discarded now has its
result dropped. Signing in again — or switching Apple account — changes
neither the context nor the account ID, so the previous login's channels
would otherwise arrive as the current account's.

Caches with no recorded owner are discarded rather than adopted. The
first launch after upgrading finds feeds written before anyone recorded
whose they were, and the Apple account may have changed since.

The legacy validator's auto-correction moves the data context with it,
and Manage Channels reloads on the context rather than the legacy value
— it could list one account's channels while unsubscribes went to
another.
2026-08-27 18:19:29 +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
4f617b52e1 Stop a status refresh from erasing a rejected credential
The API layer marking an account `credentialRejected` posts the same
notification that recomputes statuses, which saw the Keychain item still
present and immediately reported "signed in" again. The rejection could
therefore never survive the moment it was recorded, and the user was
never asked to sign in again. Only the server can clear a rejection now;
storing a new credential resets it explicitly.

Cache invalidation follows the effective context rather than the stored
one. A credential arriving or disappearing flips reads between the
account and local without the stored context moving, so the global feed
caches kept whichever side had populated them.

The login sheet stays open when the credential could not be stored — an
unresolved identity scope or a Keychain failure — instead of dismissing
and discarding what the user typed.
2026-08-26 19:27:28 +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
7c966b18a9 Keep deleted credentials and cached feeds from outliving their account
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.
2026-08-25 23:59:17 +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
9065234606 Key subscription providers by account rather than backend type
SubscriptionService held one provider per backend and each resolved its
instance from settingsManager.subscriptionAccount, falling back to "the
first enabled Invidious instance" when that was unset. With several logins
possible on one instance that model breaks twice: two accounts would share
a single in-memory subscription cache, and the fallback could send an
account's request to a server it holds no credential for.

Providers are now built per account and cached by account ID, and each
resolves its instance and credential from the account it was built for -
no fallback. The protocol's accountType becomes a DataContext, which is
what actually identifies a provider now.

Views that branched on "is the subscription account local" read the context
instead, and the settings picker writes both stores. Keeping the legacy
setting in step is deliberate for now: several views still read it, and
letting the two diverge would show one account in the UI while serving
another's data. The Phase 3 switcher removes the last readers.

Signing out discards the account's provider so its cached subscriptions
cannot be served after the account is gone.
2026-08-24 23:56:40 +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
2d23820e14 Stop blind rollbacks in the Keychain delete paths
Fifth review pass, which prompted measuring whether SecItemDelete can be
conditioned on a value. It cannot: a delete issued with a deliberately
wrong kSecValueData still returns 0 and removes the item, so there is no
compare-and-delete and every check-then-delete is a race by construction.
That measurement decides both fixes here.

Adoption no longer rolls back its own write when the verification read
comes back empty. The rollback could only ever be a blind delete of
whatever occupied the key by then - including a credential synced from
another device in the gap - and it would propagate. Nothing is deleted; no
marker is written, so the account stays signed out and an ordinary sign-in
recovers it.

Legacy deletion re-reads immediately before deleting and aborts if the
value moved, shrinking the window to a single call gap and catching a
replacement that landed while the fingerprint was being computed. A
residual race remains and is documented at the call site: closing it is not
possible with these APIs, and never deleting would leave a credential the
user explicitly removed sitting in their iCloud Keychain.

The debug provenance report repeated the same nil-token ambiguity the scope
logic was fixed for, reporting "no Apple account signed in" when iCloud
Drive is merely off. It now reports the token as unavailable and shows
CloudKit's account status separately, so a re-run cannot draw a false
conclusion from it.
2026-08-23 23:59:59 +02:00