Commit Graph

170 Commits

Author SHA1 Message Date
Arkadiusz Fal
794b2b0181 Let accounts be set inactive without signing them out
An inactive account keeps its credential and syncs like a rename, but
drops out of every picker and cannot be used until activated again.
Deactivating the selected account switches to the local context as a
published choice, so other devices follow instead of falling back.
2026-09-19 23:58:24 +02:00
Arkadiusz Fal
de8ed91029 Use a scheme-adaptive background for tvOS full screen covers
tvOS full screen covers are transparent, so each one filled itself with
a hardcoded black — invisible black-on-black text under the light color
scheme (e.g. the View All Comments page). A shared
tvFullScreenCoverBackground() modifier now picks black or light gray
from the color scheme; applied to the comments cover, subscription
destination picker, instance login, edit source, iCloud progress, and
legacy accounts import covers.
2026-09-19 23:04:01 +02:00
Arkadiusz Fal
5cd8d586c6 Add build date to the About settings view 2026-09-18 23:59:29 +02:00
Arkadiusz Fal
d22abf6088 Add an option to share videos via privacy frontend URLs
When enabled (the default), share links to YouTube videos point to the
active source's web watch page instead of youtube.com. Invidious serves
its frontend at the API URL; Piped uses its configured frontend URL.
Videos the active source can't serve (PeerTube, extracted sources) and
sources without a web frontend keep their original links.

The toggle lives in Settings > Layout & Navigation > Sharing on iOS and
macOS, and is shown only when an enabled source has a web watch page.
2026-09-18 23:57:34 +02:00
Arkadiusz Fal
172d712f25 Add an optional frontend URL for Piped instances used by browser actions 2026-09-18 23:36:11 +02:00
Arkadiusz Fal
2a816bd893 Move basic auth credentials into the Security section in the instance edit form 2026-09-17 23:59:28 +02:00
Arkadiusz Fal
101880d35f Route instance subscription imports through SubscriptionService with a destination picker
ImportSubscriptionsView wrote every imported channel straight into the
local SwiftData store via dataManager.subscribe(to:), regardless of the
selected account context. Imports now go through
SubscriptionService.subscribe(to:in:) aimed at a destination the user
picks — the local store or any logged-in account that supports
subscriptions, minus the source instance's own account, the same roster
the subscribe menus offer.

The already-subscribed marks are asked per destination through a new
SubscriptionService.subscribedChannelIDs(in:) helper (cold server
destinations fetch their list once, generation-fenced like the other
reads) and reload when the destination changes. Individual adds show a
per-row spinner while the server write is in flight, alreadySubscribed
counts as success, and Add All runs serially with an added/failed tally.

Also drop the "Invidious" wording from the import empty state — the
view serves Piped sources too.
2026-09-17 23:23:56 +02:00
Arkadiusz Fal
b413648ab9 Hold the Accounts settings entry until a source can hold an account 2026-09-16 22:45:41 +02:00
Arkadiusz Fal
ded53d7962 Fold the tvOS account actions into a press-raised dialog
Each account was four focusable rows tall on tvOS — Use this account,
Sign in again, Rename and Sign out all spelled out as siblings, a lot
of clicking past on a remote. The row itself is now the one focusable
unit: pressing it raises the same actions as a confirmation dialog,
mirroring the context menu the other platforms already attach.
2026-09-14 18:28:28 +02:00
Arkadiusz Fal
69d22cdc98 Withhold every account switcher until an account exists to offer 2026-09-13 20:16:14 +02:00
Arkadiusz Fal
5136b79f49 Let the user hide the account pickers outside Settings 2026-09-13 18:56:18 +02:00
Arkadiusz Fal
b8921387dd Let the accounts screen own the switcher instead of the settings list
The main settings list carried both an Accounts row and a switcher row
beside it — two account controls at the top level, one of them opening
the screen the other belonged on. The switcher row moves to the top of
the accounts screen as a Current Account section, where the selection
it names sits directly above the checkmarked row it corresponds to.

