diff --git a/docs/settings.md b/docs/settings.md index 1ac07517..ed8b6e39 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -528,7 +528,19 @@ Sync automatically after merging files Setting key: keepReplicationActiveInBackground Desktop only; uses more battery and network. This setting applies to continuous and periodic replication. -Finite remote operations, including one-shot replication, P2P peer discovery and selection, rebuilds, fetches, and remote chunk fetching, request best-effort screen-awake protection automatically and do not require this setting. That protection does not guarantee execution while Obsidian is hidden or while the operating system suspends the device. +#### Allow sleep during synchronisation + +Setting key: allowSleepDuringSynchronisation + +Allow the operating system to sleep while finite synchronisation operations are in progress. This option applies on every platform and is disabled by default. When it is disabled, finite operations request best-effort screen-awake protection. + +#### Allow sleep during synchronisation on the desktop + +Setting key: allowSleepDuringSynchronisationOnDesktop + +Desktop only. Allow the operating system to sleep during finite synchronisation operations even when the general option is disabled. This option is enabled by default, so periodic or event-driven synchronisation does not repeatedly prevent automatic desktop sleep. Disable it to retain best-effort screen-awake protection on desktop. + +Setup URIs preserve both sleep preferences. Older Setup URIs which do not contain them use the defaults described above. The preferences cover finite remote operations, including one-shot replication, P2P peer discovery and selection, rebuilds, fetches, remote chunk fetching, and applying downloaded documents to the Vault. They do not control whether continuous replication remains active while Obsidian is hidden, and allowing sleep does not force the operating system to suspend the device. ### 3. Update thinning diff --git a/package-lock.json b/package-lock.json index 69b673a1..7aead08d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.3", + "@vrtmrz/livesync-commonlib": "0.1.4", "@vrtmrz/obsidian-plugin-kit": "0.1.3", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", @@ -4775,9 +4775,9 @@ } }, "node_modules/@vrtmrz/livesync-commonlib": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.3.tgz", - "integrity": "sha512-M6+mOlf4R60pf6mMuQdIlOoeIjOlDQn/hIRjELCkAwAcJXo5V/l8LfBCujeuvVk9FOzO3S0Q4WET9VaDHDxosw==", + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@vrtmrz/livesync-commonlib/-/livesync-commonlib-0.1.4.tgz", + "integrity": "sha512-qQst1QCZEgfxwpdjnjFmXE3JNy4ATKhN8ihJSUUho4mdWC1O4zyRRhmQjg0GHu5hQPZokufr2mrP4Hur8niDNg==", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.808.0", diff --git a/package.json b/package.json index fba48a6d..b2550433 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "@smithy/types": "^4.14.3", "@smithy/util-retry": "^4.4.5", "@vrtmrz/browser-ui-kit": "0.1.0", - "@vrtmrz/livesync-commonlib": "0.1.3", + "@vrtmrz/livesync-commonlib": "0.1.4", "@vrtmrz/obsidian-plugin-kit": "0.1.3", "@vrtmrz/ui-interactions": "0.1.2", "diff-match-patch": "^1.0.5", diff --git a/src/modules/features/SettingDialogue/PaneSyncSettings.ts b/src/modules/features/SettingDialogue/PaneSyncSettings.ts index 64c2a930..1f9edc52 100644 --- a/src/modules/features/SettingDialogue/PaneSyncSettings.ts +++ b/src/modules/features/SettingDialogue/PaneSyncSettings.ts @@ -194,6 +194,10 @@ export function paneSyncSettings( ), }); } + new Setting(paneEl).setClass("wizardHidden").autoWireToggle("allowSleepDuringSynchronisation"); + if (!this.services.API.isMobile()) { + new Setting(paneEl).setClass("wizardHidden").autoWireToggle("allowSleepDuringSynchronisationOnDesktop"); + } }); void addPanel( diff --git a/src/modules/services/ObsidianServiceHub.ts b/src/modules/services/ObsidianServiceHub.ts index 5cfd7fde..a5d25801 100644 --- a/src/modules/services/ObsidianServiceHub.ts +++ b/src/modules/services/ObsidianServiceHub.ts @@ -91,6 +91,7 @@ export class ObsidianServiceHub extends InjectableServiceHub API.isMobile(), }); const replication = new ObsidianReplicationService(context, { APIService: API, diff --git a/src/modules/services/ObsidianServices.ts b/src/modules/services/ObsidianServices.ts index 9ea120d2..daf43e48 100644 --- a/src/modules/services/ObsidianServices.ts +++ b/src/modules/services/ObsidianServices.ts @@ -11,17 +11,53 @@ import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceC import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService"; import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService"; import { reactiveSource } from "octagonal-wheels/dataobject/reactive"; +import type { ReplicatorServiceDependencies } from "@vrtmrz/livesync-commonlib/compat/services/base/ReplicatorService"; +import type { ObsidianLiveSyncSettings } from "@vrtmrz/livesync-commonlib/compat/common/types"; type ActivityOptions = { label?: string; }; +type ObsidianReplicatorServiceDependencies = ReplicatorServiceDependencies & { + isMobile: () => boolean; +}; + +type SleepPreferenceSettings = Pick< + ObsidianLiveSyncSettings, + "allowSleepDuringSynchronisation" | "allowSleepDuringSynchronisationOnDesktop" +>; + +export function shouldAllowSleepDuringSynchronisation(settings: SleepPreferenceSettings, isMobile: boolean): boolean { + return settings.allowSleepDuringSynchronisation || (!isMobile && settings.allowSleepDuringSynchronisationOnDesktop); +} + +function withSleepPreference(dependencies: ObsidianReplicatorServiceDependencies): ReplicatorServiceDependencies { + const activityRunner = dependencies.activityRunner; + if (!activityRunner) return dependencies; + return { + ...dependencies, + activityRunner: { + async run(task: () => T | PromiseLike, options?: ActivityOptions): Promise { + const allowSleep = shouldAllowSleepDuringSynchronisation( + dependencies.settingService.currentSettings(), + dependencies.isMobile() + ); + return allowSleep ? await task() : await activityRunner.run(task, options); + }, + }, + }; +} + export class ObsidianDatabaseEventService extends InjectableDatabaseEventService {} // InjectableReplicatorService export class ObsidianReplicatorService extends InjectableReplicatorService { readonly boundedLocalApplicationActivityCount = reactiveSource(0); + constructor(context: ObsidianServiceContext, dependencies: ObsidianReplicatorServiceDependencies) { + super(context, withSleepPreference(dependencies)); + } + async runBoundedLocalApplicationActivity( task: () => T | PromiseLike, options?: ActivityOptions diff --git a/src/modules/services/ObsidianServices.unit.spec.ts b/src/modules/services/ObsidianServices.unit.spec.ts index 1e09755d..a46c05c3 100644 --- a/src/modules/services/ObsidianServices.unit.spec.ts +++ b/src/modules/services/ObsidianServices.unit.spec.ts @@ -1,25 +1,53 @@ import { promiseWithResolvers } from "octagonal-wheels/promises"; import { describe, expect, it, vi } from "vitest"; -import { ObsidianReplicatorService } from "./ObsidianServices"; +import { ObsidianReplicatorService, shouldAllowSleepDuringSynchronisation } from "./ObsidianServices"; function handler() { return { addHandler: vi.fn() }; } describe("ObsidianReplicatorService", () => { + it.each([ + { general: false, desktop: false, mobile: false, expected: false }, + { general: false, desktop: true, mobile: false, expected: true }, + { general: false, desktop: true, mobile: true, expected: false }, + { general: true, desktop: false, mobile: false, expected: true }, + { general: true, desktop: false, mobile: true, expected: true }, + ])("applies the sleep preference policy: $general/$desktop/$mobile", ({ general, desktop, mobile, expected }) => { + expect( + shouldAllowSleepDuringSynchronisation( + { + allowSleepDuringSynchronisation: general, + allowSleepDuringSynchronisationOnDesktop: desktop, + }, + mobile + ) + ).toBe(expected); + }); + it("tracks local application activity without extending remote activity", async () => { const activity = promiseWithResolvers(); - const service = new ObsidianReplicatorService({ events: {}, translate: String } as never, { - settingService: { onRealiseSetting: handler() }, - appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, - databaseEventService: { - onResetDatabase: handler(), - onDatabaseInitialisation: handler(), - onDatabaseInitialised: handler(), - onDatabaseHasReady: handler(), - }, - activityRunner: { run: vi.fn(async (task: () => Promise) => await task()) }, - } as never); + const service = new ObsidianReplicatorService( + { events: {}, translate: String } as never, + { + settingService: { + onRealiseSetting: handler(), + currentSettings: () => ({ + allowSleepDuringSynchronisation: false, + allowSleepDuringSynchronisationOnDesktop: false, + }), + }, + appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, + databaseEventService: { + onResetDatabase: handler(), + onDatabaseInitialisation: handler(), + onDatabaseInitialised: handler(), + onDatabaseHasReady: handler(), + }, + activityRunner: { run: vi.fn(async (task: () => Promise) => await task()) }, + isMobile: () => false, + } as never + ); const running = service.runBoundedLocalApplicationActivity(() => activity.promise); @@ -32,4 +60,34 @@ describe("ObsidianReplicatorService", () => { expect(service.boundedLocalApplicationActivityCount.value).toBe(0); expect(service.boundedRemoteActivityCount.value).toBe(0); }); + + it("allows desktop sleep throughout bounded synchronisation activity when configured", async () => { + const runWithWakeLock = vi.fn(async (task: () => Promise) => await task()); + const service = new ObsidianReplicatorService( + { events: {}, translate: String } as never, + { + settingService: { + onRealiseSetting: handler(), + currentSettings: () => ({ + allowSleepDuringSynchronisation: false, + allowSleepDuringSynchronisationOnDesktop: true, + }), + }, + appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() }, + databaseEventService: { + onResetDatabase: handler(), + onDatabaseInitialisation: handler(), + onDatabaseInitialised: handler(), + onDatabaseHasReady: handler(), + }, + activityRunner: { run: runWithWakeLock }, + isMobile: () => false, + } as never + ); + + await service.runBoundedRemoteActivity(async () => undefined); + await service.runBoundedLocalApplicationActivity(async () => undefined); + + expect(runWithWakeLock).not.toHaveBeenCalled(); + }); }); diff --git a/test/e2e-obsidian/scripts/settings-ui.ts b/test/e2e-obsidian/scripts/settings-ui.ts index 93b85786..3afe2010 100644 --- a/test/e2e-obsidian/scripts/settings-ui.ts +++ b/test/e2e-obsidian/scripts/settings-ui.ts @@ -18,7 +18,14 @@ type LiveSyncTestPlugin = { core: { services: { setting: { - currentSettings(): { versionUpFlash: string }; + currentSettings(): { + versionUpFlash: string; + allowSleepDuringSynchronisation: boolean; + allowSleepDuringSynchronisationOnDesktop: boolean; + useAdvancedMode: boolean; + usePowerUserMode: boolean; + useEdgeCaseMode: boolean; + }; getSmallConfig(key: string): string | null; }; }; @@ -196,6 +203,30 @@ async function verifyConfigDoctorFollowsCompatibilityReview(): Promise { async function verifyEffectiveSettings(): Promise { await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => { + const sleepPreferences = await page.evaluate(() => { + const plugin = (globalThis as ObsidianTestGlobal).app?.plugins?.plugins["obsidian-livesync"]; + if (plugin === undefined) throw new Error("Self-hosted LiveSync is unavailable"); + const settings = plugin.core.services.setting.currentSettings(); + return { + general: settings.allowSleepDuringSynchronisation, + desktop: settings.allowSleepDuringSynchronisationOnDesktop, + advancedMode: settings.useAdvancedMode, + powerUserMode: settings.usePowerUserMode, + edgeCaseMode: settings.useEdgeCaseMode, + }; + }); + if ( + sleepPreferences.general !== false || + sleepPreferences.desktop !== true || + sleepPreferences.advancedMode !== false || + sleepPreferences.powerUserMode !== false || + sleepPreferences.edgeCaseMode !== false + ) { + throw new Error( + `Unexpected effective sleep preferences: ${JSON.stringify(sleepPreferences)}` + ); + } + await page.evaluate(() => { const setting = (globalThis as ObsidianTestGlobal).app?.setting; if (setting === undefined) throw new Error("Obsidian settings are unavailable"); @@ -205,6 +236,16 @@ async function verifyEffectiveSettings(): Promise { const liveSyncSettings = page.locator(".sls-setting"); await liveSyncSettings.waitFor({ state: "visible", timeout: uiTimeoutMs }); + const settingsClass = await liveSyncSettings.getAttribute("class"); + for (const modeClass of [ + "menu-setting-advanced-disabled", + "menu-setting-poweruser-disabled", + "menu-setting-edgecase-disabled", + ]) { + if (!settingsClass?.split(/\s+/u).includes(modeClass)) { + throw new Error(`The settings UI did not disable ${modeClass}.`); + } + } await liveSyncSettings.locator('.sls-setting-menu-btn[title="Change Log"]').click(); const removedAcknowledgements = liveSyncSettings.getByRole("button", { @@ -226,6 +267,35 @@ async function verifyEffectiveSettings(): Promise { }); await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click(); + const generalSleepSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Allow sleep during synchronisation", { exact: true }), + }); + await generalSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await generalSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 0) { + throw new Error("The general sleep preference must be disabled by default."); + } + + const desktopSleepSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Allow sleep during synchronisation on the desktop", { exact: true }), + }); + await desktopSleepSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + if ((await desktopSleepSetting.locator(".checkbox-container.is-enabled").count()) !== 1) { + throw new Error("The desktop sleep preference must be enabled by default."); + } + + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Setup"]').click(); + const advancedModeSetting = liveSyncSettings.locator(".setting-item").filter({ + has: page.getByText("Enable advanced features", { exact: true }), + }); + await advancedModeSetting.waitFor({ state: "visible", timeout: uiTimeoutMs }); + await advancedModeSetting.locator(".checkbox-container").click(); + await page.waitForFunction( + () => document.querySelector(".sls-setting")?.classList.contains("menu-setting-advanced-enabled") === true, + undefined, + { timeout: uiTimeoutMs } + ); + await liveSyncSettings.locator('.sls-setting-menu-btn[title="Sync Settings"]').click(); + const deletionPanel = liveSyncSettings .locator("h4.sls-setting-panel-title") .filter({ hasText: "Deletion Propagation" }) @@ -271,8 +341,9 @@ async function main(): Promise { syncAfterMerge: false, periodicReplication: false, handleFilenameCaseSensitive: false, - useAdvancedMode: true, - useEdgeCaseMode: true, + useAdvancedMode: false, + usePowerUserMode: false, + useEdgeCaseMode: false, }, }); await waitForLiveSyncCoreReady(cli.binary, session.cliEnv); diff --git a/updates.md b/updates.md index 67d61a28..65825bb9 100644 --- a/updates.md +++ b/updates.md @@ -12,6 +12,12 @@ Earlier releases remain available in the 0.25 release history and the legacy rel ## Unreleased +### Synchronisation and storage + +#### Improved + +- Added settings to control whether finite synchronisation operations keep the screen awake. Desktop devices now allow automatic sleep by default, while mobile devices retain screen-awake protection unless the general option is enabled (#1073). + ## 1.0.4 5th August, 2026