diff --git a/README.md b/README.md index 78fe620..a76aa18 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Contributor setup, tests, architecture notes, and local real-Obsidian workflows `Create Differential Backup` compares the current vault with `backupinfo.md` and writes a new ZIP only for changes. Files under the configured backup folder and restore folder are skipped. +While a backup, restore, or selective-sync Fetch or Send operation is running, DiffZip requests a screen wake lock on supported devices. The request is best effort: the browser or operating system can deny or release it, and it does not keep DiffZip running in the background. DiffZip releases its request when the operation completes, is cancelled, or fails. It records: - new files diff --git a/docs/devs.md b/docs/devs.md index 517989c..985552a 100644 --- a/docs/devs.md +++ b/docs/devs.md @@ -65,3 +65,9 @@ Real Modal rendering and dismissal use the local-only Obsidian harness documente ## Fancy Kit dependencies `package.json` pins the Fancy Kit packages and `octagonal-wheels` to exact npm versions so the tested dependency set remains reproducible. Review and update the four versions together when adopting a newer contract. The plug-in kit declares an exact dependency on the matching `@vrtmrz/ui-interactions` release, and the lockfile records each package integrity hash. + +## Screen wake lock + +The plug-in owns one lifecycle-aware screen wake-lock manager. Differential backups, archive restore, and the Fetch and Send phases of selective sync use its closure-based runner, so normal completion, cancellation, and errors release their logical lease automatically. Overlapping and nested operations share the platform wake lock. Confirmation dialogues do not acquire a lease; restore protection starts only when archive input and Vault output begin. + +The Screen Wake Lock API is best effort. Consumer workflows must continue when it is unavailable or rejected, and must not rely on it for background execution. Dispose the manager when the plug-in unloads. Keep the manager injectable through the focused helper in `src/wakeLock.ts`; App-free tests use that boundary instead of constructing the Obsidian plug-in. diff --git a/main.ts b/main.ts index 9f3d902..9fae540 100644 --- a/main.ts +++ b/main.ts @@ -30,10 +30,16 @@ import { detectChangedFiles, planBatches, packBatches, type ArchivedBatch } from import { delay } from "octagonal-wheels/promises"; import { createObsidianUi, type UiInteractions } from "@vrtmrz/obsidian-plugin-kit/ui"; import { confirmRestore } from "./src/restoreConfirmation.ts"; +import { createDiffZipWakeLock, runWithDiffZipWakeLock, type DiffZipWakeLockLabel } from "./src/wakeLock.ts"; export default class DiffZipBackupPlugin extends Plugin { settings!: DiffZipBackupSettings; ui!: UiInteractions; + readonly operationWakeLock = createDiffZipWakeLock(); + + runWhileAwake(label: DiffZipWakeLockLabel, task: () => T | PromiseLike): Promise { + return runWithDiffZipWakeLock(this.operationWakeLock, label, task); + } get isMobile(): boolean { // @ts-ignore @@ -195,6 +201,12 @@ export default class DiffZipBackupPlugin extends Plugin { } async createZip(verbosity: boolean, onlyNew = false, skipDeleted: boolean = false) { + return await this.runWhileAwake("differential-backup", () => + this.createZipWithoutWakeLock(verbosity, onlyNew, skipDeleted) + ); + } + + private async createZipWithoutWakeLock(verbosity: boolean, onlyNew: boolean, skipDeleted: boolean) { const key = "proc-zip-process-" + Date.now(); const log = verbosity ? (msg: string, key?: string) => this.logWrite(msg, key) @@ -399,6 +411,17 @@ export default class DiffZipBackupPlugin extends Plugin { extractFiles: string | string[], restoreAs: string | undefined = undefined, restorePrefix: string = "" + ): Promise { + return await this.runWhileAwake("archive-restore", () => + this.extractWithoutWakeLock(zipFile, extractFiles, restoreAs, restorePrefix) + ); + } + + private async extractWithoutWakeLock( + zipFile: string, + extractFiles: string | string[], + restoreAs: string | undefined, + restorePrefix: string ): Promise { const hasMultipleSupplied = Array.isArray(extractFiles); const zipPath = this.backups.normalizePath(`${this.backupFolder}${this.sep}${zipFile}`); @@ -421,6 +444,7 @@ export default class DiffZipBackupPlugin extends Plugin { } if (files.length == 0) { this.logMessage("Archived ZIP files were not found!"); + return; } const restored = [] as string[]; @@ -457,6 +481,7 @@ export default class DiffZipBackupPlugin extends Plugin { extractor.addZippedContent(chunk); } } + await extractor.finalise(); } async selectAndRestore() { @@ -786,6 +811,9 @@ export default class DiffZipBackupPlugin extends Plugin { // console.dir(zipFileMap); } async onload() { + this.register(() => { + void this.operationWakeLock.dispose(); + }); this.ui = createObsidianUi(this.app); await this.loadSettings(); if ("backupFolder" in this.settings) { diff --git a/manifest.json b/manifest.json index a6bbd10..efcbde3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "diffzip", "name": "Differential ZIP Backup", - "version": "0.1.8", + "version": "0.1.9-wakelock.1", "minAppVersion": "1.8.7", "description": "Back our vault up with lesser storage.", "author": "vorotamoroz", diff --git a/package-lock.json b/package-lock.json index 98ea4e4..8ef8c9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "diffzip", - "version": "0.1.8", + "version": "0.1.9-wakelock.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "diffzip", - "version": "0.1.8", + "version": "0.1.9-wakelock.1", "license": "MIT", "dependencies": { "@vrtmrz/obsidian-plugin-kit": "0.1.0", "@vrtmrz/ui-interactions": "0.1.0", "fflate": "^0.8.2", - "octagonal-wheels": "0.1.48" + "octagonal-wheels": "0.1.51" }, "devDependencies": { "@aws-sdk/client-s3": "^3.726.1", @@ -7429,9 +7429,9 @@ } }, "node_modules/octagonal-wheels": { - "version": "0.1.48", - "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.48.tgz", - "integrity": "sha512-uCUEGK4UUkmumPI/fE45r877+mpANvSPe9E4O2LT95vN6Nk9PHaqQDq8NWCiYTWTlGptlhsNBstH/WxSh0ocFg==", + "version": "0.1.51", + "resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.51.tgz", + "integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==", "license": "MIT", "dependencies": { "idb": "^8.0.3" diff --git a/package.json b/package.json index 094909b..a3283d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "diffzip", - "version": "0.1.8", + "version": "0.1.9-wakelock.1", "description": "Differential ZIP Backup", "main": "main.js", "scripts": { @@ -12,6 +12,7 @@ "test:ui:watch": "vitest --config vitest.config.ts", "check:e2e:obsidian": "tsc -p test/e2e-obsidian/tsconfig.json", "test:e2e:obsidian:restore-confirmation": "npm run build && tsx test/e2e-obsidian/restore-confirmation.mts", + "test:e2e:obsidian:wake-lock": "npm run build && tsx test/e2e-obsidian/wake-lock.mts", "version": "node version-bump.mjs && git add manifest.json versions.json", "pretty": "npm run prettyNoWrite -- --write --log-level error", "prettyCheck": "npm run prettyNoWrite -- --check", @@ -61,6 +62,6 @@ "@vrtmrz/obsidian-plugin-kit": "0.1.0", "@vrtmrz/ui-interactions": "0.1.0", "fflate": "^0.8.2", - "octagonal-wheels": "0.1.48" + "octagonal-wheels": "0.1.51" } } diff --git a/src/Archive.test.ts b/src/Archive.test.ts index 75d2b0b..7efaf37 100644 --- a/src/Archive.test.ts +++ b/src/Archive.test.ts @@ -36,8 +36,7 @@ Deno.test("Archiver + Extractor: round-trip a single text file", async () => { }, ); extractor.addZippedContent(zipData, true); - // Give async callbacks time to settle - await new Promise((res) => setTimeout(res, 100)); + await extractor.finalise(); assertEquals(extracted["note.md"], "hello, world", "Extracted content must match original"); }); @@ -63,7 +62,7 @@ Deno.test("Archiver + Extractor: round-trip multiple files", async () => { }, ); extractor.addZippedContent(zipData, true); - await new Promise((res) => setTimeout(res, 100)); + await extractor.finalise(); for (const [path, text] of Object.entries(files)) { assertEquals(extracted[path], text, `Extracted content of ${path} must match original`); @@ -84,7 +83,7 @@ Deno.test("Extractor: filter function skips unwanted files", async () => { }, ); extractor.addZippedContent(zipData, true); - await new Promise((res) => setTimeout(res, 100)); + await extractor.finalise(); assert("keep.md" in extracted, "keep.md must be extracted"); assert(!("skip.md" in extracted), "skip.md must be skipped"); @@ -132,7 +131,7 @@ Deno.test("Archiver: large file triggers multi-chunk path and progress callback" }, ); extractor.addZippedContent(zipData, true); - await new Promise((res) => setTimeout(res, 500)); + await extractor.finalise(); assert("large.bin" in extracted, "large.bin must be extracted"); assertEquals(extracted["large.bin"].length, SIZE, "Extracted size must match original"); @@ -158,9 +157,49 @@ Deno.test("Extractor: finalise() correctly ends streamed zip input", async () => const half = Math.floor(zipData.length / 2); extractor.addZippedContent(zipData.slice(0, half), false); extractor.addZippedContent(zipData.slice(half), false); - extractor.finalise(); - - await new Promise((res) => setTimeout(res, 200)); + await extractor.finalise(); assertEquals(extracted["stream.md"], "streamed content", "Streamed extraction via finalise() must match original"); }); + +Deno.test("Extractor: finalise() waits for an asynchronous extraction callback", async () => { + const archiver = new Archiver(); + archiver.addTextFile("delayed content", "delayed.md"); + const zipData = await archiver.finalize(); + + let callbackFinished = false; + const extractor = new Extractor( + () => true, + async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + callbackFinished = true; + }, + ); + extractor.addZippedContent(zipData, true); + await extractor.finalise(); + + assert(callbackFinished, "finalise() must not resolve before the extraction callback finishes"); +}); + +Deno.test("Extractor: finalise() propagates extraction callback errors", async () => { + const archiver = new Archiver(); + archiver.addTextFile("content", "failure.md"); + const zipData = await archiver.finalize(); + + const expected = new Error("write failed"); + const extractor = new Extractor( + () => true, + async () => { + throw expected; + }, + ); + extractor.addZippedContent(zipData, true); + + let actual: unknown; + try { + await extractor.finalise(); + } catch (error: unknown) { + actual = error; + } + assert(actual === expected, "finalise() must propagate the extraction callback error"); +}); diff --git a/src/Archive.ts b/src/Archive.ts index b7b5ea5..41e1bd8 100644 --- a/src/Archive.ts +++ b/src/Archive.ts @@ -109,6 +109,9 @@ export class Extractor { _zipFile: fflate.Unzip; _isFileShouldBeExtracted: (file: fflate.UnzipFile) => boolean | Promise; _onExtracted: (filename: string, content: XByteArray) => Promise; + _pendingFiles = new Set>(); + _failures: unknown[] = []; + _inputFinalised = false; constructor(isFileShouldBeExtracted: Extractor["_isFileShouldBeExtracted"], callback: Extractor["_onExtracted"]) { const unzipper = new fflate.Unzip(); @@ -117,34 +120,64 @@ export class Extractor { this._isFileShouldBeExtracted = isFileShouldBeExtracted; this._onExtracted = callback; - const onFile = async (file: fflate.UnzipFile) => { - if (await this._isFileShouldBeExtracted(file)) { - const data: XByteArray[] = []; - const onData = async (err: fflate.FlateError | null, dat: Uint8Array, isFinal: boolean) => { - if (err) { - console.error("Error extracting file", err); - return; - } - if (dat && dat.length > 0) data.push(new Uint8Array(dat)); - - if (isFinal) { - const total = new Blob(data, { type: "application/octet-stream" }); - const result = new Uint8Array(await total.arrayBuffer()); - await this._onExtracted(file.name, result); - } - }; - file.ondata = (err, dat, isFinal) => void onData(err, dat, isFinal); - file.start(); - } - }; - unzipper.onfile = (file) => void onFile(file); + unzipper.onfile = (file) => this.trackFile(file); } addZippedContent(data: XByteArray, isFinal = false) { this._zipFile.push(data, isFinal); + this._inputFinalised ||= isFinal; } - finalise() { - this._zipFile.push(new Uint8Array(), true); + /** Finalise the ZIP input and wait for every selected file callback to finish. */ + async finalise(): Promise { + if (!this._inputFinalised) { + this._zipFile.push(new Uint8Array(), true); + this._inputFinalised = true; + } + while (this._pendingFiles.size > 0) { + await Promise.all(this._pendingFiles); + } + if (this._failures.length > 0) { + throw this._failures[0]; + } + } + + private trackFile(file: fflate.UnzipFile): void { + let tracked: Promise; + tracked = this.extractFile(file) + .catch((error: unknown) => { + this._failures.push(error); + }) + .finally(() => { + this._pendingFiles.delete(tracked); + }); + this._pendingFiles.add(tracked); + } + + private async extractFile(file: fflate.UnzipFile): Promise { + if (!(await this._isFileShouldBeExtracted(file))) { + return; + } + const data: XByteArray[] = []; + await new Promise((resolve, reject) => { + file.ondata = (err, dat, isFinal) => { + if (err) { + reject(err); + return; + } + if (dat && dat.length > 0) { + data.push(new Uint8Array(dat)); + } + if (!isFinal) { + return; + } + const total = new Blob(data, { type: "application/octet-stream" }); + void total + .arrayBuffer() + .then((buffer) => this._onExtracted(file.name, new Uint8Array(buffer))) + .then(resolve, reject); + }; + file.start(); + }); } } diff --git a/src/SyncRemoteDialog.ts b/src/SyncRemoteDialog.ts index 9d6d5a8..e496049 100644 --- a/src/SyncRemoteDialog.ts +++ b/src/SyncRemoteDialog.ts @@ -204,6 +204,10 @@ export class SyncRemoteDialog extends Modal { } async applyFetch(fetchItems: SyncItem[]) { + return await this.plugin.runWhileAwake("selective-sync-fetch", () => this.applyFetchWithoutWakeLock(fetchItems)); + } + + private async applyFetchWithoutWakeLock(fetchItems: SyncItem[]) { const totalOps = fetchItems.length; const progress = new ProgressFragment({ title: "Mirroring remote...", @@ -243,29 +247,31 @@ export class SyncRemoteDialog extends Modal { } async applySend(sendItems: SyncItem[]) { - const { sentCount } = await executeSend( - sendItems, - this.plugin.vaultAccess, - this.plugin.backups, - () => this.plugin.loadTOC(), - (i) => this.makeSyncZipName(i), - { - backupFolder: this.plugin.backupFolder, - sep: this.plugin.sep, - maxFilesInZip: this.plugin.settings.maxFilesInZip, - maxTotalSizeInZip: - this.plugin.settings.maxTotalSizeInZip > 0 - ? this.plugin.settings.maxTotalSizeInZip * 1024 * 1024 - : 0, - maxSize: - this.plugin.settings.maxSize > 0 - ? this.plugin.settings.maxSize * 1024 * 1024 - : 0, - serializeYaml: stringifyYaml, - debugExecutionToConsole: DEBUG_SYNC_LOG, - }, - ); - return sentCount; + return await this.plugin.runWhileAwake("selective-sync-send", async () => { + const { sentCount } = await executeSend( + sendItems, + this.plugin.vaultAccess, + this.plugin.backups, + () => this.plugin.loadTOC(), + (i) => this.makeSyncZipName(i), + { + backupFolder: this.plugin.backupFolder, + sep: this.plugin.sep, + maxFilesInZip: this.plugin.settings.maxFilesInZip, + maxTotalSizeInZip: + this.plugin.settings.maxTotalSizeInZip > 0 + ? this.plugin.settings.maxTotalSizeInZip * 1024 * 1024 + : 0, + maxSize: + this.plugin.settings.maxSize > 0 + ? this.plugin.settings.maxSize * 1024 * 1024 + : 0, + serializeYaml: stringifyYaml, + debugExecutionToConsole: DEBUG_SYNC_LOG, + }, + ); + return sentCount; + }); } onClose() { diff --git a/src/wakeLock.test.ts b/src/wakeLock.test.ts new file mode 100644 index 0000000..ce102ef --- /dev/null +++ b/src/wakeLock.test.ts @@ -0,0 +1,112 @@ +import { type ScreenWakeLockEvent, type ScreenWakeLockSentinel } from "octagonal-wheels/browser/wakeLock"; +import { createDiffZipWakeLock, runWithDiffZipWakeLock } from "./wakeLock.ts"; + +declare const Deno: { + test: (name: string, fn: () => void | Promise) => void; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertEquals(actual: T, expected: T, message: string): void { + if (actual !== expected) { + throw new Error(`${message}: expected=${String(expected)}, actual=${String(actual)}`); + } +} + +class FakeSentinel implements ScreenWakeLockSentinel { + released = false; + releaseCalls = 0; + listeners = new Set<() => void>(); + + addEventListener(_type: "release", listener: () => void): void { + this.listeners.add(listener); + } + + removeEventListener(_type: "release", listener: () => void): void { + this.listeners.delete(listener); + } + + async release(): Promise { + if (this.released) return; + this.released = true; + this.releaseCalls++; + for (const listener of this.listeners) listener(); + } +} + +Deno.test("DiffZip wake lock: protects a backup and releases after completion", async () => { + const sentinel = new FakeSentinel(); + const events: ScreenWakeLockEvent[] = []; + const wakeLock = createDiffZipWakeLock({ + provider: { request: async () => sentinel }, + document: null, + onEvent: (event) => events.push(event), + }); + + const result = await runWithDiffZipWakeLock(wakeLock, "differential-backup", async () => { + assert(wakeLock.held, "The platform wake lock should be held while the backup runs"); + return "completed"; + }); + + assertEquals(result, "completed", "The backup result should be preserved"); + assertEquals(wakeLock.activeLeaseCount, 0, "The logical lease should be released"); + assertEquals(sentinel.releaseCalls, 1, "The platform wake lock should be released once"); + assert( + events.some((event) => event.type === "lease-acquired" && event.label === "differential-backup"), + "The diagnostic label should identify the backup operation" + ); + await wakeLock.dispose(); +}); + +Deno.test("DiffZip wake lock: releases when a backup exits early", async () => { + const sentinel = new FakeSentinel(); + const wakeLock = createDiffZipWakeLock({ + provider: { request: async () => sentinel }, + document: null, + }); + + const result = await runWithDiffZipWakeLock(wakeLock, "differential-backup", () => "cancelled"); + + assertEquals(result, "cancelled", "An early backup result should be preserved"); + assertEquals(wakeLock.activeLeaseCount, 0, "An early exit should release the logical lease"); + assertEquals(sentinel.releaseCalls, 1, "An early exit should release the platform wake lock"); + await wakeLock.dispose(); +}); + +Deno.test("DiffZip wake lock: releases and preserves a backup error", async () => { + const sentinel = new FakeSentinel(); + const wakeLock = createDiffZipWakeLock({ + provider: { request: async () => sentinel }, + document: null, + }); + const expected = new Error("backup failed"); + let caught: unknown; + + try { + await runWithDiffZipWakeLock(wakeLock, "selective-sync-send", () => { + throw expected; + }); + } catch (error) { + caught = error; + } + + assertEquals(caught, expected, "The original backup error should propagate"); + assertEquals(wakeLock.activeLeaseCount, 0, "An error should release the logical lease"); + assertEquals(sentinel.releaseCalls, 1, "An error should release the platform wake lock"); + await wakeLock.dispose(); +}); + +Deno.test("DiffZip wake lock: unsupported platforms still run the backup", async () => { + const wakeLock = createDiffZipWakeLock({ provider: null, document: null }); + let ran = false; + + await runWithDiffZipWakeLock(wakeLock, "differential-backup", () => { + ran = true; + }); + + assert(ran, "The backup should run when the Screen Wake Lock API is unavailable"); + assertEquals(wakeLock.activeLeaseCount, 0, "The unsupported run should not leak a logical lease"); + await wakeLock.dispose(); +}); diff --git a/src/wakeLock.ts b/src/wakeLock.ts new file mode 100644 index 0000000..defd7ec --- /dev/null +++ b/src/wakeLock.ts @@ -0,0 +1,25 @@ +import { + createScreenWakeLockManager, + type ScreenWakeLockManager, + type ScreenWakeLockManagerOptions, +} from "octagonal-wheels/browser/wakeLock"; + +export type DiffZipWakeLockLabel = + | "archive-restore" + | "differential-backup" + | "selective-sync-fetch" + | "selective-sync-send"; + +export type DiffZipWakeLock = Pick; + +export function createDiffZipWakeLock(options: ScreenWakeLockManagerOptions = {}): ScreenWakeLockManager { + return createScreenWakeLockManager(options); +} + +export function runWithDiffZipWakeLock( + wakeLock: DiffZipWakeLock, + label: DiffZipWakeLockLabel, + task: () => T | PromiseLike +): Promise { + return wakeLock.run(task, { label }); +} diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 78fdadf..724780a 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -4,11 +4,14 @@ This local-only suite installs the built DiffZip plug-in into an isolated vault The restore-confirmation scenario seeds one backup-information entry, invokes the real restore workflow, and verifies rendered Markdown, the visible Cancel action, explicit cancellation, and Escape dismissal in a real Obsidian Modal. It does not use scripted `UiInteractions` responses. +The wake-lock scenario creates a Vault fixture, invokes the real differential-backup entry point, removes the original, and restores it from the generated ZIP. It verifies that one logical wake-lock lease is active and released for both operations, confirms that the fixture reached `backupinfo.md`, and checks the restored content. Platform support is collected as evidence rather than required on desktop. Physical display behaviour for backup, restore, and selective sync remains a manual mobile review. + The suite is currently validated on Linux only. Set `OBSIDIAN_BINARY` and `OBSIDIAN_CLI` when the executables are outside the discovery paths inherited from the shared test-session package. ```bash npm run check:e2e:obsidian npm run test:e2e:obsidian:restore-confirmation +npm run test:e2e:obsidian:wake-lock ``` Set `E2E_OBSIDIAN_KEEP_VAULT=true` to preserve the temporary vault and isolated application state for debugging. diff --git a/test/e2e-obsidian/wake-lock.mts b/test/e2e-obsidian/wake-lock.mts new file mode 100644 index 0000000..b0013ae --- /dev/null +++ b/test/e2e-obsidian/wake-lock.mts @@ -0,0 +1,112 @@ +import { withObsidianPage } from "@vrtmrz/obsidian-test-session"; +import { + DIFFZIP_PLUGIN_ID, + startDiffZipTestSession, + stopDiffZipTestSession, + type DiffZipTestSession, +} from "./harness.mts"; + +interface WakeLockBackupEvidence { + backupActiveDuring: number; + backupActiveAfter: number; + restoreActiveDuring: number; + restoreActiveAfter: number; + supported: boolean; + tocContainsFixture: boolean; + restoredContent: string; +} + +async function verifyBackupWakeLock(testSession: DiffZipTestSession): Promise { + return await withObsidianPage(testSession.session.remoteDebuggingPort, async (page) => { + return await page.evaluate(async (pluginId) => { + const obsidianApp = ( + globalThis as typeof globalThis & { + app?: { + plugins?: { + plugins?: Record< + string, + { + operationWakeLock: { + activeLeaseCount: number; + supported: boolean; + }; + createZip(verbosity: boolean): Promise; + extract(zipFile: string, extractFiles: string[]): Promise; + loadTOC(): Promise>; + } + >; + }; + vault?: { + adapter: { + read(path: string): Promise; + }; + create(path: string, data: string): Promise; + delete(file: unknown, force: boolean): Promise; + getAbstractFileByPath(path: string): unknown; + }; + }; + } + ).app; + const plugin = obsidianApp?.plugins?.plugins?.[pluginId]; + const vault = obsidianApp?.vault; + if (!plugin || !vault) throw new Error(`DiffZip is not loaded: ${pluginId}`); + + const fixture = "wake-lock-e2e.md"; + await vault.create(fixture, "Wake Lock E2E"); + const backup = plugin.createZip(false); + const backupActiveDuring = plugin.operationWakeLock.activeLeaseCount; + const supported = plugin.operationWakeLock.supported; + await backup; + const backupActiveAfter = plugin.operationWakeLock.activeLeaseCount; + const tocText = await vault.adapter.read("backup/backupinfo.md"); + const toc = await plugin.loadTOC(); + const zipName = toc[fixture]?.history.at(-1)?.zipName; + if (!zipName) throw new Error(`The backup did not record ${fixture}`); + const abstractFile = vault.getAbstractFileByPath(fixture); + if (!abstractFile) throw new Error(`The backup fixture disappeared: ${fixture}`); + await vault.delete(abstractFile, true); + + const restore = plugin.extract(zipName, [fixture]); + const restoreActiveDuring = plugin.operationWakeLock.activeLeaseCount; + await restore; + + return { + backupActiveDuring, + backupActiveAfter, + restoreActiveDuring, + restoreActiveAfter: plugin.operationWakeLock.activeLeaseCount, + supported, + tocContainsFixture: tocText.includes(fixture), + restoredContent: await vault.adapter.read(fixture), + }; + }, DIFFZIP_PLUGIN_ID); + }); +} + +async function main(): Promise { + let testSession: DiffZipTestSession | undefined; + try { + testSession = await startDiffZipTestSession(); + const evidence = await verifyBackupWakeLock(testSession); + if (evidence.backupActiveDuring !== 1 || evidence.restoreActiveDuring !== 1) { + throw new Error(`Expected one active operation lease, received ${JSON.stringify(evidence)}`); + } + if (evidence.backupActiveAfter !== 0 || evidence.restoreActiveAfter !== 0) { + throw new Error(`An operation wake lock was not released: ${JSON.stringify(evidence)}`); + } + if (!evidence.tocContainsFixture) { + throw new Error(`The backup did not record its fixture: ${JSON.stringify(evidence)}`); + } + if (evidence.restoredContent !== "Wake Lock E2E") { + throw new Error(`The restore did not reproduce its fixture: ${JSON.stringify(evidence)}`); + } + console.log(`DiffZip operation wake-lock lifecycle passed in real Obsidian: ${JSON.stringify(evidence)}`); + } finally { + if (testSession) await stopDiffZipTestSession(testSession); + } +} + +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.stack : error); + process.exit(1); +}); diff --git a/updates.md b/updates.md new file mode 100644 index 0000000..121f3a5 --- /dev/null +++ b/updates.md @@ -0,0 +1,11 @@ +# Updates + +## Unreleased + +### New features + +- Backup, restore, and selective-sync operations now request a best-effort screen wake lock on supported devices. The request is released when the operation completes, is cancelled, or fails. + +### Improved + +- Restore completion now waits for every selected archive file to finish writing before completion is reported and the screen wake lock is released.