From c78e583399ce97e9aa0919a6865dca868ffc39e5 Mon Sep 17 00:00:00 2001 From: Miguel Ferreira Date: Thu, 4 Jun 2026 21:09:45 +0100 Subject: [PATCH] feat: opt-in desktop setting to keep replication active in the background Replication is suspended when the Obsidian window becomes hidden (document.hidden), so LiveSync and Periodic stop syncing while minimised until the window is focused. Add keepReplicationActiveInBackground (default off, desktop only). When enabled, the window-visibility handler no longer suspends on hide, so replication keeps running while minimised. Becoming visible forces a teardown before reopen (LiveSync only) so a stalled, half-open channel is always replaced. Includes the setting definition (src/lib submodule), a desktop-only toggle in the Sync pane shown for LiveSync and Periodic, a docs/settings.md entry, and unit tests for the visibility handler. --- docs/settings.md | 5 + src/lib | 2 +- .../essentialObsidian/ModuleObsidianEvents.ts | 32 +++- .../ModuleObsidianEvents.unit.spec.ts | 174 ++++++++++++++++++ .../SettingDialogue/PaneSyncSettings.ts | 11 ++ 5 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts diff --git a/docs/settings.md b/docs/settings.md index e0f2ca5..3727346 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -488,6 +488,11 @@ Automatically Sync all files when opening Obsidian. Setting key: syncAfterMerge Sync automatically after merging files +#### Keep replication active in the background + +Setting key: keepReplicationActiveInBackground +Desktop only; uses more battery and network. + ### 3. Update thinning #### Batch database update diff --git a/src/lib b/src/lib index 76d9167..309bd6c 160000 --- a/src/lib +++ b/src/lib @@ -1 +1 @@ -Subproject commit 76d91674c235c1ccf991a14802c737e82e144ef1 +Subproject commit 309bd6c8acc9b9940361ab296aa30c6e3b96bb50 diff --git a/src/modules/essentialObsidian/ModuleObsidianEvents.ts b/src/modules/essentialObsidian/ModuleObsidianEvents.ts index 638b960..1129ef0 100644 --- a/src/modules/essentialObsidian/ModuleObsidianEvents.ts +++ b/src/modules/essentialObsidian/ModuleObsidianEvents.ts @@ -2,7 +2,7 @@ import { AbstractObsidianModule } from "../AbstractObsidianModule.ts"; import { EVENT_FILE_RENAMED, EVENT_LEAF_ACTIVE_CHANGED, eventHub } from "../../common/events.js"; import { LOG_LEVEL_NOTICE, LOG_LEVEL_VERBOSE } from "octagonal-wheels/common/logger"; import { scheduleTask } from "octagonal-wheels/concurrency/task"; -import { type TFile } from "../../deps.ts"; +import { Platform, type TFile } from "../../deps.ts"; import { fireAndForget } from "octagonal-wheels/promises"; import { type FilePathWithPrefix } from "../../lib/src/common/types.ts"; import { reactive, reactiveSource, type ReactiveSource } from "octagonal-wheels/dataobject/reactive"; @@ -138,12 +138,38 @@ export class ModuleObsidianEvents extends AbstractObsidianModule { await this.services.fileProcessing.commitPendingFileEvents(); + // Desktop opt-in (LiveSync/Periodic only): keep the background channel running while the + // window is hidden, instead of suspending on hide. On hide we skip the suspend for both + // modes (LiveSync's continuous replication and Periodic's timer both stall otherwise); + // becoming visible reopens normally, and for LiveSync additionally forces a teardown first + // (see the resume branch) so a stalled continuous channel is always replaced. + const keepActiveInBackground = + this.settings.keepReplicationActiveInBackground && + (this.settings.liveSync || this.settings.periodicReplication) && + Platform.isDesktopApp; + if (isHidden) { - await this.services.appLifecycle.onSuspending(); + if (!keepActiveInBackground) await this.services.appLifecycle.onSuspending(); } else { // suspend all temporary. if (this.services.appLifecycle.isSuspended()) return; - // Do not block resume by focus state here; visibility recovery should be enough. + // Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB + // emits paused/retry while the replicator keeps its AbortController set, so the reopen + // below would no-op on exactly the channel that needs replacing. Force a teardown first + // so becoming visible always re-establishes a fresh channel (restoring the default's + // reset-on-visibility). Periodic mode has no such channel — its timer just resumes via + // the normal path below — so this teardown is gated on liveSync to avoid needlessly + // bouncing it. The teardown's closeReplication() aborts synchronously while the reopen is + // deferred (fireAndForget + awaited isReplicationReady/initializeDatabaseForReplication), + // so the aborted continuousReplication run (and its shareRunningResult lock) unwinds in + // microtasks before the reopen runs: it neither double-opens nor gets swallowed by the + // still-registered shared run. + if (keepActiveInBackground && this.settings.liveSync) { + await this.services.appLifecycle.onSuspending(); + } + // Resume is not gated on focus in this branch, but note the top-of-handler check + // (isLastHidden && !hasFocus) still defers the whole handler when the window becomes + // visible again while unfocused; in that case recovery happens on the next focus. await this.services.appLifecycle.onResuming(); await this.services.appLifecycle.onResumed(); } diff --git a/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts b/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts new file mode 100644 index 0000000..8324b18 --- /dev/null +++ b/src/modules/essentialObsidian/ModuleObsidianEvents.unit.spec.ts @@ -0,0 +1,174 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +// Unit tests stub out `obsidian`, so deps.ts (which re-exports from it) can't be loaded for real. +// ModuleObsidianEvents only needs `Platform` from deps at runtime; provide a mutable stub so each +// test can choose desktop vs mobile. +vi.mock("../../deps.ts", () => ({ Platform: { isDesktopApp: true } })); + +import { Platform } from "../../deps.ts"; +import { ModuleObsidianEvents } from "./ModuleObsidianEvents"; +import { DEFAULT_SETTINGS, REMOTE_COUCHDB } from "@lib/common/types"; + +type SetupOptions = { + settings?: Partial; + hidden: boolean; + isLastHidden?: boolean; + hasFocus?: boolean; + isSuspended?: boolean; +}; + +function setup(opts: SetupOptions) { + const appLifecycle = { + isReady: vi.fn(() => true), + isSuspended: vi.fn(() => opts.isSuspended ?? false), + onSuspending: vi.fn(async () => true), + onResuming: vi.fn(async () => true), + onResumed: vi.fn(async () => true), + }; + const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) }; + + const core = { + _services: { + API: { + addLog: vi.fn(), + addCommand: vi.fn(), + registerWindow: vi.fn(), + addRibbonIcon: vi.fn(), + registerProtocolHandler: vi.fn(), + }, + setting: { saveSettingData: vi.fn(async () => undefined) }, + appLifecycle, + fileProcessing, + }, + settings: { + ...DEFAULT_SETTINGS, + remoteType: REMOTE_COUCHDB, + isConfigured: true, + ...opts.settings, + }, + } as any; + Object.defineProperty(core, "services", { get: () => core._services }); + + const module = new ModuleObsidianEvents({} as any, core); + module.isLastHidden = opts.isLastHidden ?? false; + module.hasFocus = opts.hasFocus ?? true; + + // The handler reads `activeWindow.document.hidden`. + (globalThis as any).activeWindow = { document: { hidden: opts.hidden } }; + + return { module, appLifecycle, fileProcessing }; +} + +describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => { + beforeEach(() => { + (Platform as any).isDesktopApp = true; + }); + + afterEach(() => { + // The handler reads a global `activeWindow`; the Platform mock is module-scoped. Both would + // otherwise leak into sibling spec files running in the same worker, so reset them here. + delete (globalThis as any).activeWindow; + (Platform as any).isDesktopApp = true; + }); + + it("does NOT suspend on hide when enabled in LiveSync mode on the desktop app", async () => { + const { module, appLifecycle } = setup({ + settings: { keepReplicationActiveInBackground: true, liveSync: true }, + hidden: true, + }); + await module.watchWindowVisibilityAsync(); + expect(appLifecycle.onSuspending).not.toHaveBeenCalled(); + }); + + it("suspends on hide by default (setting off)", async () => { + const { module, appLifecycle } = setup({ + settings: { keepReplicationActiveInBackground: false, liveSync: true }, + hidden: true, + }); + await module.watchWindowVisibilityAsync(); + expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1); + }); + + it("forces onSuspending before the resume on becoming visible when enabled (LiveSync teardown)", async () => { + const { module, appLifecycle } = setup({ + settings: { keepReplicationActiveInBackground: true, liveSync: true }, + hidden: false, + isLastHidden: true, // hidden -> visible transition + }); + await module.watchWindowVisibilityAsync(); + // Decision-logic only: on visible + enabled + LiveSync the handler calls onSuspending (the + // forced teardown) before onResuming. The actual stalled-channel replacement is exercised by + // the manual integration test, not here. + expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1); + expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1); + expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1); + expect(appLifecycle.onSuspending.mock.invocationCallOrder[0]).toBeLessThan( + appLifecycle.onResuming.mock.invocationCallOrder[0] + ); + }); + + it("does not force a teardown on becoming visible by default (setting off)", async () => { + const { module, appLifecycle } = setup({ + settings: { keepReplicationActiveInBackground: false, liveSync: true }, + hidden: false, + isLastHidden: true, + }); + await module.watchWindowVisibilityAsync(); + expect(appLifecycle.onSuspending).not.toHaveBeenCalled(); + expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1); + }); + + it("does not apply in On-Events mode even if the flag is set (no scope leak)", async () => { + const { module, appLifecycle } = setup({ + settings: { + keepReplicationActiveInBackground: true, + liveSync: false, + periodicReplication: false, + }, + hidden: true, + }); + await module.watchWindowVisibilityAsync(); + expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1); + }); + + it("does NOT suspend on hide when enabled in Periodic mode (the periodic timer also stalls otherwise)", async () => { + const { module, appLifecycle } = setup({ + settings: { + keepReplicationActiveInBackground: true, + liveSync: false, + periodicReplication: true, + }, + hidden: true, + }); + await module.watchWindowVisibilityAsync(); + expect(appLifecycle.onSuspending).not.toHaveBeenCalled(); + }); + + it("does NOT force a teardown on becoming visible in Periodic mode (only the continuous channel can stall)", async () => { + const { module, appLifecycle } = setup({ + settings: { + keepReplicationActiveInBackground: true, + liveSync: false, + periodicReplication: true, + }, + hidden: false, + isLastHidden: true, + }); + await module.watchWindowVisibilityAsync(); + // The teardown is gated on liveSync: a periodic timer doesn't go half-open, so bouncing it + // on every restore would be needless churn. Resume still runs normally. + expect(appLifecycle.onSuspending).not.toHaveBeenCalled(); + expect(appLifecycle.onResuming).toHaveBeenCalledTimes(1); + expect(appLifecycle.onResumed).toHaveBeenCalledTimes(1); + }); + + it("does not apply on a non-desktop app even if the flag is set", async () => { + (Platform as any).isDesktopApp = false; + const { module, appLifecycle } = setup({ + settings: { keepReplicationActiveInBackground: true, liveSync: true }, + hidden: true, + }); + await module.watchWindowVisibilityAsync(); + expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/features/SettingDialogue/PaneSyncSettings.ts b/src/modules/features/SettingDialogue/PaneSyncSettings.ts index 394c7c1..49f2c95 100644 --- a/src/modules/features/SettingDialogue/PaneSyncSettings.ts +++ b/src/modules/features/SettingDialogue/PaneSyncSettings.ts @@ -11,6 +11,7 @@ import { EVENT_REQUEST_COPY_SETUP_URI, eventHub } from "../../../common/events.t import type { ObsidianLiveSyncSettingTab } from "./ObsidianLiveSyncSettingTab.ts"; import type { PageFunctions } from "./SettingPane.ts"; import { visibleOnly } from "./SettingPane.ts"; +import { Platform } from "../../../deps.ts"; export function paneSyncSettings( this: ObsidianLiveSyncSettingTab, paneEl: HTMLElement, @@ -189,6 +190,16 @@ export function paneSyncSettings( new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnFileOpen", { onUpdate: onlyOnNonLiveSync }); new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncOnStart", { onUpdate: onlyOnNonLiveSync }); new Setting(paneEl).setClass("wizardHidden").autoWireToggle("syncAfterMerge", { onUpdate: onlyOnNonLiveSync }); + // Desktop app only, and only for the sync modes that keep a background replication channel + // (LiveSync and Periodic). Ignored on mobile, where suspending preserves battery. The + // visibility predicate mirrors the runtime guard in ModuleObsidianEvents. + if (Platform.isDesktopApp) { + new Setting(paneEl).setClass("wizardHidden").autoWireToggle("keepReplicationActiveInBackground", { + onUpdate: visibleOnly( + () => this.isConfiguredAs("syncMode", "LIVESYNC") || this.isConfiguredAs("syncMode", "PERIODIC") + ), + }); + } }); void addPanel(