The subscriptions-settings row and the macOS sidebar footer keep
theirs: those are contextual surfaces, not the settings list.
2026-09-12 23:58:32 +02:00
Arkadiusz Fal
3281840a8e Let the login sheet respect its edges and the account footer tell the truth
MAC2/MAC15: the macOS login form is grouped like its Edit Source parent, so
labels sit inside the sheet and the add-flow step stops clipping; footers
wrap instead of truncating.
MAC12: the account section footer consults account statuses before claiming
the user is logged in.
2026-09-11 19:36:50 +02:00
Arkadiusz Fal
e661635079 Let every menu mark the current context and a disabled source admit it
MAC1: switcher rows are Toggles on macOS so the checkmark really renders,
and the settings row names its context via LabeledContent with a tooltip.
MAC4: accessible names are words, never raw SF Symbol identifiers.
MAC14: new displayStatus(for:) surfaces instanceDisabled on account rows,
badges, and the switcher tooltip without disturbing status(for:) consumers.
MAC16: disambiguated labels guard the username branch and fall back to the
source name only when rows would still collide.
Also carries the new Localizable strings for the whole macOS fix batch.
2026-09-11 08:02:26 +02:00
Arkadiusz Fal
9562510c67 Let a failed detect offer a retry instead of a dead Add Source
After detection failed, Add Source stayed enabled and tapping it just
re-ran detection into the same error — an enabled-looking button that
visibly did nothing, forever (TV8). The error state now shows the
button that can help, Retry Detection, holds a perceptible detecting
beat even when the host fails instantly, and hides the display-name
section that made the form read as submittable. Add Source itself now
requires a detected type, so it can no longer render enabled without
one.
2026-09-10 23:50:24 +02:00
Arkadiusz Fal
0eee1d5d73 Let Edit Source account actions be rows focus can reach on tvOS
The account rows hung everything off a contextMenu, which tvOS can
only open on a focused element — and a bare Form row is not one, so
focus skipped from Password to Add Account and the actions were
unreachable (TV2). The Accounts screen already solved this by
emitting the actions as sibling rows on tvOS; the section now shares
that shape, keeping the context menu everywhere else.
2026-09-10 20:42:07 +02:00
Arkadiusz Fal
011ffee53c Let the words fit the things they describe
tvOS UX campaign polish (TV1, TV3, TV5, TV6):
- Invidious sign-in asks for a Username, which is what it verifies;
  the wire field is still called email, noted where it is sent
- Video counts pluralize ("1 video"), via catalog plural variations;
  the two-argument count-and-duration key uses fully positional
  substitutions, since a mixed format made CFRetain fault on the
  integer argument (TV10)
- Character counters render their limits verbatim ("0/1000"), here
  and in the preset-name and bookmark-note counters with the same bug
- Deleting a playlist, local or remote, names the playlist the way
  removing an account already named the account
2026-09-10 20:34:46 +02:00
Arkadiusz Fal
b67b3472f6 Let a purge be answered once and a re-seed be answered until it sticks
The two answers to a deleted zone were sharing one lifecycle, and only one of
them deserved it. A re-seed only adds, so it is held open until the engine has
actually serialized the work and redone for as long as it has not. A purge
removes, and replaying it removes more than it was asked to — the account added
while the app kept running, the category switched back on, the reset that runs
its own upload afterwards. It is done the moment its durable half is, and the
comment that called both idempotent now says which is which.

The note itself was never scoped to an identity. A marker written under one
Apple account replayed against the next one's namespace, answering a wipe that
never happened there, and a purge would have taken that account's staged records
and its outbox with it. The account change forgets it now, the way it forgets
everything else it holds.

Retiring the marker trusted whichever state update arrived next, which is not
the same as one that carries the work: an update handed over before the recovery
registered anything can still reach the main actor after it, describing a state
in which none of it exists. Deliveries are counted where the engine hands them
over, and only a later one can retire what was armed.

A refused re-seed waited for the next launch. The accounts it could not speak
for announce themselves when they load, and the recovery is answered then —
outside the callback tree that announcement comes from.

A purge left two ways back for what it dropped: a pending zone save would have
recreated the zone on the next send, and the pre-engine pending-change keys
would have been rediscovered on a later launch. It clears both, and it now runs
after everything that puts work into the engine rather than before.

The account half fences before it destroys, like its upload twin, so a purge
arriving during the identity reload gap answers with nothing rather than with
somebody else's records.

And the reset that promises a re-upload is refused while sync is off, where
nothing would have performed one.

Re-entering the engine from inside its own callback is what started all of this,
and nothing in the code said so. A task-local marks the callback tree and the
two entry points assert on it in debug builds — a plain Task inherits it, which
is the mistake; Task.detached escapes it, which is the fix.
2026-09-09 23:59:59 +02:00
Arkadiusz Fal
0c6c7763db Let an untouched Save keep the name it was already showing 2026-09-09 23:59:57 +02:00
Arkadiusz Fal
2e1f411cc8 Let the label the account syncs be one the user can set 2026-09-09 23:52:54 +02:00
Arkadiusz Fal
f1e034111c Prune a record only once nothing of it is left behind 2026-09-08 22:23:30 +02:00
Arkadiusz Fal
92a8777e0a Let a signed-out import be driven without an Apple account 2026-09-08 19:41:43 +02:00
Arkadiusz Fal
02efc71e7c Tell the accounts apart, and let a disabled source come back 2026-09-07 18:53:21 +02:00
Arkadiusz Fal
5ac6b7188d Ask what a backend supports instead of what it is 2026-09-07 18:48:50 +02:00
Arkadiusz Fal
0e289ae920 Show a history from the account that recorded it 2026-09-06 16:23:28 +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
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
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
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
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
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
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
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