Handle resuming migration with app opened in multiple tabs

- Secure context:
  - Yes: allow only one tab to resume the migration
  - No: disable resume functionality
- Sync progress between tabs
This commit is contained in:
schroda
2026-06-06 17:45:43 +02:00
parent 4ec3c62ae5
commit fb439d058e
7 changed files with 111 additions and 30 deletions

View File

@@ -28,6 +28,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Migration**) Sort unselected matched entries by 1. their latest chapter, 2. their source priority, 3. their title
- (**Migration**) Change migration match exclude/include icons
- (**Migration**) Show the exclude/include button only for an entry with a selected match
- (**Migration**) Allow resuming a migration only in a secure context (localhost or https)
- (**Source/Extension**) Rename language "All" to "Multi"
- (**Reader**) Simplify changing settings in desktop sidebar
- (**Reader**) Ignore tap zone clicks while window does not have focus
@@ -48,6 +49,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Migration**) Fix being unable to retry failed search for a match without a selected match
- (**Migration**) Fix selecting a destination from a source browse search page
- (**Migration**) Fix being able to migrate an entry to itself
- (**Migration**) Fix resuming migration when app is opened in multiple tabs
- (**Reader**) Fix scrollbar appearing with "fit to widt/height/screen" page scale mode and applied safe area insets
- (**Reader**) Fix wrongly positioned mobile progress bar current page indicator
- (**Reader**) Fix mobile progress bar previous/next chapter button visibility on hover and while disabled

View File

