From 8dd58be4210042cf1740ff585f39c6cbfd7d6da0 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:35:40 -0500 Subject: [PATCH 1/4] Sync global placeholders, brand profiles and language with guides --- app/cloud-account.js | 1 + app/google-drive.js | 5 + app/main.js | 17 +- app/preload.js | 3 + app/renderer/app.js | 12 + app/renderer/dialogs.js | 5 +- core/branding.js | 24 ++ core/cloud-sync.js | 141 ++++++++++-- core/folder-storage.js | 10 +- core/shared-settings.js | 314 +++++++++++++++++++++++++++ docs/ARCHITECTURE.md | 11 + docs/BRANDING.md | 3 + docs/CLOUD_ACCOUNTS.md | 5 +- docs/GOOGLE_DRIVE.md | 26 ++- docs/PRIVACY.md | 11 +- tests/unit/agent-brand-tools.test.js | 2 +- tests/unit/folder-storage.test.js | 6 +- tests/unit/shared-settings.test.js | 225 +++++++++++++++++++ 18 files changed, 790 insertions(+), 31 deletions(-) create mode 100644 core/shared-settings.js create mode 100644 tests/unit/shared-settings.test.js diff --git a/app/cloud-account.js b/app/cloud-account.js index 59c24c97..33014a3b 100644 --- a/app/cloud-account.js +++ b/app/cloud-account.js @@ -348,6 +348,7 @@ class CloudAccount { listVersions() { return this.storage.listVersions(); } listDeletions() { return this.storage.listDeletions(); } + listSettings() { return this.storage.listSettings(); } listParts() { return this.storage.listParts(); } upload(file) { return this.storage.upload(file); } download(id, options) { return this.storage.download(id, options); } diff --git a/app/google-drive.js b/app/google-drive.js index f2dcc22e..812ee625 100644 --- a/app/google-drive.js +++ b/app/google-drive.js @@ -394,6 +394,11 @@ class GoogleDrive { return this.listAppData("trashed = false and appProperties has { key='stepforge' and value='deletion-v1' }"); } + // Global placeholders, branding and language (see core/shared-settings.js). + async listSettings() { + return this.listAppData("trashed = false and appProperties has { key='stepforge' and value='settings-v1' }"); + } + // Files shared between versions (see core/cloud-parts.js). async listParts() { return this.listAppData("trashed = false and appProperties has { key='stepforge' and value='part-v1' }"); diff --git a/app/main.js b/app/main.js index 449d526c..e3bcb078 100644 --- a/app/main.js +++ b/app/main.js @@ -25,6 +25,7 @@ const { GitHubPages } = require('./github-pages'); const { EXPIRY_DAYS, titleFromHtml } = require('../core/pages-site'); const { TransferMeter } = require('../core/transfer-meter'); const { CloudSync } = require('../core/cloud-sync'); +const { SharedSettings } = require('../core/shared-settings'); const { createCredentialVault } = require('./credential-vault'); const { SearchIndex } = require('../core/search'); const { findMatches, applyReplacements, isMatchArg } = require('../core/find-replace'); @@ -114,6 +115,8 @@ let cloudSync; let cloudConnecting = false; let cloudTesting = false; let cloudEditorDirty = false; +// Synced settings wait while Settings is open, so saving it can't undo them. +let settingsOpen = false; // The sync services, in the order Settings → Accounts lists them. const CLOUD_LABELS = { google: 'Google Drive', onedrive: 'OneDrive', dropbox: 'Dropbox', webdav: 'Nextcloud' }; @@ -279,6 +282,8 @@ function createWindow() { // away from it and every popup is denied, so no other document can run // with this window's preload bridge. security.installWindowSecurity(mainWindow, 'main'); + // A reload closes Settings without saying so. + mainWindow.webContents.on('did-start-loading', () => { settingsOpen = false; }); mainWindow.webContents.on('before-input-event', (event, input) => { // Electron reports both the key-down and key-up as separate // before-input-event calls; only act on the down edge or every tap @@ -759,6 +764,13 @@ function setupIpc() { if (trustedSender(event)) cloudEditorDirty = Boolean(dirty); }); + // Whatever was changed in Settings syncs as soon as it closes. + ipcMain.on('cloud:settings-open', (event, open) => { + if (!trustedSender(event)) return; + settingsOpen = Boolean(open); + if (!settingsOpen) cloudSync?.markSharedChanged(); + }); + const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs const h = (channel, fn, opts = {}) => { const { maxChars = 2 * 1024 * 1024, validate = null } = opts; @@ -1880,6 +1892,7 @@ if (!gotLock) { const dataDir = libraryLocation.applyPending(); store = new GuideStore(dataDir); settings = new Settings(store.settingsDir); + branding = new BrandingStore(store.settingsDir); // Dev builds keep their sign-in to their own data folder instead of // sharing (and possibly clearing) the release build's OS backup. const cloudVault = (slot) => (devBuild ? null : createCredentialVault({ slot })); @@ -1947,6 +1960,9 @@ if (!gotLock) { sendToRenderer('cloud:library-changed', { guideId }); }, onStatus: () => sendToRenderer('cloud:status', cloudStatus()), + shared: new SharedSettings({ settings, branding }), + sharedReady: () => !settingsOpen, + onSharedChange: (changes) => sendToRenderer('cloud:settings-changed', { changes }), }); searchIndex = new SearchIndex(store.indexDir); // Rebuild/reconcile the index against the library at startup so a missing, @@ -1966,7 +1982,6 @@ if (!gotLock) { setInterval(emptyOldTrash, TRASH_CHECK_MS); setTimeout(convertOldSnapshots, 60 * 1000); templates = new TemplateManager(store.templatesDir); - branding = new BrandingStore(store.settingsDir); textIntel = new TextIntelService({ store, settings, diff --git a/app/preload.js b/app/preload.js index 63c4f935..4dbccfbe 100644 --- a/app/preload.js +++ b/app/preload.js @@ -78,6 +78,7 @@ const api = { }, cloud: { setEditorDirty: (dirty) => ipcRenderer.send('cloud:editor-dirty', Boolean(dirty)), + setSettingsOpen: (open) => ipcRenderer.send('cloud:settings-open', Boolean(open)), status: invoke('cloud:status'), // Only the service and, for WebDAV, the server and login cross IPC. App // registrations stay in the main process. @@ -102,6 +103,8 @@ const api = { test: invoke('cloud:test'), onStatus: (fn) => { const listener = (e, payload) => fn(payload); ipcRenderer.on('cloud:status', listener); return () => ipcRenderer.removeListener('cloud:status', listener); }, onLibraryChanged: (fn) => ipcRenderer.on('cloud:library-changed', (e, payload) => fn(payload)), + // Global placeholders, branding or language arrived from another computer. + onSettingsChanged: (fn) => ipcRenderer.on('cloud:settings-changed', (e, payload) => fn(payload)), }, github: { status: invoke('github:status'), diff --git a/app/renderer/app.js b/app/renderer/app.js index 3e5af005..cc540710 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -66,6 +66,7 @@ class StepForgeApp { this.cloudStatus.addEventListener('click', () => this.openSettings(!this.cloudProvider || this.cloudProvider === 'google' ? 'drive' : this.cloudProvider)); api.cloud.onStatus((status) => this.renderCloudStatus(status)); api.cloud.onLibraryChanged(() => this.refreshLibrary().catch(console.error)); + api.cloud.onSettingsChanged(() => this.onSettingsSynced().catch(console.error)); api.cloud.status().then((status) => this.renderCloudStatus(status)).catch(console.error); this.libraryRenderToken = 0; @@ -131,6 +132,17 @@ class StepForgeApp { api.branding.onChangedByAgent(() => this.onBrandingChangedByAgent()); } + /** + * Global placeholders, branding or language arrived from another + * computer: pick up the active brand profile, and draw the PNG copies any + * new SVG or JPG images need. + */ + async onSettingsSynced() { + this.state.settings = await api.settings.all(); + this.editor.setSettings(this.state.settings); + await ensurePendingBrandRasters(api); + } + /** * An AI agent changed brand profiles: pick up the active profile (its * annotation colors), and draw the PNG copies new SVG or JPG images need. diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index bda5ed84..8508ad09 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -585,7 +585,10 @@ function showSettingsDialog({ flushEdits = null, section: initialSection = 'general', } = {}) { - return new Promise((resolve) => { + return new Promise((done) => { + // Synced settings wait while this is open, so Save can't undo them. + api.cloud?.setSettingsOpen?.(true); + const resolve = (saved) => { api.cloud?.setSettingsOpen?.(false); done(saved); }; const form = el('form', { className: 'settings-form' }); // A service's id ("drive", "onedrive", "dropbox", "webdav", "github") // opens Accounts with that service's panel showing. diff --git a/core/branding.js b/core/branding.js index 9509c18a..80c836d2 100644 --- a/core/branding.js +++ b/core/branding.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const zlib = require('node:zlib'); +const { isDeepStrictEqual } = require('node:util'); const { newId, nowIso, writeJsonSync, readJsonIfExists, atomicWriteFileSync } = require('./util'); const { zipSync, unzipSync } = require('./zip'); const { decodePng } = require('./png'); @@ -308,6 +309,28 @@ class BrandingStore { this.collectGarbage(); } + /** + * Make the saved profiles exactly `profiles` (cloud sync). Unlike save(), + * each keeps its own timestamps, so the same profiles synced back aren't a + * change. Asset references that don't exist are dropped. + */ + replaceProfiles(profiles) { + const keep = new Set(); + for (const raw of profiles) { + const profile = normalizeProfile(raw, { id: raw.id }); + const file = this.profileFile(profile.id); + const exists = (asset) => (asset && this.assetMeta(asset) ? asset : null); + profile.logo = exists(profile.logo); + profile.banner = exists(profile.banner); + profile.fonts.heading.asset = exists(profile.fonts.heading.asset); + profile.fonts.body.asset = exists(profile.fonts.body.asset); + if (!isDeepStrictEqual(readJsonIfExists(file, null), profile)) writeJsonSync(file, profile); + keep.add(profile.id); + } + for (const { id } of this.list()) if (!keep.has(id)) fs.rmSync(this.profileFile(id), { force: true }); + this.collectGarbage(); + } + duplicate(id) { const source = this.get(id); if (!source) throw new Error('That brand profile no longer exists.'); @@ -626,6 +649,7 @@ function brandTemplateDefaults(format, profile) { module.exports = { BrandingStore, normalizeProfile, + profileAssetIds, loadBranding, brandTemplateDefaults, checkFileSize, diff --git a/core/cloud-sync.js b/core/cloud-sync.js index 6c920ee3..ce658835 100644 --- a/core/cloud-sync.js +++ b/core/cloud-sync.js @@ -10,6 +10,7 @@ const { atomicWriteFileSync, writeJsonSync, readJsonIfExists } = require('./util const { TransferMeter } = require('./transfer-meter'); const { mergeGuideEntries } = require('./guide-merge'); const { createSnapshot } = require('./snapshots'); +const { mergeSharedSettings, UNZIP_LIMITS: SHARED_LIMITS } = require('./shared-settings'); const parts = require('./cloud-parts'); const validId = (id) => typeof id === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(id) && !['__proto__', 'constructor', 'prototype'].includes(id); @@ -137,9 +138,13 @@ const PART_CLEANUP_MS = 6 * 60 * 60 * 1000; /** Immutable Drive snapshots: concurrent writers create branches, never overwrite bytes. */ class CloudSync { + // `shared` (core/shared-settings.js) syncs global placeholders, branding + // and language too; `sharedReady` is false while Settings is open, so + // saving it can't undo what arrived meanwhile. constructor({ store, drive, enabled, canReplace = () => true, canUpload = () => true, onChange = () => {}, onDelete = () => {}, onStatus = () => {}, settleMs = 3000, - inlineLimit = parts.INLINE_LIMIT, partGraceMs = PART_GRACE_MS }) { - Object.assign(this, { store, drive, enabled, canReplace, canUpload, onChange, onDelete, onStatus, settleMs, inlineLimit, partGraceMs }); + inlineLimit = parts.INLINE_LIMIT, partGraceMs = PART_GRACE_MS, shared = null, sharedReady = () => true, onSharedChange = () => {} }) { + Object.assign(this, { store, drive, enabled, canReplace, canUpload, onChange, onDelete, onStatus, settleMs, inlineLimit, partGraceMs, + shared, sharedReady, onSharedChange }); this.directory = path.join(store.root, 'cloud'); this.partIndexCache = null; this.lastPartCleanup = 0; @@ -327,35 +332,37 @@ class CloudSync { basePath(id) { return path.join(this.directory, 'bases', `${id}.json`); } + sharedBasePath() { return path.join(this.directory, 'settings-base.json'); } + /** * Remember the content this computer last agreed on with the cloud, so a * later merge has a base even when that version is gone from the cloud. * Screenshots are kept as hashes only. */ - rememberBase(id, entries) { + rememberBase(file, entries) { try { const list = entries.filter((entry) => entry.name !== 'manifest.json').map((entry) => { const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(String(entry.data), 'utf8'); return entry.name.endsWith('.json') ? { name: entry.name, data: data.toString('base64') } : { name: entry.name, sha: parts.sha256(data) }; }); - fs.mkdirSync(path.dirname(this.basePath(id)), { recursive: true }); - writeJsonSync(this.basePath(id), { hash: parts.contentHash(entries), entries: list }); + fs.mkdirSync(path.dirname(file), { recursive: true }); + writeJsonSync(file, { hash: parts.contentHash(entries), entries: list }); } catch { /* only a cache */ } } /** The remembered base entries, if they are the content with this hash. */ - rememberedBase(id, hash) { - const saved = readJsonIfExists(this.basePath(id), null); + rememberedBase(file, hash) { + const saved = readJsonIfExists(file, null); if (!hash || saved?.hash !== hash || !Array.isArray(saved.entries)) return null; return saved.entries.map((entry) => ({ name: entry.name, sha: entry.sha, data: entry.data === undefined ? null : Buffer.from(entry.data, 'base64') })); } /** - * Base entries for merging `tips` (and the local guide, when `record` is + * Base entries for merging `tips` (and the local copy, when `record` is * given): the newest version every side descends from. Without one, the - * remembered base for the local guide, or null to merge without a base. + * base remembered in `baseFile`, or null to merge without a base. */ - async mergeBase(id, versions, tips, record) { + async mergeBase(baseFile, versions, tips, record) { const byId = new Map(versions.map((file) => [file.id, file])); const ancestry = (start) => { const seen = new Set(); @@ -371,8 +378,8 @@ class CloudSync { const lines = starts.map(ancestry); const common = [...lines[0]].filter((vid) => lines.every((line) => line.has(vid))).map((vid) => byId.get(vid)) .sort((a, b) => compareText(a.createdTime || '', b.createdTime || '') || compareText(a.id, b.id)).at(-1); - if (!common) return record ? this.rememberedBase(id, record.hash) : null; - return this.rememberedBase(id, common.appProperties.hash) || this.versionEntries(common); + if (!common) return record ? this.rememberedBase(baseFile, record.hash) : null; + return this.rememberedBase(baseFile, common.appProperties.hash) || this.versionEntries(common); } /** A version's entries for use as a merge base; screenshots may be hashes only. */ @@ -493,6 +500,13 @@ class CloudSync { markChanged(id) { this.changedAt.set(id, Date.now()); this.fingerprints.delete(id); + this.scheduleSync(); + } + + /** Global placeholders, branding or language may have changed here. */ + markSharedChanged() { this.scheduleSync(); } + + scheduleSync() { if (!this.enabled()) return; this.publish('pending', 'Changes waiting to sync.'); clearTimeout(this.timer); @@ -569,6 +583,11 @@ class CloudSync { const keep = protectedVersions(versions, retain); remove.push(...versions.filter((file) => !keep.has(file.id))); } + if (this.shared) { + const versions = await this.drive.listSettings(); + const keep = protectedVersions(versions, retain); + remove.push(...versions.filter((file) => !keep.has(file.id))); + } for (const file of remove) await this.drive.deleteFile(file.id); let reclaimedBytes = remove.reduce((n, file) => n + sizeOf(file), 0); let converted = 0; @@ -630,6 +649,8 @@ class CloudSync { purged += 1; } for (const file of files) await this.drive.deleteFile(file.id); + for (const file of this.shared ? await this.drive.listSettings() : []) await this.drive.deleteFile(file.id); + delete this.state.settings; for (const file of await this.drive.listParts()) await this.drive.deleteFile(file.id); this.partIndexCache = null; for (const file of markers) if (!keepMarkers.has(file.id)) await this.drive.deleteFile(file.id); @@ -873,6 +894,81 @@ class CloudSync { } } + // ---- shared settings (core/shared-settings.js) --------------------------- + + /** + * Global placeholders, branding and language sync like one guide: + * immutable `settings-v1` versions with parents, merged against the + * version every side started from. Returns { pending } when something + * has to wait for the next sync. + */ + async syncShared(check) { + const versions = (await this.drive.listSettings()) + .filter((file) => validId(file.id) && /^[a-f0-9]{64}$/.test(file.appProperties?.hash || '')); + check(); + let record = this.state.settings || null; + if (record?.head && !versions.some((file) => file.id === record.head)) { + // As for guides: removed from the cloud, pruned or replaced, or a + // listing that hasn't caught up with this computer's own upload yet. + if (!versions.length) record = null; + else if (Date.now() - (record.syncedAt || 0) > STALE_HEAD_MS || versions.some((file) => parentsOf(file).includes(record.head))) record = { hash: record.hash }; + else return { pending: true }; + } + if (!this.sharedReady()) return { pending: true }; + const local = this.shared.snapshot(); + const heads = headsOf(versions); + const latest = heads.at(-1); + if (latest && heads.some((head) => !record?.heads?.includes(head.id))) { + if (record?.hash && heads.every((head) => head.appProperties.hash === record.hash)) { + this.state.settings = { ...record, head: latest.id, heads: heads.map((head) => head.id), syncedAt: Date.now() }; + } else { + const localChanged = !record || local.hash !== record.hash; + const tips = [...new Map(heads.map((head) => [head.appProperties.hash, head])).values()]; + const withLocal = localChanged && !tips.some((head) => head.appProperties.hash === local.hash); + const merging = tips.length > 1 || withLocal; + const sides = []; + for (const tip of merging ? tips : [latest]) { + const entries = unzipSync(await this.downloadArchive(tip), { limits: SHARED_LIMITS }); + check(); + if (parts.contentHash(entries) !== tip.appProperties.hash) throw new Error('Synced settings failed their integrity check. Settings here are unchanged.'); + sides.push({ entries, hash: tip.appProperties.hash, time: tip.createdTime || '' }); + } + let incoming = sides[0].entries; + if (merging) { + // A computer that never synced shares no history with the cloud: + // merge without a base, so nothing counts as deleted. + const base = withLocal && !record ? null : await this.mergeBase(this.sharedBasePath(), versions, tips, withLocal ? record : null); + check(); + if (withLocal) sides.push({ entries: local.entries, hash: local.hash, time: local.time }); + incoming = mergeSharedSettings({ base, sides }).entries; + } + // Changed here, or Settings opened, while downloading: try again next time. + if (!this.sharedReady() || this.shared.snapshot().hash !== local.hash) return { pending: true }; + if (parts.contentHash(incoming) !== local.hash) { + const changes = this.shared.apply(incoming); + if (changes.length) this.onSharedChange(changes); + } + this.state.settings = { head: latest.id, heads: heads.map((head) => head.id), hash: latest.appProperties.hash, syncedAt: Date.now(), + ...(merging ? { merged: heads.filter((head) => head.id !== latest.id).map((head) => head.id) } : {}) }; + if (!merging) this.rememberBase(this.sharedBasePath(), incoming); + } + this.saveState(); + } + const baseline = this.state.settings || null; + const absorbed = mergedProperty((baseline?.merged || []).filter((vid) => vid !== baseline.head && validId(vid))); + const current = this.shared.snapshot(); + if (baseline && current.hash === baseline.hash && !absorbed) return { pending: false }; + const entries = current.entries; + const hash = parts.contentHash(entries); + const file = await this.uploadArchive({ data: zipSync(entries), name: 'StepForge settings', + properties: { stepforge: 'settings-v1', hash, ...(baseline?.head ? { parent: baseline.head } : {}), ...(absorbed ? { merged: absorbed } : {}) } }); + check(); + this.state.settings = { head: file.id, heads: [...heads.filter((head) => head.id !== baseline?.head).map((head) => head.id), file.id], hash, syncedAt: Date.now() }; + this.saveState(); + this.rememberBase(this.sharedBasePath(), entries); + return { pending: this.shared.snapshot().hash !== hash }; + } + sync() { if (this.manualCount) return Promise.resolve(this.status); if (this.running) return this.running; @@ -1034,7 +1130,7 @@ class CloudSync { downloads.push({ tip, bytes: await this.downloadVersion(tip) }); check(); } - const base = merging ? await this.mergeBase(id, versions, tips, withLocal ? record : null) : null; + const base = merging ? await this.mergeBase(this.basePath(id), versions, tips, withLocal ? record : null) : null; check(); if (!this.canReplace(id) || this.store.guideExists(id) !== exists || (exists && this.localSnapshot(id).hash !== local.hash)) { pending = true; continue; } @@ -1062,7 +1158,7 @@ class CloudSync { this.state.records[id] = { head: latest.id, heads: heads.map((h) => h.id), hash: latest.appProperties.hash, syncedAt: Date.now(), ...(merging ? { merged: heads.filter((h) => h.id !== latest.id).map((h) => h.id) } : {}) }; this.saveState(); - if (!merging) this.rememberBase(id, unzipSync(incoming)); + if (!merging) this.rememberBase(this.basePath(id), unzipSync(incoming)); } if (!exists) continue; const baseline = Object.hasOwn(this.state.records, id) ? this.state.records[id] : null; @@ -1095,7 +1191,7 @@ class CloudSync { check(); this.state.records[id] = { head: file.id, heads: [...heads.filter((h) => h.id !== baseline?.head).map((h) => h.id), file.id], hash: local.hash, syncedAt: Date.now() }; this.saveState(); - this.rememberBase(id, uploaded); + this.rememberBase(this.basePath(id), uploaded); // If edits happened during upload, this baseline describes only uploaded bytes. if (this.store.guideExists(id) && this.localSnapshot(id).hash !== local.hash) pending = true; } @@ -1104,12 +1200,25 @@ class CloudSync { errors.push(err.message); } } + let sharedError = null; + let sharedPending = false; + if (this.shared) { + try { + check(); + sharedPending = (await this.syncShared(check)).pending; + } catch (err) { + check(); + sharedError = err.message; + } + } if (errors.length) this.publish('error', `${errors.length} guide(s) could not sync: ${errors[0]}`); + else if (sharedError) this.publish('error', `Placeholders and branding could not sync: ${sharedError}`); else if (pending) this.publish('pending', 'Changes pending. Incoming updates wait until the guide is closed.'); + else if (sharedPending) this.publish('pending', this.sharedReady() ? 'Changes waiting to sync.' : 'Placeholders and branding sync when Settings closes.'); else this.publish('synced', mergeMessage(this.label, merged, mergeConflicts), { lastSync: new Date().toISOString() }); // Snapshots are immutable. Retain the current version and two prior // versions on every live conflict branch after a successful sync. - if (!errors.length && !pending) await this.pruneNow(); + if (!errors.length && !sharedError && !pending && !sharedPending) await this.pruneNow(); return this.status; } diff --git a/core/folder-storage.js b/core/folder-storage.js index ced99165..fc286acc 100644 --- a/core/folder-storage.js +++ b/core/folder-storage.js @@ -15,7 +15,7 @@ const { writeJsonSync, readJsonIfExists } = require('./util'); * * parts/ a shared part (core/cloud-parts.js), named by its hash, * so listing the folder is all a sync needs. - * objects/.bin the data of a version or deletion record. + * objects/.bin the data of a version, settings or deletion record. * objects/.json its name, time, size and properties. Written last, so * a file only counts once its data is complete. Small * data such as deletion records lives inside it. @@ -80,8 +80,8 @@ class FolderStorage { return { id, name: details.name, createdTime: details.createdTime, size: String(size ?? details.size ?? 0), appProperties: { ...details.appProperties } }; } - // Versions and deletion records together. listVersions and listDeletions - // run side by side, so they share one listing. + // Versions, deletion records and settings together. They're listed side by + // side, so they share one listing. objects() { this.listing ||= (async () => { const entries = await this.backend.list('objects'); @@ -130,6 +130,10 @@ class FolderStorage { return (await this.objects()).filter((file) => file.appProperties.stepforge === 'deletion-v1'); } + async listSettings() { + return (await this.objects()).filter((file) => file.appProperties.stepforge === 'settings-v1'); + } + async listParts() { return (await this.backend.list('parts')).filter((entry) => SHA.test(entry.name)).map((entry) => ({ id: `${PART_PREFIX}${entry.name}`, name: `part-${entry.name}`, createdTime: isoTime(entry.modified), diff --git a/core/shared-settings.js b/core/shared-settings.js new file mode 100644 index 00000000..b350c2cf --- /dev/null +++ b/core/shared-settings.js @@ -0,0 +1,314 @@ +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); +const { DEFAULT_SETTINGS } = require('./settings'); +const { normalizeProfile, profileAssetIds } = require('./branding'); +const { contentHash } = require('./cloud-parts'); + +/* + * Settings that follow you to every computer that syncs: global + * placeholders, brand profiles (with their logos, banners and fonts), the + * active brand profile, and the interface language. The rest of Settings + * describes one computer (capture, hotkeys, storage, AI) and stays on it. + * + * Cloud sync (core/cloud-sync.js) keeps them as one archive: + * placeholders.json global placeholders, by name + * settings.json language and activeProfileId, each only + * when it isn't the default, so a new + * computer's defaults never win a merge + * branding/profiles/.json one brand profile each + * branding/assets/.json an asset's kind, extension and file name + * branding/assets/. the image or font + * branding/assets/.raster.png its PNG drawing, when it has one + * Only assets a profile uses are included. + * + * Merging works like guides (core/guide-merge.js): each placeholder, setting + * and profile is compared with the version every side started from. Changes + * to different ones combine; when sides changed the same one differently, + * the newest wins, and an edit beats a deletion. Without a base nothing is + * treated as deleted. + */ + +const SHARED_KEYS = { language: 'language', activeProfileId: 'branding.activeProfileId' }; +const DEFAULTS = { language: DEFAULT_SETTINGS.language, activeProfileId: DEFAULT_SETTINGS.branding.activeProfileId }; + +const PLACEHOLDER_NAME = /^[A-Za-z0-9_ .-]{1,200}$/; +const LANGUAGE = /^(system|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})$/; +const PROFILE_ID = /^brand-[A-Za-z0-9-]{1,64}$/; +const ASSET_ID = /^[a-f0-9]{32}$/; +const ASSET_EXTS = { image: ['png', 'jpg', 'svg'], font: ['woff2', 'woff', 'ttf', 'otf'] }; + +const PROFILE_ENTRY = /^branding\/profiles\/([^/]+)\.json$/; +const ASSET_ENTRY = /^branding\/assets\/([a-f0-9]{32})(\.raster\.png|\.[a-z0-9]+)$/; + +// A downloaded archive is checked like any other: no more than a few +// hundred profiles' worth of files. +const UNZIP_LIMITS = { maxEntries: 5000, maxTotalUncompressed: 1024 * 1024 * 1024, maxEntryUncompressed: 128 * 1024 * 1024 }; + +const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0; +const same = (a, b) => isDeepStrictEqual(a, b); +const sortedObject = (obj) => Object.fromEntries(Object.keys(obj).sort(compareText).map((key) => [key, obj[key]])); +const textOf = (entry) => (Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data)); + +function emptyModel() { + return { placeholders: {}, settings: {}, profiles: {}, assets: {} }; +} + +/** A placeholder value as StepForge stores it, or undefined for anything else. */ +function cleanPlaceholder(value) { + if (typeof value === 'string') return value; + if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.text === 'string') { + return value.format === undefined ? { text: value.text } : { format: String(value.format), text: value.text }; + } + return undefined; +} + +function cleanPlaceholders(raw) { + const out = {}; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out; + for (const [name, value] of Object.entries(raw)) { + const clean = cleanPlaceholder(value); + if (PLACEHOLDER_NAME.test(name) && clean !== undefined) out[name] = clean; + } + return out; +} + +/** The shared settings that aren't the default. */ +function cleanSettings(raw) { + const out = {}; + if (typeof raw?.language === 'string' && LANGUAGE.test(raw.language) && raw.language !== DEFAULTS.language) out.language = raw.language; + if (typeof raw?.activeProfileId === 'string' && PROFILE_ID.test(raw.activeProfileId)) out.activeProfileId = raw.activeProfileId; + return out; +} + +/** Archive entries for a model. The same model always gives the same bytes. */ +function toEntries(model) { + const json = (value) => JSON.stringify(value, null, 2); + const entries = [ + { name: 'placeholders.json', data: json(sortedObject(model.placeholders)) }, + { name: 'settings.json', data: json(sortedObject(model.settings)) }, + ]; + const used = new Set(); + for (const id of Object.keys(model.profiles).sort(compareText)) { + entries.push({ name: `branding/profiles/${id}.json`, data: json(model.profiles[id]) }); + profileAssetIds(model.profiles[id]).forEach((asset) => used.add(asset)); + } + for (const id of [...used].sort(compareText)) { + const asset = model.assets[id]; + if (!asset?.data) continue; + entries.push({ name: `branding/assets/${id}.json`, data: json({ id, kind: asset.kind, ext: asset.ext, name: asset.name }) }); + entries.push({ name: `branding/assets/${id}.${asset.ext}`, data: asset.data }); + if (asset.raster) entries.push({ name: `branding/assets/${id}.raster.png`, data: asset.raster }); + } + return entries; +} + +/** + * Read an archive's entries into a model. Asset bytes may be missing (a + * remembered merge base keeps only hashes). Anything unknown means a newer + * StepForge wrote it, and merging without it would lose it. + */ +function fromEntries(entries) { + const damaged = () => new Error('Synced settings are damaged and can’t be read.'); + const model = emptyModel(); + const parse = (entry) => { + try { return JSON.parse(textOf(entry)); } catch { throw damaged(); } + }; + const files = new Map(); + for (const entry of entries) { + if (entry.name === 'manifest.json') continue; + if (entry.name === 'placeholders.json') model.placeholders = cleanPlaceholders(parse(entry)); + else if (entry.name === 'settings.json') model.settings = cleanSettings(parse(entry)); + else if (PROFILE_ENTRY.test(entry.name)) { + const id = PROFILE_ENTRY.exec(entry.name)[1]; + if (!PROFILE_ID.test(id)) throw damaged(); + const raw = parse(entry); + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw damaged(); + model.profiles[id] = normalizeProfile(raw, { id }); + } else if (ASSET_ENTRY.test(entry.name)) files.set(entry.name, entry); + else throw new Error('These settings were synced by a newer StepForge. Update StepForge on this computer.'); + } + for (const [name, entry] of files) { + const [, id, suffix] = ASSET_ENTRY.exec(name); + if (suffix !== '.json') continue; + const meta = parse(entry); + if (meta?.id !== id || !ASSET_EXTS[meta.kind]?.includes(meta.ext)) throw damaged(); + const data = files.get(`branding/assets/${id}.${meta.ext}`); + const raster = files.get(`branding/assets/${id}.raster.png`); + if (!data) throw damaged(); + model.assets[id] = { kind: meta.kind, ext: meta.ext, name: typeof meta.name === 'string' ? meta.name.slice(0, 160) : '', + data: data.data ?? null, raster: raster ? raster.data ?? null : null }; + } + return model; +} + +/** + * Three-way merge of one kind of item (placeholders, settings, profiles). + * `sides` run oldest to newest; `rank` orders two edits before side order. + */ +function mergeItems(base, sides, rank = () => '') { + const out = {}; + let conflicts = 0; + const keys = new Set([...Object.keys(base), ...sides.flatMap((side) => Object.keys(side))]); + for (const key of [...keys].sort(compareText)) { + const before = Object.hasOwn(base, key) ? base[key] : undefined; + const changed = sides.map((side, order) => ({ value: Object.hasOwn(side, key) ? side[key] : undefined, order })) + .filter(({ value }) => !same(value, before)); + let value = before; + if (changed.length) { + if (changed.some(({ value: v }) => !same(v, changed[0].value))) conflicts += 1; + const edits = changed.filter(({ value: v }) => v !== undefined); + value = edits.length + ? edits.reduce((best, edit) => (compareText(rank(edit.value), rank(best.value)) >= 0 ? edit : best)).value + : undefined; + } + if (value !== undefined) out[key] = value; + } + return { items: out, conflicts }; +} + +/** + * Merge shared settings. `sides` are [{ entries, time, hash }], where `time` + * is when that side last changed. The result depends only on the base and + * the sides, never on which computer merges. + */ +function mergeSharedSettings({ base = null, sides }) { + const before = base ? fromEntries(base) : emptyModel(); + const models = sides.map((side) => ({ model: fromEntries(side.entries), time: String(side.time || ''), hash: side.hash || '' })) + .sort((a, b) => compareText(a.time, b.time) || compareText(a.hash, b.hash)) + .map(({ model }) => model); + const placeholders = mergeItems(before.placeholders, models.map((m) => m.placeholders)); + const settings = mergeItems(before.settings, models.map((m) => m.settings)); + const profiles = mergeItems(before.profiles, models.map((m) => m.profiles), (profile) => profile.updatedAt || ''); + const merged = { placeholders: placeholders.items, settings: settings.items, profiles: profiles.items, assets: {} }; + // Assets are named by their content, so any side's copy will do; the + // newest side's is used, with the newest PNG drawing any side has. + for (const profile of Object.values(merged.profiles)) { + for (const id of profileAssetIds(profile)) { + const copies = models.map((m) => m.assets[id]).filter((asset) => asset?.data).reverse(); + if (copies.length) merged.assets[id] = { ...copies[0], raster: copies.find((asset) => asset.raster)?.raster || null }; + } + } + return { entries: toEntries(merged), conflicts: placeholders.conflicts + settings.conflicts + profiles.conflicts }; +} + +/** This computer's shared settings, read from and written to Settings and the branding store. */ +class SharedSettings { + constructor({ settings, branding }) { + Object.assign(this, { settings, branding }); + this.cached = null; + } + + /** The model of what's saved on this computer now. */ + read() { + const model = emptyModel(); + model.placeholders = cleanPlaceholders(this.settings.getGlobalPlaceholders()); + model.settings = cleanSettings(Object.fromEntries(Object.entries(SHARED_KEYS).map(([key, keyPath]) => [key, this.settings.get(keyPath)]))); + for (const { id } of this.branding.list()) { + const profile = this.branding.get(id); + if (profile) model.profiles[id] = profile; + } + for (const profile of Object.values(model.profiles)) { + for (const id of profileAssetIds(profile)) { + const meta = this.branding.assetMeta(id); + if (!meta || model.assets[id]) continue; + let raster = null; + if (meta.raster) { try { raster = fs.readFileSync(this.branding.rasterFile(meta)); } catch { /* drawn again later */ } } + model.assets[id] = { kind: meta.kind, ext: meta.ext, name: meta.name || '', data: fs.readFileSync(this.branding.assetFile(meta)), raster }; + } + } + return model; + } + + // Sizes and times of every file involved, so an unchanged state isn't + // reread (fonts and logos can be large) on every sync. + signature() { + const stat = (file) => { + try { const s = fs.statSync(file); return `${file}:${s.size}:${s.mtimeMs}:${s.ino}`; } catch { return `${file}:-`; } + }; + const lines = [JSON.stringify(Object.values(SHARED_KEYS).map((keyPath) => this.settings.get(keyPath) ?? null)), stat(this.settings.globalPlaceholdersFile)]; + for (const dir of [this.branding.profilesDir, this.branding.assetsDir]) { + let names = []; + try { names = fs.readdirSync(dir).sort(compareText); } catch { /* none yet */ } + for (const name of names) lines.push(stat(path.join(dir, name))); + } + return lines.join('\n'); + } + + /** When shared settings last changed here, for merges where both computers changed the same thing. */ + modifiedAt() { + let newest = 0; + const files = [this.settings.file, this.settings.globalPlaceholdersFile]; + try { files.push(...fs.readdirSync(this.branding.profilesDir).map((name) => path.join(this.branding.profilesDir, name))); } catch { /* none yet */ } + for (const file of files) { + try { newest = Math.max(newest, fs.statSync(file).mtimeMs); } catch { /* not saved yet */ } + } + return newest ? new Date(newest).toISOString() : ''; + } + + /** { hash, time, entries } of what's saved now; entries are read when asked for. */ + snapshot() { + const key = this.signature(); + if (!this.cached || this.cached.key !== key) { + this.cached = { key, hash: contentHash(toEntries(this.read())) }; + } + const shared = this; + return { hash: this.cached.hash, time: this.modifiedAt(), get entries() { return toEntries(shared.read()); } }; + } + + /** + * Save synced settings on this computer. Every incoming image and font is + * checked again as if uploaded. Returns what changed: any of + * 'placeholders', 'branding', 'language'. + */ + apply(entries) { + const next = fromEntries(entries); + const now = this.read(); + const changes = []; + if (!same(next.placeholders, now.placeholders)) { + this.settings.setGlobalPlaceholders(next.placeholders); + changes.push('placeholders'); + } + // An asset that comes out with another id (a sanitized SVG) is renamed in the profiles. + const renamed = new Map(); + for (const [id, asset] of Object.entries(next.assets)) { + if (this.branding.assetMeta(id)) { + if (asset.raster && !this.branding.assetMeta(id).raster) { + try { this.branding.setRaster(id, asset.raster); } catch { /* drawn again later */ } + } + continue; + } + try { + const added = this.branding.addAsset(asset.data, { kind: asset.kind, fileName: asset.name }); + if (added.id !== id) renamed.set(id, added.id); + if (asset.raster && added.needsRaster) { + try { this.branding.setRaster(added.id, asset.raster); } catch { /* drawn again later */ } + } + } catch { /* refused; profiles that use it go without */ } + } + const rename = (id) => (id ? renamed.get(id) || id : id); + const profiles = Object.values(next.profiles).map((profile) => ({ + ...profile, logo: rename(profile.logo), banner: rename(profile.banner), + fonts: { heading: { ...profile.fonts.heading, asset: rename(profile.fonts.heading.asset) }, body: { ...profile.fonts.body, asset: rename(profile.fonts.body.asset) } }, + })); + if (!same(next.profiles, now.profiles) || renamed.size || Object.keys(next.assets).some((id) => !now.assets[id])) { + this.branding.replaceProfiles(profiles); + changes.push('branding'); + } + const activeProfileId = next.settings.activeProfileId && this.branding.get(next.settings.activeProfileId) ? next.settings.activeProfileId : DEFAULTS.activeProfileId; + if (this.settings.get(SHARED_KEYS.activeProfileId) !== activeProfileId) { + this.settings.set(SHARED_KEYS.activeProfileId, activeProfileId); + if (!changes.includes('branding')) changes.push('branding'); + } + const language = next.settings.language || DEFAULTS.language; + if (this.settings.get(SHARED_KEYS.language) !== language) { + this.settings.set(SHARED_KEYS.language, language); + changes.push('language'); + } + return changes; + } +} + +module.exports = { SharedSettings, mergeSharedSettings, toEntries, fromEntries, UNZIP_LIMITS }; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8428cc04..ca60a4f1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -243,6 +243,17 @@ opt-in and off by default: time in a background worker shared with linked-archive writes and automatic backups. See [GOOGLE_DRIVE.md](GOOGLE_DRIVE.md) and [SECURITY.md](SECURITY.md). +- **Synced settings** (`core/shared-settings.js`): global placeholders, brand + profiles with the assets they use, the active profile and the language + travel as one `settings-v1` archive per version, versioned and pruned like + a guide, with the same parents, heads and `merged` property. Placeholders, + settings and profiles merge three ways by key (newest side wins a conflict, + a profile by its `updatedAt`, and an edit beats a deletion); assets are + content-addressed, so they follow the profiles. Settings left at their + default aren't stored, so a new computer's defaults never win a merge. + The base is `cloud/settings-base.json`. Incoming settings wait while the + Settings dialog is open, and every image and font is re-checked by + `BrandingStore.addAsset`. - **OneDrive, Dropbox and Nextcloud/WebDAV** (`app/onedrive.js`, `app/dropbox.js`, `app/webdav.js` on `app/cloud-account.js`) plug into the same `CloudSync`. These services have no per-file properties, so diff --git a/docs/BRANDING.md b/docs/BRANDING.md index ff0ee44d..3b4264d0 100644 --- a/docs/BRANDING.md +++ b/docs/BRANDING.md @@ -279,3 +279,6 @@ cleaned of scripts, links and anything else that isn't drawing. Profiles live in your data folder under `settings/branding/` (see **Settings → About → Data folder**). Logos, banners and fonts are kept beside them. An upload that no profile uses any more is deleted after a day. + +With [cloud sync](GOOGLE_DRIVE.md#settings-that-sync) on, your profiles, their +files and the active profile follow you to every computer that syncs. diff --git a/docs/CLOUD_ACCOUNTS.md b/docs/CLOUD_ACCOUNTS.md index 2ee387bd..ea871fbb 100644 --- a/docs/CLOUD_ACCOUNTS.md +++ b/docs/CLOUD_ACCOUNTS.md @@ -27,8 +27,9 @@ an operating-system credential store, make sure a keyring is installed and unlocked, then restart StepForge. Sync works the same way on every service: versions, restoring, combining -edits from different computers, recently deleted guides, and **Free up space** -are all described in +edits from different computers, the [settings that sync](GOOGLE_DRIVE.md#settings-that-sync) +with your guides, recently deleted guides, and **Free up space** are all +described in [Google Drive sync](GOOGLE_DRIVE.md). Every service stores only what changed between versions, so a screenshot that appears in many versions is stored once. diff --git a/docs/GOOGLE_DRIVE.md b/docs/GOOGLE_DRIVE.md index ad1714a3..8c93993b 100644 --- a/docs/GOOGLE_DRIVE.md +++ b/docs/GOOGLE_DRIVE.md @@ -2,7 +2,8 @@ Google Drive sync keeps your StepForge library the same on every computer you use. Sign in once on each machine and your guides, including screenshots, -annotations, and descriptions, follow you. +annotations, and descriptions, follow you, along with the +[settings guides depend on](#settings-that-sync). Sync is **optional and off by default**. StepForge works fully offline without it. @@ -76,6 +77,27 @@ or open the Drive settings, to see the guide's name and the transfer speed. - **Going offline is fine.** Local edits are kept and sync resumes when you're back online. +### Settings that sync + +So a guide exports the same on every computer, these settings sync with it: + +- **Global placeholders** (**Settings → Placeholders**) +- **Brand profiles**, with their logos, banners and fonts, and which profile + is active (**Settings → Branding**) + +Everything else in Settings, such as capture options, shortcuts, AI and guide +storage, stays on each computer. + +They're combined the same way as guides: changes to different placeholders or +profiles on different computers all survive, a placeholder or profile deleted +on one computer is deleted on the others, and when both computers changed the +same one, the most recent change is kept. A computer syncing for the first +time adds its placeholders and profiles to the ones already in the cloud, +and its default settings never replace a choice made on another computer. + +Changes from another computer wait while **Settings** is open, so saving it +can't undo them, and arrive when you close it. + ## Manage what's in Drive **Settings → Accounts → Google Drive** also lets you see and tidy up your Drive storage. @@ -134,7 +156,7 @@ If your computers have drifted apart and you want one of them to win, use 1. Deletes everything StepForge has stored in Drive, including all versions and recovery copies. -2. Uploads the guides on this computer. +2. Uploads the guides and [settings](#settings-that-sync) on this computer. 3. Tells your other computers to move any guide that isn't on this computer to their trash, and to update the rest to this computer's version. Unsynced edits on those computers are combined with this computer's version. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index ca1e7436..c974f955 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -127,8 +127,10 @@ Drive sync is **off by default**. When you sign in files it creates in its own hidden folder in your Drive. It can't see your other Drive files. - **Your whole library is uploaded**: screenshots, text, annotations, - placeholders, and the capture details listed above. It goes to that private - folder in *your* Google Drive, not to StepForge or anyone else. + placeholders, and the capture details listed above, along with your global + placeholders and brand profiles (with their logos, banners and fonts). It + goes to that private folder in *your* Google Drive, + not to StepForge or anyone else. The rest of Settings isn't uploaded. Screenshots are stored once and shared between versions, labelled with a fingerprint (SHA-256 hash) of their contents so each is only uploaded once. - StepForge also reads your Google account's email address, profile photo, and @@ -137,7 +139,8 @@ Drive sync is **off by default**. When you sign in - Data travels over HTTPS. It isn't additionally encrypted by StepForge. - To combine edits made on different computers, StepForge keeps the text of each synced guide as it was last synced, with screenshot fingerprints but no - screenshots, in the `cloud` folder of your [data folder](#where-your-data-lives). + screenshots, in the `cloud` folder of your [data folder](#where-your-data-lives), + and the synced settings the same way, without their logos, banners or fonts. Before it combines unsynced edits, it saves your version as one of the guide's local backups. - Your Google token is stored encrypted with your operating system's @@ -158,7 +161,7 @@ Drive sync is **off by default**. When you sign in ## Optional OneDrive, Dropbox and Nextcloud sync These work like Google Drive sync above: off by default, your whole library -is uploaded to *your* account and nowhere else, screenshots are stored once +and the same settings are uploaded to *your* account and nowhere else, screenshots are stored once by SHA-256 fingerprint, and sign-in details are stored encrypted with your operating system's credential storage. StepForge syncs with one service at a time. [How it works](CLOUD_ACCOUNTS.md). diff --git a/tests/unit/agent-brand-tools.test.js b/tests/unit/agent-brand-tools.test.js index 1a9628e0..f002af81 100644 --- a/tests/unit/agent-brand-tools.test.js +++ b/tests/unit/agent-brand-tools.test.js @@ -222,7 +222,7 @@ test('the app applies an agent’s brand changes and draws the images they need' assert.match(mcp, /onBrandingChange: \(change\) => recordBrandingChange\(store\.libraryDir, change\)/); const app = fs.readFileSync(path.join(ROOT, 'app/renderer/app.js'), 'utf8'); assert.match(app, /api\.branding\.onChangedByAgent\(/); - assert.equal((app.match(/ensurePendingBrandRasters\(api\)/g) || []).length, 2, 'on start, and when an agent changes a profile'); + assert.equal((app.match(/ensurePendingBrandRasters\(api\)/g) || []).length, 3, 'on start, when an agent changes a profile, and when profiles sync'); const dialogs = fs.readFileSync(path.join(ROOT, 'app/renderer/dialogs.js'), 'utf8'); assert.match(dialogs, /branding: agentsBranding\.checked/); }); diff --git a/tests/unit/folder-storage.test.js b/tests/unit/folder-storage.test.js index 39bd8166..6fa0243b 100644 --- a/tests/unit/folder-storage.test.js +++ b/tests/unit/folder-storage.test.js @@ -43,7 +43,7 @@ function account(backend, root, name) { }); } -test('versions, deletion records and parts round-trip with their properties', async (t) => { +test('versions, deletion records, settings and parts round-trip with their properties', async (t) => { const root = makeTmpDir('folder-storage'); t.after(() => rmrf(root)); const backend = memoryBackend(); @@ -64,6 +64,10 @@ test('versions, deletion records and parts round-trip with their properties', as assert.match(versions[0].createdTime, /^\d{4}-\d\d-\d\dT/); assert.deepEqual((await storage.listDeletions()).map((file) => file.appProperties.state), ['deleted']); assert.deepEqual((await storage.listParts()).map((file) => [file.id, file.appProperties.sha, file.size]), [[part.id, sha, '4']]); + assert.deepEqual(await storage.listSettings(), []); + const settings = await storage.upload({ data: Buffer.from('zip'), name: 'StepForge settings', properties: { stepforge: 'settings-v1', hash: 'c'.repeat(64) } }); + assert.deepEqual((await storage.listSettings()).map((file) => [file.id, file.appProperties.hash]), [[settings.id, 'c'.repeat(64)]]); + assert.equal((await storage.listVersions()).length, 1, 'settings aren’t guide versions'); assert.deepEqual(await storage.download(version.id), big); assert.deepEqual((await storage.download(marker.id)).toString(), '{}'); diff --git a/tests/unit/shared-settings.test.js b/tests/unit/shared-settings.test.js new file mode 100644 index 00000000..8be64bf1 --- /dev/null +++ b/tests/unit/shared-settings.test.js @@ -0,0 +1,225 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { GuideStore } = require('../../core/store'); +const { CloudSync } = require('../../core/cloud-sync'); +const { Settings } = require('../../core/settings'); +const { BrandingStore } = require('../../core/branding'); +const { SharedSettings, mergeSharedSettings, toEntries, fromEntries } = require('../../core/shared-settings'); +const { zipSync, unzipSync } = require('../../core/zip'); +const parts = require('../../core/cloud-parts'); +const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers'); + +const SVG = Buffer.from(''); + +function setup(t) { + const root = makeTmpDir('shared-settings'); + t.after(() => rmrf(root)); + const files = []; + const bytes = new Map(); + let nextFileId = 0; + const drive = { + clientId: 'test-client', + status: () => ({ connected: true }), + account: async () => 'account-a', + cancel() {}, + async listVersions() { return files.filter((file) => file.appProperties?.stepforge === 'guide-v1'); }, + async listDeletions() { return files.filter((file) => file.appProperties?.stepforge === 'deletion-v1'); }, + async listParts() { return files.filter((file) => file.appProperties?.stepforge === 'part-v1'); }, + async listSettings() { return files.filter((file) => file.appProperties?.stepforge === 'settings-v1'); }, + async upload({ data, name, properties }) { + const file = { id: `file-${++nextFileId}`, name, appProperties: properties, size: String(data.length), + createdTime: new Date(Date.UTC(2026, 0, 1) + nextFileId * 1000).toISOString() }; + files.push(file); bytes.set(file.id, data); return file; + }, + async download(id) { return bytes.get(id); }, + async deleteFile(id) { + const index = files.findIndex((file) => file.id === id); + if (index >= 0) files.splice(index, 1); + bytes.delete(id); + }, + }; + function device(name) { + const store = new GuideStore(path.join(root, name)); + const settings = new Settings(store.settingsDir); + const branding = new BrandingStore(store.settingsDir); + const changes = []; + const d = { store, settings, branding, changes, ready: true }; + d.sync = new CloudSync({ store, drive, enabled: () => true, settleMs: 0, + shared: new SharedSettings({ settings, branding }), sharedReady: () => d.ready, onSharedChange: (c) => changes.push(c) }); + t.after(() => d.sync.stop()); + return d; + } + return { drive, files, bytes, device }; +} + +const settingsFiles = (files) => files.filter((file) => file.appProperties?.stepforge === 'settings-v1'); +async function synced(sync) { const status = await sync.sync(); assert.notEqual(status.phase, 'error', status.message); return status; } +// Merges let the most recent change win; date this computer's changes before +// any cloud version (the fake cloud's clock starts in 2026). +function changedLongAgo(d) { + const old = new Date(Date.UTC(2020, 0, 1)); + for (const file of [d.settings.file, d.settings.globalPlaceholdersFile]) if (fs.existsSync(file)) fs.utimesSync(file, old, old); +} + +test('global placeholders, brand profiles with their files, the active profile and language reach another computer', async (t) => { + const { device, files } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.setGlobalPlaceholders({ Company: 'Acme', Support: { format: 'markdown', text: '**Call** us' } }); + const logo = a.branding.addAsset(TINY_PNG, { kind: 'image', fileName: 'logo.png' }); + const banner = a.branding.addAsset(SVG, { kind: 'image', fileName: 'banner.svg' }); + a.branding.setRaster(banner.id, TINY_PNG); + const profile = a.branding.save({ name: 'Acme', logo: logo.id, banner: banner.id, colors: { primary: '#112233' } }); + a.settings.set('branding.activeProfileId', profile.id); + a.settings.set('language', 'fr'); + + await synced(a.sync); await synced(b.sync); + + assert.deepEqual(b.settings.getGlobalPlaceholders(), { Company: 'Acme', Support: { format: 'markdown', text: '**Call** us' } }); + assert.deepEqual(b.branding.get(profile.id), a.branding.get(profile.id), 'the profile arrives with its own timestamps'); + assert.equal(b.branding.describe(logo.id).name, 'logo.png'); + assert.equal(b.branding.describe(banner.id).needsRaster, false, 'the PNG drawing of the SVG comes along'); + assert.equal(b.settings.get('branding.activeProfileId'), profile.id); + assert.equal(b.settings.get('language'), 'fr'); + assert.deepEqual(b.changes, [['placeholders', 'branding', 'language']]); + + // Both sides now agree: nothing more is uploaded. + await synced(a.sync); await synced(b.sync); await synced(a.sync); + assert.equal(settingsFiles(files).length, 1); +}); + +test('changes on two computers combine, and a deletion reaches the other computer', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.setGlobalPlaceholders({ Company: 'Acme', Old: 'gone soon' }); + await synced(a.sync); await synced(b.sync); + + a.settings.setGlobalPlaceholders({ Company: 'Acme', Old: 'gone soon', Phone: '555' }); + const profile = b.branding.save({ name: 'From B' }); + b.settings.setGlobalPlaceholders({ Company: 'Acme', Email: 'help@acme.test' }); + await synced(a.sync); await synced(b.sync); await synced(a.sync); + + const expected = { Company: 'Acme', Email: 'help@acme.test', Phone: '555' }; + assert.deepEqual(a.settings.getGlobalPlaceholders(), expected); + assert.deepEqual(b.settings.getGlobalPlaceholders(), expected); + assert.equal(a.branding.get(profile.id)?.name, 'From B'); + + b.branding.remove(profile.id); + await synced(b.sync); await synced(a.sync); + assert.equal(a.branding.get(profile.id), null); +}); + +test('when both computers change the same placeholder, the newest change wins', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.setGlobalPlaceholders({ Company: 'Acme' }); + await synced(a.sync); await synced(b.sync); + + b.settings.setGlobalPlaceholders({ Company: 'Acme Ltd' }); + changedLongAgo(b); + a.settings.setGlobalPlaceholders({ Company: 'Acme Inc' }); + await synced(a.sync); await synced(b.sync); await synced(a.sync); + assert.equal(b.settings.getGlobalPlaceholders().Company, 'Acme Inc'); + assert.equal(a.settings.getGlobalPlaceholders().Company, 'Acme Inc'); +}); + +test('a new computer’s default settings never replace another computer’s choices', async (t) => { + const { device } = setup(t); + const a = device('a'); + a.settings.set('language', 'de'); + await synced(a.sync); + // B starts later with every default, and its settings file is newer. + const b = device('b'); + b.settings.set('appearance', 'dark'); + b.settings.setGlobalPlaceholders({ Laptop: 'B' }); + await synced(b.sync); await synced(a.sync); + assert.equal(b.settings.get('language'), 'de'); + assert.equal(a.settings.get('language'), 'de'); + assert.equal(a.settings.get('appearance'), 'system', 'other settings stay on each computer'); + assert.deepEqual(a.settings.getGlobalPlaceholders(), { Laptop: 'B' }); +}); + +test('incoming settings wait while Settings is open, then sync', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.setGlobalPlaceholders({ Company: 'Acme' }); + await synced(a.sync); + b.ready = false; + const status = await b.sync.sync(); + assert.equal(status.phase, 'pending'); + assert.match(status.message, /Placeholders and branding sync when Settings closes/); + assert.deepEqual(b.settings.getGlobalPlaceholders(), {}); + b.ready = true; + await synced(b.sync); + assert.deepEqual(b.settings.getGlobalPlaceholders(), { Company: 'Acme' }); +}); + +test('settings saved by a newer StepForge are left alone', async (t) => { + const { device, drive } = setup(t); + const b = device('b'); + b.settings.setGlobalPlaceholders({ Mine: 'kept' }); + const entries = [...toEntries({ placeholders: { Theirs: 'x' }, settings: {}, profiles: {}, assets: {} }), { name: 'templates/one.json', data: '{}' }]; + await drive.upload({ data: zipSync(entries), name: 'StepForge settings', properties: { stepforge: 'settings-v1', hash: parts.contentHash(entries) } }); + const status = await b.sync.sync(); + assert.equal(status.phase, 'error'); + assert.match(status.message, /newer StepForge/); + assert.deepEqual(b.settings.getGlobalPlaceholders(), { Mine: 'kept' }); +}); + +test('a settings version whose content doesn’t match its hash is refused', async (t) => { + const { device, drive } = setup(t); + const b = device('b'); + const entries = toEntries({ placeholders: { Theirs: 'x' }, settings: {}, profiles: {}, assets: {} }); + await drive.upload({ data: zipSync(entries), name: 'StepForge settings', properties: { stepforge: 'settings-v1', hash: 'a'.repeat(64) } }); + const status = await b.sync.sync(); + assert.equal(status.phase, 'error'); + assert.match(status.message, /integrity check/); + assert.deepEqual(b.settings.getGlobalPlaceholders(), {}); +}); + +test('old settings versions are pruned and Replace cloud with local replaces them too', async (t) => { + const { device, files } = setup(t); + const a = device('a'); const b = device('b'); + for (let i = 1; i <= 5; i++) { + a.settings.setGlobalPlaceholders({ Count: String(i) }); + await synced(a.sync); + } + assert.equal(settingsFiles(files).length, 3, 'the current version and two before it'); + await synced(b.sync); + b.settings.setGlobalPlaceholders({ Only: 'B' }); + await b.sync.replaceCloudWithLocal(); + assert.equal(settingsFiles(files).length, 1); + a.sync.state.settings.syncedAt = 0; // long enough ago that it isn't the cloud lagging + await synced(a.sync); + assert.deepEqual(a.settings.getGlobalPlaceholders(), { Only: 'B' }, 'B’s settings are the cloud’s now'); +}); + +test('merging is the same whichever computer does it', () => { + const base = toEntries({ placeholders: { A: '1', B: '2' }, settings: {}, profiles: {}, assets: {} }); + const one = { entries: toEntries({ placeholders: { A: 'one', B: '2' }, settings: { language: 'fr' }, profiles: {}, assets: {} }), time: '2026-01-02T00:00:00.000Z', hash: '1' }; + const two = { entries: toEntries({ placeholders: { A: 'two' }, settings: {}, profiles: {}, assets: {} }), time: '2026-01-03T00:00:00.000Z', hash: '2' }; + const forward = mergeSharedSettings({ base, sides: [one, two] }); + const backward = mergeSharedSettings({ base, sides: [two, one] }); + assert.deepEqual(forward.entries, backward.entries); + assert.equal(forward.conflicts, 1); + assert.deepEqual(fromEntries(forward.entries).placeholders, { A: 'two' }, 'B was deleted, A took the newest edit'); + assert.deepEqual(fromEntries(forward.entries).settings, { language: 'fr' }); +}); + +test('the archive round-trips and only includes files a profile uses', (t) => { + const root = makeTmpDir('shared-settings-entries'); + t.after(() => rmrf(root)); + const settings = new Settings(root); + const branding = new BrandingStore(root); + const used = branding.addAsset(TINY_PNG, { kind: 'image', fileName: 'used.png' }); + branding.addAsset(SVG, { kind: 'image', fileName: 'unused.svg' }); + branding.save({ name: 'One', logo: used.id }); + const shared = new SharedSettings({ settings, branding }); + const entries = shared.snapshot().entries; + assert.deepEqual(entries.filter((e) => e.name.startsWith('branding/assets/')).map((e) => e.name).sort(), + [`branding/assets/${used.id}.json`, `branding/assets/${used.id}.png`, `branding/assets/${used.id}.raster.png`]); + const again = toEntries(fromEntries(unzipSync(zipSync(entries)))); + assert.equal(parts.contentHash(again), shared.snapshot().hash); +}); From 466f3348590287ab559e9644cd6ffc3e41228b12 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 10:55:22 -0500 Subject: [PATCH 2/4] Fix shared settings validation and cross-device convergence --- app/renderer/app.js | 9 ++- core/branding.js | 61 ++++++++------- core/cloud-sync.js | 2 +- core/shared-settings.js | 90 +++++++++++++++++------ tests/unit/shared-settings.test.js | 114 +++++++++++++++++++++++++++++ 5 files changed, 225 insertions(+), 51 deletions(-) diff --git a/app/renderer/app.js b/app/renderer/app.js index cc540710..e733d1a3 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -66,7 +66,7 @@ class StepForgeApp { this.cloudStatus.addEventListener('click', () => this.openSettings(!this.cloudProvider || this.cloudProvider === 'google' ? 'drive' : this.cloudProvider)); api.cloud.onStatus((status) => this.renderCloudStatus(status)); api.cloud.onLibraryChanged(() => this.refreshLibrary().catch(console.error)); - api.cloud.onSettingsChanged(() => this.onSettingsSynced().catch(console.error)); + api.cloud.onSettingsChanged((payload) => this.onSettingsSynced(payload?.changes).catch(console.error)); api.cloud.status().then((status) => this.renderCloudStatus(status)).catch(console.error); this.libraryRenderToken = 0; @@ -137,10 +137,15 @@ class StepForgeApp { * computer: pick up the active brand profile, and draw the PNG copies any * new SVG or JPG images need. */ - async onSettingsSynced() { + async onSettingsSynced(changes = []) { this.state.settings = await api.settings.all(); this.editor.setSettings(this.state.settings); await ensurePendingBrandRasters(api); + // When language switching is available, apply a synced choice through + // its handler, saving open edits before it reloads the window. + if (changes.includes('language') && api.app?.setLanguage && await this.flushEditorBeforeNavigation()) { + await api.app?.setLanguage?.({ language: this.state.settings.language }); + } } /** diff --git a/core/branding.js b/core/branding.js index 80c836d2..427298cc 100644 --- a/core/branding.js +++ b/core/branding.js @@ -257,6 +257,37 @@ function familyFromFileName(fileName) { return cleanFontName(base.split(/[-_]/)[0] || base) || 'Brand font'; } +/** Validate and normalize an image or font without writing to the library. */ +function prepareAsset(bytes, { kind, fileName = '' }) { + if (!Buffer.isBuffer(bytes) || !bytes.length) throw new Error('That file is empty.'); + let data = bytes; + let meta; + if (kind === 'image') { + checkFileSize('image', bytes.length); + const type = imageType(bytes); + if (!type) throw new Error('Choose a PNG, JPG or SVG image.'); + let size; + if (type === 'svg') { + data = Buffer.from(sanitizeSvg(bytes), 'utf8'); + size = svgSize(data.toString('utf8')); + } else { + size = type === 'png' ? pngSize(bytes) : jpegSize(bytes); + } + if (!(size.width > 0 && size.height > 0) && type !== 'svg') throw new Error('StepForge couldn’t read the size of that image.'); + meta = { kind, ...IMAGE_TYPES[type], width: size.width || 0, height: size.height || 0 }; + } else if (kind === 'font') { + checkFileSize('font', bytes.length); + const type = fontType(bytes); + if (!type) throw new Error('Choose a WOFF2, WOFF, TTF or OTF font file.'); + meta = { kind, ...FONT_TYPES[type], family: fontFamilyName(bytes) || familyFromFileName(fileName) }; + } else { + throw new Error('Unknown asset kind.'); + } + const id = crypto.createHash('sha256').update(data).digest('hex').slice(0, 32); + meta = { id, ...meta, name: cleanText(path.basename(String(fileName)), 160), addedAt: nowIso() }; + return { data, meta }; +} + // ---- store --------------------------------------------------------------------- class BrandingStore { @@ -361,32 +392,9 @@ class BrandingStore { * `needsRaster` means the renderer should draw it to a PNG (setRaster). */ addAsset(bytes, { kind, fileName = '' }) { - if (!Buffer.isBuffer(bytes) || !bytes.length) throw new Error('That file is empty.'); - let data = bytes; - let meta; - if (kind === 'image') { - checkFileSize('image', bytes.length); - const type = imageType(bytes); - if (!type) throw new Error('Choose a PNG, JPG or SVG image.'); - let size; - if (type === 'svg') { - data = Buffer.from(sanitizeSvg(bytes), 'utf8'); - size = svgSize(data.toString('utf8')); - } else { - size = type === 'png' ? pngSize(bytes) : jpegSize(bytes); - } - if (!(size.width > 0 && size.height > 0) && type !== 'svg') throw new Error('StepForge couldn’t read the size of that image.'); - meta = { kind, ...IMAGE_TYPES[type], width: size.width || 0, height: size.height || 0 }; - } else if (kind === 'font') { - checkFileSize('font', bytes.length); - const type = fontType(bytes); - if (!type) throw new Error('Choose a WOFF2, WOFF, TTF or OTF font file.'); - meta = { kind, ...FONT_TYPES[type], family: fontFamilyName(bytes) || familyFromFileName(fileName) }; - } else { - throw new Error('Unknown asset kind.'); - } - const id = crypto.createHash('sha256').update(data).digest('hex').slice(0, 32); - meta = { id, ...meta, name: cleanText(path.basename(String(fileName)), 160), addedAt: nowIso() }; + const { data, meta: prepared } = prepareAsset(bytes, { kind, fileName }); + let meta = prepared; + const { id } = meta; fs.mkdirSync(this.assetsDir, { recursive: true }); const file = this.assetFile(meta); if (!fs.existsSync(file)) atomicWriteFileSync(file, data); @@ -650,6 +658,7 @@ module.exports = { BrandingStore, normalizeProfile, profileAssetIds, + prepareAsset, loadBranding, brandTemplateDefaults, checkFileSize, diff --git a/core/cloud-sync.js b/core/cloud-sync.js index ce658835..3bbcbe75 100644 --- a/core/cloud-sync.js +++ b/core/cloud-sync.js @@ -910,7 +910,7 @@ class CloudSync { if (record?.head && !versions.some((file) => file.id === record.head)) { // As for guides: removed from the cloud, pruned or replaced, or a // listing that hasn't caught up with this computer's own upload yet. - if (!versions.length) record = null; + if (!versions.length) { record = null; delete this.state.settings; } else if (Date.now() - (record.syncedAt || 0) > STALE_HEAD_MS || versions.some((file) => parentsOf(file).includes(record.head))) record = { hash: record.hash }; else return { pending: true }; } diff --git a/core/shared-settings.js b/core/shared-settings.js index b350c2cf..47b16f23 100644 --- a/core/shared-settings.js +++ b/core/shared-settings.js @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { isDeepStrictEqual } = require('node:util'); const { DEFAULT_SETTINGS } = require('./settings'); -const { normalizeProfile, profileAssetIds } = require('./branding'); +const { normalizeProfile, profileAssetIds, prepareAsset } = require('./branding'); const { contentHash } = require('./cloud-parts'); /* @@ -50,6 +50,17 @@ const UNZIP_LIMITS = { maxEntries: 5000, maxTotalUncompressed: 1024 * 1024 * 102 const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0; const same = (a, b) => isDeepStrictEqual(a, b); const sortedObject = (obj) => Object.fromEntries(Object.keys(obj).sort(compareText).map((key) => [key, obj[key]])); +const newerSettings = () => new Error('These settings were synced by a newer StepForge. Update StepForge on this computer.'); +const plainObject = (value) => value && typeof value === 'object' && !Array.isArray(value); + +// Refuse fields an older client would silently strip when publishing a merge. +function checkKnownFields(raw, shape) { + for (const [key, value] of Object.entries(raw)) { + if (!Object.hasOwn(shape, key)) throw newerSettings(); + if (plainObject(value) && plainObject(shape[key])) checkKnownFields(value, shape[key]); + } +} + const textOf = (entry) => (Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data)); function emptyModel() { @@ -117,24 +128,42 @@ function fromEntries(entries) { try { return JSON.parse(textOf(entry)); } catch { throw damaged(); } }; const files = new Map(); + const names = new Set(); for (const entry of entries) { + if (names.has(entry.name)) throw damaged(); + names.add(entry.name); if (entry.name === 'manifest.json') continue; - if (entry.name === 'placeholders.json') model.placeholders = cleanPlaceholders(parse(entry)); - else if (entry.name === 'settings.json') model.settings = cleanSettings(parse(entry)); - else if (PROFILE_ENTRY.test(entry.name)) { + if (entry.name === 'placeholders.json') { + const raw = parse(entry); + if (!plainObject(raw)) throw damaged(); + for (const value of Object.values(raw)) { + if (plainObject(value)) checkKnownFields(value, { text: '', format: '' }); + } + model.placeholders = cleanPlaceholders(raw); + } else if (entry.name === 'settings.json') { + const raw = parse(entry); + if (!plainObject(raw)) throw damaged(); + checkKnownFields(raw, SHARED_KEYS); + model.settings = cleanSettings(raw); + } else if (PROFILE_ENTRY.test(entry.name)) { const id = PROFILE_ENTRY.exec(entry.name)[1]; if (!PROFILE_ID.test(id)) throw damaged(); const raw = parse(entry); - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw damaged(); + if (!plainObject(raw)) throw damaged(); + if (raw.schemaVersion !== undefined && raw.schemaVersion !== 1) throw newerSettings(); + checkKnownFields(raw, normalizeProfile({}, { id, createdAt: 'schema', updatedAt: 'schema' })); model.profiles[id] = normalizeProfile(raw, { id }); } else if (ASSET_ENTRY.test(entry.name)) files.set(entry.name, entry); - else throw new Error('These settings were synced by a newer StepForge. Update StepForge on this computer.'); + else throw newerSettings(); } + if (!names.has('placeholders.json') || !names.has('settings.json')) throw damaged(); for (const [name, entry] of files) { const [, id, suffix] = ASSET_ENTRY.exec(name); if (suffix !== '.json') continue; const meta = parse(entry); - if (meta?.id !== id || !ASSET_EXTS[meta.kind]?.includes(meta.ext)) throw damaged(); + if (!plainObject(meta)) throw damaged(); + checkKnownFields(meta, { id: '', kind: '', ext: '', name: '' }); + if (meta.id !== id || !Object.hasOwn(ASSET_EXTS, meta.kind) || !ASSET_EXTS[meta.kind].includes(meta.ext)) throw damaged(); const data = files.get(`branding/assets/${id}.${meta.ext}`); const raster = files.get(`branding/assets/${id}.raster.png`); if (!data) throw damaged(); @@ -266,27 +295,44 @@ class SharedSettings { apply(entries) { const next = fromEntries(entries); const now = this.read(); - const changes = []; - if (!same(next.placeholders, now.placeholders)) { - this.settings.setGlobalPlaceholders(next.placeholders); - changes.push('placeholders'); + // Validate every required asset before changing placeholders or profiles. + // Otherwise a refused image could be turned into a deleted logo and synced + // back to every other computer as a successful settings version. + const prepared = new Map(); + for (const [id, asset] of Object.entries(next.assets)) { + try { prepared.set(id, prepareAsset(asset.data, { kind: asset.kind, fileName: asset.name })); } + catch (error) { throw new Error(`Synced brand asset ${id} could not be read: ${error.message}`); } } + for (const profile of Object.values(next.profiles)) { + for (const id of profileAssetIds(profile)) { + if (!prepared.has(id)) throw new Error(`Synced brand asset ${id} is missing. Settings here are unchanged.`); + } + } + const changes = []; // An asset that comes out with another id (a sanitized SVG) is renamed in the profiles. const renamed = new Map(); for (const [id, asset] of Object.entries(next.assets)) { - if (this.branding.assetMeta(id)) { - if (asset.raster && !this.branding.assetMeta(id).raster) { - try { this.branding.setRaster(id, asset.raster); } catch { /* drawn again later */ } - } - continue; + const { data, meta } = prepared.get(id); + const known = this.branding.assetMeta(meta.id); + // Names are part of the archive too: an existing content-addressed asset + // must adopt the merged name, or two devices continually undo each other. + if (!known || known.name !== meta.name || known.kind !== meta.kind || known.ext !== meta.ext + || !fs.readFileSync(this.branding.assetFile(known)).equals(data)) { + this.branding.addAsset(asset.data, { kind: asset.kind, fileName: asset.name }); } - try { - const added = this.branding.addAsset(asset.data, { kind: asset.kind, fileName: asset.name }); - if (added.id !== id) renamed.set(id, added.id); - if (asset.raster && added.needsRaster) { - try { this.branding.setRaster(added.id, asset.raster); } catch { /* drawn again later */ } + if (meta.id !== id) renamed.set(id, meta.id); + if (asset.raster) { + const stored = this.branding.assetMeta(meta.id); + let raster = null; + if (stored.raster) { try { raster = fs.readFileSync(this.branding.rasterFile(stored)); } catch { /* replace missing raster */ } } + if (!raster?.equals(asset.raster)) { + try { this.branding.setRaster(meta.id, asset.raster); } catch { /* drawn again later */ } } - } catch { /* refused; profiles that use it go without */ } + } + } + if (!same(next.placeholders, now.placeholders)) { + this.settings.setGlobalPlaceholders(next.placeholders); + changes.push('placeholders'); } const rename = (id) => (id ? renamed.get(id) || id : id); const profiles = Object.values(next.profiles).map((profile) => ({ diff --git a/tests/unit/shared-settings.test.js b/tests/unit/shared-settings.test.js index 8be64bf1..3b928183 100644 --- a/tests/unit/shared-settings.test.js +++ b/tests/unit/shared-settings.test.js @@ -3,12 +3,14 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const vm = require('node:vm'); const { GuideStore } = require('../../core/store'); const { CloudSync } = require('../../core/cloud-sync'); const { Settings } = require('../../core/settings'); const { BrandingStore } = require('../../core/branding'); const { SharedSettings, mergeSharedSettings, toEntries, fromEntries } = require('../../core/shared-settings'); const { zipSync, unzipSync } = require('../../core/zip'); +const { encodePng } = require('../../core/png'); const parts = require('../../core/cloud-parts'); const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers'); @@ -223,3 +225,115 @@ test('the archive round-trips and only includes files a profile uses', (t) => { const again = toEntries(fromEntries(unzipSync(zipSync(entries)))); assert.equal(parts.contentHash(again), shared.snapshot().hash); }); + +test('newer settings fields and brand schemas are refused before they can be merged away', async (t) => { + const { device, drive, files } = setup(t); + const d = device('local'); + d.settings.setGlobalPlaceholders({ Mine: 'keep' }); + const profile = d.branding.save({ name: 'Keep my brand' }); + const shared = d.sync.shared; + for (const alter of [ + (model) => { model.settings.futureSetting = true; }, + (model) => { model.profiles[profile.id].schemaVersion = 2; }, + (model) => { model.profiles[profile.id].fonts.heading.futureFeature = true; }, + ]) { + const model = shared.read(); + model.placeholders = { Theirs: 'incoming' }; + alter(model); + const entries = toEntries(model); + const file = await drive.upload({ data: zipSync(entries), name: 'Settings', properties: { stepforge: 'settings-v1', hash: parts.contentHash(entries) } }); + assert.equal((await d.sync.sync()).phase, 'error'); + assert.deepEqual(d.settings.getGlobalPlaceholders(), { Mine: 'keep' }); + assert.equal(d.branding.get(profile.id).name, 'Keep my brand'); + assert.equal(settingsFiles(files).length, 1, 'no replacement version uploaded'); + await drive.deleteFile(file.id); + } +}); + +test('invalid or missing synced brand assets leave local settings and profiles intact', async (t) => { + const { device, drive, files } = setup(t); + const d = device('local'); + d.settings.setGlobalPlaceholders({ Mine: 'keep' }); + const kept = d.branding.save({ name: 'Keep my brand' }); + const assetId = 'a'.repeat(32); + for (const data of [Buffer.from('not an image'), null]) { + const model = d.sync.shared.read(); + model.placeholders = { Theirs: 'incoming' }; + model.profiles[kept.id].name = 'Incoming brand'; + model.profiles[kept.id].updatedAt = '2099-01-01T00:00:00.000Z'; + model.profiles[kept.id].logo = assetId; + if (data) model.assets[assetId] = { kind: 'image', ext: 'png', name: 'logo.png', data }; + const entries = toEntries(model); + const remote = await drive.upload({ data: zipSync(entries), name: 'Settings', properties: { stepforge: 'settings-v1', hash: parts.contentHash(entries) } }); + const status = await d.sync.sync(); + assert.equal(status.phase, 'error'); + assert.match(status.message, /brand asset/); + assert.deepEqual(d.settings.getGlobalPlaceholders(), { Mine: 'keep' }); + assert.equal(d.branding.get(kept.id).name, 'Keep my brand'); + assert.equal(settingsFiles(files).length, 1, 'the damaged settings were not republished'); + await drive.deleteFile(remote.id); + } +}); + +test('the same image uploaded with different names converges without repeated uploads', async (t) => { + const { device, files } = setup(t); + const a = device('a'); const b = device('b'); + const one = a.branding.addAsset(TINY_PNG, { kind: 'image', fileName: 'logo.png' }); + const two = b.branding.addAsset(TINY_PNG, { kind: 'image', fileName: 'company.png' }); + assert.equal(one.id, two.id); + // Independently rendered PNG copies can differ across computers too. + a.branding.setRaster(one.id, encodePng({ width: 1, height: 1, data: Buffer.from([255, 0, 0, 255]) })); + b.branding.setRaster(two.id, encodePng({ width: 1, height: 1, data: Buffer.from([0, 0, 255, 255]) })); + a.branding.save({ name: 'A', logo: one.id }); + b.branding.save({ name: 'B', logo: two.id }); + await synced(a.sync); await synced(b.sync); await synced(a.sync); await synced(b.sync); + assert.equal(a.sync.shared.snapshot().hash, b.sync.shared.snapshot().hash); + assert.equal(a.branding.assetMeta(one.id).name, b.branding.assetMeta(one.id).name); + assert.deepEqual(fs.readFileSync(a.branding.rasterFile(a.branding.assetMeta(one.id))), + fs.readFileSync(b.branding.rasterFile(b.branding.assetMeta(one.id)))); + const count = settingsFiles(files).length; + await synced(a.sync); await synced(b.sync); + assert.equal(settingsFiles(files).length, count); +}); + +test('an empty cloud settings listing republishes the local settings instead of retaining a missing head', async (t) => { + const { device, files, drive } = setup(t); + const a = device('a'); + a.settings.setGlobalPlaceholders({ Company: 'Acme' }); + await synced(a.sync); + const oldHead = a.sync.state.settings.head; + for (const file of [...settingsFiles(files)]) await drive.deleteFile(file.id); + await synced(a.sync); + assert.equal(settingsFiles(files).length, 1); + assert.notEqual(a.sync.state.settings.head, oldHead); + const b = device('b'); + await synced(b.sync); + assert.deepEqual(b.settings.getGlobalPlaceholders(), { Company: 'Acme' }); +}); + + +test('synced language changes use the language reload handler only after open edits are saved', async () => { + for (const available of [false, true]) { + for (const saved of [false, true]) { + const events = []; + const settings = { language: 'fr' }; + const api = { settings: { all: async () => settings }, app: {} }; + if (available) api.app.setLanguage = async ({ language }) => { events.push(`language:${language}`); }; + const context = { + window: { stepforge: api }, document: { addEventListener() {} }, + ensurePendingBrandRasters: async () => {}, + }; + const source = fs.readFileSync(path.join(__dirname, '../../app/renderer/app.js'), 'utf8'); + vm.runInNewContext(source.replace('\nboot();', '\n'), context); + const app = Object.create(context.window.StepForgeApp.prototype); + app.state = {}; + app.editor = { setSettings(value) { assert.equal(value, settings); } }; + app.flushEditorBeforeNavigation = async () => { events.push('saved'); return saved; }; + await app.onSettingsSynced(['language']); + assert.deepEqual(events, available ? (saved ? ['saved', 'language:fr'] : ['saved']) : []); + events.length = 0; + await app.onSettingsSynced(['placeholders']); + assert.deepEqual(events, [], 'other synced settings never reload the window'); + } + } +}); From c7608274f54b4cb5d3e4ff0b01dd5801a4b0adf8 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 11:43:11 -0500 Subject: [PATCH 3/4] Preserve per-value edit times and merge brand profile fields --- core/cloud-sync.js | 2 +- core/settings.js | 39 ++++++++- core/shared-settings.js | 112 +++++++++++++++++++------- docs/ARCHITECTURE.md | 12 ++- docs/GOOGLE_DRIVE.md | 18 +++-- tests/unit/shared-settings.test.js | 124 ++++++++++++++++++++++++++++- 6 files changed, 266 insertions(+), 41 deletions(-) diff --git a/core/cloud-sync.js b/core/cloud-sync.js index 3bbcbe75..59bb71b8 100644 --- a/core/cloud-sync.js +++ b/core/cloud-sync.js @@ -933,7 +933,7 @@ class CloudSync { if (parts.contentHash(entries) !== tip.appProperties.hash) throw new Error('Synced settings failed their integrity check. Settings here are unchanged.'); sides.push({ entries, hash: tip.appProperties.hash, time: tip.createdTime || '' }); } - let incoming = sides[0].entries; + let incoming = mergeSharedSettings({ sides: [sides[0]] }).entries; if (merging) { // A computer that never synced shares no history with the cloud: // merge without a base, so nothing counts as deleted. diff --git a/core/settings.js b/core/settings.js index 9eb4caa8..1cf94ede 100644 --- a/core/settings.js +++ b/core/settings.js @@ -1,6 +1,8 @@ 'use strict'; const path = require('node:path'); +const fs = require('node:fs'); +const { isDeepStrictEqual } = require('node:util'); const { writeJsonSync, readJsonIfExists, deepClone } = require('./util'); const DEFAULT_SETTINGS = { @@ -129,7 +131,22 @@ class Settings { constructor(settingsDir) { this.file = path.join(settingsDir, 'app-settings.json'); this.globalPlaceholdersFile = path.join(settingsDir, 'placeholders.json'); + this.sharedTimesFile = path.join(settingsDir, 'shared-settings-times.json'); this.data = this.load(); + // Seed legacy values before any unrelated setting can change the file's + // time. New edits below get their own per-value clocks. + if (!fs.existsSync(this.sharedTimesFile)) { + const time = (file) => { try { return fs.statSync(file).mtime.toISOString(); } catch { return ''; } }; + const times = {}; + for (const [key, value, fallback] of [ + ['language', this.data.language, DEFAULT_SETTINGS.language], + ['activeProfileId', this.data.branding?.activeProfileId, DEFAULT_SETTINGS.branding.activeProfileId], + ]) if (value !== fallback && time(this.file)) times[`setting:${key}`] = time(this.file); + for (const name of Object.keys(this.getGlobalPlaceholders() || {})) { + if (time(this.globalPlaceholdersFile)) times[`placeholder:${name}`] = time(this.globalPlaceholdersFile); + } + if (Object.keys(times).length) this.setSharedChangeTimes(times); + } } load() { @@ -150,7 +167,14 @@ class Settings { return keyPath.split('.').reduce((o, k) => (o == null ? undefined : o[k]), this.data); } + getSharedChangeTimes() { return readJsonIfExists(this.sharedTimesFile, {}); } + + setSharedChangeTimes(times) { + if (!isDeepStrictEqual(this.getSharedChangeTimes(), times)) writeJsonSync(this.sharedTimesFile, times); + } + set(keyPath, value) { + const before = { language: this.data.language, activeProfileId: this.data.branding?.activeProfileId }; const keys = keyPath.split('.'); let obj = this.data; for (const k of keys.slice(0, -1)) { @@ -158,7 +182,14 @@ class Settings { obj = obj[k]; } obj[keys[keys.length - 1]] = value; - return this.save(); + const saved = this.save(); + const after = { language: this.data.language, activeProfileId: this.data.branding?.activeProfileId }; + const times = this.getSharedChangeTimes(); + for (const key of Object.keys(before)) { + if (!isDeepStrictEqual(before[key], after[key])) times[`setting:${key}`] = new Date().toISOString(); + } + this.setSharedChangeTimes(times); + return saved; } getGlobalPlaceholders() { @@ -166,7 +197,13 @@ class Settings { } setGlobalPlaceholders(values) { + const before = this.getGlobalPlaceholders() || {}; + const times = this.getSharedChangeTimes(); + for (const name of new Set([...Object.keys(before), ...Object.keys(values)])) { + if (!isDeepStrictEqual(before[name], values[name])) times[`placeholder:${name}`] = new Date().toISOString(); + } writeJsonSync(this.globalPlaceholdersFile, values); + this.setSharedChangeTimes(times); return values; } } diff --git a/core/shared-settings.js b/core/shared-settings.js index 47b16f23..6577a5a2 100644 --- a/core/shared-settings.js +++ b/core/shared-settings.js @@ -15,9 +15,10 @@ const { contentHash } = require('./cloud-parts'); * * Cloud sync (core/cloud-sync.js) keeps them as one archive: * placeholders.json global placeholders, by name - * settings.json language and activeProfileId, each only - * when it isn't the default, so a new - * computer's defaults never win a merge + * settings.json language and activeProfileId, omitting + * untouched defaults, with per-value + * edit times (modifiedAt), so unrelated + * saves can't promote an older choice * branding/profiles/.json one brand profile each * branding/assets/.json an asset's kind, extension and file name * branding/assets/. the image or font @@ -25,8 +26,8 @@ const { contentHash } = require('./cloud-parts'); * Only assets a profile uses are included. * * Merging works like guides (core/guide-merge.js): each placeholder, setting - * and profile is compared with the version every side started from. Changes - * to different ones combine; when sides changed the same one differently, + * and profile field is compared with the version every side started from. + * Changes to different ones combine; when sides changed the same one differently, * the newest wins, and an edit beats a deletion. Without a base nothing is * treated as deleted. */ @@ -64,7 +65,7 @@ function checkKnownFields(raw, shape) { const textOf = (entry) => (Buffer.isBuffer(entry.data) ? entry.data.toString('utf8') : String(entry.data)); function emptyModel() { - return { placeholders: {}, settings: {}, profiles: {}, assets: {} }; + return { placeholders: {}, settings: {}, profiles: {}, assets: {}, modifiedAt: {} }; } /** A placeholder value as StepForge stores it, or undefined for anything else. */ @@ -99,7 +100,7 @@ function toEntries(model) { const json = (value) => JSON.stringify(value, null, 2); const entries = [ { name: 'placeholders.json', data: json(sortedObject(model.placeholders)) }, - { name: 'settings.json', data: json(sortedObject(model.settings)) }, + { name: 'settings.json', data: json({ ...sortedObject(model.settings), ...(Object.keys(model.modifiedAt || {}).length ? { modifiedAt: sortedObject(model.modifiedAt) } : {}) }) }, ]; const used = new Set(); for (const id of Object.keys(model.profiles).sort(compareText)) { @@ -143,8 +144,18 @@ function fromEntries(entries) { } else if (entry.name === 'settings.json') { const raw = parse(entry); if (!plainObject(raw)) throw damaged(); - checkKnownFields(raw, SHARED_KEYS); - model.settings = cleanSettings(raw); + const { modifiedAt = {}, ...values } = raw; + checkKnownFields(values, SHARED_KEYS); + if (!plainObject(modifiedAt) || Object.entries(modifiedAt).some(([key, time]) => + !(/^(setting:(language|activeProfileId)|placeholder:[A-Za-z0-9_ .-]{1,200})$/.test(key)) + || typeof time !== 'string' || !Number.isFinite(Date.parse(time)))) throw damaged(); + model.modifiedAt = modifiedAt; + model.settings = cleanSettings(values); + // A deliberate reset has an edit clock; a new computer's untouched + // defaults have none. Keep the former as a value, not a deletion. + for (const key of Object.keys(SHARED_KEYS)) { + if (Object.hasOwn(values, key) && same(values[key], DEFAULTS[key]) && modifiedAt[`setting:${key}`]) model.settings[key] = values[key]; + } } else if (PROFILE_ENTRY.test(entry.name)) { const id = PROFILE_ENTRY.exec(entry.name)[1]; if (!PROFILE_ID.test(id)) throw damaged(); @@ -175,27 +186,31 @@ function fromEntries(entries) { /** * Three-way merge of one kind of item (placeholders, settings, profiles). - * `sides` run oldest to newest; `rank` orders two edits before side order. + * `sides` run oldest to newest; `rank(value, sideIndex, key)` orders + * conflicting edits before side order. */ -function mergeItems(base, sides, rank = () => '') { +function mergeItems(base, sides, rank = () => '', changedSinceBase = (value, _index, _key, before) => !same(value, before)) { const out = {}; let conflicts = 0; + const chosen = {}; const keys = new Set([...Object.keys(base), ...sides.flatMap((side) => Object.keys(side))]); for (const key of [...keys].sort(compareText)) { const before = Object.hasOwn(base, key) ? base[key] : undefined; const changed = sides.map((side, order) => ({ value: Object.hasOwn(side, key) ? side[key] : undefined, order })) - .filter(({ value }) => !same(value, before)); + .filter(({ value, order }) => changedSinceBase(value, order, key, before)); let value = before; if (changed.length) { if (changed.some(({ value: v }) => !same(v, changed[0].value))) conflicts += 1; const edits = changed.filter(({ value: v }) => v !== undefined); - value = edits.length - ? edits.reduce((best, edit) => (compareText(rank(edit.value), rank(best.value)) >= 0 ? edit : best)).value - : undefined; + const winner = edits.length + ? edits.reduce((best, edit) => (compareText(rank(edit.value, edit.order, key), rank(best.value, best.order, key)) >= 0 ? edit : best)) + : null; + value = winner?.value; + if (winner) chosen[key] = winner.order; } if (value !== undefined) out[key] = value; } - return { items: out, conflicts }; + return { items: out, conflicts, chosen }; } /** @@ -205,13 +220,47 @@ function mergeItems(base, sides, rank = () => '') { */ function mergeSharedSettings({ base = null, sides }) { const before = base ? fromEntries(base) : emptyModel(); - const models = sides.map((side) => ({ model: fromEntries(side.entries), time: String(side.time || ''), hash: side.hash || '' })) - .sort((a, b) => compareText(a.time, b.time) || compareText(a.hash, b.hash)) - .map(({ model }) => model); - const placeholders = mergeItems(before.placeholders, models.map((m) => m.placeholders)); - const settings = mergeItems(before.settings, models.map((m) => m.settings)); + const ranked = sides.map((side) => ({ model: fromEntries(side.entries), time: String(side.time || ''), hash: side.hash || '' })) + .sort((a, b) => compareText(a.time, b.time) || compareText(a.hash, b.hash)); + const models = ranked.map(({ model }) => model); + const stamp = (prefix, index, key) => models[index].modifiedAt[`${prefix}:${key}`] || ranked[index].time; + const valueChanged = (prefix) => (value, index, key, baseValue) => !same(value, baseValue) + || (value !== undefined && models[index].modifiedAt[`${prefix}:${key}`] + && models[index].modifiedAt[`${prefix}:${key}`] !== before.modifiedAt[`${prefix}:${key}`]); + const placeholders = mergeItems(before.placeholders, models.map((m) => m.placeholders), (_value, index, key) => stamp('placeholder', index, key), valueChanged('placeholder')); + const settings = mergeItems(before.settings, models.map((m) => m.settings), (_value, index, key) => stamp('setting', index, key), valueChanged('setting')); const profiles = mergeItems(before.profiles, models.map((m) => m.profiles), (profile) => profile.updatedAt || ''); - const merged = { placeholders: placeholders.items, settings: settings.items, profiles: profiles.items, assets: {} }; + // First decide whether a profile survives a deletion. For profiles with a + // common base, merge fields from the surviving edits so changing a logo on + // one computer doesn't discard a color changed on another. + for (const id of Object.keys(profiles.items)) { + if (!before.profiles[id]) continue; + const present = models.map((m) => m.profiles[id]).filter(Boolean); + const flatten = (value, prefix = '', out = {}) => { + for (const [key, item] of Object.entries(value)) { + const name = prefix ? `${prefix}.${key}` : key; + if (plainObject(item)) flatten(item, name, out); else out[name] = item; + } + return out; + }; + const fields = mergeItems(flatten(before.profiles[id]), present.map((profile) => flatten(profile)), (_value, index) => present[index].updatedAt || ''); + const profile = {}; + for (const [name, value] of Object.entries(fields.items)) { + const path = name.split('.'); + let object = profile; + for (const key of path.slice(0, -1)) object = object[key] ||= {}; + object[path.at(-1)] = value; + } + profiles.items[id] = profile; + } + const merged = { placeholders: placeholders.items, settings: settings.items, profiles: profiles.items, assets: {}, modifiedAt: {} }; + for (const [prefix, result] of [['placeholder', placeholders], ['setting', settings]]) { + for (const key of Object.keys(result.items)) { + const index = result.chosen[key]; + const time = index === undefined ? before.modifiedAt[`${prefix}:${key}`] : stamp(prefix, index, key); + if (time) merged.modifiedAt[`${prefix}:${key}`] = time; + } + } // Assets are named by their content, so any side's copy will do; the // newest side's is used, with the newest PNG drawing any side has. for (const profile of Object.values(merged.profiles)) { @@ -235,6 +284,13 @@ class SharedSettings { const model = emptyModel(); model.placeholders = cleanPlaceholders(this.settings.getGlobalPlaceholders()); model.settings = cleanSettings(Object.fromEntries(Object.entries(SHARED_KEYS).map(([key, keyPath]) => [key, this.settings.get(keyPath)]))); + const times = this.settings.getSharedChangeTimes(); + for (const [key, keyPath] of Object.entries(SHARED_KEYS)) { + if (same(this.settings.get(keyPath), DEFAULTS[key]) && times[`setting:${key}`]) model.settings[key] = DEFAULTS[key]; + } + for (const [prefix, values] of [['placeholder', model.placeholders], ['setting', model.settings]]) { + for (const key of Object.keys(values)) if (times[`${prefix}:${key}`]) model.modifiedAt[`${prefix}:${key}`] = times[`${prefix}:${key}`]; + } for (const { id } of this.branding.list()) { const profile = this.branding.get(id); if (profile) model.profiles[id] = profile; @@ -257,7 +313,7 @@ class SharedSettings { const stat = (file) => { try { const s = fs.statSync(file); return `${file}:${s.size}:${s.mtimeMs}:${s.ino}`; } catch { return `${file}:-`; } }; - const lines = [JSON.stringify(Object.values(SHARED_KEYS).map((keyPath) => this.settings.get(keyPath) ?? null)), stat(this.settings.globalPlaceholdersFile)]; + const lines = [JSON.stringify(Object.values(SHARED_KEYS).map((keyPath) => this.settings.get(keyPath) ?? null)), stat(this.settings.globalPlaceholdersFile), stat(this.settings.sharedTimesFile)]; for (const dir of [this.branding.profilesDir, this.branding.assetsDir]) { let names = []; try { names = fs.readdirSync(dir).sort(compareText); } catch { /* none yet */ } @@ -268,12 +324,9 @@ class SharedSettings { /** When shared settings last changed here, for merges where both computers changed the same thing. */ modifiedAt() { - let newest = 0; - const files = [this.settings.file, this.settings.globalPlaceholdersFile]; - try { files.push(...fs.readdirSync(this.branding.profilesDir).map((name) => path.join(this.branding.profilesDir, name))); } catch { /* none yet */ } - for (const file of files) { - try { newest = Math.max(newest, fs.statSync(file).mtimeMs); } catch { /* not saved yet */ } - } + const times = Object.values(this.settings.getSharedChangeTimes()); + for (const { updatedAt } of this.branding.list()) times.push(updatedAt); + const newest = Math.max(0, ...times.map((time) => Date.parse(time) || 0)); return newest ? new Date(newest).toISOString() : ''; } @@ -353,6 +406,7 @@ class SharedSettings { this.settings.set(SHARED_KEYS.language, language); changes.push('language'); } + this.settings.setSharedChangeTimes(next.modifiedAt); return changes; } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ca60a4f1..3682fb79 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -247,10 +247,14 @@ opt-in and off by default: profiles with the assets they use, the active profile and the language travel as one `settings-v1` archive per version, versioned and pruned like a guide, with the same parents, heads and `merged` property. Placeholders, - settings and profiles merge three ways by key (newest side wins a conflict, - a profile by its `updatedAt`, and an edit beats a deletion); assets are - content-addressed, so they follow the profiles. Settings left at their - default aren't stored, so a new computer's defaults never win a merge. + settings and profile fields merge three ways by key (newest edit wins a + conflict, and an edit beats a deletion); assets are content-addressed, so + they follow the profiles. Untouched default settings aren't stored, so a + new computer's defaults never win a merge. + Per-value edit times travel in `settings.json.modifiedAt`; their local + copy is `settings/shared-settings-times.json`, updated only when that value + changes. Older archives fall back to their version time. Profile field + conflicts compare the profile's `updatedAt`. The base is `cloud/settings-base.json`. Incoming settings wait while the Settings dialog is open, and every image and font is re-checked by `BrandingStore.addAsset`. diff --git a/docs/GOOGLE_DRIVE.md b/docs/GOOGLE_DRIVE.md index 8c93993b..f204d39d 100644 --- a/docs/GOOGLE_DRIVE.md +++ b/docs/GOOGLE_DRIVE.md @@ -89,15 +89,23 @@ Everything else in Settings, such as capture options, shortcuts, AI and guide storage, stays on each computer. They're combined the same way as guides: changes to different placeholders or -profiles on different computers all survive, a placeholder or profile deleted -on one computer is deleted on the others, and when both computers changed the -same one, the most recent change is kept. A computer syncing for the first -time adds its placeholders and profiles to the ones already in the cloud, -and its default settings never replace a choice made on another computer. +fields within a brand profile on different computers all survive. A placeholder +or profile deleted on one computer is deleted on the others unless another +computer edited it. When both computers changed the same value, the most recent +edit to that value is kept; changing capture options or another placeholder +doesn't make an older choice newer. A computer syncing for the first time adds +its placeholders and profiles to the ones already in the cloud. Conflicting +explicit choices use the same newest-edit rule, including a deliberate reset +to a default. Its untouched defaults never replace a choice made on another computer. Changes from another computer wait while **Settings** is open, so saving it can't undo them, and arrive when you close it. +If a settings version is damaged or needs a newer StepForge, settings sync +shows an error and leaves the saved settings alone. Guide sync continues. +StepForge doesn't skip that settings version, because publishing a merge +without it could discard values it couldn't read. + ## Manage what's in Drive **Settings → Accounts → Google Drive** also lets you see and tidy up your Drive storage. diff --git a/tests/unit/shared-settings.test.js b/tests/unit/shared-settings.test.js index 3b928183..5f73a33f 100644 --- a/tests/unit/shared-settings.test.js +++ b/tests/unit/shared-settings.test.js @@ -6,7 +6,7 @@ const path = require('node:path'); const vm = require('node:vm'); const { GuideStore } = require('../../core/store'); const { CloudSync } = require('../../core/cloud-sync'); -const { Settings } = require('../../core/settings'); +const { Settings, DEFAULT_SETTINGS } = require('../../core/settings'); const { BrandingStore } = require('../../core/branding'); const { SharedSettings, mergeSharedSettings, toEntries, fromEntries } = require('../../core/shared-settings'); const { zipSync, unzipSync } = require('../../core/zip'); @@ -64,6 +64,7 @@ async function synced(sync) { const status = await sync.sync(); assert.notEqual( function changedLongAgo(d) { const old = new Date(Date.UTC(2020, 0, 1)); for (const file of [d.settings.file, d.settings.globalPlaceholdersFile]) if (fs.existsSync(file)) fs.utimesSync(file, old, old); + d.settings.setSharedChangeTimes(Object.fromEntries(Object.keys(d.settings.getSharedChangeTimes()).map((key) => [key, old.toISOString()]))); } test('global placeholders, brand profiles with their files, the active profile and language reach another computer', async (t) => { @@ -337,3 +338,124 @@ test('synced language changes use the language reload handler only after open ed } } }); + + +const setSharedTime = (device, key, time) => device.settings.setSharedChangeTimes({ ...device.settings.getSharedChangeTimes(), [key]: time }); + +test('unrelated settings and placeholder edits cannot promote an older conflicting language choice', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.set('language', 'fr'); + setSharedTime(a, 'setting:language', '2020-01-01T00:00:00.000Z'); + await synced(a.sync); await synced(b.sync); + a.settings.set('language', 'de'); + setSharedTime(a, 'setting:language', '2021-01-01T00:00:00.000Z'); + b.settings.set('language', 'ja'); + setSharedTime(b, 'setting:language', '2022-01-01T00:00:00.000Z'); + a.settings.set('appearance', 'dark'); + a.settings.set('capture.delayMs', 500); + a.settings.setGlobalPlaceholders({ New: 'A later placeholder edit' }); + setSharedTime(a, 'placeholder:New', '2023-01-01T00:00:00.000Z'); + // The unsynced choice was made before upload, not at upload time. + fs.utimesSync(b.settings.file, new Date('2024-01-01'), new Date('2024-01-01')); + await synced(a.sync); await synced(b.sync); await synced(a.sync); + assert.equal(a.settings.get('language'), 'ja'); + assert.equal(b.settings.get('language'), 'ja'); + assert.deepEqual(b.settings.getGlobalPlaceholders(), { New: 'A later placeholder edit' }); +}); + +test('shared value clocks survive reloads and ignore saves that do not change shared values', (t) => { + const { device } = setup(t); + const a = device('a'); + a.settings.set('language', 'fr'); + a.settings.setGlobalPlaceholders({ Company: 'Acme' }); + const before = a.sync.shared.snapshot(); + a.settings.set('appearance', 'dark'); + a.settings.set('capture.delayMs', 500); + a.settings.set('language', 'fr'); + a.settings.setGlobalPlaceholders({ Company: 'Acme' }); + const after = a.sync.shared.snapshot(); + assert.equal(after.hash, before.hash); + assert.equal(after.time, before.time); + assert.deepEqual(new Settings(a.store.settingsDir).getSharedChangeTimes(), a.settings.getSharedChangeTimes()); +}); + +test('first sync preserves newer explicit local choices and combines nonconflicting placeholders', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.set('language', 'fr'); + a.settings.setGlobalPlaceholders({ Remote: 'A', Company: 'Old company' }); + for (const key of Object.keys(a.settings.getSharedChangeTimes())) setSharedTime(a, key, '2020-01-01T00:00:00.000Z'); + await synced(a.sync); + b.settings.set('language', 'ja'); + b.settings.setGlobalPlaceholders({ Local: 'B', Company: 'New company' }); + for (const key of Object.keys(b.settings.getSharedChangeTimes())) setSharedTime(b, key, '2021-01-01T00:00:00.000Z'); + for (const file of [b.settings.file, b.settings.globalPlaceholdersFile]) fs.utimesSync(file, new Date('2021-01-01'), new Date('2021-01-01')); + await synced(b.sync); await synced(a.sync); + for (const d of [a, b]) { + assert.equal(d.settings.get('language'), 'ja'); + assert.deepEqual(d.settings.getGlobalPlaceholders(), { Remote: 'A', Local: 'B', Company: 'New company' }); + } +}); + +test('edits to different fields in the same brand profile combine across computers', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + const profile = a.branding.save({ name: 'Acme', colors: { primary: '#112233' }, header: { text: 'Original' } }); + await synced(a.sync); await synced(b.sync); + a.branding.save({ ...a.branding.get(profile.id), colors: { ...profile.colors, primary: '#FF0000' } }); + b.branding.save({ ...b.branding.get(profile.id), header: { text: 'New header' } }); + await synced(a.sync); await synced(b.sync); await synced(a.sync); + for (const d of [a, b]) { + assert.equal(d.branding.get(profile.id).colors.primary, '#FF0000'); + assert.equal(d.branding.get(profile.id).header.text, 'New header'); + } +}); + +test('a malformed settings head stops settings sync without blocking guides or discarding local settings', async (t) => { + const { device, drive, files } = setup(t); + const a = device('a'); + const guide = a.store.createGuide({ title: 'A valid guide' }); + a.settings.setGlobalPlaceholders({ Mine: 'kept' }); + const entries = [{ name: 'settings.json', data: 'not json' }]; + const remote = await drive.upload({ data: zipSync(entries), name: 'Broken settings', properties: { stepforge: 'settings-v1', hash: parts.contentHash(entries) } }); + for (let attempt = 0; attempt < 2; attempt++) { + assert.equal((await a.sync.sync()).phase, 'error'); + assert.deepEqual(a.settings.getGlobalPlaceholders(), { Mine: 'kept' }); + assert.deepEqual(settingsFiles(files).map((file) => file.id), [remote.id]); + assert.ok((await drive.listVersions()).some((file) => file.appProperties.guideId === guide.guideId)); + } +}); + + +test('an explicit change back to the base language is still a newer edit', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.set('language', 'fr'); + setSharedTime(a, 'setting:language', '2020-01-01T00:00:00.000Z'); + await synced(a.sync); await synced(b.sync); + b.settings.set('language', 'ja'); + setSharedTime(b, 'setting:language', '2021-01-01T00:00:00.000Z'); + a.settings.set('language', 'de'); + a.settings.set('language', 'fr'); + setSharedTime(a, 'setting:language', '2022-01-01T00:00:00.000Z'); + await synced(b.sync); await synced(a.sync); await synced(b.sync); + assert.equal(a.settings.get('language'), 'fr'); + assert.equal(b.settings.get('language'), 'fr'); +}); + + +test('a deliberate reset to the default language is newer than a conflicting older choice', async (t) => { + const { device } = setup(t); + const a = device('a'); const b = device('b'); + a.settings.set('language', 'fr'); + setSharedTime(a, 'setting:language', '2020-01-01T00:00:00.000Z'); + await synced(a.sync); await synced(b.sync); + b.settings.set('language', 'ja'); + setSharedTime(b, 'setting:language', '2021-01-01T00:00:00.000Z'); + a.settings.set('language', DEFAULT_SETTINGS.language); + setSharedTime(a, 'setting:language', '2022-01-01T00:00:00.000Z'); + await synced(a.sync); await synced(b.sync); await synced(a.sync); + assert.equal(a.settings.get('language'), DEFAULT_SETTINGS.language); + assert.equal(b.settings.get('language'), DEFAULT_SETTINGS.language); +}); From a0e30a6d5e310d44ef2bae07f3b82ed25730aff4 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Thu, 29 Oct 2026 13:09:06 -0500 Subject: [PATCH 4/4] Sync configurable recording shortcuts across computers --- app/main.js | 7 ++- core/settings.js | 33 +++++++----- core/shared-settings.js | 33 ++++++++---- docs/ARCHITECTURE.md | 6 ++- docs/GOOGLE_DRIVE.md | 7 +-- docs/PRIVACY.md | 3 +- tests/unit/shared-settings.test.js | 85 ++++++++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 29 deletions(-) diff --git a/app/main.js b/app/main.js index e3bcb078..659bf765 100644 --- a/app/main.js +++ b/app/main.js @@ -1357,7 +1357,7 @@ function setupIpc() { settings.set('library.trashEmptyingSince', Date.now()); } if (keyPath === 'appearance') applyTheme(); - if (keyPath.startsWith('capture.hotkey')) registerHotkeys(); + if (keyPath === 'capture' || keyPath.startsWith('capture.hotkey')) registerHotkeys(); return settings.data; }, { validate: (a) => c.settingsKeyPath(a.keyPath) }); h('ai:test', async ({ enabled = null, ollama = null } = {}) => { @@ -1962,7 +1962,10 @@ if (!gotLock) { onStatus: () => sendToRenderer('cloud:status', cloudStatus()), shared: new SharedSettings({ settings, branding }), sharedReady: () => !settingsOpen, - onSharedChange: (changes) => sendToRenderer('cloud:settings-changed', { changes }), + onSharedChange: (changes) => { + if (changes.includes('shortcuts')) registerHotkeys(); + sendToRenderer('cloud:settings-changed', { changes }); + }, }); searchIndex = new SearchIndex(store.indexDir); // Rebuild/reconcile the index against the library at startup so a missing, diff --git a/core/settings.js b/core/settings.js index 1cf94ede..5f655a3a 100644 --- a/core/settings.js +++ b/core/settings.js @@ -127,23 +127,32 @@ const DEFAULT_SETTINGS = { }, }; +// Values that sync independently; changing a whole capture/branding object +// still records clocks only for the shared fields that actually changed. +const SHARED_SETTING_PATHS = Object.freeze({ + language: 'language', activeProfileId: 'branding.activeProfileId', + hotkeyCapture: 'capture.hotkeyCapture', hotkeyPauseResume: 'capture.hotkeyPauseResume', +}); +const valueAt = (data, keyPath) => keyPath.split('.').reduce((value, key) => value?.[key], data); +const sharedValues = (data) => Object.fromEntries(Object.entries(SHARED_SETTING_PATHS) + .map(([key, keyPath]) => [key, valueAt(data, keyPath)])); + class Settings { constructor(settingsDir) { this.file = path.join(settingsDir, 'app-settings.json'); this.globalPlaceholdersFile = path.join(settingsDir, 'placeholders.json'); this.sharedTimesFile = path.join(settingsDir, 'shared-settings-times.json'); this.data = this.load(); - // Seed legacy values before any unrelated setting can change the file's - // time. New edits below get their own per-value clocks. - if (!fs.existsSync(this.sharedTimesFile)) { + // Seed legacy values (including newly shared keys) before an unrelated + // save changes the file's time. Existing per-value clocks stay intact. + { const time = (file) => { try { return fs.statSync(file).mtime.toISOString(); } catch { return ''; } }; - const times = {}; - for (const [key, value, fallback] of [ - ['language', this.data.language, DEFAULT_SETTINGS.language], - ['activeProfileId', this.data.branding?.activeProfileId, DEFAULT_SETTINGS.branding.activeProfileId], - ]) if (value !== fallback && time(this.file)) times[`setting:${key}`] = time(this.file); + const times = this.getSharedChangeTimes(); + for (const [key, keyPath] of Object.entries(SHARED_SETTING_PATHS)) { + if (!Object.hasOwn(times, `setting:${key}`) && !isDeepStrictEqual(this.get(keyPath), valueAt(DEFAULT_SETTINGS, keyPath)) && time(this.file)) times[`setting:${key}`] = time(this.file); + } for (const name of Object.keys(this.getGlobalPlaceholders() || {})) { - if (time(this.globalPlaceholdersFile)) times[`placeholder:${name}`] = time(this.globalPlaceholdersFile); + if (!Object.hasOwn(times, `placeholder:${name}`) && time(this.globalPlaceholdersFile)) times[`placeholder:${name}`] = time(this.globalPlaceholdersFile); } if (Object.keys(times).length) this.setSharedChangeTimes(times); } @@ -174,7 +183,7 @@ class Settings { } set(keyPath, value) { - const before = { language: this.data.language, activeProfileId: this.data.branding?.activeProfileId }; + const before = sharedValues(this.data); const keys = keyPath.split('.'); let obj = this.data; for (const k of keys.slice(0, -1)) { @@ -183,7 +192,7 @@ class Settings { } obj[keys[keys.length - 1]] = value; const saved = this.save(); - const after = { language: this.data.language, activeProfileId: this.data.branding?.activeProfileId }; + const after = sharedValues(this.data); const times = this.getSharedChangeTimes(); for (const key of Object.keys(before)) { if (!isDeepStrictEqual(before[key], after[key])) times[`setting:${key}`] = new Date().toISOString(); @@ -219,4 +228,4 @@ function mergeDeep(base, extra) { return base; } -module.exports = { Settings, DEFAULT_SETTINGS }; +module.exports = { Settings, DEFAULT_SETTINGS, SHARED_SETTING_PATHS }; diff --git a/core/shared-settings.js b/core/shared-settings.js index 6577a5a2..206fa6f9 100644 --- a/core/shared-settings.js +++ b/core/shared-settings.js @@ -3,20 +3,21 @@ const fs = require('node:fs'); const path = require('node:path'); const { isDeepStrictEqual } = require('node:util'); -const { DEFAULT_SETTINGS } = require('./settings'); +const { DEFAULT_SETTINGS, SHARED_SETTING_PATHS } = require('./settings'); const { normalizeProfile, profileAssetIds, prepareAsset } = require('./branding'); const { contentHash } = require('./cloud-parts'); /* * Settings that follow you to every computer that syncs: global * placeholders, brand profiles (with their logos, banners and fonts), the - * active brand profile, and the interface language. The rest of Settings - * describes one computer (capture, hotkeys, storage, AI) and stays on it. + * active brand profile, the interface language and configurable recording + * shortcuts. Other capture options, storage and AI stay on each computer. * * Cloud sync (core/cloud-sync.js) keeps them as one archive: * placeholders.json global placeholders, by name - * settings.json language and activeProfileId, omitting - * untouched defaults, with per-value + * settings.json language, activeProfileId and recording + * shortcuts, omitting untouched defaults, + * with per-value * edit times (modifiedAt), so unrelated * saves can't promote an older choice * branding/profiles/.json one brand profile each @@ -32,8 +33,11 @@ const { contentHash } = require('./cloud-parts'); * treated as deleted. */ -const SHARED_KEYS = { language: 'language', activeProfileId: 'branding.activeProfileId' }; -const DEFAULTS = { language: DEFAULT_SETTINGS.language, activeProfileId: DEFAULT_SETTINGS.branding.activeProfileId }; +const SHARED_KEYS = SHARED_SETTING_PATHS; +const DEFAULTS = Object.fromEntries(Object.entries(SHARED_KEYS).map(([key, keyPath]) => + [key, keyPath.split('.').reduce((value, part) => value[part], DEFAULT_SETTINGS)])); +const HOTKEYS = ['hotkeyCapture', 'hotkeyPauseResume']; +const validShortcut = (value) => typeof value === 'string' && value.length <= 256 && !/[\u0000-\u001f\u007f]/.test(value); const PLACEHOLDER_NAME = /^[A-Za-z0-9_ .-]{1,200}$/; const LANGUAGE = /^(system|[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3})$/; @@ -92,6 +96,9 @@ function cleanSettings(raw) { const out = {}; if (typeof raw?.language === 'string' && LANGUAGE.test(raw.language) && raw.language !== DEFAULTS.language) out.language = raw.language; if (typeof raw?.activeProfileId === 'string' && PROFILE_ID.test(raw.activeProfileId)) out.activeProfileId = raw.activeProfileId; + for (const key of HOTKEYS) { + if (validShortcut(raw?.[key]) && raw[key] !== DEFAULTS[key]) out[key] = raw[key]; + } return out; } @@ -146,8 +153,9 @@ function fromEntries(entries) { if (!plainObject(raw)) throw damaged(); const { modifiedAt = {}, ...values } = raw; checkKnownFields(values, SHARED_KEYS); + for (const key of HOTKEYS) if (Object.hasOwn(values, key) && !validShortcut(values[key])) throw damaged(); if (!plainObject(modifiedAt) || Object.entries(modifiedAt).some(([key, time]) => - !(/^(setting:(language|activeProfileId)|placeholder:[A-Za-z0-9_ .-]{1,200})$/.test(key)) + !(key.startsWith('setting:') ? Object.hasOwn(SHARED_KEYS, key.slice(8)) : /^placeholder:[A-Za-z0-9_ .-]{1,200}$/.test(key)) || typeof time !== 'string' || !Number.isFinite(Date.parse(time)))) throw damaged(); model.modifiedAt = modifiedAt; model.settings = cleanSettings(values); @@ -343,7 +351,7 @@ class SharedSettings { /** * Save synced settings on this computer. Every incoming image and font is * checked again as if uploaded. Returns what changed: any of - * 'placeholders', 'branding', 'language'. + * 'placeholders', 'branding', 'language', 'shortcuts'. */ apply(entries) { const next = fromEntries(entries); @@ -406,6 +414,13 @@ class SharedSettings { this.settings.set(SHARED_KEYS.language, language); changes.push('language'); } + for (const key of HOTKEYS) { + const shortcut = next.settings[key] ?? DEFAULTS[key]; + if (this.settings.get(SHARED_KEYS[key]) !== shortcut) { + this.settings.set(SHARED_KEYS[key], shortcut); + if (!changes.includes('shortcuts')) changes.push('shortcuts'); + } + } this.settings.setSharedChangeTimes(next.modifiedAt); return changes; } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3682fb79..bdd6eca4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -244,7 +244,8 @@ opt-in and off by default: automatic backups. See [GOOGLE_DRIVE.md](GOOGLE_DRIVE.md) and [SECURITY.md](SECURITY.md). - **Synced settings** (`core/shared-settings.js`): global placeholders, brand - profiles with the assets they use, the active profile and the language + profiles with the assets they use, the active profile, the language and + configurable recording shortcuts travel as one `settings-v1` archive per version, versioned and pruned like a guide, with the same parents, heads and `merged` property. Placeholders, settings and profile fields merge three ways by key (newest edit wins a @@ -256,7 +257,8 @@ opt-in and off by default: changes. Older archives fall back to their version time. Profile field conflicts compare the profile's `updatedAt`. The base is `cloud/settings-base.json`. Incoming settings wait while the - Settings dialog is open, and every image and font is re-checked by + Settings dialog is open. Incoming recording shortcuts re-register the running + app’s global hotkeys immediately. Every image and font is re-checked by `BrandingStore.addAsset`. - **OneDrive, Dropbox and Nextcloud/WebDAV** (`app/onedrive.js`, `app/dropbox.js`, `app/webdav.js` on `app/cloud-account.js`) plug into the diff --git a/docs/GOOGLE_DRIVE.md b/docs/GOOGLE_DRIVE.md index f204d39d..b18553a6 100644 --- a/docs/GOOGLE_DRIVE.md +++ b/docs/GOOGLE_DRIVE.md @@ -79,14 +79,15 @@ or open the Drive settings, to see the guide's name and the transfer speed. ### Settings that sync -So a guide exports the same on every computer, these settings sync with it: +These settings follow your guides across computers: - **Global placeholders** (**Settings → Placeholders**) - **Brand profiles**, with their logos, banners and fonts, and which profile is active (**Settings → Branding**) +- **Recording shortcuts**: Capture and Pause/resume (**Settings → Capture → Hotkeys**). + Cleared shortcuts stay disabled; incoming changes take effect immediately. -Everything else in Settings, such as capture options, shortcuts, AI and guide -storage, stays on each computer. +Other capture options, AI and guide storage stay on each computer. They're combined the same way as guides: changes to different placeholders or fields within a brand profile on different computers all survive. A placeholder diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index c974f955..9f266ac4 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -128,7 +128,8 @@ Drive sync is **off by default**. When you sign in other Drive files. - **Your whole library is uploaded**: screenshots, text, annotations, placeholders, and the capture details listed above, along with your global - placeholders and brand profiles (with their logos, banners and fonts). It + placeholders, brand profiles (with their logos, banners and fonts), and + configured Capture and Pause/resume keyboard shortcuts. It goes to that private folder in *your* Google Drive, not to StepForge or anyone else. The rest of Settings isn't uploaded. Screenshots are stored once and shared between versions, labelled with a diff --git a/tests/unit/shared-settings.test.js b/tests/unit/shared-settings.test.js index 5f73a33f..d4961b78 100644 --- a/tests/unit/shared-settings.test.js +++ b/tests/unit/shared-settings.test.js @@ -459,3 +459,88 @@ test('a deliberate reset to the default language is newer than a conflicting old assert.equal(a.settings.get('language'), DEFAULT_SETTINGS.language); assert.equal(b.settings.get('language'), DEFAULT_SETTINGS.language); }); + +test('recording shortcuts sync immediately, including disabled bindings, while other capture options stay local', async (t) => { + const { device, files } = setup(t); + const a = device('a'), b = device('b'); + a.settings.set('capture', { ...a.settings.get('capture'), hotkeyCapture: 'CommandOrControl+Shift+7', hotkeyPauseResume: '', delayMs: 750, mode: 'window' }); + b.settings.set('capture.delayMs', 250); + await synced(a.sync); await synced(b.sync); + assert.equal(b.settings.get('capture.hotkeyCapture'), 'CommandOrControl+Shift+7'); + assert.equal(b.settings.get('capture.hotkeyPauseResume'), '', 'a cleared shortcut stays disabled'); + assert.equal(b.settings.get('capture.delayMs'), 250); + assert.equal(b.settings.get('capture.mode'), 'fullscreen'); + assert.ok(b.changes.some((changes) => changes.includes('shortcuts'))); + const count = settingsFiles(files).length; + await synced(a.sync); await synced(b.sync); + assert.equal(settingsFiles(files).length, count, 'matching shortcuts do not trigger repeated uploads'); +}); + +test('shortcut edits merge independently, and a newer explicit default reset wins a conflict', async (t) => { + const { device } = setup(t); + const a = device('a'), b = device('b'); + a.settings.set('capture.hotkeyCapture', 'Alt+1'); + setSharedTime(a, 'setting:hotkeyCapture', '2020-01-01T00:00:00.000Z'); + await synced(a.sync); await synced(b.sync); + a.settings.set('capture.hotkeyCapture', 'Alt+3'); + setSharedTime(a, 'setting:hotkeyCapture', '2021-01-01T00:00:00.000Z'); + b.settings.set('capture.hotkeyPauseResume', 'Alt+4'); + await synced(a.sync); await synced(b.sync); await synced(a.sync); + for (const d of [a, b]) { + assert.equal(d.settings.get('capture.hotkeyCapture'), 'Alt+3'); + assert.equal(d.settings.get('capture.hotkeyPauseResume'), 'Alt+4'); + } + b.settings.set('capture.hotkeyCapture', 'Alt+5'); + setSharedTime(b, 'setting:hotkeyCapture', '2022-01-01T00:00:00.000Z'); + a.settings.set('capture.hotkeyCapture', DEFAULT_SETTINGS.capture.hotkeyCapture); + setSharedTime(a, 'setting:hotkeyCapture', '2023-01-01T00:00:00.000Z'); + a.settings.set('capture.delayMs', 900); + await synced(b.sync); await synced(a.sync); await synced(b.sync); + for (const d of [a, b]) assert.equal(d.settings.get('capture.hotkeyCapture'), DEFAULT_SETTINGS.capture.hotkeyCapture); +}); + +test('new computer defaults do not overwrite shortcuts, and unrelated capture saves do not change their clocks', async (t) => { + const { device } = setup(t); + const a = device('a'), b = device('b'); + a.settings.set('capture.hotkeyCapture', 'Alt+8'); + const clocks = a.settings.getSharedChangeTimes(); + a.settings.set('capture', { ...a.settings.get('capture'), delayMs: 150 }); + assert.deepEqual(a.settings.getSharedChangeTimes(), clocks); + await synced(a.sync); await synced(b.sync); + assert.equal(b.settings.get('capture.hotkeyCapture'), 'Alt+8'); + const before = a.sync.shared.snapshot().hash; + a.settings.set('capture.delayMs', 1000); + assert.equal(a.sync.shared.snapshot().hash, before); + // Upgrading from a version whose clock file tracked only language. + a.settings.setSharedChangeTimes({ 'setting:language': '2020-01-01T00:00:00.000Z' }); + const loaded = new Settings(a.store.settingsDir); + assert.ok(loaded.getSharedChangeTimes()['setting:hotkeyCapture']); + assert.equal(loaded.getSharedChangeTimes()['setting:language'], '2020-01-01T00:00:00.000Z'); +}); + +test('incoming shortcut changes re-register the running app hotkeys before notifying the renderer', () => { + const source = fs.readFileSync(path.join(__dirname, '../../app/main.js'), 'utf8'); + const hook = source.match(/onSharedChange: (\(changes\) => \{[\s\S]*?\n \}),/)[1]; + const events = []; + const onSharedChange = vm.runInNewContext(`(${hook})`, { + registerHotkeys: () => events.push('register'), sendToRenderer: (name, payload) => events.push([name, [...payload.changes]]), + }); + onSharedChange(['shortcuts']); + assert.deepEqual(events, ['register', ['cloud:settings-changed', ['shortcuts']]]); + events.length = 0; + onSharedChange(['branding']); + assert.deepEqual(events, [['cloud:settings-changed', ['branding']]]); + assert.match(source, /keyPath === 'capture' \|\| keyPath\.startsWith\('capture\.hotkey'\)/); +}); + +test('malformed shortcut values are refused before applying synced settings', (t) => { + const { device } = setup(t); + const a = device('a'); + const shared = a.sync.shared; + for (const value of [null, 42, 'Alt+\nA', 'A'.repeat(257)]) { + const entries = shared.snapshot().entries.map((entry) => entry.name === 'settings.json' + ? { ...entry, data: JSON.stringify({ hotkeyCapture: value }) } : entry); + assert.throws(() => shared.apply(entries), /damaged/); + assert.equal(a.settings.get('capture.hotkeyCapture'), DEFAULT_SETTINGS.capture.hotkeyCapture); + } +});