From f1d4b07a6a93956cf0d71e03c1f07de16d8d0ac3 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 12:52:06 +0300 Subject: [PATCH 01/13] fix(deps): update YAML and sanitizer security patches --- desktop-app/resources/index.html | 4 +-- index.html | 4 +-- package-lock.json | 18 +++++------- package.json | 4 +-- tests/e2e/runtime-dependencies.spec.js | 14 +++++++++ tests/helpers/app.js | 4 +-- tests/unit/runtime-dependencies.test.mjs | 37 ++++++++++++++++++++++++ 7 files changed, 67 insertions(+), 18 deletions(-) create mode 100644 tests/e2e/runtime-dependencies.spec.js create mode 100644 tests/unit/runtime-dependencies.test.mjs diff --git a/desktop-app/resources/index.html b/desktop-app/resources/index.html index 6901db2b..683c1f45 100644 --- a/desktop-app/resources/index.html +++ b/desktop-app/resources/index.html @@ -32,10 +32,10 @@ - + - +
diff --git a/index.html b/index.html index c7085e90..6317e012 100644 --- a/index.html +++ b/index.html @@ -132,10 +132,10 @@ - + - +
diff --git a/package-lock.json b/package-lock.json index 48ec9f43..840cd7f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,14 +11,14 @@ "@highlightjs/cdn-assets": "11.9.0", "@playwright/test": "1.61.1", "bootstrap": "5.3.2", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "entities": "6.0.1", "esbuild": "0.25.12", "file-saver": "2.0.5", "github-markdown-css": "5.3.0", "github-slugger": "2.0.0", "highlight.js": "11.9.0", - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "jszip": "3.10.1", "marked": "9.1.6", "pako": "2.1.0" @@ -550,11 +550,10 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", "dev": true, - "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -688,9 +687,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ { @@ -702,7 +701,6 @@ "url": "https://github.com/sponsors/nodeca" } ], - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, diff --git a/package.json b/package.json index 1872f23c..da437478 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ "@highlightjs/cdn-assets": "11.9.0", "@playwright/test": "1.61.1", "bootstrap": "5.3.2", - "dompurify": "3.4.13", + "dompurify": "3.4.16", "entities": "6.0.1", "esbuild": "0.25.12", "file-saver": "2.0.5", "github-markdown-css": "5.3.0", "github-slugger": "2.0.0", "highlight.js": "11.9.0", - "js-yaml": "4.3.1", + "js-yaml": "4.3.2", "jszip": "3.10.1", "marked": "9.1.6", "pako": "2.1.0" diff --git a/tests/e2e/runtime-dependencies.spec.js b/tests/e2e/runtime-dependencies.spec.js new file mode 100644 index 00000000..73964923 --- /dev/null +++ b/tests/e2e/runtime-dependencies.spec.js @@ -0,0 +1,14 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +test('updated YAML and sanitizer preserve frontmatter and reject active HTML', async ({ page }) => { + const errors = await openApp(page); + await setEditorContent(page, '---\ntitle: Dependency smoke test\ntags:\n - markdown\n - safety\n---\n\n# Safe preview\n\n**Formatted text**\n\n\n\nUnsafe link'); + const preview = page.locator('#markdown-preview'); + await expect(preview.locator('.frontmatter-table')).toContainText('Dependency smoke test'); + await expect(preview.locator('h1')).toHaveText('Safe preview'); + await expect(preview.locator('strong')).toContainText('Formatted text'); + await expect(preview.locator('[onerror], [href^="javascript:"]')).toHaveCount(0); + expect(await page.evaluate(() => window.__unsafePreview)).toBeUndefined(); + expect(errors).toEqual([]); +}); diff --git a/tests/helpers/app.js b/tests/helpers/app.js index f31a45d9..8e25474b 100644 --- a/tests/helpers/app.js +++ b/tests/helpers/app.js @@ -14,9 +14,9 @@ const localBrowserAssets = [ ['https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js', 'node_modules/marked/marked.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js', 'node_modules/@highlightjs/cdn-assets/highlight.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/powershell.min.js', 'node_modules/@highlightjs/cdn-assets/languages/powershell.min.js', 'application/javascript'], - ['https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.13/purify.min.js', 'node_modules/dompurify/dist/purify.min.js', 'application/javascript'], + ['https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js', 'node_modules/dompurify/dist/purify.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js', 'node_modules/file-saver/dist/FileSaver.min.js', 'application/javascript'], - ['https://cdn.jsdelivr.net/npm/js-yaml@4.3.1/dist/js-yaml.min.js', 'node_modules/js-yaml/dist/js-yaml.min.js', 'application/javascript'], + ['https://cdn.jsdelivr.net/npm/js-yaml@4.3.2/dist/js-yaml.min.js', 'node_modules/js-yaml/dist/js-yaml.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/js/bootstrap.bundle.min.js', 'node_modules/bootstrap/dist/js/bootstrap.bundle.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', 'node_modules/jszip/dist/jszip.min.js', 'application/javascript'], ['https://cdnjs.cloudflare.com/ajax/libs/pako/2.1.0/pako.min.js', 'node_modules/pako/dist/pako.min.js', 'application/javascript'] diff --git a/tests/unit/runtime-dependencies.test.mjs b/tests/unit/runtime-dependencies.test.mjs new file mode 100644 index 00000000..4bfe40b6 --- /dev/null +++ b/tests/unit/runtime-dependencies.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import yaml from 'js-yaml'; + +const root = new URL('../../', import.meta.url); +const read = relative => fs.readFileSync(new URL(relative, root)); +const manifest = JSON.parse(read('package.json')); +const lock = JSON.parse(read('package-lock.json')); +const html = read('index.html').toString(); +const fixtures = read('tests/helpers/app.js').toString(); + +for (const [name, version, url, asset] of [ + ['dompurify', '3.4.16', 'https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.4.16/purify.min.js', 'dist/purify.min.js'], + ['js-yaml', '4.3.2', 'https://cdn.jsdelivr.net/npm/js-yaml@4.3.2/dist/js-yaml.min.js', 'dist/js-yaml.min.js'] +]) { + test(`${name}: package, lockfile, browser URL and SRI agree`, () => { + assert.equal(manifest.devDependencies[name], version); + assert.equal(lock.packages[''].devDependencies[name], version); + assert.equal(lock.packages[`node_modules/${name}`].version, version); + assert.equal(JSON.parse(read(`node_modules/${name}/package.json`)).version, version); + const tag = html.match(/]*>/g).find(tag => tag.includes(`src="${url}"`)); + assert.ok(tag, 'Missing pinned CDN script'); + const expected = 'sha384-' + crypto.createHash('sha384') + .update(read(`node_modules/${name}/${asset}`)).digest('base64'); + assert.ok(tag.includes(`integrity="${expected}"`), 'SRI differs from installed browser asset'); + assert.ok(fixtures.includes(url), 'Browser fixtures must route the same CDN URL'); + }); +} + +test('YAML counts empty merge sources against the configured work limit', () => { + // Bounded regression: no expensive denial-of-service payload is necessary. + const document = 'sources: &sources [{}, {}, {}]\nvalue:\n <<: *sources\n'; + assert.throws(() => yaml.load(document, { maxTotalMergeKeys: 2 }), /maxTotalMergeKeys/); + assert.deepEqual(yaml.load(document, { maxTotalMergeKeys: 3 }).value, {}); +}); From fb287773b1b80f075e52524d88045bf2c386a9fb Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 12:53:20 +0300 Subject: [PATCH 02/13] fix(storage): retain newer recovery journals after saves --- desktop-app/resources/js/script.js | 80 +++++++++++-- desktop-app/resources/js/workspace-storage.js | 20 +++- script.js | 80 +++++++++++-- tests/e2e/recovery-journal-commit.spec.js | 50 ++++++++ tests/unit/recovery-journal-commit.test.mjs | 109 ++++++++++++++++++ workspace-storage.js | 20 +++- 6 files changed, 331 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/recovery-journal-commit.spec.js create mode 100644 tests/unit/recovery-journal-commit.test.mjs diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index dd54b8e8..ec7b3cb9 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -10826,8 +10826,61 @@ document.addEventListener("DOMContentLoaded", async function () { return source; } - function clearDirtyDocumentJournal(tab) { + async function captureDirtyDocumentJournalSnapshot(storageTabs, changedIds) { + const snapshots = new Map(); + changedIds.forEach(function(id) { + const tab = storageTabs.find(function(item) { return item.id === id; }); + // Metadata-only writes do not commit a document body. + if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return; + const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id)))); + const snapshot = { content: tab.content, local: {}, session: {}, indexed: {} }; + [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) { + keys.forEach(function(key) { + try { + const raw = pair[0].getItem(key); + const value = raw && JSON.parse(raw); + if (value && value.documentId === id && value.content === snapshot.content) pair[1][key] = raw; + } catch (_) { /* Unknown recovery data must be retained. */ } + }); + }); + snapshots.set(id, snapshot); + }); + if (workspaceStorage && typeof workspaceStorage.listDirtyJournals === 'function') { + try { + (await workspaceStorage.listDirtyJournals()).forEach(function(record) { + const snapshot = snapshots.get(record.documentId); + if (snapshot && record.journalId && record.content === snapshot.content) { + snapshot.indexed[record.journalId] = record; + } + }); + } catch (error) { + console.warn('Unable to snapshot recovery journals; retaining them:', error); + } + } + return snapshots; + } + + function clearDirtyDocumentJournal(tab, committedSnapshot) { if (!tab) return Promise.resolve(); + if (committedSnapshot) { + // Compare the exact journal, not merely its key or timestamp. A new edit + // may reuse the same key while a storage operation is awaiting completion. + [[localStorage, committedSnapshot.local], [sessionStorage, committedSnapshot.session]].forEach(function(pair) { + Object.keys(pair[1]).forEach(function(key) { + try { + if (pair[0].getItem(key) === pair[1][key]) pair[0].removeItem(key); + } catch (_) {} + }); + }); + if (workspaceStorage && typeof workspaceStorage.deleteDirtyJournals === 'function') { + return workspaceStorage.deleteDirtyJournals(Object.keys(committedSnapshot.indexed), { + expectedJournals: committedSnapshot.indexed + }).catch(function(error) { + console.warn('Unable to clear a committed emergency journal:', error); + }); + } + return Promise.resolve(); + } const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(tab.id)))); keys.forEach(function(key) { try { localStorage.removeItem(key); } catch (_) {} @@ -11095,20 +11148,25 @@ document.addEventListener("DOMContentLoaded", async function () { } try { const storageTabs = getTabsForStorage(tabsArr); + const commit = async function() { + const journalSnapshots = await captureDirtyDocumentJournalSnapshot(storageTabs, changedIds); + if (workspaceStorage) { + await workspaceStorage.saveDocuments(storageTabs, documentOrganization, { changedIds: changedIds }); + } else { + saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + } + await Promise.all(changedIds.map(function(id) { + const tab = tabs.find(function(item) { return item.id === id; }); + const snapshot = journalSnapshots.get(id); + return tab && snapshot ? clearDirtyDocumentJournal(tab, snapshot) : Promise.resolve(); + })); + }; if (workspaceStorage) { - workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(function() { - return workspaceStorage.saveDocuments(storageTabs, documentOrganization, { - changedIds: changedIds - }); - }); + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(commit); await workspacePersistenceChain; } else { - saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + await commit(); } - await Promise.all(changedIds.map(function(id) { - const tab = tabs.find(function(item) { return item.id === id; }); - return tab ? clearDirtyDocumentJournal(tab) : Promise.resolve(); - })); evictInactiveDocumentContent(); updateSaveStatus('saved'); return true; diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index df3eb067..bdf2fa28 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -1861,14 +1861,28 @@ return records; } - async deleteDirtyJournals(journalIds) { + async deleteDirtyJournals(journalIds, options) { if (this.desktop) return; const ids = Array.isArray(journalIds) ? journalIds.filter(Boolean) : []; if (!ids.length) return; const transaction = this.db.transaction('journals', 'readwrite'); + const completion = transactionToPromise(transaction); const store = transaction.objectStore('journals'); - ids.forEach(function(id) { store.delete(id); }); - await transactionToPromise(transaction); + const expected = options && options.expectedJournals; + ids.forEach(function(id) { + if (!expected) { + store.delete(id); + return; + } + if (!Object.prototype.hasOwnProperty.call(expected, id)) return; + const request = store.get(id); + request.onsuccess = function() { + // The read and conditional delete share a readwrite transaction, + // so a concurrent replacement cannot be deleted between them. + if (JSON.stringify(request.result) === JSON.stringify(expected[id])) store.delete(id); + }; + }); + await completion; } async restoreTrashItem(trashId) { diff --git a/script.js b/script.js index dd54b8e8..ec7b3cb9 100644 --- a/script.js +++ b/script.js @@ -10826,8 +10826,61 @@ document.addEventListener("DOMContentLoaded", async function () { return source; } - function clearDirtyDocumentJournal(tab) { + async function captureDirtyDocumentJournalSnapshot(storageTabs, changedIds) { + const snapshots = new Map(); + changedIds.forEach(function(id) { + const tab = storageTabs.find(function(item) { return item.id === id; }); + // Metadata-only writes do not commit a document body. + if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return; + const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id)))); + const snapshot = { content: tab.content, local: {}, session: {}, indexed: {} }; + [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) { + keys.forEach(function(key) { + try { + const raw = pair[0].getItem(key); + const value = raw && JSON.parse(raw); + if (value && value.documentId === id && value.content === snapshot.content) pair[1][key] = raw; + } catch (_) { /* Unknown recovery data must be retained. */ } + }); + }); + snapshots.set(id, snapshot); + }); + if (workspaceStorage && typeof workspaceStorage.listDirtyJournals === 'function') { + try { + (await workspaceStorage.listDirtyJournals()).forEach(function(record) { + const snapshot = snapshots.get(record.documentId); + if (snapshot && record.journalId && record.content === snapshot.content) { + snapshot.indexed[record.journalId] = record; + } + }); + } catch (error) { + console.warn('Unable to snapshot recovery journals; retaining them:', error); + } + } + return snapshots; + } + + function clearDirtyDocumentJournal(tab, committedSnapshot) { if (!tab) return Promise.resolve(); + if (committedSnapshot) { + // Compare the exact journal, not merely its key or timestamp. A new edit + // may reuse the same key while a storage operation is awaiting completion. + [[localStorage, committedSnapshot.local], [sessionStorage, committedSnapshot.session]].forEach(function(pair) { + Object.keys(pair[1]).forEach(function(key) { + try { + if (pair[0].getItem(key) === pair[1][key]) pair[0].removeItem(key); + } catch (_) {} + }); + }); + if (workspaceStorage && typeof workspaceStorage.deleteDirtyJournals === 'function') { + return workspaceStorage.deleteDirtyJournals(Object.keys(committedSnapshot.indexed), { + expectedJournals: committedSnapshot.indexed + }).catch(function(error) { + console.warn('Unable to clear a committed emergency journal:', error); + }); + } + return Promise.resolve(); + } const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(tab.id)))); keys.forEach(function(key) { try { localStorage.removeItem(key); } catch (_) {} @@ -11095,20 +11148,25 @@ document.addEventListener("DOMContentLoaded", async function () { } try { const storageTabs = getTabsForStorage(tabsArr); + const commit = async function() { + const journalSnapshots = await captureDirtyDocumentJournalSnapshot(storageTabs, changedIds); + if (workspaceStorage) { + await workspaceStorage.saveDocuments(storageTabs, documentOrganization, { changedIds: changedIds }); + } else { + saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + } + await Promise.all(changedIds.map(function(id) { + const tab = tabs.find(function(item) { return item.id === id; }); + const snapshot = journalSnapshots.get(id); + return tab && snapshot ? clearDirtyDocumentJournal(tab, snapshot) : Promise.resolve(); + })); + }; if (workspaceStorage) { - workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(function() { - return workspaceStorage.saveDocuments(storageTabs, documentOrganization, { - changedIds: changedIds - }); - }); + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(commit); await workspacePersistenceChain; } else { - saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + await commit(); } - await Promise.all(changedIds.map(function(id) { - const tab = tabs.find(function(item) { return item.id === id; }); - return tab ? clearDirtyDocumentJournal(tab) : Promise.resolve(); - })); evictInactiveDocumentContent(); updateSaveStatus('saved'); return true; diff --git a/tests/e2e/recovery-journal-commit.spec.js b/tests/e2e/recovery-journal-commit.spec.js new file mode 100644 index 00000000..bf1954e1 --- /dev/null +++ b/tests/e2e/recovery-journal-commit.spec.js @@ -0,0 +1,50 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +test('an edit arriving as an older save finishes keeps its browser recovery journal', async ({ page }) => { + await openApp(page); + await page.evaluate(() => { + const prototype = window.MarkdownWorkspaceStorage.prototype; + const save = prototype.saveDocuments; + prototype.saveDocuments = async function(documents, ...args) { + const shouldInject = !window.__newJournalInjected && documents.some(tab => tab.content === '# Older snapshot'); + const result = await save.call(this, documents, ...args); + if (shouldInject) { + const editor = document.querySelector('#markdown-editor'); + editor.value = '# Newer recovery draft'; + editor.dispatchEvent(new Event('input', { bubbles: true })); + window.__newJournalInjected = true; + // Observe after the old flush cleanup, before the next debounced save. + setTimeout(() => { + window.__journalAfterOldSave = Object.keys(localStorage) + .filter(key => key.startsWith('markdownViewerDirtyDocument:')) + .map(key => { try { return JSON.parse(localStorage.getItem(key)); } catch (_) { return null; } }) + .filter(Boolean); + }, 100); + } + return result; + }; + }); + await setEditorContent(page, '# Older snapshot'); + await page.waitForFunction(() => window.__journalAfterOldSave); + const records = await page.evaluate(() => window.__journalAfterOldSave); + expect(records.some(record => record.content === '# Newer recovery draft')).toBe(true); + await expect(page.locator('#markdown-editor')).toHaveValue('# Newer recovery draft'); +}); + +test('IndexedDB compare-and-delete retains a replacement record and deletes a matching record', async ({ page }) => { + await openApp(page); + const result = await page.evaluate(async () => { + const storage = new window.MarkdownWorkspaceStorage(); + await storage.init(); + const old = { journalId: 'conditional-journal-test', documentId: 'test-document', content: 'OLD', savedAt: 1 }; + const newer = { ...old, content: 'NEW', savedAt: 2 }; + await storage.saveDirtyJournal(old); + await storage.saveDirtyJournal(newer); + await storage.deleteDirtyJournals([old.journalId], { expectedJournals: { [old.journalId]: old } }); + const retained = (await storage.listDirtyJournals()).find(record => record.journalId === old.journalId); + await storage.deleteDirtyJournals([old.journalId], { expectedJournals: { [old.journalId]: newer } }); + return { retained: retained?.content, remaining: (await storage.listDirtyJournals()).some(record => record.journalId === old.journalId) }; + }); + expect(result).toEqual({ retained: 'NEW', remaining: false }); +}); diff --git a/tests/unit/recovery-journal-commit.test.mjs b/tests/unit/recovery-journal-commit.test.mjs new file mode 100644 index 00000000..51b4a6de --- /dev/null +++ b/tests/unit/recovery-journal-commit.test.mjs @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../../script.js', import.meta.url), 'utf8'); +function productionFunction(name) { + const start = source.indexOf(` function ${name}(`) >= 0 + ? source.indexOf(` function ${name}(`) : source.indexOf(` async function ${name}(`); + assert.ok(start >= 0, `Missing production function ${name}`); + const end = source.indexOf('\n }', start) + 4; + return source.slice(start, end); +} +function storage() { + const records = new Map(); + return { getItem: key => records.get(key) ?? null, setItem: (key, value) => records.set(key, value), removeItem: key => records.delete(key) }; +} +function setup() { + const tab = { id: 'notes', content: 'OLD', contentLoaded: true }; + const target = { + tabs: [tab], documentOrganization: {}, localStorage: storage(), sessionStorage: storage(), + DIRTY_DOCUMENT_JOURNAL_PREFIX: 'journal:', workspaceWriterId: 'writer', + saveTabStateTimeout: null, suspendWorkspacePersistence: false, + pendingWorkspacePersistAll: false, pendingWorkspacePersistIds: new Set(), + workspacePersistenceChain: Promise.resolve(), clearTimeout() {}, + getTabsForStorage: tabs => tabs, isPrivateStorageMode: () => false, + evictInactiveDocumentContent() {}, updateSaveStatus() {}, + preserveWorkspaceConflict: async () => false, showAppToast() {}, + console: { warn() {} }, saved: [], journals: new Map() + }; + target.workspaceStorage = { + async saveDocuments(tabs) { target.saved.push(tabs[0].content); }, + async listDirtyJournals() { return [...target.journals.values()]; }, + async deleteDirtyJournals(ids, options) { + for (const id of ids) { + const current = target.journals.get(id); + if (!options || !options.expectedJournals || JSON.stringify(current) === JSON.stringify(options.expectedJournals[id])) target.journals.delete(id); + } + } + }; + const names = ['getDirtyDocumentJournalKey', 'clearDirtyDocumentJournal', '_flushTabsToStorage']; + if (source.includes('function captureDirtyDocumentJournalSnapshot(')) names.unshift('captureDirtyDocumentJournalSnapshot'); + vm.createContext(target); + vm.runInContext(names.map(productionFunction).join('\n'), target); + tab._dirtyJournalKeys = ['journal:writer:notes']; + return { target, tab, key: tab._dirtyJournalKeys[0] }; +} +function journal(content, savedAt = 1) { return { documentId: 'notes', content, savedAt }; } + +for (const location of ['localStorage', 'sessionStorage', 'indexeddb']) { + test(`older save must retain newer ${location} recovery journal`, async () => { + const { target, tab, key } = setup(); + if (location === 'indexeddb') target.journals.set(key, { journalId: key, ...journal('OLD') }); + else target[location].setItem(key, JSON.stringify(journal('OLD'))); + target.workspaceStorage.saveDocuments = async () => { + target.saved.push('OLD'); + tab.content = 'NEW'; + if (location === 'indexeddb') target.journals.set(key, { journalId: key, ...journal('NEW', 2) }); + else target[location].setItem(key, JSON.stringify(journal('NEW', 2))); + }; + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true); + const current = location === 'indexeddb' ? target.journals.get(key) : JSON.parse(target[location].getItem(key)); + assert.equal(current?.content, 'NEW'); + }); +} + +test('successful save clears matching journals but retains a journal newer than tab.content', async () => { + const { target, tab, key } = setup(); + target.localStorage.setItem(key, JSON.stringify(journal('OLD'))); + target.sessionStorage.setItem(key, JSON.stringify(journal('NEW', 2))); + target.journals.set(key, { journalId: key, ...journal('OLD') }); + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true); + assert.equal(target.localStorage.getItem(key), null); + assert.equal(target.journals.has(key), false); + assert.equal(JSON.parse(target.sessionStorage.getItem(key)).content, 'NEW'); +}); + +test('failed save preserves the existing recovery journal', async () => { + const { target, tab, key } = setup(); + target.localStorage.setItem(key, JSON.stringify(journal('OLD'))); + target.workspaceStorage.saveDocuments = async () => { throw Error('simulated failure'); }; + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), false); + assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'OLD'); +}); + +test('metadata-only save must not discard recovery content', async () => { + const { target, tab, key } = setup(); + tab.contentLoaded = false; + tab.content = undefined; + target.localStorage.setItem(key, JSON.stringify(journal('RECOVER ME'))); + assert.equal(await target._flushTabsToStorage([tab], { changedIds: [tab.id] }), true); + assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'RECOVER ME'); +}); + +test('a newer journal survives an old save, then is cleared by its own successful save', async () => { + const { target, tab, key } = setup(); + target.localStorage.setItem(key, JSON.stringify(journal('OLD'))); + const normalSave = target.workspaceStorage.saveDocuments; + target.workspaceStorage.saveDocuments = async () => { + tab.content = 'NEW'; + target.localStorage.setItem(key, JSON.stringify(journal('NEW', 2))); + }; + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(JSON.parse(target.localStorage.getItem(key)).content, 'NEW'); + target.workspaceStorage.saveDocuments = normalSave; + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(target.localStorage.getItem(key), null); + assert.equal(target.saved.at(-1), 'NEW'); +}); diff --git a/workspace-storage.js b/workspace-storage.js index df3eb067..bdf2fa28 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -1861,14 +1861,28 @@ return records; } - async deleteDirtyJournals(journalIds) { + async deleteDirtyJournals(journalIds, options) { if (this.desktop) return; const ids = Array.isArray(journalIds) ? journalIds.filter(Boolean) : []; if (!ids.length) return; const transaction = this.db.transaction('journals', 'readwrite'); + const completion = transactionToPromise(transaction); const store = transaction.objectStore('journals'); - ids.forEach(function(id) { store.delete(id); }); - await transactionToPromise(transaction); + const expected = options && options.expectedJournals; + ids.forEach(function(id) { + if (!expected) { + store.delete(id); + return; + } + if (!Object.prototype.hasOwnProperty.call(expected, id)) return; + const request = store.get(id); + request.onsuccess = function() { + // The read and conditional delete share a readwrite transaction, + // so a concurrent replacement cannot be deleted between them. + if (JSON.stringify(request.result) === JSON.stringify(expected[id])) store.delete(id); + }; + }); + await completion; } async restoreTrashItem(trashId) { From 4353a540e81b109cb845943a30647086dd11d670 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 12:53:49 +0300 Subject: [PATCH 03/13] fix(review): recover comment metadata without losing newer replies --- desktop-app/resources/js/script.js | 21 +++++++++++++++++---- script.js | 21 +++++++++++++++++---- tests/unit/recovery-journal-commit.test.mjs | 16 ++++++++++++++++ 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index ec7b3cb9..7edb218f 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -8346,6 +8346,12 @@ document.addEventListener("DOMContentLoaded", async function () { function persistReviewThreads(message) { syncLiveReviewThreadsFromTab(); + const tab = getActiveReviewTab(); + if (tab && tab.contentLoaded !== false) { + saveCurrentTabState(); + const journal = writeDirtyDocumentJournal(tab, tab.content); + if (!journal) persistEditWithoutDurableJournal(tab, tab.content); + } if (activeTabId) saveTabsToStorage(tabs, [activeTabId]); decorateReviewTargets(); renderReviewPanel(); @@ -10169,7 +10175,7 @@ document.addEventListener("DOMContentLoaded", async function () { const input = reviewList && reviewList.querySelector( '.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input' ); - if (input) { + if (input && document.activeElement !== input) { input.focus({ preventScroll: true }); input.setSelectionRange(input.value.length, input.value.length); } @@ -10592,6 +10598,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10622,6 +10629,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10645,6 +10653,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10833,13 +10842,14 @@ document.addEventListener("DOMContentLoaded", async function () { // Metadata-only writes do not commit a document body. if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return; const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id)))); - const snapshot = { content: tab.content, local: {}, session: {}, indexed: {} }; + const snapshot = { content: tab.content, reviewThreads: JSON.stringify(tab.reviewThreads || []), local: {}, session: {}, indexed: {} }; [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) { keys.forEach(function(key) { try { const raw = pair[0].getItem(key); const value = raw && JSON.parse(raw); - if (value && value.documentId === id && value.content === snapshot.content) pair[1][key] = raw; + if (value && value.documentId === id && value.content === snapshot.content && + (!value.metadata || !Array.isArray(value.metadata.reviewThreads) || JSON.stringify(value.metadata.reviewThreads) === snapshot.reviewThreads)) pair[1][key] = raw; } catch (_) { /* Unknown recovery data must be retained. */ } }); }); @@ -10849,7 +10859,8 @@ document.addEventListener("DOMContentLoaded", async function () { try { (await workspaceStorage.listDirtyJournals()).forEach(function(record) { const snapshot = snapshots.get(record.documentId); - if (snapshot && record.journalId && record.content === snapshot.content) { + if (snapshot && record.journalId && record.content === snapshot.content && + (!record.metadata || !Array.isArray(record.metadata.reviewThreads) || JSON.stringify(record.metadata.reviewThreads) === snapshot.reviewThreads)) { snapshot.indexed[record.journalId] = record; } }); @@ -10952,6 +10963,7 @@ document.addEventListener("DOMContentLoaded", async function () { existing.content = journal.content; existing.contentLoaded = true; existing.lastEditedAt = Number(journal.savedAt) || Date.now(); + if (journal.metadata && Array.isArray(journal.metadata.reviewThreads)) existing.reviewThreads = normalizeReviewThreads(journal.metadata.reviewThreads); existing._dirtyJournalKeys = Array.from(new Set((existing._dirtyJournalKeys || []).concat(entry.key))); existing._dirtyIndexedDbJournalIds = Array.from(new Set((existing._dirtyIndexedDbJournalIds || []).concat(entry.key))); restoredOriginalIds.add(existing.id); @@ -10965,6 +10977,7 @@ document.addEventListener("DOMContentLoaded", async function () { folderId: metadata.folderId || null }); recovered.favorite = metadata.favorite === true; + recovered.reviewThreads = normalizeReviewThreads(metadata.reviewThreads); recovered.createdAt = Number(metadata.createdAt) || recovered.createdAt; recovered.lastOpenedAt = Number(metadata.lastOpenedAt) || recovered.lastOpenedAt; recovered.lastEditedAt = Number(journal.savedAt) || Date.now(); diff --git a/script.js b/script.js index ec7b3cb9..7edb218f 100644 --- a/script.js +++ b/script.js @@ -8346,6 +8346,12 @@ document.addEventListener("DOMContentLoaded", async function () { function persistReviewThreads(message) { syncLiveReviewThreadsFromTab(); + const tab = getActiveReviewTab(); + if (tab && tab.contentLoaded !== false) { + saveCurrentTabState(); + const journal = writeDirtyDocumentJournal(tab, tab.content); + if (!journal) persistEditWithoutDurableJournal(tab, tab.content); + } if (activeTabId) saveTabsToStorage(tabs, [activeTabId]); decorateReviewTargets(); renderReviewPanel(); @@ -10169,7 +10175,7 @@ document.addEventListener("DOMContentLoaded", async function () { const input = reviewList && reviewList.querySelector( '.review-reply-edit-form[data-review-id="' + thread.id + '"][data-review-reply-id="' + reply.id + '"] .review-reply-edit-input' ); - if (input) { + if (input && document.activeElement !== input) { input.focus({ preventScroll: true }); input.setSelectionRange(input.value.length, input.value.length); } @@ -10592,6 +10598,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10622,6 +10629,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10645,6 +10653,7 @@ document.addEventListener("DOMContentLoaded", async function () { savedAt: Date.now(), metadata: { title: tab.title || 'Untitled', + reviewThreads: tab.reviewThreads || [], workspaceId: tab.workspaceId || DEFAULT_WORKSPACE_ID, folderId: tab.folderId || null, viewMode: tab.viewMode || 'split', @@ -10833,13 +10842,14 @@ document.addEventListener("DOMContentLoaded", async function () { // Metadata-only writes do not commit a document body. if (!tab || tab.contentLoaded === false || typeof tab.content !== 'string') return; const keys = Array.from(new Set((tab._dirtyJournalKeys || []).concat(getDirtyDocumentJournalKey(id)))); - const snapshot = { content: tab.content, local: {}, session: {}, indexed: {} }; + const snapshot = { content: tab.content, reviewThreads: JSON.stringify(tab.reviewThreads || []), local: {}, session: {}, indexed: {} }; [[localStorage, snapshot.local], [sessionStorage, snapshot.session]].forEach(function(pair) { keys.forEach(function(key) { try { const raw = pair[0].getItem(key); const value = raw && JSON.parse(raw); - if (value && value.documentId === id && value.content === snapshot.content) pair[1][key] = raw; + if (value && value.documentId === id && value.content === snapshot.content && + (!value.metadata || !Array.isArray(value.metadata.reviewThreads) || JSON.stringify(value.metadata.reviewThreads) === snapshot.reviewThreads)) pair[1][key] = raw; } catch (_) { /* Unknown recovery data must be retained. */ } }); }); @@ -10849,7 +10859,8 @@ document.addEventListener("DOMContentLoaded", async function () { try { (await workspaceStorage.listDirtyJournals()).forEach(function(record) { const snapshot = snapshots.get(record.documentId); - if (snapshot && record.journalId && record.content === snapshot.content) { + if (snapshot && record.journalId && record.content === snapshot.content && + (!record.metadata || !Array.isArray(record.metadata.reviewThreads) || JSON.stringify(record.metadata.reviewThreads) === snapshot.reviewThreads)) { snapshot.indexed[record.journalId] = record; } }); @@ -10952,6 +10963,7 @@ document.addEventListener("DOMContentLoaded", async function () { existing.content = journal.content; existing.contentLoaded = true; existing.lastEditedAt = Number(journal.savedAt) || Date.now(); + if (journal.metadata && Array.isArray(journal.metadata.reviewThreads)) existing.reviewThreads = normalizeReviewThreads(journal.metadata.reviewThreads); existing._dirtyJournalKeys = Array.from(new Set((existing._dirtyJournalKeys || []).concat(entry.key))); existing._dirtyIndexedDbJournalIds = Array.from(new Set((existing._dirtyIndexedDbJournalIds || []).concat(entry.key))); restoredOriginalIds.add(existing.id); @@ -10965,6 +10977,7 @@ document.addEventListener("DOMContentLoaded", async function () { folderId: metadata.folderId || null }); recovered.favorite = metadata.favorite === true; + recovered.reviewThreads = normalizeReviewThreads(metadata.reviewThreads); recovered.createdAt = Number(metadata.createdAt) || recovered.createdAt; recovered.lastOpenedAt = Number(metadata.lastOpenedAt) || recovered.lastOpenedAt; recovered.lastEditedAt = Number(journal.savedAt) || Date.now(); diff --git a/tests/unit/recovery-journal-commit.test.mjs b/tests/unit/recovery-journal-commit.test.mjs index 51b4a6de..4f7e4c31 100644 --- a/tests/unit/recovery-journal-commit.test.mjs +++ b/tests/unit/recovery-journal-commit.test.mjs @@ -47,6 +47,22 @@ function setup() { } function journal(content, savedAt = 1) { return { documentId: 'notes', content, savedAt }; } +test('a newer comments-only journal survives an older save of unchanged Markdown', async () => { + const { target, tab, key } = setup(); + tab.reviewThreads = [{ body: 'OLD COMMENT' }]; + target.localStorage.setItem(key, JSON.stringify({ ...journal('OLD'), metadata: { reviewThreads: [{ body: 'NEW COMMENT' }] } })); + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(JSON.parse(target.localStorage.getItem(key)).metadata.reviewThreads[0].body, 'NEW COMMENT'); +}); + +test('a matching comments journal clears only after its save succeeds', async () => { + const { target, tab, key } = setup(); + tab.reviewThreads = [{ body: 'COMMENT' }]; + target.localStorage.setItem(key, JSON.stringify({ ...journal('OLD'), metadata: { reviewThreads: tab.reviewThreads } })); + await target._flushTabsToStorage([tab], { changedIds: [tab.id] }); + assert.equal(target.localStorage.getItem(key), null); +}); + for (const location of ['localStorage', 'sessionStorage', 'indexeddb']) { test(`older save must retain newer ${location} recovery journal`, async () => { const { target, tab, key } = setup(); From 855d593678c6761ad354a78ec083452d0a3fa371 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 12:53:50 +0300 Subject: [PATCH 04/13] fix(editor): prevent reference chooser from stealing input focus --- desktop-app/resources/js/script.js | 8 ++++---- script.js | 8 ++++---- tests/e2e/slash-command-actions.spec.js | 23 +++++++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 7edb218f..3c89deca 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -21332,10 +21332,10 @@ ${selector} .arrowheadPath { urlInput.addEventListener('keydown', onKey); titleInput.addEventListener('keydown', onKey); - requestAnimationFrame(function() { - numberInput.focus(); - numberInput.select(); - }); + // The modal is already displayed. Focus synchronously so a deferred callback + // cannot steal focus after the user starts filling the URL or title field. + numberInput.focus(); + numberInput.select(); } function escapeRegExp(text) { diff --git a/script.js b/script.js index 7edb218f..3c89deca 100644 --- a/script.js +++ b/script.js @@ -21332,10 +21332,10 @@ ${selector} .arrowheadPath { urlInput.addEventListener('keydown', onKey); titleInput.addEventListener('keydown', onKey); - requestAnimationFrame(function() { - numberInput.focus(); - numberInput.select(); - }); + // The modal is already displayed. Focus synchronously so a deferred callback + // cannot steal focus after the user starts filling the URL or title field. + numberInput.focus(); + numberInput.select(); } function escapeRegExp(text) { diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js index 26f37ef2..9fe0ccbc 100644 --- a/tests/e2e/slash-command-actions.spec.js +++ b/tests/e2e/slash-command-actions.spec.js @@ -154,6 +154,29 @@ for (const pane of ['primary', 'secondary']) { await expect(editor).toBeFocused(); }); + test('reference chooser does not steal focus from a field already being edited', async ({ page }) => { + // Capture the chooser's deferred focus callback without delaying slash-menu + // opening. Flushing it after a user selects another field exposes the race. + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame; + window.__referenceFocusFrames = []; + window.requestAnimationFrame = function(callback) { + if (String(callback).includes('numberInput.focus()')) { + window.__referenceFocusFrames.push(callback); + return 0; + } + return nativeFrame.call(window, callback); + }; + }); + await enterCommand(page, editor, 'reference', 'reference'); + await page.locator('#reference-modal-url').fill('https://example.com/focus'); + await page.evaluate(() => window.__referenceFocusFrames.splice(0).forEach(callback => callback())); + await expect(page.locator('#reference-modal-url')).toBeFocused(); + await page.locator('#reference-modal-title-input').fill('Kept title'); + await page.locator('#reference-modal-apply').click(); + await expect(editor).toHaveValue(prefix + '[1]' + suffix + '\n[1]: https://example.com/focus "Kept title"'); + }); + test('cancels insertion when the target changes during upload', async ({ page }) => { let releaseUpload; const pendingUpload = new Promise(resolve => { releaseUpload = resolve; }); From de4f5e8a4ca6f64f2bef6fddf472bab5a9710591 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 12:54:37 +0300 Subject: [PATCH 05/13] fix(storage): coordinate deletions with queued document saves --- desktop-app/resources/js/script.js | 43 +++++++++++++----- script.js | 43 +++++++++++++----- tests/unit/document-delete-races.test.mjs | 54 +++++++++++++++++++++++ 3 files changed, 118 insertions(+), 22 deletions(-) create mode 100644 tests/unit/document-delete-races.test.mjs diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 3c89deca..dac212ae 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -11162,11 +11162,13 @@ document.addEventListener("DOMContentLoaded", async function () { try { const storageTabs = getTabsForStorage(tabsArr); const commit = async function() { - const journalSnapshots = await captureDirtyDocumentJournalSnapshot(storageTabs, changedIds); + // A queued save must not resurrect documents removed before it runs. + const currentStorageTabs = storageTabs.filter(function(tab) { return tabs.some(function(item) { return item.id === tab.id; }); }); + const journalSnapshots = await captureDirtyDocumentJournalSnapshot(currentStorageTabs, changedIds); if (workspaceStorage) { - await workspaceStorage.saveDocuments(storageTabs, documentOrganization, { changedIds: changedIds }); + await workspaceStorage.saveDocuments(currentStorageTabs, documentOrganization, { changedIds: changedIds }); } else { - saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + saveStorageItem(STORAGE_KEY, JSON.stringify(currentStorageTabs)); } await Promise.all(changedIds.map(function(id) { const tab = tabs.find(function(item) { return item.id === id; }); @@ -12552,7 +12554,16 @@ document.addEventListener("DOMContentLoaded", async function () { return true; } - async function deleteTab(tabId) { + let documentDeletionChain = Promise.resolve(); + function deleteTab(tabId) { + const operation = documentDeletionChain.catch(function() {}).then(function() { + return deleteTabSnapshot(tabId); + }); + documentDeletionChain = operation; + return operation; + } + + async function deleteTabSnapshot(tabId) { if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) { cancelReviewDeleteConfirmation(); } @@ -12565,11 +12576,11 @@ document.addEventListener("DOMContentLoaded", async function () { return; } - const idx = tabs.findIndex(function(t) { return t.id === tabId; }); - if (idx === -1) return; + let idx = tabs.findIndex(function(t) { return t.id === tabId; }); + if (idx === -1) return false; const tab = tabs[idx]; - const wasActive = activeTabId === tabId; - const splitPartnerId = wasActive ? secondarySplitTabId : null; + let wasActive = activeTabId === tabId; + let splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) saveCurrentTabState(); saveSecondarySplitState(); if (!isTemporaryDocument(tab) && tab.workspaceId !== SECRET_WORKSPACE_ID && workspaceStorage && !isPrivateStorageMode()) { @@ -12582,7 +12593,14 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } try { - await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + // Use the same queue as document saves. Remove the live entry before + // subsequent queued saves select their current documents. + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(async function() { + await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + const currentIndex = tabs.indexOf(tab); + if (currentIndex >= 0) { idx = currentIndex; tabs.splice(currentIndex, 1); } + }); + await workspacePersistenceChain; } catch (error) { if (error && error.name === 'WorkspaceConflictError') { showAppToast('The document changed in another tab and was not deleted. Reload before trying again.', { @@ -12599,6 +12617,8 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } } + wasActive = activeTabId === tabId; + splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) { closeReviewComposer(); clearReviewDecorations(); @@ -12614,7 +12634,8 @@ document.addEventListener("DOMContentLoaded", async function () { delete tabHistories[tabId]; } - tabs.splice(idx, 1); + const remainingIndex = tabs.indexOf(tab); + if (remainingIndex >= 0) { idx = remainingIndex; tabs.splice(remainingIndex, 1); } clearDirtyDocumentJournal(tab); if (tab.workspaceId === SECRET_WORKSPACE_ID) saveTabsToStorage(tabs, []); if (wasActive) { @@ -12626,7 +12647,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else { clearActiveDocument(); } - return; + return true; } if (selectedDocumentId === tabId) selectedDocumentId = activeTabId; renderDocumentSplitView(); diff --git a/script.js b/script.js index 3c89deca..dac212ae 100644 --- a/script.js +++ b/script.js @@ -11162,11 +11162,13 @@ document.addEventListener("DOMContentLoaded", async function () { try { const storageTabs = getTabsForStorage(tabsArr); const commit = async function() { - const journalSnapshots = await captureDirtyDocumentJournalSnapshot(storageTabs, changedIds); + // A queued save must not resurrect documents removed before it runs. + const currentStorageTabs = storageTabs.filter(function(tab) { return tabs.some(function(item) { return item.id === tab.id; }); }); + const journalSnapshots = await captureDirtyDocumentJournalSnapshot(currentStorageTabs, changedIds); if (workspaceStorage) { - await workspaceStorage.saveDocuments(storageTabs, documentOrganization, { changedIds: changedIds }); + await workspaceStorage.saveDocuments(currentStorageTabs, documentOrganization, { changedIds: changedIds }); } else { - saveStorageItem(STORAGE_KEY, JSON.stringify(storageTabs)); + saveStorageItem(STORAGE_KEY, JSON.stringify(currentStorageTabs)); } await Promise.all(changedIds.map(function(id) { const tab = tabs.find(function(item) { return item.id === id; }); @@ -12552,7 +12554,16 @@ document.addEventListener("DOMContentLoaded", async function () { return true; } - async function deleteTab(tabId) { + let documentDeletionChain = Promise.resolve(); + function deleteTab(tabId) { + const operation = documentDeletionChain.catch(function() {}).then(function() { + return deleteTabSnapshot(tabId); + }); + documentDeletionChain = operation; + return operation; + } + + async function deleteTabSnapshot(tabId) { if (pendingReviewDelete && pendingReviewDelete.tabId === tabId) { cancelReviewDeleteConfirmation(); } @@ -12565,11 +12576,11 @@ document.addEventListener("DOMContentLoaded", async function () { return; } - const idx = tabs.findIndex(function(t) { return t.id === tabId; }); - if (idx === -1) return; + let idx = tabs.findIndex(function(t) { return t.id === tabId; }); + if (idx === -1) return false; const tab = tabs[idx]; - const wasActive = activeTabId === tabId; - const splitPartnerId = wasActive ? secondarySplitTabId : null; + let wasActive = activeTabId === tabId; + let splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) saveCurrentTabState(); saveSecondarySplitState(); if (!isTemporaryDocument(tab) && tab.workspaceId !== SECRET_WORKSPACE_ID && workspaceStorage && !isPrivateStorageMode()) { @@ -12582,7 +12593,14 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } try { - await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + // Use the same queue as document saves. Remove the live entry before + // subsequent queued saves select their current documents. + workspacePersistenceChain = workspacePersistenceChain.catch(function() {}).then(async function() { + await workspaceStorage.deleteDocument(tab.id, { expectedRevision: tab._storageRevision }); + const currentIndex = tabs.indexOf(tab); + if (currentIndex >= 0) { idx = currentIndex; tabs.splice(currentIndex, 1); } + }); + await workspacePersistenceChain; } catch (error) { if (error && error.name === 'WorkspaceConflictError') { showAppToast('The document changed in another tab and was not deleted. Reload before trying again.', { @@ -12599,6 +12617,8 @@ document.addEventListener("DOMContentLoaded", async function () { return false; } } + wasActive = activeTabId === tabId; + splitPartnerId = wasActive ? secondarySplitTabId : null; if (wasActive) { closeReviewComposer(); clearReviewDecorations(); @@ -12614,7 +12634,8 @@ document.addEventListener("DOMContentLoaded", async function () { delete tabHistories[tabId]; } - tabs.splice(idx, 1); + const remainingIndex = tabs.indexOf(tab); + if (remainingIndex >= 0) { idx = remainingIndex; tabs.splice(remainingIndex, 1); } clearDirtyDocumentJournal(tab); if (tab.workspaceId === SECRET_WORKSPACE_ID) saveTabsToStorage(tabs, []); if (wasActive) { @@ -12626,7 +12647,7 @@ document.addEventListener("DOMContentLoaded", async function () { } else { clearActiveDocument(); } - return; + return true; } if (selectedDocumentId === tabId) selectedDocumentId = activeTabId; renderDocumentSplitView(); diff --git a/tests/unit/document-delete-races.test.mjs b/tests/unit/document-delete-races.test.mjs new file mode 100644 index 00000000..d9987479 --- /dev/null +++ b/tests/unit/document-delete-races.test.mjs @@ -0,0 +1,54 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +function setup() { + const source = readFileSync(new URL('../../script.js', import.meta.url), 'utf8'); + const start = source.indexOf(' let documentDeletionChain ='); + const end = source.indexOf(' function closeTab(', start); + const deleted = [], gates = []; + const context = { tabs: [{ id: 'A' }, { id: 'B' }, { id: 'C' }], activeTabId: null, + secondarySplitTabId: null, pendingReviewDelete: null, liveCollaboration: null, + SECRET_WORKSPACE_ID: 'secret', selectedDocumentId: null, + workspacePersistenceChain: Promise.resolve(), shareSnapshotViewOnlyTabIds: new Set(), tabHistories: {}, console, + workspaceStorage: { deleteDocument: async id => deleted.push(id) }, + _flushTabsToStorage: () => new Promise(resolve => gates.push(resolve)), + isPrivateStorageMode: () => false, isTemporaryDocument: () => false }; + for (const name of ['saveSecondarySplitState', 'markReleaseNotesSeen', 'clearDirtyDocumentJournal', + 'renderDocumentSplitView', 'renderTabBar', 'closeReviewComposer', 'renderReviewPanel', 'renderDocumentSidebar', 'showAppToast']) context[name] = () => {}; + const remove = vm.runInNewContext(source.slice(start, end) + '\ndeleteTab;', context); + return { context, remove, deleted, gates }; +} +const tick = () => new Promise(resolve => setImmediate(resolve)); + +test('simultaneous deletions serialize and remove only the requested IDs', async () => { + const s = setup(); + const a = s.remove('A'), b = s.remove('B'); + await tick(); assert.equal(s.gates.length, 1); + s.gates[0](true); assert.equal(await a, true); + await tick(); s.gates[1](true); assert.equal(await b, true); + assert.deepEqual(s.deleted, ['A', 'B']); + assert.deepEqual(s.context.tabs.map(x => x.id), ['C']); +}); + +test('another list mutation during a deletion cannot remove a different document', async () => { + const s = setup(); const b = s.remove('B'); await tick(); + s.context.tabs.shift(); s.gates[0](true); await b; + assert.deepEqual(s.context.tabs.map(x => x.id), ['C']); +}); + +test('duplicate delete requests do not double-delete or remove a neighbour', async () => { + const s = setup(); const a = s.remove('A'), again = s.remove('A'); + await tick(); s.gates[0](true); await a; + assert.equal(await again, false); + assert.deepEqual(s.deleted, ['A']); + assert.deepEqual(s.context.tabs.map(x => x.id), ['B', 'C']); +}); + +test('a cancelled delete does not poison the deletion queue', async () => { + const s = setup(); const a = s.remove('A'), b = s.remove('B'); + await tick(); s.gates[0](false); assert.equal(await a, false); + await tick(); s.gates[1](true); await b; + assert.deepEqual(s.context.tabs.map(x => x.id), ['A', 'C']); +}); From 07db99fbbf76ff213e99d7332ec5bea406803362 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 12:56:15 +0300 Subject: [PATCH 06/13] fix(storage): refuse destructive desktop document move collisions --- desktop-app/resources/js/script.js | 2 +- desktop-app/resources/js/workspace-storage.js | 44 +++++++++--- script.js | 2 +- tests/e2e/data-loss-hardening.spec.js | 2 +- tests/e2e/desktop-move-safety.spec.js | 28 ++++++++ tests/helpers/desktop-filesystem.js | 72 +++++++++++++++++++ tests/unit/desktop-move-safety.test.mjs | 69 ++++++++++++++++++ workspace-storage.js | 44 +++++++++--- 8 files changed, 244 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/desktop-move-safety.spec.js create mode 100644 tests/helpers/desktop-filesystem.js create mode 100644 tests/unit/desktop-move-safety.test.mjs diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index dac212ae..30dd3ff9 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -11192,7 +11192,7 @@ document.addEventListener("DOMContentLoaded", async function () { } console.warn('Failed to save workspace documents:', e); updateSaveStatus('error'); - showAppToast('The workspace could not be saved. Existing saved documents were kept.', { + showAppToast(e.code === 'WORKSPACE_MOVE_BLOCKED' ? e.message : 'The workspace could not be saved. Existing saved documents were kept.', { tone: 'error', title: 'Changes not saved' }); diff --git a/desktop-app/resources/js/workspace-storage.js b/desktop-app/resources/js/workspace-storage.js index bdf2fa28..b8dc26b1 100644 --- a/desktop-app/resources/js/workspace-storage.js +++ b/desktop-app/resources/js/workspace-storage.js @@ -1229,6 +1229,37 @@ tab._storageContentJournalPaths = Array.from(new Set((tab._storageContentJournalPaths || []).concat(journalPath))); } + async _desktopInspectMovePath(path) { + try { return await Neutralino.filesystem.getStats(path); } + catch (error) { + if (!error || error.code !== 'NE_FS_NOPATHE') throw error; + // Native stat can report missing for an inaccessible entry. Confirm + // absence using its parent; never turn permission errors into absence. + const normalized = normalizePathSeparators(path); + const separator = normalized.lastIndexOf('/'); + if (separator < 0) throw error; + const entries = await Neutralino.filesystem.readDirectory(normalized.slice(0, separator)); + const name = normalized.slice(separator + 1); + const insensitive = typeof NL_OS !== 'undefined' && NL_OS === 'Windows'; + if (entries.some(function(entry) { + return insensitive ? String(entry.entry).toLowerCase() === name.toLowerCase() : entry.entry === name; + })) { + throw Object.assign(new Error('Move cancelled: a file exists but cannot be inspected at ' + path + '. No existing file was replaced.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + return null; + } + } + + async _desktopCheckMovePaths(source, destination) { + const stats = await this._desktopInspectMovePath(source); + if (!stats || stats.isDirectory || stats.isFile === false) { + throw Object.assign(new Error('Move cancelled: the source is missing or is not a file at ' + source + '. No replacement was created.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + if (await this._desktopInspectMovePath(destination)) { + throw Object.assign(new Error('Move cancelled: the destination already exists at ' + destination + '. Both files were left unchanged.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + } + async _desktopSaveDocument(tab, organization, forceContent) { requireDocumentId(tab && tab.id); const existing = this.vaultIndex.documents.find(function (item) { return item.id === tab.id; }); @@ -1254,6 +1285,7 @@ if (existing && existing.vaultRelativePath && existing.vaultRelativePath !== relativePath) { const oldPath = await this._desktopResolveVaultRelativePath(existing.vaultRelativePath); + await this._desktopCheckMovePaths(oldPath, fullPath); const journalDirectory = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'journal'); const moveJournalPath = await this._pathJoin(journalDirectory, sanitizePathSegment(tab.id, 'document') + '.move.json'); await this._writeJsonFile(moveJournalPath, { @@ -1266,14 +1298,10 @@ startedAt: Date.now() }); tab._storageMoveJournalPath = moveJournalPath; - if (await this._pathExists(oldPath)) { - if (await this._pathExists(fullPath)) { - await Neutralino.filesystem.writeFile(fullPath, typeof tab.content === 'string' ? tab.content : await Neutralino.filesystem.readFile(oldPath)); - await Neutralino.filesystem.remove(oldPath); - } else { - await Neutralino.filesystem.move(oldPath, fullPath); - } - } + // Recheck after asynchronous journal writing. Do not overwrite a + // collision, delete the source or fall back to a destructive copy. + await this._desktopCheckMovePaths(oldPath, fullPath); + await Neutralino.filesystem.move(oldPath, fullPath); } if (forceContent || tab.contentLoaded !== false) { const content = typeof tab.content === 'string' ? tab.content : ''; diff --git a/script.js b/script.js index dac212ae..30dd3ff9 100644 --- a/script.js +++ b/script.js @@ -11192,7 +11192,7 @@ document.addEventListener("DOMContentLoaded", async function () { } console.warn('Failed to save workspace documents:', e); updateSaveStatus('error'); - showAppToast('The workspace could not be saved. Existing saved documents were kept.', { + showAppToast(e.code === 'WORKSPACE_MOVE_BLOCKED' ? e.message : 'The workspace could not be saved. Existing saved documents were kept.', { tone: 'error', title: 'Changes not saved' }); diff --git a/tests/e2e/data-loss-hardening.spec.js b/tests/e2e/data-loss-hardening.spec.js index 3c5d6c05..d9331dd4 100644 --- a/tests/e2e/data-loss-hardening.spec.js +++ b/tests/e2e/data-loss-hardening.spec.js @@ -401,7 +401,7 @@ test('desktop journals recover a new file and a move before their index commits' path = normalize(path); if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() }; if (files.has(path)) return { isDirectory: false, size: files.get(path).length, modifiedAt: Date.now() }; - throw new Error('missing'); + throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' }); }, createDirectory: async path => directories.add(normalize(path)), readFile: async path => { diff --git a/tests/e2e/desktop-move-safety.spec.js b/tests/e2e/desktop-move-safety.spec.js new file mode 100644 index 00000000..1b88f3bd --- /dev/null +++ b/tests/e2e/desktop-move-safety.spec.js @@ -0,0 +1,28 @@ +const { test, expect } = require('@playwright/test'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +test('stock desktop rename collision preserves both bodies and the committed index', async ({ page }) => { + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + const result = await page.evaluate(async () => { + const storage = new MarkdownWorkspaceStorage(); + await storage.init(); + const tab = { id: 'rename-test', title: 'Before', content: '# original', contentLoaded: true, workspaceId: 'workspace_default' }; + await storage.saveDocuments([tab], { folders: [] }, { forceContent: true }); + const original = storage.vaultPath + '/' + tab._vaultRelativePath; + const indexPath = storage.vaultPath + '/.markdown-viewer/index.json'; + const index = __desktopFiles.get(indexPath); + tab.title = 'After'; + const destination = storage.vaultPath + '/' + await storage._desktopDocumentRelativePath(tab, { folders: [] }); + __desktopFiles.set(destination, '# unrelated body'); + let error; + try { await storage.saveDocuments([tab], { folders: [] }, { changedIds: [tab.id] }); } + catch (failure) { error = failure.message; } + return { error, original: __desktopFiles.get(original), destination: __desktopFiles.get(destination), indexUnchanged: __desktopFiles.get(indexPath) === index }; + }); + expect(result.error).toMatch(/destination.*exists/i); + expect(result.original).toBe('# original'); + expect(result.destination).toBe('# unrelated body'); + expect(result.indexUnchanged).toBe(true); +}); diff --git a/tests/helpers/desktop-filesystem.js b/tests/helpers/desktop-filesystem.js new file mode 100644 index 00000000..b8e8eacf --- /dev/null +++ b/tests/helpers/desktop-filesystem.js @@ -0,0 +1,72 @@ +// In-memory Neutralino filesystem for testing the real desktop storage backend. +function installDesktopFilesystem(target) { + const files = new Map(); + const directories = new Set(['C:', 'C:/Documents']); + const normalize = value => String(value).replace(/\\/g, '/').replace(/\/+/g, '/').replace(/\/$/, ''); + const join = (...parts) => normalize(parts.filter(Boolean).join('/')); + const storageData = new Map(); + target.__desktopFiles = files; + target.NL_PORT = 1; + target.Neutralino = { + os: { getPath: async () => 'C:/Documents', open: async () => true }, + events: { on: async () => true }, + storage: { + getData: async key => { + if (!storageData.has(key)) throw new Error('missing'); + return storageData.get(key); + }, + setData: async (key, value) => storageData.set(key, value), + removeData: async key => storageData.delete(key) + }, + filesystem: { + getJoinedPath: async (...parts) => join(...parts), + getStats: async path => { + path = normalize(path); + if (directories.has(path)) return { isDirectory: true, size: 0, modifiedAt: Date.now() }; + if (files.has(path)) return { isDirectory: false, isFile: true, size: new TextEncoder().encode(files.get(path)).byteLength, modifiedAt: Date.now() }; + throw Object.assign(new Error('missing'), { code: 'NE_FS_NOPATHE' }); + }, + createDirectory: async path => directories.add(normalize(path)), + readFile: async (path, options) => { + path = normalize(path); + if (!files.has(path)) throw new Error('missing'); + const bytes = new TextEncoder().encode(files.get(path)); + const pos = options && options.pos || 0; + return new TextDecoder().decode(bytes.slice(pos, options && options.size !== undefined ? pos + options.size : undefined)); + }, + writeFile: async (path, value) => files.set(normalize(path), String(value)), + copy: async (source, destination) => files.set(normalize(destination), files.get(normalize(source))), + move: async (source, destination) => { + source = normalize(source); + destination = normalize(destination); + if (!files.has(source)) throw new Error('missing'); + files.set(destination, files.get(source)); + files.delete(source); + }, + remove: async path => { + path = normalize(path); + files.delete(path); + directories.delete(path); + }, + readDirectory: async path => { + const prefix = normalize(path) + '/'; + const entries = new Map(); + directories.forEach(candidate => { + if (!candidate.startsWith(prefix)) return; + const rest = candidate.slice(prefix.length); + if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'DIRECTORY' }); + }); + files.forEach((_, candidate) => { + if (!candidate.startsWith(prefix)) return; + const rest = candidate.slice(prefix.length); + if (rest && !rest.includes('/')) entries.set(rest, { entry: rest, type: 'FILE' }); + }); + return Array.from(entries.values()); + }, + createWatcher: async () => 1, + removeWatcher: async () => true + } + }; +} + +module.exports = { installDesktopFilesystem }; diff --git a/tests/unit/desktop-move-safety.test.mjs b/tests/unit/desktop-move-safety.test.mjs new file mode 100644 index 00000000..216eae12 --- /dev/null +++ b/tests/unit/desktop-move-safety.test.mjs @@ -0,0 +1,69 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { webcrypto } from 'node:crypto'; +import mock from '../helpers/desktop-filesystem.js'; +const source = fs.readFileSync(new URL('../../workspace-storage.js', import.meta.url), 'utf8'); +async function setup() { + const target = {crypto:webcrypto, TextEncoder, console, NL_OS:'Windows', localStorage:{getItem:()=>null,removeItem(){}}}; + target.window=target; target.self=target; mock.installDesktopFilesystem(target); + vm.runInNewContext(source,target); + const storage = new target.MarkdownWorkspaceStorage(); await storage.init(); + const tab = {id:'moving',title:'Before',content:'# original',contentLoaded:true,workspaceId:'workspace_default',_storageRevision:0}; + await storage.saveDocuments([tab],{folders:[]},{forceContent:true}); + const original = storage.vaultPath + '/' + tab._vaultRelativePath; + const index = target.__desktopFiles.get(storage.vaultPath+'/.markdown-viewer/index.json'); + tab.title='After'; + const destination = storage.vaultPath + '/' + await storage._desktopDocumentRelativePath(tab,{folders:[]}); + return {target,storage,tab,original,destination,index,save:()=>storage.saveDocuments([tab],{folders:[]},{changedIds:[tab.id]})}; +} +test('occupied move destination preserves both files and committed index', async () => { + const s=await setup(); s.target.__desktopFiles.set(s.destination,'# unrelated existing file'); + await assert.rejects(s.save(),/destination.*exists/i); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.get(s.destination),'# unrelated existing file'); + assert.equal(s.target.__desktopFiles.get(s.storage.vaultPath+'/.markdown-viewer/index.json'),s.index); +}); +test('missing move source does not silently create a replacement body', async () => { + const s=await setup(); s.target.__desktopFiles.delete(s.original); + await assert.rejects(s.save(),/source.*missing/i); + assert.equal(s.target.__desktopFiles.has(s.destination),false); +}); +test('stat failure with an existing destination entry must not permit overwrite', async () => { + const s=await setup(); s.target.__desktopFiles.set(s.destination,'# private existing file'); + const stat=s.target.Neutralino.filesystem.getStats; + s.target.Neutralino.filesystem.getStats=async p=>{if(p===s.destination)throw {code:'NE_FS_NOPATHE'}; return stat(p);}; + await assert.rejects(s.save(),/cannot be inspected/i); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.get(s.destination),'# private existing file'); +}); +test('destination appearing while move journal is written is not overwritten', async () => { + const s=await setup(); const write=s.target.Neutralino.filesystem.writeFile; + s.target.Neutralino.filesystem.writeFile=async(p,text)=>{await write(p,text); if(p.endsWith('.move.json'))s.target.__desktopFiles.set(s.destination,'# externally created');}; + await assert.rejects(s.save(),/destination.*exists/i); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.get(s.destination),'# externally created'); +}); +test('normal document rename persists and reopens its body', async () => { + const s=await setup(); await s.save(); + assert.equal(s.target.__desktopFiles.has(s.original),false); + assert.equal(s.target.__desktopFiles.get(s.destination),'# original'); + const reopened=new s.target.MarkdownWorkspaceStorage(); await reopened.init(); + assert.equal(await reopened.loadDocumentContent(s.tab.id),'# original'); +}); +test('move permission failure leaves source and index unchanged', async () => { + const s=await setup(); s.target.Neutralino.filesystem.move=async()=>{throw new Error('Access denied');}; + await assert.rejects(s.save(),/Access denied/); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.has(s.destination),false); + assert.equal(s.target.__desktopFiles.get(s.storage.vaultPath+'/.markdown-viewer/index.json'),s.index); + const reopened=new s.target.MarkdownWorkspaceStorage(); await reopened.init(); + assert.equal(await reopened.loadDocumentContent(s.tab.id),'# original'); +}); +test('unavailable parent listing stops move rather than assuming absence', async () => { + const s=await setup(); s.target.Neutralino.filesystem.readDirectory=async()=>{throw new Error('Parent access denied');}; + await assert.rejects(s.save(),/Parent access denied/); + assert.equal(s.target.__desktopFiles.get(s.original),'# original'); + assert.equal(s.target.__desktopFiles.has(s.destination),false); +}); diff --git a/workspace-storage.js b/workspace-storage.js index bdf2fa28..b8dc26b1 100644 --- a/workspace-storage.js +++ b/workspace-storage.js @@ -1229,6 +1229,37 @@ tab._storageContentJournalPaths = Array.from(new Set((tab._storageContentJournalPaths || []).concat(journalPath))); } + async _desktopInspectMovePath(path) { + try { return await Neutralino.filesystem.getStats(path); } + catch (error) { + if (!error || error.code !== 'NE_FS_NOPATHE') throw error; + // Native stat can report missing for an inaccessible entry. Confirm + // absence using its parent; never turn permission errors into absence. + const normalized = normalizePathSeparators(path); + const separator = normalized.lastIndexOf('/'); + if (separator < 0) throw error; + const entries = await Neutralino.filesystem.readDirectory(normalized.slice(0, separator)); + const name = normalized.slice(separator + 1); + const insensitive = typeof NL_OS !== 'undefined' && NL_OS === 'Windows'; + if (entries.some(function(entry) { + return insensitive ? String(entry.entry).toLowerCase() === name.toLowerCase() : entry.entry === name; + })) { + throw Object.assign(new Error('Move cancelled: a file exists but cannot be inspected at ' + path + '. No existing file was replaced.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + return null; + } + } + + async _desktopCheckMovePaths(source, destination) { + const stats = await this._desktopInspectMovePath(source); + if (!stats || stats.isDirectory || stats.isFile === false) { + throw Object.assign(new Error('Move cancelled: the source is missing or is not a file at ' + source + '. No replacement was created.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + if (await this._desktopInspectMovePath(destination)) { + throw Object.assign(new Error('Move cancelled: the destination already exists at ' + destination + '. Both files were left unchanged.'), { code: 'WORKSPACE_MOVE_BLOCKED' }); + } + } + async _desktopSaveDocument(tab, organization, forceContent) { requireDocumentId(tab && tab.id); const existing = this.vaultIndex.documents.find(function (item) { return item.id === tab.id; }); @@ -1254,6 +1285,7 @@ if (existing && existing.vaultRelativePath && existing.vaultRelativePath !== relativePath) { const oldPath = await this._desktopResolveVaultRelativePath(existing.vaultRelativePath); + await this._desktopCheckMovePaths(oldPath, fullPath); const journalDirectory = await this._pathJoin(this.vaultPath, INTERNAL_DIR, 'journal'); const moveJournalPath = await this._pathJoin(journalDirectory, sanitizePathSegment(tab.id, 'document') + '.move.json'); await this._writeJsonFile(moveJournalPath, { @@ -1266,14 +1298,10 @@ startedAt: Date.now() }); tab._storageMoveJournalPath = moveJournalPath; - if (await this._pathExists(oldPath)) { - if (await this._pathExists(fullPath)) { - await Neutralino.filesystem.writeFile(fullPath, typeof tab.content === 'string' ? tab.content : await Neutralino.filesystem.readFile(oldPath)); - await Neutralino.filesystem.remove(oldPath); - } else { - await Neutralino.filesystem.move(oldPath, fullPath); - } - } + // Recheck after asynchronous journal writing. Do not overwrite a + // collision, delete the source or fall back to a destructive copy. + await this._desktopCheckMovePaths(oldPath, fullPath); + await Neutralino.filesystem.move(oldPath, fullPath); } if (forceContent || tab.contentLoaded !== false) { const content = typeof tab.content === 'string' ? tab.content : ''; From d5d28ecf224bf5d26e359ea936a07285273ef155 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:01:24 +0300 Subject: [PATCH 07/13] fix(storage): guard workspace replacement against pending operations --- assets/i18n/bg.json | 3 +- assets/i18n/de.json | 3 +- assets/i18n/en.json | 3 +- assets/i18n/es.json | 3 +- assets/i18n/fr.json | 3 +- assets/i18n/it.json | 3 +- assets/i18n/ja.json | 3 +- assets/i18n/ko.json | 3 +- assets/i18n/pl.json | 3 +- assets/i18n/pt.json | 3 +- assets/i18n/ru.json | 3 +- assets/i18n/tr.json | 3 +- assets/i18n/tw.json | 3 +- assets/i18n/uk.json | 3 +- assets/i18n/zh.json | 3 +- desktop-app/resources/js/script.js | 77 ++++++++++++++++------- script.js | 77 ++++++++++++++++------- tests/e2e/workspace-maintenance.spec.js | 66 +++++++++++++++++++ tests/unit/document-delete-races.test.mjs | 3 +- tests/unit/workspace-maintenance.test.mjs | 64 +++++++++++++++++++ 20 files changed, 272 insertions(+), 60 deletions(-) create mode 100644 tests/e2e/workspace-maintenance.spec.js create mode 100644 tests/unit/workspace-maintenance.test.mjs diff --git a/assets/i18n/bg.json b/assets/i18n/bg.json index 9fa706dc..bdd64833 100644 --- a/assets/i18n/bg.json +++ b/assets/i18n/bg.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Сгъваем блок за съдържанието", "No headings in the selected range.": "Няма заглавия в избрания диапазон.", "Copy entire document as Markdown": "Копиране на целия документ като Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копиране на целия документ като Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копиране на целия документ като Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Възстановяването и връщането към предишното състояние са неуспешни. Редактирането остава блокирано; презаредете, преди да продължите." } diff --git a/assets/i18n/de.json b/assets/i18n/de.json index 533e2f72..7f9ee837 100644 --- a/assets/i18n/de.json +++ b/assets/i18n/de.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Einklappbares Inhaltsverzeichnis", "No headings in the selected range.": "Keine Überschriften im ausgewählten Bereich.", "Copy entire document as Markdown": "Gesamtes Dokument als Markdown kopieren", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Gesamtes Dokument als Markdown kopieren (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Gesamtes Dokument als Markdown kopieren (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Wiederherstellung und Rücksetzung sind fehlgeschlagen. Die Bearbeitung bleibt gesperrt; laden Sie die Anwendung neu, bevor Sie fortfahren." } diff --git a/assets/i18n/en.json b/assets/i18n/en.json index e658ac55..42aa5cef 100644 --- a/assets/i18n/en.json +++ b/assets/i18n/en.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Collapsible TOC wrapper", "No headings in the selected range.": "No headings in the selected range.", "Copy entire document as Markdown": "Copy entire document as Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Restore and rollback failed. Editing remains blocked; reload before continuing." } diff --git a/assets/i18n/es.json b/assets/i18n/es.json index 1fc747fa..0c768888 100644 --- a/assets/i18n/es.json +++ b/assets/i18n/es.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Tabla de contenido plegable", "No headings in the selected range.": "No hay encabezados en el rango seleccionado.", "Copy entire document as Markdown": "Copiar todo el documento como Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo el documento como Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo el documento como Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "La restauración y la reversión fallaron. La edición sigue bloqueada; recarga la aplicación antes de continuar." } diff --git a/assets/i18n/fr.json b/assets/i18n/fr.json index 7c1df13f..ad32c39e 100644 --- a/assets/i18n/fr.json +++ b/assets/i18n/fr.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Table des matières repliable", "No headings in the selected range.": "Aucun titre dans la plage sélectionnée.", "Copy entire document as Markdown": "Copier tout le document au format Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copier tout le document au format Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copier tout le document au format Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "La restauration et le retour à l’état précédent ont échoué. La modification reste bloquée ; rechargez l’application avant de continuer." } diff --git a/assets/i18n/it.json b/assets/i18n/it.json index e36ea04d..2d6aa7b3 100644 --- a/assets/i18n/it.json +++ b/assets/i18n/it.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Sommario comprimibile", "No headings in the selected range.": "Nessun titolo nell’intervallo selezionato.", "Copy entire document as Markdown": "Copia l’intero documento come Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copia l’intero documento come Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copia l’intero documento come Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Il ripristino e il ritorno allo stato precedente non sono riusciti. La modifica rimane bloccata; ricarica l’applicazione prima di continuare." } diff --git a/assets/i18n/ja.json b/assets/i18n/ja.json index bebb5c02..c58ea8f5 100644 --- a/assets/i18n/ja.json +++ b/assets/i18n/ja.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "折りたたみ可能な目次", "No headings in the selected range.": "選択した範囲に見出しがありません。", "Copy entire document as Markdown": "ドキュメント全体をMarkdownとしてコピー", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "ドキュメント全体をMarkdownとしてコピー (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "ドキュメント全体をMarkdownとしてコピー (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "復元と以前の状態へのロールバックに失敗しました。編集は引き続きブロックされています。続行する前にアプリを再読み込みしてください。" } diff --git a/assets/i18n/ko.json b/assets/i18n/ko.json index 2b1a7da3..bef21102 100644 --- a/assets/i18n/ko.json +++ b/assets/i18n/ko.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "접을 수 있는 목차", "No headings in the selected range.": "선택한 범위에 제목이 없습니다.", "Copy entire document as Markdown": "전체 문서를 Markdown으로 복사", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "전체 문서를 Markdown으로 복사 (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "전체 문서를 Markdown으로 복사 (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "복원과 이전 상태로의 되돌리기가 실패했습니다. 편집이 계속 차단됩니다. 계속하기 전에 앱을 새로고침하세요." } diff --git a/assets/i18n/pl.json b/assets/i18n/pl.json index bafc8513..ff02adbb 100644 --- a/assets/i18n/pl.json +++ b/assets/i18n/pl.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Zwijany spis treści", "No headings in the selected range.": "Brak nagłówków w wybranym zakresie.", "Copy entire document as Markdown": "Kopiuj cały dokument jako Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Kopiuj cały dokument jako Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Kopiuj cały dokument jako Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Przywracanie i powrót do poprzedniego stanu nie powiodły się. Edycja pozostaje zablokowana; odśwież aplikację przed kontynuowaniem." } diff --git a/assets/i18n/pt.json b/assets/i18n/pt.json index 9cb78c4f..d6cae46f 100644 --- a/assets/i18n/pt.json +++ b/assets/i18n/pt.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Sumário recolhível", "No headings in the selected range.": "Nenhum título no intervalo selecionado.", "Copy entire document as Markdown": "Copiar todo o documento como Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo o documento como Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Copiar todo o documento como Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "A restauração e a reversão falharam. A edição permanece bloqueada; recarregue o aplicativo antes de continuar." } diff --git a/assets/i18n/ru.json b/assets/i18n/ru.json index fe777e89..2c0d25ed 100644 --- a/assets/i18n/ru.json +++ b/assets/i18n/ru.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Сворачиваемое оглавление", "No headings in the selected range.": "В выбранном диапазоне нет заголовков.", "Copy entire document as Markdown": "Копировать весь документ как Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копировать весь документ как Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копировать весь документ как Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Восстановление и откат не удались. Редактирование остаётся заблокированным; перезагрузите приложение перед продолжением." } diff --git a/assets/i18n/tr.json b/assets/i18n/tr.json index 5be7e9e5..9fbd17cc 100644 --- a/assets/i18n/tr.json +++ b/assets/i18n/tr.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Daraltılabilir içindekiler tablosu", "No headings in the selected range.": "Seçilen aralıkta başlık yok.", "Copy entire document as Markdown": "Belgenin tamamını Markdown olarak kopyala", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Belgenin tamamını Markdown olarak kopyala (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Belgenin tamamını Markdown olarak kopyala (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Geri yükleme ve önceki duruma dönüş başarısız oldu. Düzenleme engellenmeye devam ediyor; devam etmeden önce uygulamayı yeniden yükleyin." } diff --git a/assets/i18n/tw.json b/assets/i18n/tw.json index 753cea98..a303335e 100644 --- a/assets/i18n/tw.json +++ b/assets/i18n/tw.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "可摺疊目錄", "No headings in the selected range.": "所選範圍內沒有標題。", "Copy entire document as Markdown": "將整份文件複製為 Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "將整份文件複製為 Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "將整份文件複製為 Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "還原及回復至先前狀態均失敗。編輯仍被封鎖;請重新載入應用程式後再繼續。" } diff --git a/assets/i18n/uk.json b/assets/i18n/uk.json index 9b4e46c9..b2abdccc 100644 --- a/assets/i18n/uk.json +++ b/assets/i18n/uk.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "Згортаний зміст", "No headings in the selected range.": "У вибраному діапазоні немає заголовків.", "Copy entire document as Markdown": "Копіювати весь документ як Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копіювати весь документ як Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "Копіювати весь документ як Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "Відновлення та повернення до попереднього стану не вдалися. Редагування залишається заблокованим; перезавантажте застосунок, перш ніж продовжити." } diff --git a/assets/i18n/zh.json b/assets/i18n/zh.json index 5e155994..b9919509 100644 --- a/assets/i18n/zh.json +++ b/assets/i18n/zh.json @@ -1200,5 +1200,6 @@ "Collapsible TOC wrapper": "可折叠目录", "No headings in the selected range.": "所选范围内没有标题。", "Copy entire document as Markdown": "将整个文档复制为 Markdown", - "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "将整个文档复制为 Markdown (Ctrl/Cmd+Shift+C)" + "Copy entire document as Markdown (Ctrl/Cmd+Shift+C)": "将整个文档复制为 Markdown (Ctrl/Cmd+Shift+C)", + "Restore and rollback failed. Editing remains blocked; reload before continuing.": "恢复及回滚到先前状态均失败。编辑仍被阻止;请重新加载应用程序后再继续。" } diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 30dd3ff9..42b50bd6 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -2098,18 +2098,12 @@ document.addEventListener("DOMContentLoaded", async function () { detail: 'Validating backup…' }); let restoreCommitted = false; + let maintenanceStarted = false; let preferenceSnapshot = null; try { const backup = await parseWorkspaceBackup(input); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; preferenceSnapshot = captureApplicationPreferences(); try { await replaceApplicationPreferences(backup.preferences); @@ -2143,7 +2137,7 @@ document.addEventListener("DOMContentLoaded", async function () { }); window.setTimeout(function() { window.location.reload(); }, 1100); } catch (error) { - if (!restoreCommitted) suspendWorkspacePersistence = false; + if (!restoreCommitted && maintenanceStarted && !error.rollbackError) endWorkspaceMaintenance(); console.error('Workspace backup import failed:', error); if (restoreCommitted) { finishImportProgress(100, 100, { @@ -2158,7 +2152,8 @@ document.addEventListener("DOMContentLoaded", async function () { success: false, title: 'Import failed', countText: 'Workspace not restored', - detail: error && error.message ? error.message : 'The backup could not be imported.' + detail: error.rollbackError ? 'Restore and rollback failed. Editing remains blocked; reload before continuing.' : + (error && error.message ? error.message : 'The backup could not be imported.') }); } } @@ -4262,6 +4257,8 @@ document.addEventListener("DOMContentLoaded", async function () { let pendingWorkspacePersistAll = false; let pendingWorkspacePersistIds = new Set(); let suspendWorkspacePersistence = false; + let workspaceMaintenancePending = false; + let workspaceMaintenancePreviousInert = false; let secretWorkspaceEnvelopeCache = null; const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit"); const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay"); @@ -11200,7 +11197,10 @@ document.addEventListener("DOMContentLoaded", async function () { } } - async function flushWorkspaceBeforeExit() { + async function flushWorkspaceBeforeExit(options) { + if ((workspaceMaintenancePending || suspendWorkspacePersistence) && !(options && options.allowMaintenance === true)) { + throw new Error('Workspace maintenance is still in progress. Wait for it to complete before closing.'); + } saveCurrentTabState(); await secretDirtyJournalChain.catch(function() {}); const normalSaved = await _flushTabsToStorage(tabs); @@ -11214,6 +11214,36 @@ document.addEventListener("DOMContentLoaded", async function () { window.MarkdownViewerFlushWorkspace = flushWorkspaceBeforeExit; + async function beginWorkspaceMaintenance() { + if (workspaceMaintenancePending || suspendWorkspacePersistence) throw new Error('Workspace maintenance is already active. Reload or wait before retrying.'); + if (nativeMarkdownSaveInProgress) throw new Error('A file Save is still in progress. Wait for it to finish, then retry.'); + if (liveCollaboration) throw new Error('Leave the live collaboration session before replacing the workspace.'); + workspaceMaintenancePending = true; + const container = document.querySelector('.app-container'); + workspaceMaintenancePreviousInert = !!(container && container.inert); + if (container) container.inert = true; + clearTimeout(saveTabStateTimeout); + clearTimeout(secretWorkspaceSaveTimeout); + try { + await documentDeletionChain; + saveSecondarySplitState(); + await flushWorkspaceBeforeExit({ allowMaintenance: true }); + await secretWorkspaceSaveChain; + if (await organizationPersistenceChain === false) throw new Error('Workspace organization could not be saved. Maintenance was cancelled.'); + suspendWorkspacePersistence = true; + } catch (error) { + endWorkspaceMaintenance(); + throw error; + } + } + + function endWorkspaceMaintenance() { + suspendWorkspacePersistence = false; + workspaceMaintenancePending = false; + const container = document.querySelector('.app-container'); + if (container) container.inert = workspaceMaintenancePreviousInert; + } + // Browser lifecycle events cannot guarantee that asynchronous storage finishes, // so ordinary edits are still debounced continuously. The desktop close handler // explicitly awaits this hook before exiting. @@ -12535,6 +12565,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function newTab(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; closeSlashCommandMenu(); if (content === undefined) content = ''; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -12556,6 +12587,7 @@ document.addEventListener("DOMContentLoaded", async function () { let documentDeletionChain = Promise.resolve(); function deleteTab(tabId) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return Promise.resolve(false); const operation = documentDeletionChain.catch(function() {}).then(function() { return deleteTabSnapshot(tabId); }); @@ -12851,21 +12883,15 @@ document.addEventListener("DOMContentLoaded", async function () { if (closeBtn) closeBtn.disabled = true; confirmBtn.classList.add('is-loading'); confirmBtn.setAttribute('aria-busy', 'true'); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); showImportProgress(100, { title: 'Resetting workspace', detail: 'Preparing a fresh workspace…' }); let resetStarted = false; + let maintenanceStarted = false; try { - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; updateImportProgress(25, 100, 'Clearing workspace settings…'); await replaceApplicationPreferences({}); await clearLegacyWorkspaceStateForReset(); @@ -12895,7 +12921,7 @@ document.addEventListener("DOMContentLoaded", async function () { cancelBtn.disabled = resetStarted; if (backupBtn) backupBtn.disabled = resetStarted; if (closeBtn) closeBtn.disabled = resetStarted; - if (!resetStarted) suspendWorkspacePersistence = false; + if (!resetStarted && maintenanceStarted) endWorkspaceMaintenance(); isResetting = false; } } @@ -13221,6 +13247,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Late-load callback hook for Neutralino command-line files window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; if (typeof tabs === 'undefined') return; const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); if (existing) { @@ -15470,6 +15497,7 @@ ${selector} .arrowheadPath { } function createImportedDocument(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return null; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -24656,7 +24684,10 @@ ${selector} .arrowheadPath { updateStlThemes(); }); + let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown() { + if (nativeMarkdownSaveInProgress || workspaceMaintenancePending || suspendWorkspacePersistence) return; + nativeMarkdownSaveInProgress = true; try { const content = markdownEditor.value; const result = await Neutralino.os.showSaveDialog("Save Markdown File", { @@ -24679,6 +24710,8 @@ ${selector} .arrowheadPath { } catch (e) { console.error("Native save failed:", e); alert("Native save failed: " + e.message); + } finally { + nativeMarkdownSaveInProgress = false; } } diff --git a/script.js b/script.js index 30dd3ff9..42b50bd6 100644 --- a/script.js +++ b/script.js @@ -2098,18 +2098,12 @@ document.addEventListener("DOMContentLoaded", async function () { detail: 'Validating backup…' }); let restoreCommitted = false; + let maintenanceStarted = false; let preferenceSnapshot = null; try { const backup = await parseWorkspaceBackup(input); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; preferenceSnapshot = captureApplicationPreferences(); try { await replaceApplicationPreferences(backup.preferences); @@ -2143,7 +2137,7 @@ document.addEventListener("DOMContentLoaded", async function () { }); window.setTimeout(function() { window.location.reload(); }, 1100); } catch (error) { - if (!restoreCommitted) suspendWorkspacePersistence = false; + if (!restoreCommitted && maintenanceStarted && !error.rollbackError) endWorkspaceMaintenance(); console.error('Workspace backup import failed:', error); if (restoreCommitted) { finishImportProgress(100, 100, { @@ -2158,7 +2152,8 @@ document.addEventListener("DOMContentLoaded", async function () { success: false, title: 'Import failed', countText: 'Workspace not restored', - detail: error && error.message ? error.message : 'The backup could not be imported.' + detail: error.rollbackError ? 'Restore and rollback failed. Editing remains blocked; reload before continuing.' : + (error && error.message ? error.message : 'The backup could not be imported.') }); } } @@ -4262,6 +4257,8 @@ document.addEventListener("DOMContentLoaded", async function () { let pendingWorkspacePersistAll = false; let pendingWorkspacePersistIds = new Set(); let suspendWorkspacePersistence = false; + let workspaceMaintenancePending = false; + let workspaceMaintenancePreviousInert = false; let secretWorkspaceEnvelopeCache = null; const LIVE_EDIT_ORIGIN = Symbol("markdown-viewer-live-local-edit"); const LIVE_RELAY_ORIGIN = Symbol("markdown-viewer-live-relay"); @@ -11200,7 +11197,10 @@ document.addEventListener("DOMContentLoaded", async function () { } } - async function flushWorkspaceBeforeExit() { + async function flushWorkspaceBeforeExit(options) { + if ((workspaceMaintenancePending || suspendWorkspacePersistence) && !(options && options.allowMaintenance === true)) { + throw new Error('Workspace maintenance is still in progress. Wait for it to complete before closing.'); + } saveCurrentTabState(); await secretDirtyJournalChain.catch(function() {}); const normalSaved = await _flushTabsToStorage(tabs); @@ -11214,6 +11214,36 @@ document.addEventListener("DOMContentLoaded", async function () { window.MarkdownViewerFlushWorkspace = flushWorkspaceBeforeExit; + async function beginWorkspaceMaintenance() { + if (workspaceMaintenancePending || suspendWorkspacePersistence) throw new Error('Workspace maintenance is already active. Reload or wait before retrying.'); + if (nativeMarkdownSaveInProgress) throw new Error('A file Save is still in progress. Wait for it to finish, then retry.'); + if (liveCollaboration) throw new Error('Leave the live collaboration session before replacing the workspace.'); + workspaceMaintenancePending = true; + const container = document.querySelector('.app-container'); + workspaceMaintenancePreviousInert = !!(container && container.inert); + if (container) container.inert = true; + clearTimeout(saveTabStateTimeout); + clearTimeout(secretWorkspaceSaveTimeout); + try { + await documentDeletionChain; + saveSecondarySplitState(); + await flushWorkspaceBeforeExit({ allowMaintenance: true }); + await secretWorkspaceSaveChain; + if (await organizationPersistenceChain === false) throw new Error('Workspace organization could not be saved. Maintenance was cancelled.'); + suspendWorkspacePersistence = true; + } catch (error) { + endWorkspaceMaintenance(); + throw error; + } + } + + function endWorkspaceMaintenance() { + suspendWorkspacePersistence = false; + workspaceMaintenancePending = false; + const container = document.querySelector('.app-container'); + if (container) container.inert = workspaceMaintenancePreviousInert; + } + // Browser lifecycle events cannot guarantee that asynchronous storage finishes, // so ordinary edits are still debounced continuously. The desktop close handler // explicitly awaits this hook before exiting. @@ -12535,6 +12565,7 @@ document.addEventListener("DOMContentLoaded", async function () { } function newTab(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; closeSlashCommandMenu(); if (content === undefined) content = ''; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -12556,6 +12587,7 @@ document.addEventListener("DOMContentLoaded", async function () { let documentDeletionChain = Promise.resolve(); function deleteTab(tabId) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return Promise.resolve(false); const operation = documentDeletionChain.catch(function() {}).then(function() { return deleteTabSnapshot(tabId); }); @@ -12851,21 +12883,15 @@ document.addEventListener("DOMContentLoaded", async function () { if (closeBtn) closeBtn.disabled = true; confirmBtn.classList.add('is-loading'); confirmBtn.setAttribute('aria-busy', 'true'); - suspendWorkspacePersistence = true; - clearTimeout(saveTabStateTimeout); - clearTimeout(secretWorkspaceSaveTimeout); showImportProgress(100, { title: 'Resetting workspace', detail: 'Preparing a fresh workspace…' }); let resetStarted = false; + let maintenanceStarted = false; try { - await Promise.all([ - workspacePersistenceChain.catch(function() {}), - secretWorkspaceSaveChain.catch(function() {}), - secretDirtyJournalChain.catch(function() {}), - organizationPersistenceChain.catch(function() {}) - ]); + await beginWorkspaceMaintenance(); + maintenanceStarted = true; updateImportProgress(25, 100, 'Clearing workspace settings…'); await replaceApplicationPreferences({}); await clearLegacyWorkspaceStateForReset(); @@ -12895,7 +12921,7 @@ document.addEventListener("DOMContentLoaded", async function () { cancelBtn.disabled = resetStarted; if (backupBtn) backupBtn.disabled = resetStarted; if (closeBtn) closeBtn.disabled = resetStarted; - if (!resetStarted) suspendWorkspacePersistence = false; + if (!resetStarted && maintenanceStarted) endWorkspaceMaintenance(); isResetting = false; } } @@ -13221,6 +13247,7 @@ document.addEventListener("DOMContentLoaded", async function () { // Late-load callback hook for Neutralino command-line files window.NL_IMPORT_EXTERNAL_FILE = function(content, name) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return false; if (typeof tabs === 'undefined') return; const existing = tabs.find(function(t) { return t.title === name && t.content === content; }); if (existing) { @@ -15470,6 +15497,7 @@ ${selector} .arrowheadPath { } function createImportedDocument(content, title, location) { + if (workspaceMaintenancePending || suspendWorkspacePersistence) return null; const targetLocation = location || (documentOrganization ? getPreferredDocumentLocation() : { workspaceId: DEFAULT_WORKSPACE_ID, folderId: null }); @@ -24656,7 +24684,10 @@ ${selector} .arrowheadPath { updateStlThemes(); }); + let nativeMarkdownSaveInProgress = false; async function nativeSaveMarkdown() { + if (nativeMarkdownSaveInProgress || workspaceMaintenancePending || suspendWorkspacePersistence) return; + nativeMarkdownSaveInProgress = true; try { const content = markdownEditor.value; const result = await Neutralino.os.showSaveDialog("Save Markdown File", { @@ -24679,6 +24710,8 @@ ${selector} .arrowheadPath { } catch (e) { console.error("Native save failed:", e); alert("Native save failed: " + e.message); + } finally { + nativeMarkdownSaveInProgress = false; } } diff --git a/tests/e2e/workspace-maintenance.spec.js b/tests/e2e/workspace-maintenance.spec.js new file mode 100644 index 00000000..a9777716 --- /dev/null +++ b/tests/e2e/workspace-maintenance.spec.js @@ -0,0 +1,66 @@ +const { test, expect } = require('@playwright/test'); +const JSZip = require('jszip'); +const { openApp } = require('../helpers/app'); +const { installDesktopFilesystem } = require('../helpers/desktop-filesystem'); + +async function requestImport(page) { + const zip = new JSZip(); + zip.file('Workspace/New.md', '# replacement'); + zip.file('.markdown-viewer/documents.json', JSON.stringify([{ metadata: { id: 'replacement', title: 'Replacement', workspaceId: 'workspace_default' }, path: 'Workspace/New.md' }])); + zip.file('.markdown-viewer/organization.json', '{}'); + zip.file('.markdown-viewer/preferences.json', '{}'); + zip.file('markdown-viewer-backup.json', JSON.stringify({ format: 'markdown-viewer-backup', version: 1, normalDocumentCount: 1, secureRecordCount: 0 })); + await page.getByRole('button', { name: 'Open workspace settings' }).click(); + await page.locator('#storage-settings-button').click(); + await page.locator('#storage-backup-file-input').setInputFiles({ name: 'maintenance.zip', mimeType: 'application/zip', buffer: await zip.generateAsync({ type: 'nodebuffer' }) }); + await page.locator('#storage-import-confirm-confirm').click(); +} + +test.beforeEach(async ({ page }) => { + await page.route('**/libs/jszip.min.js', route => route.fulfill({ contentType: 'application/javascript', body: require('fs').readFileSync(require.resolve('jszip/dist/jszip.min.js')) })); + await page.addInitScript('(' + installDesktopFilesystem.toString() + ')(window);'); + await openApp(page); + await page.evaluate(async () => { + __desktopFiles.set('C:/notes.md', '# original'); + await NL_IMPORT_EXTERNAL_FILE('# original', 'notes', 'C:/notes.md'); + await MarkdownViewerFlushWorkspace(); + }); +}); + +test('import gates native launches, persists live drafts, and re-enables after safe failure', async ({ page }) => { + await page.locator('#markdown-editor').fill('# newest draft'); + await page.evaluate(() => { + MarkdownWorkspaceStorage.prototype.restoreBackupData = async function () { + window.__maintenanceReached = true; + await new Promise(resolve => { window.__releaseMaintenance = resolve; }); + throw new Error('Simulated restore failed safely'); + }; + }); + await requestImport(page); + await expect.poll(() => page.evaluate(() => window.__maintenanceReached)).toBe(true); + expect(await page.evaluate(async () => { + try { await MarkdownViewerFlushWorkspace(); return 'unexpected success'; } + catch (error) { return error.message; } + })).toContain('maintenance is still in progress'); + expect(await page.evaluate(async () => { + const linked = await NL_IMPORT_EXTERNAL_FILE('# late', 'late', 'C:/late.md'); + const storage = new MarkdownWorkspaceStorage(); await storage.init(); + const meta = (await storage.listDocumentMetadata()).find(x => x.title === 'notes'); + return { linked: !!linked, inert: document.querySelector('.app-container').inert, draft: await storage.loadDocumentContent(meta.id), original: __desktopFiles.get('C:/notes.md') }; + })).toEqual({ linked: false, inert: true, draft: '# newest draft', original: '# original' }); + await page.evaluate(() => __releaseMaintenance()); + await expect(page.locator('#import-progress-detail')).toContainText('Simulated restore failed safely'); + await expect.poll(() => page.evaluate(() => document.querySelector('.app-container').inert)).toBe(false); + await expect(page.locator('#markdown-editor')).toHaveValue('# newest draft'); +}); + +test('import does not resume editing when restore rollback also failed', async ({ page }) => { + await page.evaluate(() => { + MarkdownWorkspaceStorage.prototype.restoreBackupData = async () => { + const error = new Error('Failed restore'); error.rollbackError = new Error('Failed rollback'); throw error; + }; + }); + await requestImport(page); + await expect(page.locator('#import-progress-detail')).toContainText('Editing remains blocked'); + expect(await page.evaluate(() => document.querySelector('.app-container').inert)).toBe(true); +}); diff --git a/tests/unit/document-delete-races.test.mjs b/tests/unit/document-delete-races.test.mjs index d9987479..09e7e95a 100644 --- a/tests/unit/document-delete-races.test.mjs +++ b/tests/unit/document-delete-races.test.mjs @@ -11,7 +11,8 @@ function setup() { const context = { tabs: [{ id: 'A' }, { id: 'B' }, { id: 'C' }], activeTabId: null, secondarySplitTabId: null, pendingReviewDelete: null, liveCollaboration: null, SECRET_WORKSPACE_ID: 'secret', selectedDocumentId: null, - workspacePersistenceChain: Promise.resolve(), shareSnapshotViewOnlyTabIds: new Set(), tabHistories: {}, console, + workspacePersistenceChain: Promise.resolve(), workspaceMaintenancePending: false, suspendWorkspacePersistence: false, + shareSnapshotViewOnlyTabIds: new Set(), tabHistories: {}, console, workspaceStorage: { deleteDocument: async id => deleted.push(id) }, _flushTabsToStorage: () => new Promise(resolve => gates.push(resolve)), isPrivateStorageMode: () => false, isTemporaryDocument: () => false }; diff --git a/tests/unit/workspace-maintenance.test.mjs b/tests/unit/workspace-maintenance.test.mjs new file mode 100644 index 00000000..9284db97 --- /dev/null +++ b/tests/unit/workspace-maintenance.test.mjs @@ -0,0 +1,64 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +function setup() { + const source = readFileSync(new URL('../../script.js', import.meta.url), 'utf8'); + const start = source.indexOf(' async function beginWorkspaceMaintenance('); + const end = source.indexOf('\n // Browser lifecycle', start); + const container = { inert: false }, events = []; + const state = { + workspaceMaintenancePending: false, suspendWorkspacePersistence: false, + workspaceMaintenancePreviousInert: false, liveCollaboration: null, nativeMarkdownSaveInProgress: false, + saveTabStateTimeout: null, secretWorkspaceSaveTimeout: null, + documentDeletionChain: Promise.resolve(), secretWorkspaceSaveChain: Promise.resolve(), + organizationPersistenceChain: Promise.resolve(true), + document: { querySelector: () => container }, clearTimeout, + saveSecondarySplitState: () => events.push('split'), + flushWorkspaceBeforeExit: async options => { assert.equal(options.allowMaintenance, true); events.push('flush'); } + }; + vm.runInNewContext(source.slice(start, end) + '\nthis.begin = beginWorkspaceMaintenance; this.end = endWorkspaceMaintenance;', state); + return { state, container, events }; +} + +test('maintenance waits for deletions and flushes split drafts before suspension', async () => { + const { state, container, events } = setup(); let release; + state.documentDeletionChain = new Promise(resolve => { release = resolve; }); + const pending = state.begin(); + assert.equal(state.workspaceMaintenancePending, true); assert.equal(container.inert, true); + assert.equal(state.suspendWorkspacePersistence, false); assert.deepEqual(events, []); + release(); await pending; assert.deepEqual(events, ['split', 'flush']); + assert.equal(state.suspendWorkspacePersistence, true); + state.end(); assert.equal(container.inert, false); assert.equal(state.workspaceMaintenancePending, false); +}); + +test('failed draft persistence re-enables editing before destructive work', async () => { + const { state, container } = setup(); state.flushWorkspaceBeforeExit = async () => { throw new Error('disk full'); }; + await assert.rejects(state.begin(), /disk full/); + assert.equal(state.workspaceMaintenancePending, false); assert.equal(state.suspendWorkspacePersistence, false); + assert.equal(container.inert, false); +}); + +test('a second maintenance request cannot unblock the first request', async () => { + const { state, container } = setup(); await state.begin(); + await assert.rejects(state.begin(), /already active/); + assert.equal(state.suspendWorkspacePersistence, true); assert.equal(container.inert, true); +}); + +test('live collaboration prevents maintenance without taking ownership', async () => { + const { state, container } = setup(); state.liveCollaboration = {}; + await assert.rejects(state.begin(), /live collaboration/); + assert.equal(state.workspaceMaintenancePending, false); assert.equal(container.inert, false); +}); + +test('ending maintenance preserves an existing inert state', async () => { + const { state, container } = setup(); container.inert = true; + await state.begin(); state.end(); assert.equal(container.inert, true); +}); + +test('an active desktop Save prevents workspace replacement', async () => { + const { state, container } = setup(); state.nativeMarkdownSaveInProgress = true; + await assert.rejects(state.begin(), /Save is still in progress/); + assert.equal(state.workspaceMaintenancePending, false); assert.equal(container.inert, false); +}); From 21905e6adeb419033ed667eb7108db860badeb96 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:02:45 +0300 Subject: [PATCH 08/13] docs(storage): explain recovery and workspace maintenance boundaries --- wiki/Privacy-and-Security.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/wiki/Privacy-and-Security.md b/wiki/Privacy-and-Security.md index 6b918d67..e4ab874f 100644 --- a/wiki/Privacy-and-Security.md +++ b/wiki/Privacy-and-Security.md @@ -33,6 +33,17 @@ Temporary Share Snapshot and Live Share participant tabs are removed before norm Browser storage quotas and user-cleared site data still apply. The application cannot recover data after the browser, operating system, user, or storage policy deletes it. +Normal-document recovery journals retain newer edits and review comments when an +older save completes. Matching committed records are cleared conditionally; a +failed save retains its recovery records. Recovery is local and is not a substitute +for an external backup. Secret Workspace recovery continues using encrypted records. + +Document saves and deletions are coordinated to avoid restoring a document through +an obsolete queued save. Desktop moves check their source and destination before +moving a document and reject occupied destinations. These checks are not filesystem +locks or atomic compare-and-swap guarantees; another process can still change files +after a check. + Deleted normal documents and encrypted Secret Workspace recovery records remain in the application Trash for 30 days. Users can restore one item, permanently delete one item, or empty Trash after an explicit confirmation. Automatic cleanup requires a valid deletion timestamp and a recognized, structurally valid Trash record; missing, corrupt, or future-format records are retained rather than guessed at and deleted. Trash is local recovery storage, not a backup, and is excluded from exported workspace ZIP files. ## Private Mode, Reset Workspace, and Secret Workspace @@ -48,6 +59,13 @@ These controls have different purposes: Both **Reset workspace** and **Reset Secret Workspace** are destructive to encrypted content. Reset workspace clears everything; Reset Secret Workspace targets only the encrypted area. +Whole-workspace reset and backup restore temporarily block editing and new imports, +wait for pending document deletions, and flush current drafts before replacement. +Failure during preparation re-enables editing without starting replacement. If a +restore and its rollback both fail, editing remains blocked until reload so the +application cannot continue writing an uncertain workspace state. These safeguards +do not make reset reversible or guarantee recovery after storage failure. + Secret Workspace uses PBKDF2 with SHA-256, 250,000 iterations, a random salt, and AES-GCM with a 256-bit derived key. The key remains in memory only while the Workspace is unlocked. File contents and folder names are encrypted; item counts, salt, initialization vector, iteration count, and format version remain outside the ciphertext as metadata. A forgotten password cannot be recovered. Secret Workspace protects the stored payload from casual inspection at rest. It does not protect content after you unlock it, export it, send it through a network feature, or expose the running device or browser session. From bf0e404139841e45ff590c24975f4a5ff41a036c Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:03:46 +0300 Subject: [PATCH 09/13] test(storage): seed persisted document for stale writer move --- tests/e2e/data-loss-regressions.spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/data-loss-regressions.spec.js b/tests/e2e/data-loss-regressions.spec.js index b0355d26..033ba301 100644 --- a/tests/e2e/data-loss-regressions.spec.js +++ b/tests/e2e/data-loss-regressions.spec.js @@ -64,6 +64,9 @@ async function replacementBackupBuffer() { test('a stale tab move cannot delete documents created in another tab', async ({ context, page }) => { await openApp(page); + // Seed a persisted document before the second writer takes its snapshot. + await setEditorContent(page, '# Shared document before opening the stale writer'); + await waitForStoredContent(page, 'Shared document before opening the stale writer'); const staleDocumentId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); const secondPage = await context.newPage(); await openApp(secondPage); From 3596f336efbdac959413627ab0bcb017368ed001 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:04:53 +0300 Subject: [PATCH 10/13] test(theme): compare final tokens after animated theme changes --- tests/e2e/responsive-storage.spec.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/e2e/responsive-storage.spec.js b/tests/e2e/responsive-storage.spec.js index 3c2cd1df..5b5dd5bb 100644 --- a/tests/e2e/responsive-storage.spec.js +++ b/tests/e2e/responsive-storage.spec.js @@ -40,8 +40,8 @@ test('theme switching stores and restores the selected theme', async ({ page }) await expect(themeSwitch).toHaveCSS('width', await privateModeSwitch.evaluate(element => getComputedStyle(element).width)); await expect(themeSwitch).toHaveCSS('height', await privateModeSwitch.evaluate(element => getComputedStyle(element).height)); await expect(themeSwitch.locator(':scope > span')).toHaveCSS('border-radius', '50%'); - await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor)); - await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color)); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await resolveCssColor(page, '--bg-color')); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await resolveCssColor(page, '--text-color')); await themeToggle.click(); const toggledTheme = await page.evaluate(() => document.documentElement.getAttribute('data-theme')); @@ -49,8 +49,14 @@ test('theme switching stores and restores the selected theme', async ({ page }) expect(toggledTheme).not.toBe(initialTheme); await expect(themeToggle).toHaveAttribute('aria-pressed', String(toggledTheme === 'dark')); await expect(page.locator('#theme-switch-icon')).toHaveClass(new RegExp(`\\b${toggledThemeIcon}\\b`)); - await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', await page.locator('body').evaluate(element => getComputedStyle(element).backgroundColor)); - await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', await page.locator('body').evaluate(element => getComputedStyle(element).color)); + // Assert the target theme color, not a one-time snapshot halfway through + // the body's transition. Playwright still waits for the icon to reach it. + const backgroundColor = await resolveCssColor(page, '--bg-color'); + const textColor = await resolveCssColor(page, '--text-color'); + await expect(page.locator('body')).toHaveCSS('background-color', backgroundColor); + await expect(page.locator('body')).toHaveCSS('color', textColor); + await expect(themeSwitch.locator(':scope > span')).toHaveCSS('background-color', backgroundColor); + await expect(page.locator('#theme-switch-icon')).toHaveCSS('color', textColor); await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem('markdownViewerGlobalState') || '{}').theme)).toBe(toggledTheme); await page.reload(); From 54fcbae01e0d331a9464a39236bf8a859b0e57a7 Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:08:55 +0300 Subject: [PATCH 11/13] test(trash): synchronize persistence and allow selection order --- tests/e2e/trash.spec.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/trash.spec.js b/tests/e2e/trash.spec.js index 02703141..61af4f6b 100644 --- a/tests/e2e/trash.spec.js +++ b/tests/e2e/trash.spec.js @@ -148,6 +148,7 @@ test('automatic Trash cleanup removes only valid items older than 30 days', asyn test('Trash maintenance failure never blocks workspace initialization', async ({ page }) => { await openApp(page); + await page.evaluate(() => window.MarkdownViewerFlushWorkspace()); const result = await page.evaluate(async () => { const original = window.MarkdownWorkspaceStorage.prototype.purgeExpiredTrash; window.MarkdownWorkspaceStorage.prototype.purgeExpiredTrash = async function() { @@ -337,7 +338,7 @@ test('dedicated Trash restores, permanently deletes, and empties selected data', await page.locator('#trash-delete-button').click(); await expect(page.locator('#document-confirm-modal-title')).toHaveText('Permanently delete 2 files?'); await expect(page.locator('#document-confirm-modal-description')).toHaveText( - '“Deleted document” and “Delete from Trash” will be permanently removed from Trash immediately. This action cannot be undone.' + /^(?:“Deleted document” and “Delete from Trash”|“Delete from Trash” and “Deleted document”) will be permanently removed from Trash immediately\. This action cannot be undone\.$/ ); await page.locator('#document-confirm-modal-cancel').click(); await expect(page.locator('#trash-modal')).toHaveClass(/is-visible/); From 042c08f990727e80de9ec864103e98719c9deb6e Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:49:21 +0300 Subject: [PATCH 12/13] fix(editor): prevent deferred media chooser focus from redirecting input --- desktop-app/resources/js/script.js | 15 +++++++-------- script.js | 15 +++++++-------- tests/e2e/slash-command-actions.spec.js | 21 +++++++++++++++++++++ 3 files changed, 35 insertions(+), 16 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 42b50bd6..7c71f2cf 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -21225,14 +21225,13 @@ ${selector} .arrowheadPath { uploadFields.style.display = isUpload ? 'flex' : 'none'; urlFields.style.display = isUpload ? 'none' : 'flex'; if (shouldFocus) { - requestAnimationFrame(function() { - if (isUpload) { - fileInput.focus(); - } else { - urlInput.focus(); - urlInput.select(); - } - }); + // Focus immediately so a deferred callback cannot steal subsequent input. + if (isUpload) { + fileInput.focus(); + } else { + urlInput.focus(); + urlInput.select(); + } } } diff --git a/script.js b/script.js index 42b50bd6..7c71f2cf 100644 --- a/script.js +++ b/script.js @@ -21225,14 +21225,13 @@ ${selector} .arrowheadPath { uploadFields.style.display = isUpload ? 'flex' : 'none'; urlFields.style.display = isUpload ? 'none' : 'flex'; if (shouldFocus) { - requestAnimationFrame(function() { - if (isUpload) { - fileInput.focus(); - } else { - urlInput.focus(); - urlInput.select(); - } - }); + // Focus immediately so a deferred callback cannot steal subsequent input. + if (isUpload) { + fileInput.focus(); + } else { + urlInput.focus(); + urlInput.select(); + } } } diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js index 9fe0ccbc..5e477cc3 100644 --- a/tests/e2e/slash-command-actions.spec.js +++ b/tests/e2e/slash-command-actions.spec.js @@ -118,6 +118,27 @@ for (const pane of ['primary', 'secondary']) { await expect(editor).toHaveValue(prefix + expected + suffix); } }); + test('media chooser does not steal focus from an edited field', async ({ page }) => { + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame; + window.__mediaFocusFrames = []; + window.requestAnimationFrame = function(callback) { + if (String(callback).includes('fileInput.focus()')) { + window.__mediaFocusFrames.push(callback); + return 0; + } + return nativeFrame.call(window, callback); + }; + }); + await enterCommand(page, editor, 'image', 'image'); + await page.locator('#image-modal-url').fill('https://example.com/focus.mp4'); + await page.locator('#image-modal-alt').fill('Kept title'); + await page.evaluate(() => window.__mediaFocusFrames.splice(0).forEach(callback => callback())); + await expect(page.locator('#image-modal-alt')).toBeFocused(); + await page.locator('#image-modal-insert').click(); + await expect(editor).toHaveValue(prefix + '' + suffix); + }); + test('media upload stays in the command target editor (mock upload API)', async ({ page }) => { await page.route('**/api/image', route => route.fulfill({ json: { id: mediaId } })); await mockMediaDownload(page); From 42d6c06ad9f03efaa1721d00b8b12a02766c27ab Mon Sep 17 00:00:00 2001 From: Ioannis Kechagias Date: Sun, 11 Oct 2026 13:57:03 +0300 Subject: [PATCH 13/13] fix(editor): prevent deferred table chooser focus from changing row input --- desktop-app/resources/js/script.js | 7 +++---- script.js | 7 +++---- tests/e2e/slash-command-actions.spec.js | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 7c71f2cf..af11b098 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -19307,10 +19307,9 @@ ${selector} .arrowheadPath { columnInput.addEventListener('keydown', onKey); rowInput.addEventListener('keydown', onKey); - requestAnimationFrame(() => { - columnInput.focus(); - columnInput.select(); - }); + // Do not redirect input after the user has moved to the row field. + columnInput.focus(); + columnInput.select(); } function openEmojiModal() { diff --git a/script.js b/script.js index 7c71f2cf..af11b098 100644 --- a/script.js +++ b/script.js @@ -19307,10 +19307,9 @@ ${selector} .arrowheadPath { columnInput.addEventListener('keydown', onKey); rowInput.addEventListener('keydown', onKey); - requestAnimationFrame(() => { - columnInput.focus(); - columnInput.select(); - }); + // Do not redirect input after the user has moved to the row field. + columnInput.focus(); + columnInput.select(); } function openEmojiModal() { diff --git a/tests/e2e/slash-command-actions.spec.js b/tests/e2e/slash-command-actions.spec.js index 5e477cc3..ced20df8 100644 --- a/tests/e2e/slash-command-actions.spec.js +++ b/tests/e2e/slash-command-actions.spec.js @@ -118,6 +118,27 @@ for (const pane of ['primary', 'secondary']) { await expect(editor).toHaveValue(prefix + expected + suffix); } }); + test('table chooser does not steal focus from an edited row field', async ({ page }) => { + await page.evaluate(() => { + const nativeFrame = window.requestAnimationFrame; + window.__tableFocusFrames = []; + window.requestAnimationFrame = function(callback) { + if (String(callback).includes('columnInput.focus()')) { + window.__tableFocusFrames.push(callback); + return 0; + } + return nativeFrame.call(window, callback); + }; + }); + await enterCommand(page, editor, 'table', 'table'); + await page.locator('#table-modal-columns').fill('2'); + await page.locator('#table-modal-rows').fill('2'); + await page.evaluate(() => window.__tableFocusFrames.splice(0).forEach(callback => callback())); + await expect(page.locator('#table-modal-rows')).toBeFocused(); + await page.locator('#table-modal-insert').click(); + await expect(editor).toHaveValue(prefix + '| Column 1 | Column 2 |\n| --- | --- |\n| Value | Value |\n' + suffix); + }); + test('media chooser does not steal focus from an edited field', async ({ page }) => { await page.evaluate(() => { const nativeFrame = window.requestAnimationFrame;