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(