fix: retry transient AniList safeStorage failures

This commit is contained in:
2026-05-03 23:00:11 -07:00
parent 402b58385d
commit 08dc8871d3
4 changed files with 82 additions and 4 deletions
@@ -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
<!-- SECTION:DESCRIPTION:BEGIN -->
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.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [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.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
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.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
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.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -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.
@@ -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', () => { test('anilist token store saves and loads encrypted token', () => {
const filePath = createTempTokenFile(); const filePath = createTempTokenFile();
const store = createAnilistTokenStore(filePath, createLogger(), createStorage(true)); 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); 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', () => { test('anilist token store migrates legacy plaintext to encrypted', () => {
const filePath = createTempTokenFile(); const filePath = createTempTokenFile();
fs.writeFileSync( fs.writeFileSync(
@@ -69,7 +69,6 @@ export function createAnilistTokenStore(
`AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` + `AniList token encryption unavailable: safeStorage.isEncryptionAvailable() is false. ` +
`Context: ${getSafeStorageDebugContext()}`, `Context: ${getSafeStorageDebugContext()}`,
); );
safeStorageUsable = false;
return false; return false;
} }
const probe = storage.encryptString('__subminer_anilist_probe__'); const probe = storage.encryptString('__subminer_anilist_probe__');
@@ -77,7 +76,6 @@ export function createAnilistTokenStore(
notifyUser( notifyUser(
'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.', 'AniList token encryption probe failed: safeStorage.encryptString() returned plaintext bytes.',
); );
safeStorageUsable = false;
return false; return false;
} }
const roundTrip = storage.decryptString(probe); const roundTrip = storage.decryptString(probe);
@@ -85,7 +83,6 @@ export function createAnilistTokenStore(
notifyUser( notifyUser(
'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.', 'AniList token encryption probe failed: encrypt/decrypt round trip returned unexpected content.',
); );
safeStorageUsable = false;
return false; return false;
} }
safeStorageUsable = true; safeStorageUsable = true;
@@ -96,7 +93,6 @@ export function createAnilistTokenStore(
`AniList token encryption unavailable: safeStorage probe threw an error. ` + `AniList token encryption unavailable: safeStorage probe threw an error. ` +
`Context: ${getSafeStorageDebugContext()}`, `Context: ${getSafeStorageDebugContext()}`,
); );
safeStorageUsable = false;
return false; return false;
} }
}; };