@@ -212,7 +212,14 @@ const ReactRouterSetter = () => {
const ResumeMigration = () => {
useEffect(() => {
if (!MigrationManager.isActive()) {
MigrationManager.resume().catch(defaultPromiseErrorHandler('ResumeMigration'));
navigator.locks
.request('migration-executor', async () => {
const resumed = await MigrationManager.resume();
if (resumed) {
await MigrationManager.awaitCompletion();
}
})
.catch(defaultPromiseErrorHandler('ResumeMigration'));
}
}, []);

View File

@@ -21,6 +21,10 @@ class AppSessionClass {
get STARTUP_TIMESTAMP(): number {
return AppStorage.session.getItemParsed(STARTUP_TIMESTAMP_KEY, Date.now());
}
isSecureContext(): boolean {
return window.isSecureContext;
}
}
export const AppSession = new AppSessionClass();

View File

@@ -69,9 +69,15 @@ import isEqual from 'lodash/fp/isEqual';
import uniqBy from 'lodash/fp/uniqBy';
import { MigrationEntries } from '@/features/migration/MigrationEntries.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { AppSession } from '@/base/AppSession.ts';
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
import { d } from 'koration';
import merge from 'lodash/fp/merge';
const RESUMABLE_PHASES: readonly MigrationPhase[] = [MigrationPhase.SEARCHING, MigrationPhase.MIGRATING];
let initialResume = true;
const migrationStore = create<MigrationState>()(
devtools(
persist(
@@ -81,11 +87,12 @@ const migrationStore = create<MigrationState>()(
merge: (persistedState, currentState) => {
const persisted = persistedState as MigrationState | undefined;
if (!persisted || !RESUMABLE_PHASES.includes(persisted.phase)) {
if (initialResume && (!persisted || !RESUMABLE_PHASES.includes(persisted.phase))) {
initialResume = false;
return currentState;
}
return { ...currentState, ...persisted };
return merge(currentState, persisted);
},
},
),
@@ -94,6 +101,13 @@ const migrationStore = create<MigrationState>()(
const useMigrationStore = ZustandUtil.createStoreHook(migrationStore);
window.addEventListener('storage', (e) => {
const isMigrationStateUpdate = e.key === MIGRATION_LOCAL_STORAGE_KEY;
if (isMigrationStateUpdate) {
migrationStore.persist.rehydrate();
}
});
export class MigrationManager {
private static abortController: AbortController | null = null;
@@ -364,12 +378,28 @@ export class MigrationManager {
});
}
private static async awaitUserConfirmation(): Promise<void> {
await Confirmation.show({
title: t`Migration information`,
message: AppSession.isSecureContext()
? t`The migration runs on the client on the current device, NOT the server.\nAs long as the client is open, the migration will run in the background.\nThe client can be closed. The migration will be resumed once it gets opened again on the same device it got started on.`
: t`WebUI must be kept open. Migration can't be resumed`,
actions: {
confirm: {
title: t`Understood`,
},
},
});
}
static async startSearch(
destinationSourceIds: SourceIdInfo['id'][],
options: MigrationBulkSearchSettings,
): Promise<void> {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
await MigrationManager.awaitUserConfirmation();
const state = MigrationManager.getState();
const entryIds = Object.keys(state.entries).map(Number);
@@ -431,15 +461,7 @@ export class MigrationManager {
const migratableEntries = MigrationEntries.getMigratable(Object.values(MigrationManager.getState().entries));
await Confirmation.show({
title: t`Migration information`,
message: t`The migration runs on the client on the current device, NOT the server.\nAs long as the client is open, the migration will run in the background.\nThe client can be closed. The migration will be resumed once it gets opened again on the same device it got started on.`,
actions: {
confirm: {
title: t`Understood`,
},
},
});
await MigrationManager.awaitUserConfirmation();
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.MIGRATING;
@@ -530,12 +552,34 @@ export class MigrationManager {
return true;
}
static async resume(): Promise<void> {
static async awaitCompletion(): Promise<void> {
const completionPromise = new ControlledPromise();
const checkComplete = () => {
setTimeout(() => {
if (MigrationManager.getState().phase !== MigrationPhase.IDLE) {
checkComplete();
return;
}
completionPromise.resolve();
}, d(10).seconds.inWholeMilliseconds);
};
checkComplete();
return completionPromise.promise;
}
static async resume(): Promise<boolean> {
const { phase, migrateOptions, searchOptions, entries } = MigrationManager.getState();
const isResumeablePhase = RESUMABLE_PHASES.includes(phase);
if (!isResumeablePhase) {
return;
if (!AppSession.isSecureContext()) {
return false;
}
if (!MigrationManager.isResumablePhase()) {
return false;
}
const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
@@ -552,7 +596,7 @@ export class MigrationManager {
await MigrationManager.migrate(migratableEntries, migrateOptions);
return;
return true;
}
assertIsDefined(searchOptions);
@@ -570,6 +614,8 @@ export class MigrationManager {
});
await MigrationManager.search(pendingEntries, searchOptions);
return true;
}
static reset(): void {
@@ -685,17 +731,12 @@ export class MigrationManager {
return migrationStore.getState();
}
static isActive(): boolean {
const { phase } = migrationStore.getState();
return (
!!MigrationManager.abortController &&
(phase === MigrationPhase.SEARCHING || phase === MigrationPhase.MIGRATING)
);
static isResumablePhase(): boolean {
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
}
static hasPausedMigration(): boolean {
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
static isActive(): boolean {
return !!MigrationManager.abortController && MigrationManager.isResumablePhase();
}
private static getDestinationSourceIds(mangaSourceId: SourceIdInfo['id']): MigrationState['destinationSourceIds'] {
@@ -1196,6 +1237,12 @@ export class MigrationManager {
}
private static updateState(updater: (draft: MigrationState) => void): void {
// Only update the state if the tab is the active executor. Otherwise, updating the state will break the
// migration due to the executor tab rehydrating its state with a potentially outdated version
if (MigrationManager.isResumablePhase() && !MigrationManager.isActive()) {
return;
}
migrationStore.setState(updater);
}

View File

@@ -9,7 +9,7 @@
import type { MigrationMatch, TMigrationEntry } from '@/features/migration/Migration.types.ts';
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
import Paper from '@mui/material/Paper';
import { memo, useMemo } from 'react';
import { memo, useLayoutEffect, useMemo, useState } from 'react';
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
import { MigrationSourceEntry } from '@/features/migration/components/migration-entry/MigrationSourceEntry.tsx';
@@ -206,7 +206,11 @@ export const MigrationEntry = memo(
}) => {
const isTabletWidth = MediaQuery.useIsTabletWidth();
const entry = useMemo(() => MigrationManager.getUpToDateMigrationEntry(propEntry), [propEntry]);
const [entry, setEntry] = useState(propEntry);
useLayoutEffect(() => {
setEntry(MigrationManager.getUpToDateMigrationEntry(propEntry));
}, [propEntry]);
const destinationEntry = useMemo(() => {
const match = entry.searchMatches.find((matchEntry) => matchEntry.id === entry.selectedMatchMangaId);

View File

@@ -25,9 +25,11 @@ export const Migration = ({ tabsMenuHeight = 0 }: { tabsMenuHeight?: number }) =
const phase = MigrationManager.usePhase();
const { setOnBack } = useAppPageHistoryContext();
const isMigrationPage = SubpathUtil.getPathname() === AppRoutes.migrate.path;
useEffect(() => {
if (SubpathUtil.getPathname() !== AppRoutes.migrate.path) {
if (!MigrationManager.isActive()) {
if (!isMigrationPage) {
if (!MigrationManager.isResumablePhase()) {
MigrationManager.reset();
} else {
ReactRouter.navigate(AppRoutes.migrate.path);
@@ -43,6 +45,17 @@ export const Migration = ({ tabsMenuHeight = 0 }: { tabsMenuHeight?: number }) =
};
}, []);
useEffect(() => {
if (!isMigrationPage && phase !== MigrationPhase.IDLE && phase !== MigrationPhase.SELECTING_SOURCES) {
ReactRouter.navigate(AppRoutes.migrate.path);
return;
}
if (isMigrationPage && phase === MigrationPhase.IDLE) {
ReactRouter.navigate(AppRoutes.browse.path(BrowseTab.MIGRATE));
}
}, [phase]);
switch (phase) {
case MigrationPhase.IDLE:
case MigrationPhase.SELECT_SOURCE:

View File

@@ -3923,6 +3923,10 @@ msgstr "WebUI"
msgid "WebUI channel"
msgstr "WebUI channel"
#: src/features/migration/MigrationManager.ts
msgid "WebUI must be kept open. Migration can't be resumed"
msgstr "WebUI must be kept open. Migration can't be resumed"
#: src/features/settings/screens/About.tsx
msgid "WebUI version"
msgstr "WebUI version"