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:
@@ -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**) Sort unselected matched entries by 1. their latest chapter, 2. their source priority, 3. their title
|
||||||
- (**Migration**) Change migration match exclude/include icons
|
- (**Migration**) Change migration match exclude/include icons
|
||||||
- (**Migration**) Show the exclude/include button only for an entry with a selected match
|
- (**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"
|
- (**Source/Extension**) Rename language "All" to "Multi"
|
||||||
- (**Reader**) Simplify changing settings in desktop sidebar
|
- (**Reader**) Simplify changing settings in desktop sidebar
|
||||||
- (**Reader**) Ignore tap zone clicks while window does not have focus
|
- (**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 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 selecting a destination from a source browse search page
|
||||||
- (**Migration**) Fix being able to migrate an entry to itself
|
- (**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 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 wrongly positioned mobile progress bar current page indicator
|
||||||
- (**Reader**) Fix mobile progress bar previous/next chapter button visibility on hover and while disabled
|
- (**Reader**) Fix mobile progress bar previous/next chapter button visibility on hover and while disabled
|
||||||
|
|||||||
@@ -212,7 +212,14 @@ const ReactRouterSetter = () => {
|
|||||||
const ResumeMigration = () => {
|
const ResumeMigration = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!MigrationManager.isActive()) {
|
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'));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ class AppSessionClass {
|
|||||||
get STARTUP_TIMESTAMP(): number {
|
get STARTUP_TIMESTAMP(): number {
|
||||||
return AppStorage.session.getItemParsed(STARTUP_TIMESTAMP_KEY, Date.now());
|
return AppStorage.session.getItemParsed(STARTUP_TIMESTAMP_KEY, Date.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSecureContext(): boolean {
|
||||||
|
return window.isSecureContext;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AppSession = new AppSessionClass();
|
export const AppSession = new AppSessionClass();
|
||||||
|
|||||||
@@ -69,9 +69,15 @@ import isEqual from 'lodash/fp/isEqual';
|
|||||||
import uniqBy from 'lodash/fp/uniqBy';
|
import uniqBy from 'lodash/fp/uniqBy';
|
||||||
import { MigrationEntries } from '@/features/migration/MigrationEntries.ts';
|
import { MigrationEntries } from '@/features/migration/MigrationEntries.ts';
|
||||||
import { Chapters } from '@/features/chapter/services/Chapters.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];
|
const RESUMABLE_PHASES: readonly MigrationPhase[] = [MigrationPhase.SEARCHING, MigrationPhase.MIGRATING];
|
||||||
|
|
||||||
|
let initialResume = true;
|
||||||
|
|
||||||
const migrationStore = create<MigrationState>()(
|
const migrationStore = create<MigrationState>()(
|
||||||
devtools(
|
devtools(
|
||||||
persist(
|
persist(
|
||||||
@@ -81,11 +87,12 @@ const migrationStore = create<MigrationState>()(
|
|||||||
merge: (persistedState, currentState) => {
|
merge: (persistedState, currentState) => {
|
||||||
const persisted = persistedState as MigrationState | undefined;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...currentState, ...persisted };
|
return merge(currentState, persisted);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -94,6 +101,13 @@ const migrationStore = create<MigrationState>()(
|
|||||||
|
|
||||||
const useMigrationStore = ZustandUtil.createStoreHook(migrationStore);
|
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 {
|
export class MigrationManager {
|
||||||
private static abortController: AbortController | null = null;
|
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(
|
static async startSearch(
|
||||||
destinationSourceIds: SourceIdInfo['id'][],
|
destinationSourceIds: SourceIdInfo['id'][],
|
||||||
options: MigrationBulkSearchSettings,
|
options: MigrationBulkSearchSettings,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
|
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
|
||||||
|
|
||||||
|
await MigrationManager.awaitUserConfirmation();
|
||||||
|
|
||||||
const state = MigrationManager.getState();
|
const state = MigrationManager.getState();
|
||||||
const entryIds = Object.keys(state.entries).map(Number);
|
const entryIds = Object.keys(state.entries).map(Number);
|
||||||
|
|
||||||
@@ -431,15 +461,7 @@ export class MigrationManager {
|
|||||||
|
|
||||||
const migratableEntries = MigrationEntries.getMigratable(Object.values(MigrationManager.getState().entries));
|
const migratableEntries = MigrationEntries.getMigratable(Object.values(MigrationManager.getState().entries));
|
||||||
|
|
||||||
await Confirmation.show({
|
await MigrationManager.awaitUserConfirmation();
|
||||||
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`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
MigrationManager.updateState((draft) => {
|
MigrationManager.updateState((draft) => {
|
||||||
draft.phase = MigrationPhase.MIGRATING;
|
draft.phase = MigrationPhase.MIGRATING;
|
||||||
@@ -530,12 +552,34 @@ export class MigrationManager {
|
|||||||
return true;
|
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 { phase, migrateOptions, searchOptions, entries } = MigrationManager.getState();
|
||||||
|
|
||||||
const isResumeablePhase = RESUMABLE_PHASES.includes(phase);
|
if (!AppSession.isSecureContext()) {
|
||||||
if (!isResumeablePhase) {
|
return false;
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
if (!MigrationManager.isResumablePhase()) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
|
const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
|
||||||
@@ -552,7 +596,7 @@ export class MigrationManager {
|
|||||||
|
|
||||||
await MigrationManager.migrate(migratableEntries, migrateOptions);
|
await MigrationManager.migrate(migratableEntries, migrateOptions);
|
||||||
|
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
assertIsDefined(searchOptions);
|
assertIsDefined(searchOptions);
|
||||||
@@ -570,6 +614,8 @@ export class MigrationManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await MigrationManager.search(pendingEntries, searchOptions);
|
await MigrationManager.search(pendingEntries, searchOptions);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static reset(): void {
|
static reset(): void {
|
||||||
@@ -685,17 +731,12 @@ export class MigrationManager {
|
|||||||
return migrationStore.getState();
|
return migrationStore.getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
static isActive(): boolean {
|
static isResumablePhase(): boolean {
|
||||||
const { phase } = migrationStore.getState();
|
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
|
||||||
|
|
||||||
return (
|
|
||||||
!!MigrationManager.abortController &&
|
|
||||||
(phase === MigrationPhase.SEARCHING || phase === MigrationPhase.MIGRATING)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static hasPausedMigration(): boolean {
|
static isActive(): boolean {
|
||||||
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
|
return !!MigrationManager.abortController && MigrationManager.isResumablePhase();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getDestinationSourceIds(mangaSourceId: SourceIdInfo['id']): MigrationState['destinationSourceIds'] {
|
private static getDestinationSourceIds(mangaSourceId: SourceIdInfo['id']): MigrationState['destinationSourceIds'] {
|
||||||
@@ -1196,6 +1237,12 @@ export class MigrationManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static updateState(updater: (draft: MigrationState) => void): void {
|
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);
|
migrationStore.setState(updater);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import type { MigrationMatch, TMigrationEntry } from '@/features/migration/Migration.types.ts';
|
import type { MigrationMatch, TMigrationEntry } from '@/features/migration/Migration.types.ts';
|
||||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||||
import Paper from '@mui/material/Paper';
|
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 { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||||
import { MigrationSourceEntry } from '@/features/migration/components/migration-entry/MigrationSourceEntry.tsx';
|
import { MigrationSourceEntry } from '@/features/migration/components/migration-entry/MigrationSourceEntry.tsx';
|
||||||
@@ -206,7 +206,11 @@ export const MigrationEntry = memo(
|
|||||||
}) => {
|
}) => {
|
||||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
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 destinationEntry = useMemo(() => {
|
||||||
const match = entry.searchMatches.find((matchEntry) => matchEntry.id === entry.selectedMatchMangaId);
|
const match = entry.searchMatches.find((matchEntry) => matchEntry.id === entry.selectedMatchMangaId);
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export const Migration = ({ tabsMenuHeight = 0 }: { tabsMenuHeight?: number }) =
|
|||||||
const phase = MigrationManager.usePhase();
|
const phase = MigrationManager.usePhase();
|
||||||
const { setOnBack } = useAppPageHistoryContext();
|
const { setOnBack } = useAppPageHistoryContext();
|
||||||
|
|
||||||
|
const isMigrationPage = SubpathUtil.getPathname() === AppRoutes.migrate.path;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SubpathUtil.getPathname() !== AppRoutes.migrate.path) {
|
if (!isMigrationPage) {
|
||||||
if (!MigrationManager.isActive()) {
|
if (!MigrationManager.isResumablePhase()) {
|
||||||
MigrationManager.reset();
|
MigrationManager.reset();
|
||||||
} else {
|
} else {
|
||||||
ReactRouter.navigate(AppRoutes.migrate.path);
|
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) {
|
switch (phase) {
|
||||||
case MigrationPhase.IDLE:
|
case MigrationPhase.IDLE:
|
||||||
case MigrationPhase.SELECT_SOURCE:
|
case MigrationPhase.SELECT_SOURCE:
|
||||||
|
|||||||
@@ -3923,6 +3923,10 @@ msgstr "WebUI"
|
|||||||
msgid "WebUI channel"
|
msgid "WebUI channel"
|
||||||
msgstr "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
|
#: src/features/settings/screens/About.tsx
|
||||||
msgid "WebUI version"
|
msgid "WebUI version"
|
||||||
msgstr "WebUI version"
|
msgstr "WebUI version"
|
||||||
|
|||||||
Reference in New Issue
Block a user