diff --git a/packages/app-desktop/ElectronAppWrapper.ts b/packages/app-desktop/ElectronAppWrapper.ts index bda4b36a0f..788adefba8 100644 --- a/packages/app-desktop/ElectronAppWrapper.ts +++ b/packages/app-desktop/ElectronAppWrapper.ts @@ -6,7 +6,7 @@ const shim: typeof ShimType = require('@joplin/lib/shim').default; import { isCallbackUrl } from '@joplin/lib/callbackUrlUtils'; import { FileLocker } from '@joplin/utils/fs'; import { IpcMessageHandler, IpcServer, Message, newHttpError, sendMessage, SendMessageOptions, startServer, stopServer } from '@joplin/utils/ipc'; -import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme } from 'electron'; +import { BrowserWindow, Tray, WebContents, screen, App, nativeTheme, powerMonitor } from 'electron'; import bridge from './bridge'; import * as url from 'url'; const path = require('path'); @@ -401,6 +401,15 @@ export default class ElectronAppWrapper { }; addWindowEventHandlers(this.win_.webContents); + // BrowserWindow 'focus' fires when the OS gives focus to the application window + // (i.e. coming from another app or from the taskbar), not on intra-app focus switches. + // We use a dedicated IPC channel so the renderer can trigger an immediate sync on + // OS-level focus gain without conflating it with the 'window-focused' channel that + // handles Joplin-internal window routing. + this.win_.on('focus', () => { + this.win_?.webContents.send('main-window-focused'); + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied this.win_.on('close', (event: any) => { // If it's on macOS, the app is completely closed only if the user chooses to close the app (willQuitApp_ will be true) @@ -892,6 +901,11 @@ export default class ElectronAppWrapper { event.preventDefault(); void this.openCallbackUrl(url); }); + + // When the OS wakes from sleep, notify the renderer so it can trigger an immediate sync. + powerMonitor.on('resume', () => { + this.win_?.webContents.send('system-resumed'); + }); } public async openCallbackUrl(url: string) { diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts index bdbb02ad32..1fb66e9e13 100644 --- a/packages/app-desktop/app.ts +++ b/packages/app-desktop/app.ts @@ -733,6 +733,23 @@ class Application extends BaseApplication { }); } }); + + // Trigger an immediate sync when the main window gains OS-level focus (i.e. the user + // switches back to Joplin from another application) or when the system wakes from sleep. + // A 30-second cool-down prevents duplicate syncs during rapid focus-in/focus-out cycles. + const minResumeSyncIntervalMs = 30_000; + let lastFocusSyncTime = 0; + + const scheduleResumeSync = () => { + const now = Date.now(); + if (now - lastFocusSyncTime > minResumeSyncIntervalMs) { + lastFocusSyncTime = now; + void reg.scheduleSync(0); + } + }; + + ipcRenderer.on('main-window-focused', scheduleResumeSync); + ipcRenderer.on('system-resumed', scheduleResumeSync); }); addTask('app/initPluginService', () => this.initPluginService()); diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx index 6d99c7cb5d..dfd1d1dcf2 100644 --- a/packages/app-mobile/root.tsx +++ b/packages/app-mobile/root.tsx @@ -19,7 +19,7 @@ import SyncTargetJoplinServer from '@joplin/lib/SyncTargetJoplinServer'; import SyncTargetJoplinCloud from '@joplin/lib/SyncTargetJoplinCloud'; import SyncTargetOneDrive from '@joplin/lib/SyncTargetOneDrive'; import { Keyboard, BackHandler, Animated, StatusBar, Platform, Dimensions } from 'react-native'; -import { AppState as RNAppState, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native'; +import { AppState as RNAppState, AppStateStatus, EmitterSubscription, View, Text, Linking, NativeEventSubscription, Appearance, ActivityIndicator } from 'react-native'; import getResponsiveValue from './components/getResponsiveValue'; import NetInfo, { NetInfoSubscription } from '@react-native-community/netinfo'; const DropdownAlert = require('react-native-dropdownalert').default; @@ -295,7 +295,8 @@ class AppComponent extends React.Component private unsubscribeScreenWidthChangeHandler_: EmitterSubscription|undefined; private unsubscribeNetInfoHandler_: NetInfoSubscription|undefined; private unsubscribeNewShareListener_: UnsubscribeShareListener|undefined; - private onAppStateChange_: ()=> void; + private onAppStateChange_: (nextAppState: AppStateStatus)=> void; + private lastResumeSyncTime_ = 0; private backButtonHandler_: BackButtonHandler; private handleNewShare_: ()=> void; private handleOpenURL_: (event: unknown)=> void; @@ -315,8 +316,24 @@ class AppComponent extends React.Component return this.backButtonHandler(); }; - this.onAppStateChange_ = () => { + this.onAppStateChange_ = (nextAppState: AppStateStatus) => { PoorManIntervals.update(); + + // Trigger sync immediately when the app becomes active (resume from background/lock screen). + // Only run when the app becomes active, with a 30-second minimum interval + // prevent sync spam on rapid lock/unlock cycles. + const minResumeSyncIntervalMs = 30_000; + if (nextAppState === 'active') { + const elapsed = Date.now() - this.lastResumeSyncTime_; + if (elapsed >= minResumeSyncIntervalMs) { + logger.info(`onAppStateChange_: App became active - scheduling immediate sync (elapsed since last resume sync: ${elapsed}ms)`); + this.lastResumeSyncTime_ = Date.now(); + + void reg.scheduleSync(0, null, true); + } else { + logger.info(`onAppStateChange_: App became active but skipping sync - minimum interval not reached (${elapsed}ms < ${minResumeSyncIntervalMs}ms)`); + } + } }; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied