@tobq/boardclip 0.0.0-stage → 2.1.107
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.
- package/LICENSE +21 -0
- package/README.md +170 -2
- package/assets/boardclip-icon.png +0 -0
- package/bin/boardclip.js +43 -0
- package/editor-preload.js +71 -0
- package/editor.html +220 -0
- package/icon.png +0 -0
- package/icon@2x.png +0 -0
- package/iconTemplate.png +0 -0
- package/iconTemplate@2x.png +0 -0
- package/index.html +1339 -0
- package/lib/appearance.js +183 -0
- package/lib/auto-update.js +306 -0
- package/lib/backup-worker.js +46 -0
- package/lib/backup.js +275 -0
- package/lib/blob-field-store.js +275 -0
- package/lib/blob-store.js +68 -0
- package/lib/build-info.js +89 -0
- package/lib/clip-blob-store.js +161 -0
- package/lib/clip-revision.js +81 -0
- package/lib/clipboard-capture.js +95 -0
- package/lib/clipboard-follow.js +98 -0
- package/lib/clipboard-model.js +1235 -0
- package/lib/cloud-accounts.js +250 -0
- package/lib/conflict-model.js +186 -0
- package/lib/control-client.js +87 -0
- package/lib/control-server.js +149 -0
- package/lib/diagnostics.js +121 -0
- package/lib/drag-files.js +48 -0
- package/lib/ensure-directory.js +56 -0
- package/lib/fork-names.js +35 -0
- package/lib/fs-probe.js +132 -0
- package/lib/history-feed.js +48 -0
- package/lib/hmac-auth.js +78 -0
- package/lib/macos-hotkey.js +297 -0
- package/lib/macos-paste.js +112 -0
- package/lib/mcp-core.js +270 -0
- package/lib/mcp-installers.js +284 -0
- package/lib/mcp-paths.js +99 -0
- package/lib/p2p-crypto.js +54 -0
- package/lib/p2p-discovery.js +191 -0
- package/lib/quick-paste.js +187 -0
- package/lib/retention.js +43 -0
- package/lib/sync-delta.js +227 -0
- package/lib/sync-journal.js +181 -0
- package/lib/sync-paths.js +73 -0
- package/lib/tailscale.js +80 -0
- package/lib/text-blob-store.js +38 -0
- package/lib/windows-clipboard.js +213 -0
- package/lib/windows-console.js +63 -0
- package/lib/windows-dwm.js +183 -0
- package/lib/windows-hook-worker.js +236 -0
- package/lib/windows-hook.js +91 -0
- package/lib/windows-paste.js +185 -0
- package/main.js +7540 -0
- package/mcp/boardclip-mcp.js +255 -0
- package/mcp-approval-preload.js +22 -0
- package/mcp-approval.html +218 -0
- package/package.json +55 -4
- package/preload.js +106 -0
- package/site/index.html +1624 -0
- package/site/shared/clip-search.js +2330 -0
- package/site/shared/clipboard-popup.css +1046 -0
- package/site/shared/clipboard-tokens.css +326 -0
- package/site/shared/clipboard-ui-core.js +6921 -0
- package/site/shared/clipboard-window.css +36 -0
- package/site/shared/vendor/cm5/README.md +52 -0
- package/site/shared/vendor/cm5/codemirror.css +344 -0
- package/site/shared/vendor/cm5/codemirror.js +9884 -0
- package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
- package/site/shared/vendor/cm5/merge.css +119 -0
- package/site/shared/vendor/cm5/merge.js +1182 -0
- package/site/shared/vendor/fonts/README.md +19 -0
- package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
- package/viewer-preload.js +51 -0
- package/viewer.html +129 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Robust quick-paste orchestrator (numpad slots + panel number keys).
|
|
3
|
+
//
|
|
4
|
+
// The failure this module fixes: BoardClip quick-paste must (1) put a saved
|
|
5
|
+
// item onto the clipboard, (2) synthesize Ctrl/Cmd+V into the foreground app,
|
|
6
|
+
// then (3) restore the user's previous clipboard. Steps 2 and 3 race — Ctrl+V
|
|
7
|
+
// is asynchronous (it only injects a keystroke; the target app reads the
|
|
8
|
+
// clipboard whenever it next drains its input queue), so a fixed delay before
|
|
9
|
+
// restoring the previous clipboard is unreliable. Under load the target reads
|
|
10
|
+
// AFTER we've already restored → it pastes the previous clip. That is the
|
|
11
|
+
// "pastes random old stuff, worse under lag" bug.
|
|
12
|
+
//
|
|
13
|
+
// The orchestrator makes the sequence robust regardless of platform strategy:
|
|
14
|
+
// * SERIALIZES requests through a promise chain (rapid presses queue instead
|
|
15
|
+
// of being dropped — no more "press it 3 times").
|
|
16
|
+
// * COALESCES duplicate requests for the same slot inside a short window
|
|
17
|
+
// (belt-and-suspenders against key auto-repeat that slips past the hook).
|
|
18
|
+
// * VERIFIES the macro actually landed on the clipboard before pasting
|
|
19
|
+
// (a synchronous write can still be observed late under contention).
|
|
20
|
+
// * MEASURES real scheduler lag and ADAPTS the pre-restore delay to it, with
|
|
21
|
+
// a generous floor/ceiling, so a laggy machine waits longer before restore.
|
|
22
|
+
// * RESTORES SAFELY: only overwrites the clipboard if it still holds our
|
|
23
|
+
// macro, so we never clobber something the user copied mid-sequence.
|
|
24
|
+
//
|
|
25
|
+
// Everything here is dependency-injected and pure so it runs under a plain-node
|
|
26
|
+
// test with a fake clipboard + a fake "late-reading target" (see
|
|
27
|
+
// test/numpad-paste.test.js). main.js wires the real Electron clipboard and the
|
|
28
|
+
// platform paste primitive. There is a SINGLE delivery mechanism — put the item
|
|
29
|
+
// on the clipboard, synthesize Ctrl/Cmd+V, safe-restore the previous clipboard —
|
|
30
|
+
// which is the SAME primitive the panel-click paste uses. Numpad therefore can
|
|
31
|
+
// never diverge into typing keystrokes (an earlier keystroke-injection mode
|
|
32
|
+
// turned newlines into Enter presses and fired unintended sends; it was removed).
|
|
33
|
+
|
|
34
|
+
const DEFAULT_CONFIG = {
|
|
35
|
+
restore: true, // restore the user's previous clipboard afterwards
|
|
36
|
+
verifyTries: 12, // how many times to re-check the macro landed
|
|
37
|
+
verifyIntervalMs: 8, // spacing between verify checks (~100ms budget)
|
|
38
|
+
minRestoreDelayMs: 400, // floor before restoring (target needs time to read)
|
|
39
|
+
maxRestoreDelayMs: 1200, // ceiling so restore never hangs forever
|
|
40
|
+
lagMultiplier: 3, // restore delay grows this * measured scheduler lag
|
|
41
|
+
coalesceMs: 90, // drop same-key repeats that arrive within this window
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function createQuickPaster(deps) {
|
|
45
|
+
const {
|
|
46
|
+
snapshot, // () => opaque backup of current clipboard
|
|
47
|
+
restore, // (backup) => void — write backup back
|
|
48
|
+
writeItem, // (item) => void — put the macro on the clipboard
|
|
49
|
+
clipboardMatchesItem, // (item) => bool — does the clipboard hold the macro?
|
|
50
|
+
paste, // async () => pasteResult — synthesize Ctrl/Cmd+V
|
|
51
|
+
sleep, // (ms) => Promise
|
|
52
|
+
now = () => Date.now(),
|
|
53
|
+
log = () => {},
|
|
54
|
+
getConfig = () => ({}),
|
|
55
|
+
} = deps;
|
|
56
|
+
|
|
57
|
+
let chain = Promise.resolve();
|
|
58
|
+
let running = false;
|
|
59
|
+
const lastStartByKey = new Map();
|
|
60
|
+
|
|
61
|
+
function config() {
|
|
62
|
+
return { ...DEFAULT_CONFIG, ...(getConfig() || {}) };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Wait until the clipboard actually reflects the macro. A synchronous
|
|
66
|
+
// clipboard write can still be observed late while the system is busy, and
|
|
67
|
+
// pasting before it lands is exactly how stale content sneaks through.
|
|
68
|
+
async function verifySet(item, cfg, trace) {
|
|
69
|
+
for (let attempt = 1; attempt <= cfg.verifyTries; attempt++) {
|
|
70
|
+
let matched = false;
|
|
71
|
+
try { matched = !!clipboardMatchesItem(item); } catch { matched = false; }
|
|
72
|
+
if (matched) {
|
|
73
|
+
if (attempt > 1) log('quick_paste.set_verified_late', { ...trace, attempts: attempt });
|
|
74
|
+
return { ok: true, attempts: attempt };
|
|
75
|
+
}
|
|
76
|
+
if (attempt < cfg.verifyTries) await sleep(cfg.verifyIntervalMs);
|
|
77
|
+
}
|
|
78
|
+
log('quick_paste.set_unverified', { ...trace, attempts: cfg.verifyTries });
|
|
79
|
+
return { ok: false, attempts: cfg.verifyTries };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Measure how much longer than requested a short sleep actually took — a
|
|
83
|
+
// direct read of current scheduler/system lag, which we fold into the
|
|
84
|
+
// pre-restore delay so a laggy machine gives the target more time.
|
|
85
|
+
async function measureLag(sampleMs) {
|
|
86
|
+
const t0 = now();
|
|
87
|
+
await sleep(sampleMs);
|
|
88
|
+
return Math.max(0, (now() - t0) - sampleMs);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function timedDeliver({ item, paste: doPaste, cfg, trace }) {
|
|
92
|
+
writeItem(item);
|
|
93
|
+
const verify = await verifySet(item, cfg, trace);
|
|
94
|
+
// Scheduler-lag proxy: how much our own short sleeps overrun is a real
|
|
95
|
+
// signal of local system load (a bogged machine both delays us AND makes
|
|
96
|
+
// the target slower to read). Sample either side of the paste and take the
|
|
97
|
+
// worse. It's a mild safety bump — the configured floor is the main lever;
|
|
98
|
+
// we cannot measure the target process's read latency from here.
|
|
99
|
+
const lagBefore = await measureLag(20);
|
|
100
|
+
const pasteResult = await doPaste();
|
|
101
|
+
const lagAfter = await measureLag(20);
|
|
102
|
+
const lag = Math.max(lagBefore, lagAfter);
|
|
103
|
+
const restoreDelay = Math.min(
|
|
104
|
+
cfg.maxRestoreDelayMs,
|
|
105
|
+
Math.max(cfg.minRestoreDelayMs, cfg.minRestoreDelayMs + lag * cfg.lagMultiplier),
|
|
106
|
+
);
|
|
107
|
+
log('quick_paste.timed_wait', { ...trace, lag_ms: Math.round(lag), restore_delay_ms: Math.round(restoreDelay) });
|
|
108
|
+
await sleep(restoreDelay);
|
|
109
|
+
return { verified: verify.ok, verifyAttempts: verify.attempts, pasteResult, restoreDelay };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function runOne(item, opts) {
|
|
113
|
+
const cfg = config();
|
|
114
|
+
const trace = opts.trace || {};
|
|
115
|
+
const startedAt = now();
|
|
116
|
+
running = true;
|
|
117
|
+
|
|
118
|
+
let backup = null;
|
|
119
|
+
let outcome = { ok: false };
|
|
120
|
+
try {
|
|
121
|
+
// A request may supply its own paste primitive (e.g. macOS needs the
|
|
122
|
+
// target app name captured at request time); otherwise use the dep.
|
|
123
|
+
const doPaste = typeof opts.paste === 'function' ? opts.paste : paste;
|
|
124
|
+
|
|
125
|
+
backup = snapshot();
|
|
126
|
+
const delivered = await timedDeliver({ item, paste: doPaste, cfg, trace });
|
|
127
|
+
outcome = { ok: true, ...delivered };
|
|
128
|
+
|
|
129
|
+
if (cfg.restore) {
|
|
130
|
+
// Only restore if the clipboard still holds our macro. If it changed,
|
|
131
|
+
// the user (or another app) put something new there — never clobber it.
|
|
132
|
+
let stillMacro = true;
|
|
133
|
+
try { stillMacro = !!clipboardMatchesItem(item); } catch { stillMacro = true; }
|
|
134
|
+
if (stillMacro) {
|
|
135
|
+
try { restore(backup); outcome.restored = true; }
|
|
136
|
+
catch (e) { outcome.restored = false; outcome.restoreError = e && e.message; }
|
|
137
|
+
} else {
|
|
138
|
+
outcome.restored = false;
|
|
139
|
+
outcome.restoreSkipped = 'clipboard_changed';
|
|
140
|
+
log('quick_paste.restore_skipped', { ...trace, reason: 'clipboard_changed' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (e) {
|
|
144
|
+
outcome = { ok: false, error: e && e.message };
|
|
145
|
+
log('quick_paste.error', { ...trace, error: e && e.message });
|
|
146
|
+
} finally {
|
|
147
|
+
running = false;
|
|
148
|
+
log('quick_paste.done', { ...trace, total_ms: now() - startedAt, ...summarize(outcome) });
|
|
149
|
+
}
|
|
150
|
+
return outcome;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function summarize(o) {
|
|
154
|
+
return {
|
|
155
|
+
verified: o.verified,
|
|
156
|
+
restored: o.restored,
|
|
157
|
+
restore_skipped: o.restoreSkipped,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Enqueue a quick-paste. Returns a promise for the outcome. Rapid presses
|
|
162
|
+
// serialize; same-key repeats inside coalesceMs are dropped as duplicates.
|
|
163
|
+
function request(item, opts = {}) {
|
|
164
|
+
const cfg = config();
|
|
165
|
+
const key = opts.coalesceKey != null ? String(opts.coalesceKey) : null;
|
|
166
|
+
if (key != null) {
|
|
167
|
+
const prev = lastStartByKey.get(key);
|
|
168
|
+
if (prev != null && now() - prev < cfg.coalesceMs) {
|
|
169
|
+
log('quick_paste.coalesced', { ...(opts.trace || {}), key });
|
|
170
|
+
return Promise.resolve({ ok: false, coalesced: true });
|
|
171
|
+
}
|
|
172
|
+
lastStartByKey.set(key, now());
|
|
173
|
+
}
|
|
174
|
+
const next = chain.then(() => runOne(item, opts));
|
|
175
|
+
// Keep the chain alive even if one op throws.
|
|
176
|
+
chain = next.catch(() => {});
|
|
177
|
+
return next;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
request,
|
|
182
|
+
busy: () => running,
|
|
183
|
+
_config: config,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = { createQuickPaster, DEFAULT_CONFIG };
|
package/lib/retention.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Shared retention policy for on-disk buffers (history backups, the external-edit
|
|
4
|
+
// archive, ...). Pure and side-effect-free so the policy is unit-testable without
|
|
5
|
+
// the filesystem: callers enumerate the dir into { mtimeMs, size } entries, this
|
|
6
|
+
// decides WHICH to evict, the caller unlinks them.
|
|
7
|
+
//
|
|
8
|
+
// Any combination of caps may be supplied; each is skipped when its option is
|
|
9
|
+
// absent, and they compose in this order (a file removed by an earlier cap is not
|
|
10
|
+
// reconsidered):
|
|
11
|
+
// - maxAgeMs: drop entries older than the cutoff (needs `now`)
|
|
12
|
+
// - maxFiles: keep only the newest N survivors
|
|
13
|
+
// - maxBytes: evict oldest survivors until the total is within the cap
|
|
14
|
+
// Eviction is always oldest-first (LRU by mtime).
|
|
15
|
+
|
|
16
|
+
function planRetention(entries, { maxFiles, maxBytes, maxAgeMs, now } = {}) {
|
|
17
|
+
const sorted = [...entries].sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
|
|
18
|
+
const remove = new Set();
|
|
19
|
+
const survivors = () => sorted.filter((e) => !remove.has(e));
|
|
20
|
+
|
|
21
|
+
if (maxAgeMs != null && now != null) {
|
|
22
|
+
for (const e of sorted) if (now - e.mtimeMs > maxAgeMs) remove.add(e);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (maxFiles != null) {
|
|
26
|
+
const live = survivors();
|
|
27
|
+
for (let i = 0; i < live.length - maxFiles; i++) remove.add(live[i]); // oldest excess
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (maxBytes != null) {
|
|
31
|
+
let total = survivors().reduce((sum, e) => sum + (e.size || 0), 0);
|
|
32
|
+
for (const e of sorted) {
|
|
33
|
+
if (remove.has(e)) continue;
|
|
34
|
+
if (total <= maxBytes) break;
|
|
35
|
+
remove.add(e);
|
|
36
|
+
total -= e.size || 0;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return sorted.filter((e) => remove.has(e));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { planRetention };
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Delta sync: ONE changes feed shared by P2P (v2 /delta endpoints) and the
|
|
4
|
+
// cloud journals (sync/<device>/<revision>.json files in each provider folder).
|
|
5
|
+
//
|
|
6
|
+
// Model (CouchDB `_changes?since=seq` on top of the existing union merge):
|
|
7
|
+
// - `createChangeTracker` keeps, per entry (item / tombstone / group tombstone
|
|
8
|
+
// / supersede / conflict record / the small synced settings), the LOCAL
|
|
9
|
+
// revision at which that entry last changed on THIS device, whatever its
|
|
10
|
+
// source (own capture, P2P, cloud). Revisions are a per-device monotonic
|
|
11
|
+
// counter, so cursors are "the sender's revision" and never compare clocks
|
|
12
|
+
// across devices.
|
|
13
|
+
// - `deltaSince(since)` selects every entry whose arrival revision is newer
|
|
14
|
+
// than the cursor. Applying a delta is the existing `foldRemoteState` union
|
|
15
|
+
// merge, which only ever ADDS or UPDATES (deletion needs a tombstone), so a
|
|
16
|
+
// partial history is safe by construction and re-sending is idempotent.
|
|
17
|
+
// - Restart safety: the tracker is persisted lazily (sync-state.json). On load
|
|
18
|
+
// the start revision jumps to max(persisted + 1, Date.now()), and any entry
|
|
19
|
+
// whose arrival was lost (crash inside the lazy-write window) is stamped with
|
|
20
|
+
// the new start revision, so it is re-sent to every peer once. Over-sending
|
|
21
|
+
// is harmless; under-sending would be a silent hole, so the bias is deliberate.
|
|
22
|
+
|
|
23
|
+
const crypto = require('crypto');
|
|
24
|
+
const clipboardModel = require('./clipboard-model');
|
|
25
|
+
|
|
26
|
+
const PROTOCOL = 2;
|
|
27
|
+
const K_ITEM = 'i:';
|
|
28
|
+
const K_TOMB = 't:';
|
|
29
|
+
const K_GTOMB = 'g:';
|
|
30
|
+
const K_SUP = 's:';
|
|
31
|
+
const K_CONF = 'c:';
|
|
32
|
+
const K_SETTINGS = 'S';
|
|
33
|
+
|
|
34
|
+
function hashJson(value) {
|
|
35
|
+
return crypto.createHash('sha1').update(JSON.stringify(value === undefined ? null : value)).digest('hex').slice(0, 20);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The mutation clock is THE "newer" signal the merge itself uses (capture/edit,
|
|
39
|
+
// pin/groups/numpad, title, ts correction), so it is the right change signal
|
|
40
|
+
// here too; the blob refs cover a formatting payload arriving for an existing
|
|
41
|
+
// clip without any clock change.
|
|
42
|
+
function itemFingerprint(item) {
|
|
43
|
+
return [
|
|
44
|
+
clipboardModel.itemMutationClock(item),
|
|
45
|
+
item.textRef || '', item.htmlRef || '', item.rtfRef || '', item.image || '',
|
|
46
|
+
item.textHash || '', item.htmlHash || '', item.rtfHash || '',
|
|
47
|
+
].join('|');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Synced settings minus the three big time-stamped lists (which are tracked
|
|
51
|
+
// entry by entry) and minus anything that is not user data.
|
|
52
|
+
function settingsSmall(syncedSettings) {
|
|
53
|
+
const small = { ...(syncedSettings || {}) };
|
|
54
|
+
delete small.tombstones;
|
|
55
|
+
delete small.group_tombstones;
|
|
56
|
+
delete small.supersedes;
|
|
57
|
+
return small;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function conflictFingerprint(record) {
|
|
61
|
+
return `${record.updatedAt || 0}|${record.resolved ? 1 : 0}|${hashJson(record.result || null)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function startRevisionAfterLoad(persistedRevision, now = Date.now()) {
|
|
65
|
+
return Math.max((Number(persistedRevision) || 0) + 1, now);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function createChangeTracker({ revision = 0, entries = {}, now = Date.now } = {}) {
|
|
69
|
+
const map = new Map();
|
|
70
|
+
for (const [key, entry] of Object.entries(entries || {})) {
|
|
71
|
+
if (entry && Number.isFinite(entry.a)) map.set(key, { a: entry.a, f: String(entry.f || '') });
|
|
72
|
+
}
|
|
73
|
+
let rev = Number(revision) || 0;
|
|
74
|
+
let dirty = false;
|
|
75
|
+
|
|
76
|
+
function bump() {
|
|
77
|
+
rev += 1;
|
|
78
|
+
dirty = true;
|
|
79
|
+
return rev;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Stamp every entry the view holds that is new or changed with the current
|
|
83
|
+
// revision; forget entries the view no longer holds. Returns the changed keys.
|
|
84
|
+
function observe(view) {
|
|
85
|
+
const seen = new Set();
|
|
86
|
+
const changed = [];
|
|
87
|
+
const touch = (key, fingerprint) => {
|
|
88
|
+
seen.add(key);
|
|
89
|
+
const current = map.get(key);
|
|
90
|
+
if (current && current.f === fingerprint) return;
|
|
91
|
+
map.set(key, { a: rev, f: fingerprint });
|
|
92
|
+
changed.push(key);
|
|
93
|
+
};
|
|
94
|
+
for (const item of view.items || []) {
|
|
95
|
+
if (!item) continue;
|
|
96
|
+
touch(K_ITEM + clipboardModel.itemKey(item), itemFingerprint(item));
|
|
97
|
+
}
|
|
98
|
+
for (const t of view.tombstones || []) if (t && t.id) touch(K_TOMB + t.id, String(t.deletedAt || 0));
|
|
99
|
+
for (const g of view.groupTombstones || []) if (g && g.name) touch(K_GTOMB + g.name, String(g.deletedAt || 0));
|
|
100
|
+
for (const s of view.supersedes || []) if (s && s.from && s.to) touch(K_SUP + s.from + '>' + s.to, String(s.updatedAt || 0));
|
|
101
|
+
const records = view.conflicts && Array.isArray(view.conflicts.records) ? view.conflicts.records : [];
|
|
102
|
+
for (const c of records) if (c && c.id) touch(K_CONF + c.id, conflictFingerprint(c));
|
|
103
|
+
touch(K_SETTINGS, hashJson(settingsSmall(view.settings)));
|
|
104
|
+
for (const key of [...map.keys()]) if (!seen.has(key)) { map.delete(key); dirty = true; }
|
|
105
|
+
if (changed.length) dirty = true;
|
|
106
|
+
return changed;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Everything that arrived after `since`, as a self-describing envelope.
|
|
110
|
+
function deltaSince(since, view, meta = {}) {
|
|
111
|
+
const cursor = Number(since) || 0;
|
|
112
|
+
const newer = key => {
|
|
113
|
+
const entry = map.get(key);
|
|
114
|
+
return !entry || entry.a > cursor;
|
|
115
|
+
};
|
|
116
|
+
const items = [];
|
|
117
|
+
for (const item of view.items || []) {
|
|
118
|
+
if (item && newer(K_ITEM + clipboardModel.itemKey(item))) items.push(item);
|
|
119
|
+
}
|
|
120
|
+
const tombstones = (view.tombstones || []).filter(t => t && t.id && newer(K_TOMB + t.id));
|
|
121
|
+
const groupTombstones = (view.groupTombstones || []).filter(g => g && g.name && newer(K_GTOMB + g.name));
|
|
122
|
+
const supersedes = (view.supersedes || []).filter(s => s && s.from && s.to && newer(K_SUP + s.from + '>' + s.to));
|
|
123
|
+
const records = view.conflicts && Array.isArray(view.conflicts.records) ? view.conflicts.records : [];
|
|
124
|
+
const conflicts = records.filter(c => c && c.id && newer(K_CONF + c.id));
|
|
125
|
+
const includeSettings = newer(K_SETTINGS);
|
|
126
|
+
const envelope = {
|
|
127
|
+
v: PROTOCOL,
|
|
128
|
+
deviceId: meta.deviceId || '',
|
|
129
|
+
deviceName: meta.deviceName || '',
|
|
130
|
+
build: meta.build || '',
|
|
131
|
+
port: meta.port || 0,
|
|
132
|
+
since: cursor,
|
|
133
|
+
revision: rev,
|
|
134
|
+
originClock: now(),
|
|
135
|
+
full: cursor <= 0 || items.length === (view.items || []).length,
|
|
136
|
+
history: items,
|
|
137
|
+
tombstones,
|
|
138
|
+
group_tombstones: groupTombstones,
|
|
139
|
+
supersedes,
|
|
140
|
+
};
|
|
141
|
+
if (conflicts.length) envelope.conflicts = { records: conflicts };
|
|
142
|
+
if (includeSettings) envelope.settings = settingsSmall(view.settings);
|
|
143
|
+
if (typeof meta.assetsOf === 'function') envelope.assets = meta.assetsOf(items);
|
|
144
|
+
return envelope;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isEmpty(envelope) {
|
|
148
|
+
return !envelope.history.length && !envelope.tombstones.length && !envelope.group_tombstones.length
|
|
149
|
+
&& !envelope.supersedes.length && !envelope.conflicts && !envelope.settings;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function serialize() {
|
|
153
|
+
return { revision: rev, entries: Object.fromEntries(map) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
get revision() { return rev; },
|
|
158
|
+
get size() { return map.size; },
|
|
159
|
+
get dirty() { return dirty; },
|
|
160
|
+
markClean() { dirty = false; },
|
|
161
|
+
bump,
|
|
162
|
+
observe,
|
|
163
|
+
deltaSince,
|
|
164
|
+
isEmpty,
|
|
165
|
+
serialize,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isEnvelope(value) {
|
|
170
|
+
return !!(value && typeof value === 'object' && value.v === PROTOCOL && value.deviceId && Array.isArray(value.history));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The (remoteHistory, remoteSettings, remoteConflicts) triple foldRemoteState
|
|
174
|
+
// already consumes, built from an envelope. Items are still in STORED form
|
|
175
|
+
// (blob refs); the caller hydrates after fetching missing assets.
|
|
176
|
+
function envelopeToRemoteState(envelope) {
|
|
177
|
+
return {
|
|
178
|
+
remoteHistory: Array.isArray(envelope.history) ? envelope.history : [],
|
|
179
|
+
remoteSettings: {
|
|
180
|
+
...(envelope.settings || {}),
|
|
181
|
+
tombstones: Array.isArray(envelope.tombstones) ? envelope.tombstones : [],
|
|
182
|
+
group_tombstones: Array.isArray(envelope.group_tombstones) ? envelope.group_tombstones : [],
|
|
183
|
+
supersedes: Array.isArray(envelope.supersedes) ? envelope.supersedes : [],
|
|
184
|
+
},
|
|
185
|
+
remoteConflicts: envelope.conflicts && typeof envelope.conflicts === 'object' ? envelope.conflicts : {},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Ids an envelope can have touched in the merged history: its items, its
|
|
190
|
+
// tombstones, and both ends of its edit lineages. Used for O(delta) change
|
|
191
|
+
// detection instead of stringifying the whole 8 MB history twice per apply.
|
|
192
|
+
function touchedIds(envelope) {
|
|
193
|
+
const ids = new Set();
|
|
194
|
+
for (const item of envelope.history || []) if (item) ids.add(clipboardModel.itemKey(item));
|
|
195
|
+
for (const t of envelope.tombstones || []) if (t && t.id) ids.add(t.id);
|
|
196
|
+
for (const s of envelope.supersedes || []) { if (s && s.from) ids.add(s.from); if (s && s.to) ids.add(s.to); }
|
|
197
|
+
return ids;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function historyChangedBy(before, after, ids) {
|
|
201
|
+
if (before.length !== after.length) return true;
|
|
202
|
+
const beforeById = new Map();
|
|
203
|
+
for (const item of before) if (item) beforeById.set(clipboardModel.itemKey(item), item);
|
|
204
|
+
const afterById = new Map();
|
|
205
|
+
for (const item of after) if (item) afterById.set(clipboardModel.itemKey(item), item);
|
|
206
|
+
if (beforeById.size !== afterById.size) return true;
|
|
207
|
+
for (const id of ids) {
|
|
208
|
+
const a = beforeById.get(id);
|
|
209
|
+
const b = afterById.get(id);
|
|
210
|
+
if (!a && !b) continue;
|
|
211
|
+
if (!a || !b) return true;
|
|
212
|
+
if (a !== b && JSON.stringify(a) !== JSON.stringify(b)) return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
PROTOCOL,
|
|
219
|
+
createChangeTracker,
|
|
220
|
+
startRevisionAfterLoad,
|
|
221
|
+
settingsSmall,
|
|
222
|
+
itemFingerprint,
|
|
223
|
+
isEnvelope,
|
|
224
|
+
envelopeToRemoteState,
|
|
225
|
+
touchedIds,
|
|
226
|
+
historyChangedBy,
|
|
227
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Cloud-folder change journals. Instead of rewriting the whole
|
|
4
|
+
// clipboard-history.json on every copy (8 MB per copy per provider), each
|
|
5
|
+
// device appends ONE small NEW file per change under
|
|
6
|
+
// <provider>/sync/<deviceId>/<revision16>.json
|
|
7
|
+
// holding the delta envelope (lib/sync-delta.js) since its previous journal
|
|
8
|
+
// write to that folder. Readers list the other devices' directories and apply
|
|
9
|
+
// every file newer than their per-device cursor. The monolith stays as a
|
|
10
|
+
// periodic SNAPSHOT (cold start, pre-v2 readers) and the writer prunes its own
|
|
11
|
+
// journal files once a snapshot covers them.
|
|
12
|
+
//
|
|
13
|
+
// Files are only ever CREATED (tmp + rename to a fresh name), never rewritten:
|
|
14
|
+
// Google Drive File Stream forks an object when a rename lands on an existing
|
|
15
|
+
// name (lib/fork-names.js), so nothing here renames over an existing file.
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const crypto = require('crypto');
|
|
20
|
+
const blobStore = require('./blob-store');
|
|
21
|
+
|
|
22
|
+
const JOURNAL_DIR = 'sync';
|
|
23
|
+
const FOLDER_ID_FILE = '.boardclip-folder-id';
|
|
24
|
+
const NAME_RE = /^(\d{16})\.json$/;
|
|
25
|
+
const DEVICE_RE = /^[a-z0-9_-]{1,64}$/i;
|
|
26
|
+
|
|
27
|
+
function safeDeviceId(deviceId) {
|
|
28
|
+
const value = String(deviceId || '').replace(/[^a-z0-9_-]/ig, '').slice(0, 64);
|
|
29
|
+
return DEVICE_RE.test(value) ? value : '';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function journalName(revision) {
|
|
33
|
+
return String(Math.max(0, Math.floor(Number(revision) || 0))).padStart(16, '0') + '.json';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseJournalName(name) {
|
|
37
|
+
const match = NAME_RE.exec(String(name || ''));
|
|
38
|
+
return match ? Number(match[1]) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function journalRoot(syncPath) {
|
|
42
|
+
return path.join(syncPath, JOURNAL_DIR);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function deviceDir(syncPath, deviceId) {
|
|
46
|
+
return path.join(journalRoot(syncPath), safeDeviceId(deviceId));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// [{ deviceId, revision, path }] for every journal file in the folder, sorted by
|
|
50
|
+
// device then revision. `excludeDeviceId` skips the caller's own files.
|
|
51
|
+
async function listJournal(syncPath, { excludeDeviceId = '' } = {}) {
|
|
52
|
+
let devices = [];
|
|
53
|
+
try { devices = await fs.promises.readdir(journalRoot(syncPath), { withFileTypes: true }); } catch { return []; }
|
|
54
|
+
const out = [];
|
|
55
|
+
const skip = safeDeviceId(excludeDeviceId);
|
|
56
|
+
for (const entry of devices) {
|
|
57
|
+
if (!entry.isDirectory()) continue;
|
|
58
|
+
const deviceId = safeDeviceId(entry.name);
|
|
59
|
+
if (!deviceId || deviceId === skip) continue;
|
|
60
|
+
let names = [];
|
|
61
|
+
try { names = await fs.promises.readdir(path.join(journalRoot(syncPath), entry.name)); } catch { continue; }
|
|
62
|
+
for (const name of names) {
|
|
63
|
+
const revision = parseJournalName(name);
|
|
64
|
+
if (revision == null) continue;
|
|
65
|
+
out.push({ deviceId, revision, path: path.join(journalRoot(syncPath), entry.name, name) });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
out.sort((a, b) => a.deviceId.localeCompare(b.deviceId) || a.revision - b.revision);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function writeJournalEntry(syncPath, deviceId, envelope) {
|
|
73
|
+
const dir = deviceDir(syncPath, deviceId);
|
|
74
|
+
if (!safeDeviceId(deviceId)) throw new Error('journal: invalid device id');
|
|
75
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
76
|
+
const finalPath = path.join(dir, journalName(envelope.revision));
|
|
77
|
+
const tmpPath = `${finalPath}.${process.pid}.${Date.now()}.tmp`;
|
|
78
|
+
const json = JSON.stringify(envelope);
|
|
79
|
+
await fs.promises.writeFile(tmpPath, json);
|
|
80
|
+
await fs.promises.rename(tmpPath, finalPath);
|
|
81
|
+
return { path: finalPath, bytes: Buffer.byteLength(json) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function readJournalEntry(filePath) {
|
|
85
|
+
try {
|
|
86
|
+
const parsed = blobStore.parseJsonText(await fs.promises.readFile(filePath, 'utf-8'));
|
|
87
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Delete this device's own journal files that a snapshot already covers
|
|
94
|
+
// (revision <= upToRevision) and that are old enough for every reader to have
|
|
95
|
+
// consumed them. Returns how many were removed.
|
|
96
|
+
async function pruneJournal(syncPath, deviceId, { upToRevision = 0, olderThanMs = 60 * 60 * 1000, now = Date.now() } = {}) {
|
|
97
|
+
const dir = deviceDir(syncPath, deviceId);
|
|
98
|
+
let names = [];
|
|
99
|
+
try { names = await fs.promises.readdir(dir); } catch { return 0; }
|
|
100
|
+
let removed = 0;
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const revision = parseJournalName(name);
|
|
103
|
+
const isTmp = /\.tmp$/.test(name);
|
|
104
|
+
if (revision == null && !isTmp) continue;
|
|
105
|
+
const file = path.join(dir, name);
|
|
106
|
+
try {
|
|
107
|
+
const stats = await fs.promises.stat(file);
|
|
108
|
+
if (now - stats.mtimeMs < olderThanMs) continue;
|
|
109
|
+
if (!isTmp && revision > upToRevision) continue;
|
|
110
|
+
await fs.promises.unlink(file);
|
|
111
|
+
removed++;
|
|
112
|
+
} catch {}
|
|
113
|
+
}
|
|
114
|
+
return removed;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function countJournal(syncPath, deviceId) {
|
|
118
|
+
try {
|
|
119
|
+
const names = await fs.promises.readdir(deviceDir(syncPath, deviceId));
|
|
120
|
+
return names.filter(name => parseJournalName(name) != null).length;
|
|
121
|
+
} catch {
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// A stable id stored INSIDE the folder, so two mount paths of the same cloud
|
|
127
|
+
// folder (Google Drive on G: and H:) are recognised as one provider.
|
|
128
|
+
async function folderIdentity(syncPath) {
|
|
129
|
+
const marker = path.join(syncPath, FOLDER_ID_FILE);
|
|
130
|
+
try {
|
|
131
|
+
const raw = (await fs.promises.readFile(marker, 'utf-8')).trim();
|
|
132
|
+
if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
|
|
133
|
+
} catch {}
|
|
134
|
+
const id = crypto.randomBytes(16).toString('hex');
|
|
135
|
+
try {
|
|
136
|
+
await fs.promises.mkdir(syncPath, { recursive: true });
|
|
137
|
+
await fs.promises.writeFile(marker, id, { flag: 'wx' });
|
|
138
|
+
return id;
|
|
139
|
+
} catch {
|
|
140
|
+
// Lost a race with another writer (or a read-only folder): re-read, else
|
|
141
|
+
// fall back to the path itself so the folder is still treated as unique.
|
|
142
|
+
try {
|
|
143
|
+
const raw = (await fs.promises.readFile(marker, 'utf-8')).trim();
|
|
144
|
+
if (/^[a-f0-9]{32}$/i.test(raw)) return raw.toLowerCase();
|
|
145
|
+
} catch {}
|
|
146
|
+
return `path:${syncPath}`;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// entries: [{ path, id }] -> { primary: [path], duplicateOf: { path: primaryPath } }
|
|
151
|
+
function dedupeByIdentity(entries) {
|
|
152
|
+
const byId = new Map();
|
|
153
|
+
const primary = [];
|
|
154
|
+
const duplicateOf = {};
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
if (!entry || !entry.path) continue;
|
|
157
|
+
const id = entry.id || `path:${entry.path}`;
|
|
158
|
+
const first = byId.get(id);
|
|
159
|
+
if (first) { duplicateOf[entry.path] = first; continue; }
|
|
160
|
+
byId.set(id, entry.path);
|
|
161
|
+
primary.push(entry.path);
|
|
162
|
+
}
|
|
163
|
+
return { primary, duplicateOf };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
JOURNAL_DIR,
|
|
168
|
+
FOLDER_ID_FILE,
|
|
169
|
+
safeDeviceId,
|
|
170
|
+
journalName,
|
|
171
|
+
parseJournalName,
|
|
172
|
+
journalRoot,
|
|
173
|
+
deviceDir,
|
|
174
|
+
listJournal,
|
|
175
|
+
writeJournalEntry,
|
|
176
|
+
readJournalEntry,
|
|
177
|
+
pruneJournal,
|
|
178
|
+
countJournal,
|
|
179
|
+
folderIdentity,
|
|
180
|
+
dedupeByIdentity,
|
|
181
|
+
};
|