@tobq/boardclip 0.0.0-stage → 2.1.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,183 @@
1
+ 'use strict';
2
+
3
+ // Appearance settings, pure: validation, the one-time promotion of the old
4
+ // debug-only axes, the synced merge and the window surface options. main.js
5
+ // owns the live parts (OS accent watchers, window broadcasts); everything here
6
+ // is data in, data out, so it is unit-tested without Electron.
7
+ //
8
+ // Which keys travel between devices:
9
+ // - SYNCED (one choice the user makes once): theme_mode, accent_mode +
10
+ // accent_custom, ui_density, ui_corners. Each group carries a last-change stamp in
11
+ // appearance_stamps; a remote copy wins only with a NEWER stamp, so a device
12
+ // that never touched a setting (stamp 0, or an older build that sends no
13
+ // stamps) can never overwrite one that did. "System" accent is synced as the
14
+ // CHOICE; the colour it resolves to is read per device.
15
+ // - PER MACHINE: surface_style (glass support is hardware-dependent),
16
+ // glass_scope, and the audit-only ui_borders.
17
+
18
+ const ACCENT_MODES = ['system', 'blue', 'teal', 'mono', 'custom'];
19
+ const ACCENT_PRESETS = ['blue', 'teal', 'mono'];
20
+ const DENSITIES = ['normal', 'compact'];
21
+ const CORNERS = ['soft', 'sharp'];
22
+ const BORDERS = ['bordered', 'borderless'];
23
+ const SURFACE_STYLES = ['auto', 'glass', 'solid'];
24
+ const GLASS_SCOPES = ['popup', 'all'];
25
+
26
+ // Synced groups -> the settings keys each stamp covers.
27
+ const SYNCED_GROUPS = {
28
+ theme: ['theme_mode'],
29
+ accent: ['accent_mode', 'accent_custom'],
30
+ density: ['ui_density'],
31
+ corners: ['ui_corners'],
32
+ };
33
+
34
+ // '#rgb', '#rrggbb', '#rrggbbaa', or the same without '#' (Electron's
35
+ // getAccentColor and 'accent-color-changed' give 'rrggbbaa') -> '#rrggbb'
36
+ // lowercase, alpha dropped. Anything else -> null. ONE rule for main's
37
+ // validator and the renderer's Custom field: the shared core's.
38
+ const { normalizeHexColor: normalizeAccentHex } = require('../site/shared/clipboard-ui-core');
39
+
40
+ // The accent a window paints with. preset = the token palette the CSS keys on
41
+ // (data-accent); color = an explicit '#rrggbb' laid over it (System, Custom),
42
+ // null when the preset alone applies or the colour is unknown.
43
+ function resolveAccent({ mode, custom, system } = {}) {
44
+ const m = ACCENT_MODES.includes(mode) ? mode : 'system';
45
+ if (ACCENT_PRESETS.includes(m)) return { mode: m, preset: m, color: null };
46
+ const color = m === 'custom' ? normalizeAccentHex(custom) : normalizeAccentHex(system);
47
+ return { mode: m, preset: 'blue', color };
48
+ }
49
+
50
+ // Validators for every appearance key save-settings and the synced merge accept.
51
+ const THEME_MODES = ['system', 'light', 'dark'];
52
+ const VALIDATORS = {
53
+ theme_mode: (v) => (THEME_MODES.includes(v) ? v : undefined),
54
+ accent_mode: (v) => (ACCENT_MODES.includes(v) ? v : undefined),
55
+ // '' clears the custom colour.
56
+ accent_custom: (v) => (v === '' ? '' : (normalizeAccentHex(v) || undefined)),
57
+ ui_density: (v) => (DENSITIES.includes(v) ? v : undefined),
58
+ ui_corners: (v) => (CORNERS.includes(v) ? v : undefined),
59
+ ui_borders: (v) => (BORDERS.includes(v) ? v : undefined),
60
+ surface_style: (v) => (SURFACE_STYLES.includes(v) ? v : undefined),
61
+ glass_scope: (v) => (GLASS_SCOPES.includes(v) ? v : undefined),
62
+ };
63
+ const GROUP_OF = Object.fromEntries(Object.entries(SYNCED_GROUPS).flatMap(([g, keys]) => keys.map((k) => [k, g])));
64
+
65
+ function stampOf(settings, group) {
66
+ const stamps = settings && settings.appearance_stamps;
67
+ const n = Number(stamps && typeof stamps === 'object' ? stamps[group] : 0);
68
+ return Number.isFinite(n) && n > 0 ? n : 0;
69
+ }
70
+ // A new stamps object (never the shared default's) in one key order, so two
71
+ // devices holding the same stamps serialize them identically.
72
+ function withStamps(settings, updates) {
73
+ const stamps = {};
74
+ for (const group of Object.keys(SYNCED_GROUPS)) {
75
+ const n = updates[group] !== undefined ? updates[group] : stampOf(settings, group);
76
+ if (n > 0) stamps[group] = n;
77
+ }
78
+ return stamps;
79
+ }
80
+
81
+ // Applies the appearance keys of a save-settings body to `settings` (mutated).
82
+ // Invalid values are ignored. A synced group whose value changed gets a fresh
83
+ // stamp. The old debug switcher's accent_variant is an alias for accent_mode.
84
+ // -> { changed: [keys whose value changed], synced: true when a synced group changed }
85
+ function applyAppearanceSettings(settings, body, { now = Date.now() } = {}) {
86
+ const changed = [];
87
+ const b = body && typeof body === 'object' ? body : {};
88
+ const input = { ...b };
89
+ if (input.accent_mode === undefined && ACCENT_PRESETS.includes(input.accent_variant)) input.accent_mode = input.accent_variant;
90
+ const stamped = new Set();
91
+ for (const [key, validate] of Object.entries(VALIDATORS)) {
92
+ if (input[key] === undefined) continue;
93
+ const value = validate(input[key]);
94
+ if (value === undefined || settings[key] === value) continue;
95
+ settings[key] = value;
96
+ changed.push(key);
97
+ if (GROUP_OF[key]) stamped.add(GROUP_OF[key]);
98
+ }
99
+ if (stamped.size) {
100
+ const updates = {};
101
+ for (const group of stamped) updates[group] = Math.max(now, stampOf(settings, group) + 1);
102
+ settings.appearance_stamps = withStamps(settings, updates);
103
+ }
104
+ return { changed, synced: stamped.size > 0 };
105
+ }
106
+
107
+ // Folds a remote device's synced appearance into `local` (mutated): per group,
108
+ // the newer stamp wins, and only with values that validate. -> true if changed.
109
+ function mergeSyncedAppearance(local, remote) {
110
+ if (!local || !remote || typeof remote !== 'object') return false;
111
+ let changed = false;
112
+ for (const [group, keys] of Object.entries(SYNCED_GROUPS)) {
113
+ const theirs = stampOf(remote, group);
114
+ if (!(theirs > stampOf(local, group))) continue;
115
+ const values = {};
116
+ let valid = true;
117
+ for (const key of keys) {
118
+ const value = remote[key] === undefined ? undefined : VALIDATORS[key](remote[key]);
119
+ if (value === undefined) { valid = false; break; }
120
+ values[key] = value;
121
+ }
122
+ if (!valid) continue;
123
+ Object.assign(local, values);
124
+ local.appearance_stamps = withStamps(local, { [group]: theirs });
125
+ changed = true;
126
+ }
127
+ return changed;
128
+ }
129
+
130
+ // One-time promotion of the old audit axes, run on the settings file AS READ
131
+ // (before defaults fill it in). A file that already has accent_mode is done.
132
+ // The old accent_variant / ui_density / ui_corners applied ONLY under
133
+ // BOARDCLIP_DEBUG_VARIANTS, so a value left in a file from an audit session
134
+ // was never on screen (the live install still held ui_corners 'sharp' from
135
+ // 2026-09). Promoting such a value would change the look on upgrade and then
136
+ // sync it to every device: the 2026-09-02 "looks different per machine" bug.
137
+ // So they carry over only where the flag is on (where they WERE the look);
138
+ // elsewhere the defaults apply, which is exactly what that machine showed.
139
+ // 'blue' was the old default, indistinguishable from "never chosen", so it
140
+ // becomes the new default (System) like an unset value. Migrated values get
141
+ // no stamp: they stay on this device until the user picks something.
142
+ function migrateAppearanceSettings(raw, { debugVariants = false } = {}) {
143
+ if (!raw || typeof raw !== 'object') return raw;
144
+ const out = { ...raw };
145
+ if (out.accent_mode === undefined) {
146
+ const legacy = out.accent_variant;
147
+ out.accent_mode = debugVariants && (legacy === 'teal' || legacy === 'mono') ? legacy : 'system';
148
+ if (!debugVariants) { delete out.ui_density; delete out.ui_corners; }
149
+ }
150
+ delete out.accent_variant;
151
+ return out;
152
+ }
153
+
154
+ // BrowserWindow options for a window's surface. support = 'vibrancy' (macOS)
155
+ // | 'acrylic' (Windows 11) | 'none'. Windows uses acrylic (transparent stays
156
+ // false) and switches it live on any window. On macOS transparency is fixed at
157
+ // creation: a window that must switch glass <-> solid while open (live: the
158
+ // popup) stays transparent:true and switches its vibrancy; one created solid
159
+ // otherwise (a secondary window under "Glass on: Popup only") is an ordinary
160
+ // OPAQUE window, which keeps the native shadow macOS gives opaque windows, and
161
+ // a later scope change reaches it the next time it opens.
162
+ function surfaceWindowOptions({ support, on, solidBackground, live = false }) {
163
+ if (support === 'vibrancy' && (on || live)) {
164
+ return { transparent: true, vibrancy: on ? 'popover' : undefined, visualEffectState: 'active', backgroundColor: '#00000000' };
165
+ }
166
+ if (support === 'acrylic' && on) return { backgroundMaterial: 'acrylic', backgroundColor: '#00000000' };
167
+ return { backgroundColor: solidBackground };
168
+ }
169
+
170
+ module.exports = {
171
+ ACCENT_MODES,
172
+ ACCENT_PRESETS,
173
+ DENSITIES,
174
+ CORNERS,
175
+ GLASS_SCOPES,
176
+ SYNCED_GROUPS,
177
+ normalizeAccentHex,
178
+ resolveAccent,
179
+ applyAppearanceSettings,
180
+ mergeSyncedAppearance,
181
+ migrateAppearanceSettings,
182
+ surfaceWindowOptions,
183
+ };
@@ -0,0 +1,306 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const https = require('https');
5
+ const path = require('path');
6
+ const { spawn } = require('child_process');
7
+
8
+ const DEFAULT_REPO = {
9
+ owner: 'tobq',
10
+ repo: 'boardclip',
11
+ branch: 'main',
12
+ };
13
+
14
+ const DEFAULT_POLL_MS = 4 * 60 * 60 * 1000;
15
+ const STARTUP_DELAY_MS = 90 * 1000;
16
+
17
+ function requestJson(url, timeoutMs = 10000) {
18
+ return new Promise((resolve, reject) => {
19
+ const req = https.get(url, {
20
+ headers: {
21
+ 'Accept': 'application/vnd.github+json',
22
+ 'User-Agent': 'BoardClip auto-update',
23
+ 'X-GitHub-Api-Version': '2022-11-28',
24
+ },
25
+ timeout: timeoutMs,
26
+ }, (res) => {
27
+ let body = '';
28
+ res.setEncoding('utf8');
29
+ res.on('data', chunk => { body += chunk; });
30
+ res.on('end', () => {
31
+ if (res.statusCode < 200 || res.statusCode >= 300) {
32
+ reject(new Error(`GitHub returned ${res.statusCode}`));
33
+ return;
34
+ }
35
+ try {
36
+ resolve(JSON.parse(body));
37
+ } catch (error) {
38
+ reject(error);
39
+ }
40
+ });
41
+ });
42
+ req.on('timeout', () => req.destroy(new Error('GitHub request timed out')));
43
+ req.on('error', reject);
44
+ });
45
+ }
46
+
47
+ async function latestCommitSha(repo = DEFAULT_REPO) {
48
+ const url = `https://api.github.com/repos/${repo.owner}/${repo.repo}/commits/${encodeURIComponent(repo.branch)}`;
49
+ const data = await requestJson(url);
50
+ return data && data.sha ? String(data.sha) : null;
51
+ }
52
+
53
+ function updateScriptPath(appDir, platform = process.platform) {
54
+ return platform === 'win32'
55
+ ? path.win32.join(appDir, 'update.bat')
56
+ : path.posix.join(appDir, 'update.sh');
57
+ }
58
+
59
+ function updateSupport(appDir, buildInfo, platform = process.platform, { updateMode = 'production' } = {}) {
60
+ // The npm package (npm i -g @tobq/boardclip, npx @tobq/boardclip) lives in a node_modules
61
+ // folder and is updated by npm itself, not by git.
62
+ if (String(appDir).split(/[\\/]+/).includes('node_modules')) return { supported: false, reason: 'npm-install' };
63
+ if (!buildInfo || !buildInfo.fullCommit) return { supported: false, reason: 'unknown-build' };
64
+ if (!fs.existsSync(path.join(appDir, '.git'))) return { supported: false, reason: 'not-git-checkout' };
65
+ if (!fs.existsSync(updateScriptPath(appDir, platform))) return { supported: false, reason: 'missing-update-script' };
66
+ if (buildInfo.dirty && updateMode !== 'development') return { supported: false, reason: 'dirty-checkout' };
67
+ return { supported: true, reason: 'supported' };
68
+ }
69
+
70
+ function canAutoUpdate(appDir, buildInfo, platform = process.platform) {
71
+ return updateSupport(appDir, buildInfo, platform).supported;
72
+ }
73
+
74
+ function spawnAndCapture(command, args, options = {}) {
75
+ return new Promise((resolve) => {
76
+ const child = spawn(command, args, options);
77
+ let stdout = '';
78
+ let stderr = '';
79
+ if (child.stdout) child.stdout.on('data', chunk => { stdout += chunk; });
80
+ if (child.stderr) child.stderr.on('data', chunk => { stderr += chunk; });
81
+ child.on('error', error => resolve({ code: 1, stdout, stderr, error }));
82
+ child.on('close', code => resolve({ code, stdout, stderr }));
83
+ });
84
+ }
85
+
86
+ function runGit(appDir, args) {
87
+ return spawnAndCapture('git', args, {
88
+ cwd: appDir,
89
+ stdio: ['ignore', 'pipe', 'pipe'],
90
+ windowsHide: true,
91
+ });
92
+ }
93
+
94
+ async function gitHead(appDir) {
95
+ const result = await runGit(appDir, ['rev-parse', 'HEAD']);
96
+ return result.code === 0 ? result.stdout.trim() : null;
97
+ }
98
+
99
+ async function changedFilesBetween(appDir, before, after) {
100
+ if (!before || !after || before === after) return [];
101
+ const result = await runGit(appDir, ['diff', '--name-only', before, after]);
102
+ if (result.code !== 0) return [];
103
+ return result.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
104
+ }
105
+
106
+ function canHotReloadFile(file) {
107
+ return file === 'index.html' || file.startsWith('site/shared/');
108
+ }
109
+
110
+ function updateModeForChangedFiles(files) {
111
+ if (!files.length) return 'none';
112
+ return files.every(canHotReloadFile) ? 'reload' : 'relaunch';
113
+ }
114
+
115
+ function updateEnvironment(baseEnv = process.env, { applyOnly = false, updateMode = 'production' } = {}) {
116
+ const env = { ...baseEnv };
117
+ // Never inherit control flags from the parent BoardClip process. Production
118
+ // must not accidentally inherit Development's dirty-checkout bypass, and a
119
+ // detached update must not inherit apply-only's no-start flag.
120
+ delete env.BOARDCLIP_UPDATE_ALLOW_DIRTY;
121
+ delete env.BOARDCLIP_UPDATE_NO_START;
122
+ if (applyOnly) env.BOARDCLIP_UPDATE_NO_START = '1';
123
+ if (updateMode === 'development') env.BOARDCLIP_UPDATE_ALLOW_DIRTY = '1';
124
+ return env;
125
+ }
126
+
127
+ function runUpdate(appDir, platform = process.platform, { applyOnly = false, updateMode = 'production' } = {}) {
128
+ const script = updateScriptPath(appDir, platform);
129
+ const env = updateEnvironment(process.env, { applyOnly, updateMode });
130
+ const command = platform === 'win32' ? 'cmd.exe' : 'sh';
131
+ const args = platform === 'win32' ? ['/c', script] : [script];
132
+ const options = platform === 'win32'
133
+ ? {
134
+ cwd: appDir,
135
+ detached: !applyOnly,
136
+ stdio: applyOnly ? ['ignore', 'pipe', 'pipe'] : 'ignore',
137
+ windowsHide: true,
138
+ env,
139
+ }
140
+ : {
141
+ cwd: appDir,
142
+ detached: !applyOnly,
143
+ stdio: applyOnly ? ['ignore', 'pipe', 'pipe'] : 'ignore',
144
+ env,
145
+ };
146
+
147
+ if (applyOnly) {
148
+ return spawnAndCapture(command, args, options);
149
+ }
150
+ const child = spawn(command, args, options);
151
+ child.unref();
152
+ return { detached: true };
153
+ }
154
+
155
+ function createAutoUpdater({
156
+ appDir,
157
+ buildInfo,
158
+ getBuildInfo = null,
159
+ repo = DEFAULT_REPO,
160
+ pollMs = DEFAULT_POLL_MS,
161
+ startupDelayMs = STARTUP_DELAY_MS,
162
+ logger = console,
163
+ onReload,
164
+ onRelaunch,
165
+ onBuildInfoChanged,
166
+ onEvent = null,
167
+ getUpdateMode = () => 'production',
168
+ // Injection seams for tests; production uses the real network + git + script.
169
+ fetchLatest = latestCommitSha,
170
+ readHead = gitHead,
171
+ readChangedFiles = changedFilesBetween,
172
+ applyUpdate = runUpdate,
173
+ } = {}) {
174
+ let checking = false;
175
+ let updating = false;
176
+ let startupTimer = null;
177
+ let timer = null;
178
+ // The commit the RUNNING process was started from (or last hot-reloaded to).
179
+ // This, not the on-disk HEAD, is what "am I up to date" must compare against:
180
+ // update.bat pulls BEFORE it relaunches, so a script that pulls and then
181
+ // fails (2026-09-03..20: a :: comment inside a ( ) block aborted it with a
182
+ // parse error, exit 255) leaves disk at the new commit while the process
183
+ // still runs the old one. Comparing against disk then reports "current" on
184
+ // every later poll and the stale process runs until someone restarts it by
185
+ // hand. Comparing against the running commit retries the relaunch instead.
186
+ let currentCommit = buildInfo && buildInfo.fullCommit;
187
+
188
+ function emit(event) {
189
+ if (typeof onEvent !== 'function') return;
190
+ try { onEvent(event); } catch {}
191
+ }
192
+
193
+ function currentBuildInfo() {
194
+ return typeof getBuildInfo === 'function' ? (getBuildInfo() || buildInfo) : buildInfo;
195
+ }
196
+
197
+ async function check({ manual = false } = {}) {
198
+ if (checking || updating) return { ok: false, status: 'busy' };
199
+ const liveBuildInfo = currentBuildInfo();
200
+ const updateMode = getUpdateMode();
201
+ const support = updateSupport(appDir, liveBuildInfo, process.platform, { updateMode });
202
+ if (!support.supported) return { ok: false, status: 'unsupported', reason: support.reason };
203
+
204
+ checking = true;
205
+ let current = currentCommit || liveBuildInfo.fullCommit;
206
+ let latest = null;
207
+ try {
208
+ latest = await fetchLatest(repo);
209
+ if (!latest) return { ok: false, status: 'unknown' };
210
+ if (latest === current) return { ok: true, status: 'current', latest };
211
+
212
+ updating = true;
213
+ logger.log(`BoardClip update available: ${current.slice(0, 7)} -> ${latest.slice(0, 7)}`);
214
+ const updateResult = await applyUpdate(appDir, process.platform, { applyOnly: true, updateMode });
215
+ if (updateResult.code !== 0) {
216
+ const message = (updateResult.error ? updateResult.error.message : (updateResult.stderr || updateResult.stdout || 'update failed')).trim();
217
+ // The pull may already have landed: disk can now be ahead of the
218
+ // running process. Say so loudly - this is the state that used to
219
+ // go unnoticed for days.
220
+ const diskHead = await readHead(appDir);
221
+ emit({ type: 'apply_failed', from: current, to: latest, disk_head: diskHead, code: updateResult.code, message });
222
+ throw new Error(message);
223
+ }
224
+
225
+ const updatedHead = await readHead(appDir) || latest;
226
+ const changedFiles = await readChangedFiles(appDir, current, updatedHead);
227
+ const mode = updateModeForChangedFiles(changedFiles);
228
+ if (mode === 'none') {
229
+ // The pull changed nothing the running process isn't already on - e.g.
230
+ // a checkout AHEAD of GitHub main (a local commit not pushed yet), whose
231
+ // fast-forward pull is a no-op. Relaunching then restarted the app 90 s
232
+ // after every start for nothing; stay up instead.
233
+ updating = false;
234
+ emit({ type: 'unchanged', from: current, to: latest, disk_head: updatedHead });
235
+ return { ok: true, status: 'unchanged', latest, diskHead: updatedHead };
236
+ }
237
+ currentCommit = updatedHead;
238
+ if (onBuildInfoChanged) onBuildInfoChanged();
239
+ emit({ type: 'applied', from: current, to: updatedHead, mode, changed_files: changedFiles.length });
240
+
241
+ if (mode === 'reload' && onReload) {
242
+ await onReload({ latest: updatedHead, changedFiles });
243
+ updating = false;
244
+ return { ok: true, status: 'reloaded', latest: updatedHead, changedFiles, mode };
245
+ }
246
+
247
+ if (onRelaunch) {
248
+ await onRelaunch({ latest: updatedHead, changedFiles, mode });
249
+ return { ok: true, status: 'relaunching', latest: updatedHead, changedFiles, mode };
250
+ }
251
+
252
+ applyUpdate(appDir, process.platform, { updateMode });
253
+ return { ok: true, status: 'updating', latest: updatedHead, changedFiles, mode };
254
+ } catch (error) {
255
+ // A failed FETCH is routine (offline, rate limit) and stays quiet unless
256
+ // asked; a failed APPLY was already emitted above with full detail.
257
+ if (manual) logger.error(`BoardClip update check failed: ${error.message}`);
258
+ else if (latest) logger.error(`BoardClip update ${current.slice(0, 7)} -> ${latest.slice(0, 7)} failed: ${error.message}`);
259
+ updating = false;
260
+ return { ok: false, status: 'error', error };
261
+ } finally {
262
+ checking = false;
263
+ }
264
+ }
265
+
266
+ function start() {
267
+ if (startupTimer || timer) return;
268
+ // Always keep the schedule alive in a source checkout. Production mode may
269
+ // currently be blocked by tracked edits, but a developer can switch to
270
+ // Development later without restarting the app.
271
+ const baseSupport = updateSupport(appDir, currentBuildInfo(), process.platform, { updateMode: 'development' });
272
+ if (!baseSupport.supported) return;
273
+ startupTimer = setTimeout(() => {
274
+ startupTimer = null;
275
+ check();
276
+ }, startupDelayMs);
277
+ startupTimer.unref();
278
+ timer = setInterval(() => check(), pollMs);
279
+ timer.unref();
280
+ }
281
+
282
+ function stop() {
283
+ if (startupTimer) clearTimeout(startupTimer);
284
+ if (timer) clearInterval(timer);
285
+ startupTimer = null;
286
+ timer = null;
287
+ }
288
+
289
+ return { check, start, stop };
290
+ }
291
+
292
+ module.exports = {
293
+ DEFAULT_REPO,
294
+ DEFAULT_POLL_MS,
295
+ STARTUP_DELAY_MS,
296
+ canAutoUpdate,
297
+ changedFilesBetween,
298
+ createAutoUpdater,
299
+ gitHead,
300
+ latestCommitSha,
301
+ runUpdate,
302
+ updateSupport,
303
+ updateEnvironment,
304
+ updateScriptPath,
305
+ updateModeForChangedFiles,
306
+ };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ // Worker-thread entry for the history backup snapshot.
4
+ //
5
+ // The content-addressed snapshot hashes every stored item (~10k on a mature
6
+ // history) and then runs retention over the pool. Done inline on the main
7
+ // process that was 0.6s median / 2.4s p90 / 5.8s worst of blocking per
8
+ // clipboard capture (measured 2026-09-02 on a 9.7k-item, 7.7MB history), and
9
+ // every popup open queued behind it. The main process now hands over the
10
+ // PREVIOUS on-disk history + settings JSON strings (the pre-write state the
11
+ // time-machine records) and carries on; the result comes back as one message.
12
+ //
13
+ // Pure Node (fs + crypto via lib/backup.js); no Electron APIs, so it also runs
14
+ // under plain `node` in tests.
15
+
16
+ const { parentPort, workerData } = require('worker_threads');
17
+ const backupStore = require('./backup');
18
+
19
+ function parseJson(text) {
20
+ return JSON.parse(String(text || '').replace(/^\uFEFF/, ''));
21
+ }
22
+
23
+ function run(data) {
24
+ const { baseDir, historyJson, settingsJson, reason, createdAt, source, prune } = data;
25
+ const parsed = parseJson(historyJson);
26
+ const history = Array.isArray(parsed) ? parsed : [];
27
+ let settings = null;
28
+ if (settingsJson) {
29
+ try { settings = parseJson(settingsJson); } catch { settings = null; }
30
+ }
31
+ const result = backupStore.writeSnapshot(baseDir, {
32
+ history,
33
+ settings,
34
+ reason,
35
+ createdAt: new Date(createdAt),
36
+ source,
37
+ });
38
+ const pruned = prune ? backupStore.pruneBackups(baseDir, { ...prune, now: Date.now() }) : null;
39
+ return { manifestPath: result.manifestPath, count: result.manifest.count, pruned };
40
+ }
41
+
42
+ try {
43
+ parentPort.postMessage({ ok: true, ...run(workerData) });
44
+ } catch (err) {
45
+ parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
46
+ }