fix(anime): clarify bridge failures and update guidance

- Show actionable bridge errors and preserve episodes when details fail
- Check external bridges for updates without offering unsafe in-app installs
- Complete cleanup and ignore callbacks from closed Tsukihime sessions
This commit is contained in:
2026-09-16 23:25:21 -07:00
parent 93fb4eff3a
commit ca40cd6267
23 changed files with 653 additions and 149 deletions
@@ -182,7 +182,7 @@ test('with nothing installed the newest release is downloaded and marked', async
assert.ok(!(await readdir(managed)).some((entry) => entry.endsWith('.zip')));
});
test('findBridgeUpdate offers the newest release only to a managed install that is behind it', async () => {
test('findBridgeUpdate compares both managed and system installs with upstream', async () => {
const { calls, options } = fakeUpstream();
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.6.0' }, options), LATEST);
@@ -192,9 +192,12 @@ test('findBridgeUpdate offers the newest release only to a managed install that
assert.equal(await findBridgeUpdate({ origin: 'managed', version: null }, options), LATEST);
assert.equal(calls.length, 4);
// A system install is pacman's, so upstream is not even asked.
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), null);
assert.equal(calls.length, 4);
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), LATEST);
assert.equal(await findBridgeUpdate({ origin: 'system', version: LATEST }, options), null);
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.7.0' }, options), null);
// An unknown external version is not evidence that an update is needed.
assert.equal(await findBridgeUpdate({ origin: 'system', version: null }, options), null);
assert.equal(calls.length, 7);
});
test('findBridgeUpdate propagates a failed release listing', async () => {
+5 -5
View File
@@ -178,16 +178,16 @@ async function locateLatestBundle(options: BridgeReleaseOptions): Promise<Bundle
}
/**
* The newest release a managed install could move to, or null when it is
* current or is not SubMiner's to update. An install whose version cannot be
* read is offered the newest release: re-downloading is the way back to a
* known state. Network errors propagate; the caller decides how loudly.
* The newest release an install could move to, or null when it is current.
* An unreadable managed install is offered the newest release so it can be
* repaired; an unreadable system install cannot be compared. Network errors
* propagate; the caller decides how loudly.
*/
export async function findBridgeUpdate(
install: Pick<AnimeBrowserBridgeInstall, 'origin' | 'version'>,
options: BridgeReleaseOptions = {},
): Promise<string | null> {
if (install.origin !== 'managed') return null;
if (install.origin === 'system' && install.version === null) return null;
const latest = await locateLatestBundle(options);
if (install.version === null) return latest.tagName;
return compareBundleVersions(latest.tagName, install.version) > 0 ? latest.tagName : null;
@@ -107,19 +107,28 @@ test('a failed update check is logged and leaves the bridge ready', async () =>
assert.ok(logged.some((line) => /update check failed: rate limited/.test(line)));
});
test('a system install is never asked about updates', async () => {
test('a system install broadcasts available updates without allowing installation', async () => {
let asked = 0;
const { runtime } = await setup({
let staged = false;
const { runtime, states, stopped } = await setup({
ensureBinaries: async () => ({ ...OLD, origin: 'system' }),
checkBridgeUpdate: async () => {
asked += 1;
return LATEST;
},
stageBridgeUpdate: async () => {
staged = true;
throw new Error('must not stage a system bridge update');
},
});
await runtime.ensureBridge();
await tick();
assert.equal(asked, 0);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null);
assert.equal(asked, 1);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, LATEST);
assert.equal(states.at(-1)?.install?.updateAvailable, LATEST);
await assert.rejects(runtime.updateBridge(), /managed outside SubMiner/);
assert.equal(staged, false);
assert.deepEqual(stopped, []);
});
test('updateBridge stages, stops the old bridge, and restarts on the new install', async () => {
+2 -2
View File
@@ -218,12 +218,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
/**
* Ask upstream whether a managed install is behind, after the bridge is up
* Ask upstream whether the install is behind, after the bridge is up
* so a slow or failed GitHub call never delays a search. The answer lands
* in `install.updateAvailable` and is re-broadcast on the current state.
*/
async function checkForBridgeUpdate(handle: SidecarHandle): Promise<void> {
if (install === null || install.origin !== 'managed') return;
if (install === null) return;
try {
const latest = await deps.checkBridgeUpdate(install);
// The bridge may have been restarted or updated while we waited.
+72 -50
View File
@@ -67,57 +67,79 @@ test('on will quit cleanup handler runs all cleanup steps', async () => {
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
});
test('on will quit cleanup handler cleans jellyfin subtitle cache when stopping remote session fails', async () => {
const calls: string[] = [];
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: () => {},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => {
calls.push('stop-jellyfin-remote');
throw new Error('stop failed');
},
cleanupInternalSubtitleTrackCache: () => calls.push('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => calls.push('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => calls.push('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => calls.push('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => calls.push('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => calls.push('stop-discord-presence'),
});
for (const failedStep of [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
'cleanup-youtube-subtitles',
'cleanup-youtube-media',
'cleanup-remote-media-windows',
'stop-discord-presence',
'stop-sync-auto-scheduler',
]) {
test(`on will quit cleanup finishes every independent step after ${failedStep} fails`, async () => {
const calls: string[] = [];
const firstError = new Error(`${failedStep} failed`);
const recordCleanup = (step: string): void => {
calls.push(step);
if (step === failedStep) throw firstError;
if (calls.includes(failedStep)) throw new Error(`${step} also failed`);
};
const cleanup = createOnWillQuitCleanupHandler({
destroyTray: () => {},
stopConfigHotReload: () => {},
restorePreviousSecondarySubVisibility: () => {},
restoreMpvSubVisibility: () => {},
unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {},
stopSyncAutoScheduler: async () => {
await new Promise<void>((resolve) => setTimeout(resolve, 0));
recordCleanup('stop-sync-auto-scheduler');
},
clearWindowsVisibleOverlayForegroundPollLoop: () => {},
clearLinuxMpvFullscreenOverlayRefreshTimeouts: () => {},
destroyMainOverlayWindow: () => {},
destroyModalOverlayWindow: () => {},
destroyYomitanParserWindow: () => {},
clearYomitanParserState: () => {},
stopWindowTracker: () => {},
flushMpvLog: () => {},
destroyMpvSocket: () => {},
clearReconnectTimer: () => {},
destroySubtitleTimingTracker: () => {},
destroyImmersionTracker: () => {},
destroyAnkiIntegration: () => {},
destroyAnilistSetupWindow: () => {},
clearAnilistSetupWindow: () => {},
destroyJellyfinSetupWindow: () => {},
clearJellyfinSetupWindow: () => {},
destroyFirstRunSetupWindow: () => {},
clearFirstRunSetupWindow: () => {},
destroyYomitanSettingsWindow: () => {},
clearYomitanSettingsWindow: () => {},
stopJellyfinRemoteSession: () => recordCleanup('stop-jellyfin-remote'),
cleanupInternalSubtitleTrackCache: () => recordCleanup('cleanup-internal-subtitles'),
cleanupYoutubeSubtitleTempDirs: () => recordCleanup('cleanup-youtube-subtitles'),
cleanupYoutubeMediaCache: () => recordCleanup('cleanup-youtube-media'),
cleanupRemoteMediaWindows: () => recordCleanup('cleanup-remote-media-windows'),
cleanupJellyfinSubtitleCache: () => recordCleanup('cleanup-jellyfin-subtitles'),
stopDiscordPresenceService: () => recordCleanup('stop-discord-presence'),
});
await assert.rejects(cleanup(), /stop failed/);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
]);
});
await assert.rejects(cleanup(), (error) => error === firstError);
assert.deepEqual(calls, [
'stop-jellyfin-remote',
'cleanup-jellyfin-subtitles',
'cleanup-internal-subtitles',
'cleanup-youtube-subtitles',
'cleanup-youtube-media',
'cleanup-remote-media-windows',
'stop-discord-presence',
'stop-sync-auto-scheduler',
]);
});
}
test('should restore windows on activate requires initialized runtime and no windows', () => {
let initialized = false;
+22 -12
View File
@@ -37,6 +37,7 @@ export function createOnWillQuitCleanupHandler(deps: {
stopDiscordPresenceService: () => void;
}) {
return async (): Promise<void> => {
const cleanupErrors: unknown[] = [];
deps.destroyTray();
deps.stopConfigHotReload();
deps.restorePreviousSecondarySubVisibility();
@@ -44,7 +45,11 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket();
deps.stopTexthookerService();
const stopSyncAutoScheduler = deps.stopSyncAutoScheduler();
const stopSyncAutoScheduler = Promise.resolve(deps.stopSyncAutoScheduler()).catch(
(error: unknown) => {
cleanupErrors.push(error);
},
);
deps.clearWindowsVisibleOverlayForegroundPollLoop();
deps.clearLinuxMpvFullscreenOverlayRefreshTimeouts();
deps.destroyMainOverlayWindow();
@@ -66,20 +71,25 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.clearFirstRunSetupWindow();
deps.destroyYomitanSettingsWindow();
deps.clearYomitanSettingsWindow();
try {
deps.stopJellyfinRemoteSession();
} finally {
const runCleanup = (cleanup: () => void): void => {
try {
deps.cleanupJellyfinSubtitleCache();
} finally {
deps.cleanupInternalSubtitleTrackCache();
cleanup();
} catch (error) {
cleanupErrors.push(error);
}
};
try {
runCleanup(deps.stopJellyfinRemoteSession);
runCleanup(deps.cleanupJellyfinSubtitleCache);
runCleanup(deps.cleanupInternalSubtitleTrackCache);
} finally {
runCleanup(deps.cleanupYoutubeSubtitleTempDirs);
runCleanup(deps.cleanupYoutubeMediaCache);
runCleanup(deps.cleanupRemoteMediaWindows);
runCleanup(deps.stopDiscordPresenceService);
await stopSyncAutoScheduler;
}
deps.cleanupYoutubeSubtitleTempDirs();
deps.cleanupYoutubeMediaCache();
deps.cleanupRemoteMediaWindows();
deps.stopDiscordPresenceService();
await stopSyncAutoScheduler;
if (cleanupErrors.length > 0) throw cleanupErrors[0];
};
}