From fb439d058ebd723c5ad6442128435d4271306466 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:45:43 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 2 + src/App.tsx | 9 +- src/base/AppSession.ts | 4 + src/features/migration/MigrationManager.ts | 97 ++++++++++++++----- .../migration-entry/MigrationEntry.tsx | 8 +- src/features/migration/screens/Migration.tsx | 17 +++- src/i18n/locales/en.po | 4 + 7 files changed, 111 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df538edb..782d2898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 203e78a4..bf3b22ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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')); } }, []); diff --git a/src/base/AppSession.ts b/src/base/AppSession.ts index c233fafc..fcf783aa 100644 --- a/src/base/AppSession.ts +++ b/src/base/AppSession.ts @@ -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(); diff --git a/src/features/migration/MigrationManager.ts b/src/features/migration/MigrationManager.ts index 1895b702..23a2fd30 100644 --- a/src/features/migration/MigrationManager.ts +++ b/src/features/migration/MigrationManager.ts @@ -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()( devtools( persist( @@ -81,11 +87,12 @@ const migrationStore = create()( 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()( 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 { + 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 { 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 { + static async awaitCompletion(): Promise { + 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 { 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); } diff --git a/src/features/migration/components/migration-entry/MigrationEntry.tsx b/src/features/migration/components/migration-entry/MigrationEntry.tsx index 1871ed0f..011a4912 100644 --- a/src/features/migration/components/migration-entry/MigrationEntry.tsx +++ b/src/features/migration/components/migration-entry/MigrationEntry.tsx @@ -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); diff --git a/src/features/migration/screens/Migration.tsx b/src/features/migration/screens/Migration.tsx index 14ba8038..5c0a45df 100644 --- a/src/features/migration/screens/Migration.tsx +++ b/src/features/migration/screens/Migration.tsx @@ -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: diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index a95c2c44..df839fb4 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -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"