diff --git a/backlog/tasks/task-337 - Fix-transient-Linux-safeStorage-failure-poisoning-AniList-token-store.md b/backlog/tasks/task-337 - Fix-transient-Linux-safeStorage-failure-poisoning-AniList-token-store.md new file mode 100644 index 00000000..6e534600 --- /dev/null +++ b/backlog/tasks/task-337 - Fix-transient-Linux-safeStorage-failure-poisoning-AniList-token-store.md @@ -0,0 +1,39 @@ +--- +id: TASK-337 +title: Fix transient Linux safeStorage failure poisoning AniList token store +status: Done +assignee: [] +created_date: '2026-05-04 05:51' +updated_date: '2026-05-04 05:52' +labels: + - anilist + - bug + - linux +dependencies: [] +priority: high +--- + +## Description + + +AniList token store memoizes a false safeStorage availability result. On Linux this can happen before Electron/keyring readiness, causing later post-watch updates and setup saves to report missing login/encryption unavailable even after the keyring is available. + + +## Acceptance Criteria + +- [x] #1 A transient safeStorage unavailable result does not prevent a later stored AniList token load once encryption is available. +- [x] #2 A transient safeStorage unavailable result does not prevent a later AniList token save once encryption is available. +- [x] #3 Regression coverage protects the retry behavior. + + +## Implementation Notes + + +Changed AniList token store safeStorage probe to memoize successful probes only. Failed probes now return false without poisoning later load/save attempts, covering Linux startup windows where Electron safeStorage/keyring can be unavailable before app readiness but usable later. Added regression test for transient unavailable -> available load/save retry. + + +## Final Summary + + +Fixed a Linux AniList auth failure where an early safeStorage/keyring miss was cached for the whole process. Stored tokens now load and setup tokens can save after GNOME libsecret becomes available later in startup. + diff --git a/changes/337-anilist-safe-storage-retry.md b/changes/337-anilist-safe-storage-retry.md new file mode 100644 index 00000000..6330e689 --- /dev/null +++ b/changes/337-anilist-safe-storage-retry.md @@ -0,0 +1,4 @@ +type: fixed +area: anilist + +- AniList: Retried Linux safeStorage availability after transient keyring startup failures so stored tokens can load and setup tokens can save once GNOME libsecret becomes available. diff --git a/src/core/services/anilist/anilist-token-store.test.ts b/src/core/services/anilist/anilist-token-store.test.ts index a31d9d19..f36a96af 100644 --- a/src/core/services/anilist/anilist-token-store.test.ts +++ b/src/core/services/anilist/anilist-token-store.test.ts @@ -38,6 +38,24 @@ function createPassthroughStorage(): SafeStorageLike { }; } +function createTransientUnavailableStorage(): SafeStorageLike & { + setAvailable: (next: boolean) => void; +} { + let available = false; + return { + isEncryptionAvailable: () => available, + encryptString: (value: string) => Buffer.from(`enc:${value}`, 'utf-8'), + decryptString: (value: Buffer) => { + const raw = value.toString('utf-8'); + return raw.startsWith('enc:') ? raw.slice(4) : raw; + }, + getSelectedStorageBackend: () => (available ? 'gnome_libsecret' : 'unknown'), + setAvailable(next: boolean) { + available = next; + }, + } as SafeStorageLike & { setAvailable: (next: boolean) => void }; +} + test('anilist token store saves and loads encrypted token', () => { const filePath = createTempTokenFile(); const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); @@ -61,6 +79,27 @@ test('anilist token store refuses to persist token when encryption unavailable', assert.equal(store.loadToken(), null); }); +test('anilist token store retries safeStorage after transient encryption unavailability', () => { + const filePath = createTempTokenFile(); + fs.writeFileSync( + filePath, + JSON.stringify({ + encryptedToken: Buffer.from('enc:stored-token', 'utf-8').toString('base64'), + updatedAt: Date.now(), + }), + 'utf-8', + ); + const storage = createTransientUnavailableStorage(); + const store = createAnilistTokenStore(filePath, createLogger(), storage); + + assert.equal(store.loadToken(), null); + storage.setAvailable(true); + + assert.equal(store.loadToken(), 'stored-token'); + assert.equal(store.saveToken('new-token'), true); + assert.equal(store.loadToken(), 'new-token'); +}); + test('anilist token store migrates legacy plaintext to encrypted', () => { const filePath = createTempTokenFile(); fs.writeFileSync( diff --git a/src/core/services/anilist/anilist-token-store.ts b/src/core/services/anilist/anilist-token-store.ts index b34ce03b..60cd71f2 100644 --- a/src/core/services/anilist/anilist-token-store.ts +++ b/src/core/services/anilist/anilist-token-store.ts @@ -69,7 +69,6 @@ export function createAnilistTokenStore( `AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` + `Context: ${getSafeStorageDebugContext()}`, ); - safeStorageUsable = false; return false; } const probe = storage.encryptString('__subminer_anilist_probe__'); @@ -77,7 +76,6 @@ export function createAnilistTokenStore( notifyUser( 'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.', ); - safeStorageUsable = false; return false; } const roundTrip = storage.decryptString(probe); @@ -85,7 +83,6 @@ export function createAnilistTokenStore( notifyUser( 'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.', ); - safeStorageUsable = false; return false; } safeStorageUsable = true; @@ -96,7 +93,6 @@ export function createAnilistTokenStore( `AniList token encryption unavailable: safeStorage probe threw an error. ` + `Context: ${getSafeStorageDebugContext()}`, ); - safeStorageUsable = false; return false; } };