@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.
- 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,89 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { execFileSync } = require('child_process');
|
|
4
|
+
|
|
5
|
+
// Read-only git, never holding a lock: `git status` otherwise refreshes the
|
|
6
|
+
// index under .git/index.lock, and the 1 s timeout below kills a slow run with
|
|
7
|
+
// the lock still on disk (seen 2026-10-07/08 on a busy machine) - every later
|
|
8
|
+
// commit, and the auto-updater's pull in an install, then fails.
|
|
9
|
+
function runGit(baseDir, args) {
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('git', ['--no-optional-locks', ...args], {
|
|
12
|
+
cwd: baseDir,
|
|
13
|
+
encoding: 'utf-8',
|
|
14
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
15
|
+
timeout: 1000,
|
|
16
|
+
}).trim() || null;
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveGitDirs(baseDir) {
|
|
23
|
+
const dotGit = path.join(baseDir, '.git');
|
|
24
|
+
try {
|
|
25
|
+
if (fs.statSync(dotGit).isDirectory()) return [dotGit];
|
|
26
|
+
} catch {}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const text = fs.readFileSync(dotGit, 'utf-8').trim();
|
|
30
|
+
const match = text.match(/^gitdir:\s*(.+)$/);
|
|
31
|
+
if (!match) return [];
|
|
32
|
+
|
|
33
|
+
const gitDir = path.resolve(baseDir, match[1]);
|
|
34
|
+
try {
|
|
35
|
+
const commonDir = path.resolve(gitDir, fs.readFileSync(path.join(gitDir, 'commondir'), 'utf-8').trim());
|
|
36
|
+
return [...new Set([gitDir, commonDir])];
|
|
37
|
+
} catch {
|
|
38
|
+
return [gitDir];
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readCommitFromGitFiles(baseDir) {
|
|
46
|
+
const gitDirs = resolveGitDirs(baseDir);
|
|
47
|
+
if (!gitDirs.length) return null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const head = fs.readFileSync(path.join(gitDirs[0], 'HEAD'), 'utf-8').trim();
|
|
51
|
+
if (!head.startsWith('ref: ')) return head;
|
|
52
|
+
|
|
53
|
+
const ref = head.slice(5);
|
|
54
|
+
for (const gitDir of gitDirs) {
|
|
55
|
+
try {
|
|
56
|
+
return fs.readFileSync(path.join(gitDir, ref), 'utf-8').trim();
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const gitDir of gitDirs) {
|
|
61
|
+
try {
|
|
62
|
+
for (const line of fs.readFileSync(path.join(gitDir, 'packed-refs'), 'utf-8').split(/\r?\n/)) {
|
|
63
|
+
if (line.endsWith(` ${ref}`)) return line.slice(0, 40);
|
|
64
|
+
}
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
} catch {}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getBuildInfo(baseDir) {
|
|
72
|
+
const envCommit = process.env.CLIPBOARD_TRAY_COMMIT || process.env.GIT_COMMIT || process.env.COMMIT_SHA;
|
|
73
|
+
const envTag = process.env.CLIPBOARD_TRAY_TAG || process.env.GIT_TAG;
|
|
74
|
+
const fullCommit = envCommit || runGit(baseDir, ['rev-parse', 'HEAD']) || readCommitFromGitFiles(baseDir);
|
|
75
|
+
const fullCommitSha = fullCommit ? fullCommit.trim() : null;
|
|
76
|
+
const commit = fullCommitSha ? fullCommitSha.slice(0, 7) : null;
|
|
77
|
+
const tag = envTag || runGit(baseDir, ['describe', '--tags', '--exact-match', 'HEAD']);
|
|
78
|
+
// Only TRACKED changes count as "dirty" (matches update.bat's guard). Untracked
|
|
79
|
+
// runtime/data files (clipboard-RECOVERED/, PRE-RECOVERY backups, edit-archive, etc.)
|
|
80
|
+
// must NOT mark the checkout dirty, or they silently block the auto-updater
|
|
81
|
+
// (canAutoUpdate refuses a dirty checkout) after any recovery leaves files behind.
|
|
82
|
+
const dirty = !!runGit(baseDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
83
|
+
const commitLabel = commit ? `${commit}${dirty ? '-dirty' : ''}` : null;
|
|
84
|
+
const label = tag && commitLabel ? `${tag} (${commitLabel})` : (commitLabel || tag || 'unknown');
|
|
85
|
+
|
|
86
|
+
return { tag, commit, fullCommit: fullCommitSha, dirty, label };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = getBuildInfo;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { makeBlobFieldStore, DEFAULT_THRESHOLD_BYTES } = require('./blob-field-store');
|
|
4
|
+
|
|
5
|
+
// Every payload blob a text clip can carry, in one place. main.js drives storage,
|
|
6
|
+
// sync and p2p off this list, so adding a format touches this file and nothing
|
|
7
|
+
// else. `text` stays first: it is the identity payload (a clip's id is the hash
|
|
8
|
+
// of its text) and the only one with a preview.
|
|
9
|
+
const TEXT_BLOB_DIRNAME = 'clipboard-text';
|
|
10
|
+
const HTML_BLOB_DIRNAME = 'clipboard-html';
|
|
11
|
+
const RTF_BLOB_DIRNAME = 'clipboard-rtf';
|
|
12
|
+
const TEXT_PREVIEW_CHARS = 1024;
|
|
13
|
+
|
|
14
|
+
const text = makeBlobFieldStore({
|
|
15
|
+
field: 'text',
|
|
16
|
+
dirname: TEXT_BLOB_DIRNAME,
|
|
17
|
+
ext: 'txt',
|
|
18
|
+
previewChars: TEXT_PREVIEW_CHARS,
|
|
19
|
+
thresholdBytes: DEFAULT_THRESHOLD_BYTES,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const html = makeBlobFieldStore({
|
|
23
|
+
field: 'html',
|
|
24
|
+
dirname: HTML_BLOB_DIRNAME,
|
|
25
|
+
ext: 'html',
|
|
26
|
+
previewChars: 0,
|
|
27
|
+
dropWhenEmpty: true,
|
|
28
|
+
thresholdBytes: DEFAULT_THRESHOLD_BYTES,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const rtf = makeBlobFieldStore({
|
|
32
|
+
field: 'rtf',
|
|
33
|
+
dirname: RTF_BLOB_DIRNAME,
|
|
34
|
+
ext: 'rtf',
|
|
35
|
+
previewChars: 0,
|
|
36
|
+
dropWhenEmpty: true,
|
|
37
|
+
thresholdBytes: DEFAULT_THRESHOLD_BYTES,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const FIELD_STORES = [text, html, rtf];
|
|
41
|
+
const RICH_FIELD_STORES = [html, rtf];
|
|
42
|
+
const BY_FIELD = new Map(FIELD_STORES.map(store => [store.field, store]));
|
|
43
|
+
|
|
44
|
+
// Callers may pass either the full { text, html, rtf } directory map or, for the
|
|
45
|
+
// legacy text-only call shape, a bare text directory string. A store handed no
|
|
46
|
+
// directory no-ops rather than resolving a relative path.
|
|
47
|
+
function dirFor(dirs, field) {
|
|
48
|
+
if (!dirs) return '';
|
|
49
|
+
if (typeof dirs === 'string') return field === 'text' ? dirs : '';
|
|
50
|
+
return dirs[field] || '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hydrateItem(item, dirs) {
|
|
54
|
+
for (const store of FIELD_STORES) store.hydrateItem(item, dirFor(dirs, store.field));
|
|
55
|
+
return item;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hydrateHistory(items, dirs) {
|
|
59
|
+
if (!Array.isArray(items)) return [];
|
|
60
|
+
for (const item of items) hydrateItem(item, dirs);
|
|
61
|
+
return items;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function prepareItemForStorage(item, dirs, options = {}) {
|
|
65
|
+
let current = item;
|
|
66
|
+
for (const store of FIELD_STORES) {
|
|
67
|
+
current = store.prepareItemForStorage(current, dirFor(dirs, store.field), options);
|
|
68
|
+
}
|
|
69
|
+
return current;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function prepareHistoryForStorage(items, dirs, options = {}) {
|
|
73
|
+
return (Array.isArray(items) ? items : []).map(item => prepareItemForStorage(item, dirs, options));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function removeUnreferencedBlobs(item, items, dirs) {
|
|
77
|
+
for (const store of FIELD_STORES) {
|
|
78
|
+
store.removeLocalBlobIfUnreferenced(item, items, dirFor(dirs, store.field));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The rich payloads a newly captured clip can donate to an existing entry. A
|
|
83
|
+
// re-copy of identical text is deduped by id, so without this the second copy's
|
|
84
|
+
// formatting would be silently dropped.
|
|
85
|
+
function richPayloadOf(item) {
|
|
86
|
+
const payload = {};
|
|
87
|
+
if (!item || item.type === 'image') return payload;
|
|
88
|
+
for (const store of RICH_FIELD_STORES) {
|
|
89
|
+
const { value, hash, ref, size } = store.names;
|
|
90
|
+
if (item[value]) payload[value] = item[value];
|
|
91
|
+
if (item[hash]) payload[hash] = item[hash];
|
|
92
|
+
if (item[ref]) payload[ref] = item[ref];
|
|
93
|
+
if (item[size]) payload[size] = item[size];
|
|
94
|
+
}
|
|
95
|
+
return payload;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hasRichPayload(item, field) {
|
|
99
|
+
const store = BY_FIELD.get(field);
|
|
100
|
+
if (!store || !item || item.type === 'image') return false;
|
|
101
|
+
return !!(item[store.names.value] || item[store.names.ref] || item[store.names.hash]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A payload is USABLE only when the bytes are actually reachable. An item can
|
|
105
|
+
// advertise a format (htmlRef/htmlHash present) while hydrate failed to resolve
|
|
106
|
+
// the blob — synced metadata whose blob has not arrived yet. That is metadata,
|
|
107
|
+
// not content, so a fresh capture carrying the real bytes must be allowed to
|
|
108
|
+
// replace it. An UNHYDRATED item carries no flag and is treated as usable,
|
|
109
|
+
// which is the safe default: never discard a payload not proven missing.
|
|
110
|
+
function richPayloadUsable(item, field) {
|
|
111
|
+
const store = BY_FIELD.get(field);
|
|
112
|
+
if (!store || !hasRichPayload(item, field)) return false;
|
|
113
|
+
return !store.isBlobMissing(item);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Copy rich payloads from `source` onto `target` for formats the target lacks.
|
|
117
|
+
// Never downgrades a usable payload to absent: the older clip may hold the only
|
|
118
|
+
// copy of a blob, and a plain-text re-copy of the same words is not evidence the
|
|
119
|
+
// formatting is gone. Returns true when anything changed.
|
|
120
|
+
function adoptRichPayload(target, source) {
|
|
121
|
+
if (!target || !source || target.type === 'image' || source.type === 'image') return false;
|
|
122
|
+
let changed = false;
|
|
123
|
+
for (const store of RICH_FIELD_STORES) {
|
|
124
|
+
if (!richPayloadUsable(source, store.field)) continue;
|
|
125
|
+
if (richPayloadUsable(target, store.field)) continue;
|
|
126
|
+
// Clear the whole group first. Replacing a missing-blob payload leaves a
|
|
127
|
+
// stale ref/hash beside the new inline value otherwise, which describes
|
|
128
|
+
// different content — prepareItemForStorage would then judge the new value
|
|
129
|
+
// unverified and refuse to store it.
|
|
130
|
+
store.clearField(target);
|
|
131
|
+
for (const key of [store.names.value, store.names.hash, store.names.ref, store.names.size]) {
|
|
132
|
+
if (source[key] === undefined) continue;
|
|
133
|
+
target[key] = source[key];
|
|
134
|
+
changed = true;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return changed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = {
|
|
141
|
+
TEXT_BLOB_DIRNAME,
|
|
142
|
+
HTML_BLOB_DIRNAME,
|
|
143
|
+
RTF_BLOB_DIRNAME,
|
|
144
|
+
TEXT_PREVIEW_CHARS,
|
|
145
|
+
FIELD_STORES,
|
|
146
|
+
RICH_FIELD_STORES,
|
|
147
|
+
text,
|
|
148
|
+
html,
|
|
149
|
+
rtf,
|
|
150
|
+
storeFor: field => BY_FIELD.get(field),
|
|
151
|
+
dirFor,
|
|
152
|
+
hydrateItem,
|
|
153
|
+
hydrateHistory,
|
|
154
|
+
prepareItemForStorage,
|
|
155
|
+
prepareHistoryForStorage,
|
|
156
|
+
removeUnreferencedBlobs,
|
|
157
|
+
richPayloadOf,
|
|
158
|
+
hasRichPayload,
|
|
159
|
+
richPayloadUsable,
|
|
160
|
+
adoptRichPayload,
|
|
161
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Optimistic concurrency for clip mutations. Every update/delete of a clip -
|
|
4
|
+
// from the app UI, the MCP tools or any other caller - carries the rev of the
|
|
5
|
+
// version it read (clipboard-model.clipRevision). If the clip has changed since
|
|
6
|
+
// (another device's sync, another window, a concurrent AI edit) the mutation is
|
|
7
|
+
// refused with a RevisionConflict instead of silently clobbering the change.
|
|
8
|
+
//
|
|
9
|
+
// Error messages are "revision_conflict:<code>" so the code survives transports
|
|
10
|
+
// that only carry a message (Electron IPC); .code/.details carry it structured.
|
|
11
|
+
// rev_required - no rev supplied
|
|
12
|
+
// not_found - no clip with that id (deleted)
|
|
13
|
+
// superseded - the clip was edited and now lives at details.currentId
|
|
14
|
+
// stale_revision - the clip changed; details.currentRev is its rev now
|
|
15
|
+
|
|
16
|
+
const model = require('./clipboard-model');
|
|
17
|
+
|
|
18
|
+
const CONFLICT_PREFIX = 'revision_conflict:';
|
|
19
|
+
|
|
20
|
+
class RevisionConflict extends Error {
|
|
21
|
+
constructor(code, details = {}) {
|
|
22
|
+
super(`${CONFLICT_PREFIX}${code}`);
|
|
23
|
+
this.name = 'RevisionConflict';
|
|
24
|
+
this.code = code;
|
|
25
|
+
this.details = details;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findItem(history, id) {
|
|
30
|
+
return (Array.isArray(history) ? history : []).find(item => item && model.itemKey(item) === id) || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function checkRevision(history, id, expectedRev, { supersedes } = {}) {
|
|
34
|
+
const key = String(id == null ? '' : id);
|
|
35
|
+
const rev = typeof expectedRev === 'string' ? expectedRev.trim() : '';
|
|
36
|
+
if (!rev) return { ok: false, code: 'rev_required', id: key };
|
|
37
|
+
const item = key ? findItem(history, key) : null;
|
|
38
|
+
if (!item) {
|
|
39
|
+
const currentId = model.supersedeMap(supersedes).get(key);
|
|
40
|
+
if (currentId && currentId !== key && findItem(history, currentId)) {
|
|
41
|
+
return { ok: false, code: 'superseded', id: key, currentId };
|
|
42
|
+
}
|
|
43
|
+
return { ok: false, code: 'not_found', id: key };
|
|
44
|
+
}
|
|
45
|
+
const currentRev = model.clipRevision(item);
|
|
46
|
+
if (currentRev !== rev) return { ok: false, code: 'stale_revision', id: key, expectedRev: rev, currentRev };
|
|
47
|
+
return { ok: true, id: key, item };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function conflictFrom(result, extra = {}) {
|
|
51
|
+
const { ok, item, code, ...details } = result;
|
|
52
|
+
return new RevisionConflict(code, { ...details, ...extra });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertRevision(history, id, expectedRev, options) {
|
|
56
|
+
const result = checkRevision(history, id, expectedRev, options);
|
|
57
|
+
if (!result.ok) throw conflictFrom(result);
|
|
58
|
+
return result.item;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Batch form: targets are [{ id, rev }]. All-or-nothing - every target is
|
|
62
|
+
// checked before the caller changes anything; one conflict refuses the batch.
|
|
63
|
+
function assertRevisions(history, targets, options) {
|
|
64
|
+
const resolved = [];
|
|
65
|
+
const failures = [];
|
|
66
|
+
for (const target of Array.isArray(targets) ? targets : []) {
|
|
67
|
+
const result = checkRevision(history, target && target.id, target && target.rev, options);
|
|
68
|
+
if (result.ok) resolved.push({ id: result.id, item: result.item });
|
|
69
|
+
else failures.push(result);
|
|
70
|
+
}
|
|
71
|
+
if (failures.length) {
|
|
72
|
+
throw conflictFrom(failures[0], { conflicts: failures.map(({ ok, ...f }) => f) });
|
|
73
|
+
}
|
|
74
|
+
return resolved;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRevisionConflict(err) {
|
|
78
|
+
return !!err && String(err.message || '').includes(CONFLICT_PREFIX);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { RevisionConflict, CONFLICT_PREFIX, checkRevision, assertRevision, assertRevisions, isRevisionConflict };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const windowsClipboard = require('./windows-clipboard');
|
|
4
|
+
|
|
5
|
+
function formatsKey(formats) {
|
|
6
|
+
return (Array.isArray(formats) ? formats : [])
|
|
7
|
+
.map(format => String(format || ''))
|
|
8
|
+
.filter(Boolean)
|
|
9
|
+
.sort((a, b) => a.localeCompare(b))
|
|
10
|
+
.join('|');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function safeAvailableFormats(clipboard) {
|
|
14
|
+
try { return clipboard.availableFormats(); } catch { return []; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatsSuggestImage(formatsOrKey) {
|
|
18
|
+
const key = Array.isArray(formatsOrKey) ? formatsKey(formatsOrKey) : String(formatsOrKey || '');
|
|
19
|
+
return /image|png|tiff|bitmap|dib|filedrop|filename|filecontents|filegroupdescriptor|shell idlist|uri-list/i.test(key);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatsSuggestFileTransfer(formatsOrKey) {
|
|
23
|
+
const key = Array.isArray(formatsOrKey) ? formatsKey(formatsOrKey) : String(formatsOrKey || '');
|
|
24
|
+
return /filedrop|filename|filecontents|filegroupdescriptor|shell idlist|uri-list/i.test(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function imageFromNativeImage(image, source) {
|
|
28
|
+
if (!image || image.isEmpty()) return null;
|
|
29
|
+
const buffer = image.toPNG();
|
|
30
|
+
if (!buffer || !buffer.length) return null;
|
|
31
|
+
const size = image.getSize();
|
|
32
|
+
if (!size || !size.width || !size.height) return null;
|
|
33
|
+
return { buffer, width: size.width, height: size.height, source };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function imageFromBuffer(nativeImage, buffer, source, extra) {
|
|
37
|
+
if (!buffer || !buffer.length || !nativeImage || typeof nativeImage.createFromBuffer !== 'function') return null;
|
|
38
|
+
const image = nativeImage.createFromBuffer(buffer);
|
|
39
|
+
const captured = imageFromNativeImage(image, source);
|
|
40
|
+
return captured ? { ...captured, ...(extra || {}) } : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readElectronImage(clipboard) {
|
|
44
|
+
try { return clipboard.readImage(); } catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readClipboardImage(options) {
|
|
48
|
+
const opts = options || {};
|
|
49
|
+
const clipboard = opts.clipboard;
|
|
50
|
+
const nativeImage = opts.nativeImage;
|
|
51
|
+
const platform = opts.platform || process.platform;
|
|
52
|
+
const win = opts.windowsClipboard || windowsClipboard;
|
|
53
|
+
const formats = opts.formats || safeAvailableFormats(clipboard);
|
|
54
|
+
const key = formatsKey(formats);
|
|
55
|
+
if (!formatsSuggestImage(key)) return null;
|
|
56
|
+
|
|
57
|
+
if (platform === 'win32' && formatsSuggestFileTransfer(key) && win && typeof win.readImageCandidate === 'function') {
|
|
58
|
+
const candidate = win.readImageCandidate();
|
|
59
|
+
if (candidate && candidate.buffer) {
|
|
60
|
+
const fromCandidate = imageFromBuffer(nativeImage, candidate.buffer, candidate.source || 'win32-fallback', { path: candidate.path });
|
|
61
|
+
if (fromCandidate) return { ...fromCandidate, formats, formatsKey: key };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const electronImage = imageFromNativeImage(readElectronImage(clipboard), 'electron-readImage');
|
|
66
|
+
if (electronImage) return { ...electronImage, formats, formatsKey: key };
|
|
67
|
+
|
|
68
|
+
if (platform === 'win32' && win && typeof win.readImageCandidate === 'function') {
|
|
69
|
+
const candidate = win.readImageCandidate();
|
|
70
|
+
if (candidate && candidate.buffer) {
|
|
71
|
+
const fromCandidate = imageFromBuffer(nativeImage, candidate.buffer, candidate.source || 'win32-fallback', { path: candidate.path });
|
|
72
|
+
if (fromCandidate) return { ...fromCandidate, formats, formatsKey: key };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function clipboardChangeToken(formats) {
|
|
80
|
+
const sequence = windowsClipboard.getSequenceNumber();
|
|
81
|
+
if (sequence) return `winseq:${sequence}`;
|
|
82
|
+
return `formats:${Array.isArray(formats) ? formatsKey(formats) : String(formats || '')}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
formatsKey,
|
|
87
|
+
formatsSuggestImage,
|
|
88
|
+
formatsSuggestFileTransfer,
|
|
89
|
+
readClipboardImage,
|
|
90
|
+
clipboardChangeToken,
|
|
91
|
+
_private: {
|
|
92
|
+
imageFromNativeImage,
|
|
93
|
+
imageFromBuffer,
|
|
94
|
+
},
|
|
95
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Keeps an open editor's note and the system clipboard in step. While the
|
|
4
|
+
// clipboard holds the note's text, every save of the note also puts the new
|
|
5
|
+
// text on the clipboard, so a pasted copy is never stale and the user never has
|
|
6
|
+
// to re-copy from the editor. It only ever writes over its OWN text: before each
|
|
7
|
+
// write it checks the clipboard still holds what it last saw there, and if the
|
|
8
|
+
// user copied something else in between it stops following instead of
|
|
9
|
+
// clobbering it. Pure + injected (readText/writeText), so it is unit-tested
|
|
10
|
+
// without Electron; main.js owns one per editor session.
|
|
11
|
+
//
|
|
12
|
+
// The clipboard "holds the note" when its text equals one of the note's
|
|
13
|
+
// current versions (the live draft or the last saved text), line endings
|
|
14
|
+
// ignored - Windows apps hand back CRLF for text the editor keeps as LF.
|
|
15
|
+
|
|
16
|
+
function normalizeEol(text) {
|
|
17
|
+
return String(text == null ? '' : text).replace(/\r\n?/g, '\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sameText(a, b) {
|
|
21
|
+
return normalizeEol(a) === normalizeEol(b);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createClipboardFollower({ readText, writeText } = {}) {
|
|
25
|
+
let following = false;
|
|
26
|
+
let lastSeen = null; // exact clipboard text last seen/written while following
|
|
27
|
+
|
|
28
|
+
function read() {
|
|
29
|
+
try { const text = readText(); return String(text == null ? '' : text); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
function holdsNote(clip, texts) {
|
|
32
|
+
return (texts || []).some(text => String(text || '').trim() && sameText(clip, text));
|
|
33
|
+
}
|
|
34
|
+
function result(event) {
|
|
35
|
+
return { following, event };
|
|
36
|
+
}
|
|
37
|
+
function stop() {
|
|
38
|
+
following = false;
|
|
39
|
+
lastSeen = null;
|
|
40
|
+
return result('stopped');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The clipboard may have changed (a copy anywhere, the editor regaining
|
|
44
|
+
// focus, the editor opening). `texts` = the note's current versions.
|
|
45
|
+
// `clipText` lets a caller that already read the clipboard pass it in.
|
|
46
|
+
function observe(texts, clipText) {
|
|
47
|
+
const clip = clipText !== undefined ? String(clipText == null ? '' : clipText) : read();
|
|
48
|
+
if (clip == null) return result('unknown');
|
|
49
|
+
if (holdsNote(clip, texts) || (following && clip === lastSeen)) {
|
|
50
|
+
const started = !following;
|
|
51
|
+
following = true;
|
|
52
|
+
lastSeen = clip;
|
|
53
|
+
return result(started ? 'started' : 'following');
|
|
54
|
+
}
|
|
55
|
+
return following ? stop() : result('idle');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The note was saved: `prevText` was its previous saved text, `text` the new.
|
|
59
|
+
// Follows the save onto the clipboard if the clipboard holds this note.
|
|
60
|
+
function afterSave(prevText, text) {
|
|
61
|
+
const clip = read();
|
|
62
|
+
if (clip == null) return result('unknown');
|
|
63
|
+
const ours = (following && clip === lastSeen) || holdsNote(clip, [prevText, text]);
|
|
64
|
+
if (!ours) return following ? stop() : result('idle');
|
|
65
|
+
const started = !following;
|
|
66
|
+
following = true;
|
|
67
|
+
if (sameText(clip, text)) {
|
|
68
|
+
lastSeen = clip;
|
|
69
|
+
return result(started ? 'started' : 'following');
|
|
70
|
+
}
|
|
71
|
+
if (!writeText(String(text || ''))) {
|
|
72
|
+
// Not written (e.g. a paste sequence holds the clipboard). Still ours,
|
|
73
|
+
// so the next save retries.
|
|
74
|
+
lastSeen = clip;
|
|
75
|
+
return result('write_failed');
|
|
76
|
+
}
|
|
77
|
+
lastSeen = String(text || '');
|
|
78
|
+
return result('written');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The editor's Copy button: put the note on the clipboard and follow it.
|
|
82
|
+
function adopt(text) {
|
|
83
|
+
if (!String(text || '').trim()) return result(following ? 'following' : 'idle');
|
|
84
|
+
if (!writeText(String(text))) return result('write_failed');
|
|
85
|
+
following = true;
|
|
86
|
+
lastSeen = String(text);
|
|
87
|
+
return result('adopted');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
observe,
|
|
92
|
+
afterSave,
|
|
93
|
+
adopt,
|
|
94
|
+
isFollowing: () => following,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = { createClipboardFollower, normalizeEol, sameText };
|