fix: stop AniList setup reopening on Linux when keyring token exists

- Gate setup success on token persistence: `saveToken` now returns `boolean`; on failure, keeps the setup window open instead of reporting success
- Config reload passes `allowSetupPrompt: false` so playback reloads don't re-open the setup window
- Add regression test for persistence-failure path
This commit is contained in:
2026-05-03 22:35:35 -07:00
parent 47161cd8a5
commit b8dc5db14a
10 changed files with 95 additions and 14 deletions
@@ -0,0 +1,39 @@
---
id: TASK-335
title: Fix Linux AniList setup gate using stored keyring token
status: Done
assignee: []
created_date: '2026-05-04 05:26'
updated_date: '2026-05-04 05:30'
labels:
- anilist
- bug
- linux
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
AniList setup page reopens on Linux video launch even when the token exists in secret storage and post-watch updates can use it. Investigate setup gating versus update token refresh paths and make them agree on stored-token availability.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launching a video on Linux with an AniList token available in secret storage does not show the AniList setup page just because config accessToken is empty.
- [x] #2 If secret storage load fails, setup/errors surface the underlying storage problem instead of behaving like an empty token.
- [x] #3 Regression coverage exercises the setup-gate token availability path and preserves post-watch update token behavior.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Patched AniList setup callback to require successful token persistence before caching/closing the setup flow. Patched config reload auth refresh to pass allowSetupPrompt:false so normal startup/playback reloads do not open AniList setup UI. Added regression coverage around persistence failure and non-prompting config refresh.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed AniList setup/login flow so failed encrypted token persistence no longer reports success or seeds only an in-memory token. Config reload now refreshes AniList auth state without opening the setup window during playback, reducing repeated Linux setup prompts when safeStorage/keyring resolution fails.
<!-- SECTION:FINAL_SUMMARY:END -->
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anilist
- AniList: Kept config reload from opening the setup window during playback when token storage cannot be resolved, and stopped setup login from reporting success when encrypted token persistence fails.
@@ -27,7 +27,10 @@ test('consume anilist setup token main deps builder maps callbacks', () => {
const calls: string[] = []; const calls: string[] = [];
const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({ const deps = createBuildConsumeAnilistSetupTokenFromUrlMainDepsHandler({
consumeAnilistSetupCallbackUrl: () => true, consumeAnilistSetupCallbackUrl: () => true,
saveToken: () => calls.push('save'), saveToken: () => {
calls.push('save');
return true;
},
setCachedToken: () => calls.push('cache'), setCachedToken: () => calls.push('cache'),
setResolvedState: () => calls.push('resolved'), setResolvedState: () => calls.push('resolved'),
setSetupPageOpened: () => calls.push('opened'), setSetupPageOpened: () => calls.push('opened'),
@@ -38,7 +41,7 @@ test('consume anilist setup token main deps builder maps callbacks', () => {
assert.equal( assert.equal(
deps.consumeAnilistSetupCallbackUrl({ deps.consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup', rawUrl: 'subminer://anilist-setup',
saveToken: () => {}, saveToken: () => true,
setCachedToken: () => {}, setCachedToken: () => {},
setResolvedState: () => {}, setResolvedState: () => {},
setSetupPageOpened: () => {}, setSetupPageOpened: () => {},
@@ -22,7 +22,7 @@ test('createNotifyAnilistSetupHandler sends OSD when mpv client exists', () => {
test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => { test('createConsumeAnilistSetupTokenFromUrlHandler delegates with deps', () => {
const consume = createConsumeAnilistSetupTokenFromUrlHandler({ const consume = createConsumeAnilistSetupTokenFromUrlHandler({
consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'), consumeAnilistSetupCallbackUrl: (input) => input.rawUrl.includes('access_token=ok'),
saveToken: () => {}, saveToken: () => true,
setCachedToken: () => {}, setCachedToken: () => {},
setResolvedState: () => {}, setResolvedState: () => {},
setSetupPageOpened: () => {}, setSetupPageOpened: () => {},
+2 -2
View File
@@ -1,14 +1,14 @@
export type ConsumeAnilistSetupTokenDeps = { export type ConsumeAnilistSetupTokenDeps = {
consumeAnilistSetupCallbackUrl: (input: { consumeAnilistSetupCallbackUrl: (input: {
rawUrl: string; rawUrl: string;
saveToken: (token: string) => void; saveToken: (token: string) => boolean;
setCachedToken: (token: string) => void; setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void; setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void; setSetupPageOpened: (opened: boolean) => void;
onSuccess: () => void; onSuccess: () => void;
closeWindow: () => void; closeWindow: () => void;
}) => boolean; }) => boolean;
saveToken: (token: string) => void; saveToken: (token: string) => boolean;
setCachedToken: (token: string) => void; setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void; setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void; setSetupPageOpened: (opened: boolean) => void;
+31 -3
View File
@@ -90,7 +90,10 @@ test('consumeAnilistSetupCallbackUrl persists token and closes window for callba
Date.now = () => 120_000; Date.now = () => 120_000;
const handled = consumeAnilistSetupCallbackUrl({ const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token', rawUrl: 'https://anilist.subminer.moe/#access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`), saveToken: (value: string) => {
events.push(`save:${value}`);
return true;
},
setCachedToken: (value: string) => events.push(`cache:${value}`), setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) => setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
@@ -120,7 +123,10 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
Date.now = () => 120_000; Date.now = () => 120_000;
const handled = consumeAnilistSetupCallbackUrl({ const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token', rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => events.push(`save:${value}`), saveToken: (value: string) => {
events.push(`save:${value}`);
return true;
},
setCachedToken: (value: string) => events.push(`cache:${value}`), setCachedToken: (value: string) => events.push(`cache:${value}`),
setResolvedState: (timestampMs: number) => setResolvedState: (timestampMs: number) =>
events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`), events.push(`state:${timestampMs > 0 ? 'ok' : 'bad'}`),
@@ -143,11 +149,33 @@ test('consumeAnilistSetupCallbackUrl persists token for subminer deep link URL',
} }
}); });
test('consumeAnilistSetupCallbackUrl keeps setup open when token persistence fails', () => {
const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'subminer://anilist-setup?access_token=saved-token',
saveToken: (value: string) => {
events.push(`save:${value}`);
return false;
},
setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'),
setSetupPageOpened: (opened: boolean) => events.push(`opened:${opened}`),
onSuccess: () => events.push('success'),
closeWindow: () => events.push('close'),
});
assert.equal(handled, true);
assert.deepEqual(events, ['save:saved-token', 'opened:true']);
});
test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => { test('consumeAnilistSetupCallbackUrl ignores non-callback URLs', () => {
const events: string[] = []; const events: string[] = [];
const handled = consumeAnilistSetupCallbackUrl({ const handled = consumeAnilistSetupCallbackUrl({
rawUrl: 'https://anilist.co/settings/developer', rawUrl: 'https://anilist.co/settings/developer',
saveToken: () => events.push('save'), saveToken: () => {
events.push('save');
return true;
},
setCachedToken: () => events.push('cache'), setCachedToken: () => events.push('cache'),
setResolvedState: () => events.push('state'), setResolvedState: () => events.push('state'),
setSetupPageOpened: () => events.push('opened'), setSetupPageOpened: () => events.push('opened'),
+6 -2
View File
@@ -10,7 +10,7 @@ export type BuildAnilistSetupUrlDeps = {
export type ConsumeAnilistSetupCallbackUrlDeps = { export type ConsumeAnilistSetupCallbackUrlDeps = {
rawUrl: string; rawUrl: string;
saveToken: (token: string) => void; saveToken: (token: string) => boolean;
setCachedToken: (token: string) => void; setCachedToken: (token: string) => void;
setResolvedState: (resolvedAt: number) => void; setResolvedState: (resolvedAt: number) => void;
setSetupPageOpened: (opened: boolean) => void; setSetupPageOpened: (opened: boolean) => void;
@@ -71,8 +71,12 @@ export function consumeAnilistSetupCallbackUrl(deps: ConsumeAnilistSetupCallback
return false; return false;
} }
if (!deps.saveToken(token)) {
deps.setSetupPageOpened(true);
return true;
}
const resolvedAt = Date.now(); const resolvedAt = Date.now();
deps.saveToken(token);
deps.setCachedToken(token); deps.setCachedToken(token);
deps.setResolvedState(resolvedAt); deps.setResolvedState(resolvedAt);
deps.setSetupPageOpened(false); deps.setSetupPageOpened(false);
@@ -15,7 +15,7 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
}, },
consumeTokenDeps: { consumeTokenDeps: {
consumeAnilistSetupCallbackUrl: () => false, consumeAnilistSetupCallbackUrl: () => false,
saveToken: () => {}, saveToken: () => true,
setCachedToken: () => {}, setCachedToken: () => {},
setResolvedState: () => {}, setResolvedState: () => {},
setSetupPageOpened: () => {}, setSetupPageOpened: () => {},
+1 -1
View File
@@ -50,7 +50,7 @@ test('createReloadConfigHandler runs success flow with warnings', async () => {
assert.equal(showedWarningDialog, process.platform === 'darwin'); assert.equal(showedWarningDialog, process.platform === 'darwin');
assert.ok(calls.some((entry) => entry.includes('actual=10 fallback=250'))); assert.ok(calls.some((entry) => entry.includes('actual=10 fallback=250')));
assert.ok(calls.includes('hotReload:start')); assert.ok(calls.includes('hotReload:start'));
assert.deepEqual(refreshCalls, [{ force: true }]); assert.deepEqual(refreshCalls, [{ force: true, allowSetupPrompt: false }]);
}); });
test('createReloadConfigHandler fails startup for parse errors', () => { test('createReloadConfigHandler fails startup for parse errors', () => {
+5 -2
View File
@@ -27,7 +27,10 @@ export type ReloadConfigRuntimeDeps = {
logWarning: (message: string) => void; logWarning: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void; showDesktopNotification: (title: string, options: { body: string }) => void;
startConfigHotReload: () => void; startConfigHotReload: () => void;
refreshAnilistClientSecretState: (options: { force: boolean }) => Promise<unknown>; refreshAnilistClientSecretState: (options: {
force: boolean;
allowSetupPrompt?: boolean;
}) => Promise<unknown>;
failHandlers: { failHandlers: {
logError: (details: string) => void; logError: (details: string) => void;
showErrorBox: (title: string, details: string) => void; showErrorBox: (title: string, details: string) => void;
@@ -72,7 +75,7 @@ export function createReloadConfigHandler(deps: ReloadConfigRuntimeDeps): () =>
} }
deps.startConfigHotReload(); deps.startConfigHotReload();
void deps.refreshAnilistClientSecretState({ force: true }); void deps.refreshAnilistClientSecretState({ force: true, allowSetupPrompt: false });
}; };
} }