@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,73 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { probePath } = require('./fs-probe');
|
|
5
|
+
|
|
6
|
+
// A custom sync folder may be a network share, and a wedged one never answers a
|
|
7
|
+
// synchronous existence check - the same failure that froze the app on
|
|
8
|
+
// 2026-09-13. Every provider path, including this one, gets a deadline.
|
|
9
|
+
const MOUNT_PROBE_TIMEOUT_MS = 2000;
|
|
10
|
+
const exists = target => probePath(target, { timeoutMs: MOUNT_PROBE_TIMEOUT_MS });
|
|
11
|
+
|
|
12
|
+
function normalizeSyncPath(syncPath) {
|
|
13
|
+
const normalized = path.normalize(String(syncPath || '').trim());
|
|
14
|
+
if (!normalized || !path.isAbsolute(normalized)) return '';
|
|
15
|
+
return normalized;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function migrateSyncSettings(settings) {
|
|
19
|
+
if (!settings) return settings;
|
|
20
|
+
const customPaths = [
|
|
21
|
+
settings.sync_path,
|
|
22
|
+
...(Array.isArray(settings.sync_custom_paths) ? settings.sync_custom_paths : []),
|
|
23
|
+
].map(normalizeSyncPath).filter(Boolean);
|
|
24
|
+
settings.sync_custom_paths = [...new Set(customPaths)];
|
|
25
|
+
settings.sync_path = settings.sync_custom_paths[0] || '';
|
|
26
|
+
if (!Array.isArray(settings.sync_disabled_paths)) settings.sync_disabled_paths = [];
|
|
27
|
+
settings.sync_disabled_paths = [...new Set(settings.sync_disabled_paths.map(normalizeSyncPath).filter(Boolean))];
|
|
28
|
+
return settings;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function customSyncPaths(settings) {
|
|
32
|
+
migrateSyncSettings(settings);
|
|
33
|
+
return settings.sync_custom_paths || [];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addCustomSyncPath(settings, syncPath) {
|
|
37
|
+
const normalized = normalizeSyncPath(syncPath);
|
|
38
|
+
if (!normalized) return '';
|
|
39
|
+
const paths = new Set(customSyncPaths(settings));
|
|
40
|
+
paths.add(normalized);
|
|
41
|
+
settings.sync_custom_paths = [...paths];
|
|
42
|
+
settings.sync_path = settings.sync_custom_paths[0] || '';
|
|
43
|
+
return normalized;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function syncDisabledPathSet(settings) {
|
|
47
|
+
migrateSyncSettings(settings);
|
|
48
|
+
return new Set((settings.sync_disabled_paths || []).map(normalizeSyncPath).filter(Boolean));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function syncAccountsWithCustom(settings, accounts) {
|
|
52
|
+
const result = [...(accounts || [])];
|
|
53
|
+
for (const customPath of customSyncPaths(settings)) {
|
|
54
|
+
const available = (await exists(customPath)) || (await exists(path.dirname(customPath)));
|
|
55
|
+
if (!available || result.some(acc => normalizeSyncPath(acc.path) === customPath)) continue;
|
|
56
|
+
result.push({
|
|
57
|
+
provider: 'custom',
|
|
58
|
+
label: path.basename(customPath) || customPath,
|
|
59
|
+
email: '',
|
|
60
|
+
path: customPath,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
module.exports = {
|
|
67
|
+
normalizeSyncPath,
|
|
68
|
+
migrateSyncSettings,
|
|
69
|
+
customSyncPaths,
|
|
70
|
+
addCustomSyncPath,
|
|
71
|
+
syncDisabledPathSet,
|
|
72
|
+
syncAccountsWithCustom,
|
|
73
|
+
};
|
package/lib/tailscale.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Tailscale peer enrichment for P2P discovery. When the tailscale CLI is
|
|
4
|
+
// installed, `tailscale status --json` lists every device on the tailnet with
|
|
5
|
+
// its 100.64/10 address and online state; the app unicasts its discovery
|
|
6
|
+
// announcement to the online ones, so two devices on different networks pair
|
|
7
|
+
// over the (already encrypted) tailnet with no relay of our own. No CLI -> the
|
|
8
|
+
// synced endpoint registry and manual pins still work.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFile } = require('child_process');
|
|
13
|
+
|
|
14
|
+
const CANDIDATES = {
|
|
15
|
+
win32: ['C:\\Program Files\\Tailscale\\tailscale.exe', 'C:\\Program Files (x86)\\Tailscale\\tailscale.exe'],
|
|
16
|
+
darwin: [
|
|
17
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale',
|
|
18
|
+
'/opt/homebrew/bin/tailscale',
|
|
19
|
+
'/usr/local/bin/tailscale',
|
|
20
|
+
],
|
|
21
|
+
linux: ['/usr/bin/tailscale', '/usr/local/bin/tailscale'],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const IPV4_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
25
|
+
|
|
26
|
+
function findBinary({ platform = process.platform, exists = fs.existsSync, pathEnv = process.env.PATH || '' } = {}) {
|
|
27
|
+
for (const candidate of CANDIDATES[platform] || []) {
|
|
28
|
+
if (exists(candidate)) return candidate;
|
|
29
|
+
}
|
|
30
|
+
const exe = platform === 'win32' ? 'tailscale.exe' : 'tailscale';
|
|
31
|
+
const sep = platform === 'win32' ? ';' : ':';
|
|
32
|
+
const join = platform === 'win32' ? path.win32.join : path.posix.join;
|
|
33
|
+
for (const dir of pathEnv.split(sep).filter(Boolean)) {
|
|
34
|
+
const candidate = join(dir, exe);
|
|
35
|
+
if (exists(candidate)) return candidate;
|
|
36
|
+
}
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ipv4Of(list) {
|
|
41
|
+
return (Array.isArray(list) ? list : []).map(String).filter(ip => IPV4_RE.test(ip));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseStatus(status) {
|
|
45
|
+
if (!status || typeof status !== 'object') return { available: false, self: null, peers: [] };
|
|
46
|
+
const self = status.Self && typeof status.Self === 'object' ? {
|
|
47
|
+
name: String(status.Self.HostName || ''),
|
|
48
|
+
dnsName: String(status.Self.DNSName || '').replace(/\.$/, ''),
|
|
49
|
+
ips: ipv4Of(status.Self.TailscaleIPs || status.TailscaleIPs),
|
|
50
|
+
online: !!status.Self.Online,
|
|
51
|
+
} : null;
|
|
52
|
+
const peers = [];
|
|
53
|
+
for (const raw of Object.values(status.Peer && typeof status.Peer === 'object' ? status.Peer : {})) {
|
|
54
|
+
if (!raw || typeof raw !== 'object') continue;
|
|
55
|
+
peers.push({
|
|
56
|
+
id: String(raw.ID || raw.NodeID || raw.PublicKey || ''),
|
|
57
|
+
name: String(raw.HostName || ''),
|
|
58
|
+
dnsName: String(raw.DNSName || '').replace(/\.$/, ''),
|
|
59
|
+
os: String(raw.OS || ''),
|
|
60
|
+
ips: ipv4Of(raw.TailscaleIPs),
|
|
61
|
+
online: !!raw.Online,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
peers.sort((a, b) => a.name.localeCompare(b.name));
|
|
65
|
+
return { available: true, backend: String(status.BackendState || ''), self, peers };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readStatus({ binary = findBinary(), timeoutMs = 5000, exec = execFile } = {}) {
|
|
69
|
+
if (!binary) return Promise.resolve({ available: false, self: null, peers: [] });
|
|
70
|
+
return new Promise(resolve => {
|
|
71
|
+
exec(binary, ['status', '--json'], { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024, windowsHide: true }, (error, stdout) => {
|
|
72
|
+
if (error) { resolve({ available: false, error: error.message, self: null, peers: [] }); return; }
|
|
73
|
+
try { resolve(parseStatus(JSON.parse(String(stdout)))); } catch (parseError) {
|
|
74
|
+
resolve({ available: false, error: parseError.message, self: null, peers: [] });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { findBinary, parseStatus, readStatus, CANDIDATES };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const clipboardModel = require('./clipboard-model');
|
|
4
|
+
const clipBlobStore = require('./clip-blob-store');
|
|
5
|
+
|
|
6
|
+
// The text payload's blob store. The mechanics (hashing, atomic write, hydrate,
|
|
7
|
+
// externalize-above-threshold, sync filter, unreferenced cleanup) live once in
|
|
8
|
+
// lib/blob-field-store.js and are instantiated per clipboard format in
|
|
9
|
+
// lib/clip-blob-store.js — this module is the text instance's public face, kept
|
|
10
|
+
// under its original name and signatures so every existing caller and test is
|
|
11
|
+
// unaffected by the generalisation.
|
|
12
|
+
const store = clipBlobStore.text;
|
|
13
|
+
|
|
14
|
+
function setInlineText(item, text) {
|
|
15
|
+
if (!item || item.type === 'image') return item;
|
|
16
|
+
// Deliberately the MODEL's setInlineText, not the field store's: an edit must
|
|
17
|
+
// also drop the html/rtf payloads, which describe the pre-edit text. The
|
|
18
|
+
// field store only knows about its own field.
|
|
19
|
+
clipboardModel.setInlineText(item, text);
|
|
20
|
+
store.setInlineValue(item, item.text);
|
|
21
|
+
return item;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
TEXT_BLOB_DIRNAME: clipBlobStore.TEXT_BLOB_DIRNAME,
|
|
26
|
+
TEXT_BLOB_THRESHOLD_BYTES: store.thresholdBytes,
|
|
27
|
+
TEXT_PREVIEW_CHARS: clipBlobStore.TEXT_PREVIEW_CHARS,
|
|
28
|
+
textRefForHash: store.refForHash,
|
|
29
|
+
safeTextRef: store.safeRef,
|
|
30
|
+
textPreview: store.preview,
|
|
31
|
+
hydrateTextItem: (item, baseDir) => store.hydrateItem(item, baseDir),
|
|
32
|
+
hydrateHistory: (items, baseDir) => store.hydrateHistory(items, baseDir),
|
|
33
|
+
prepareTextItemForStorage: (item, baseDir, options) => store.prepareItemForStorage(item, baseDir, options),
|
|
34
|
+
prepareHistoryForStorage: (items, baseDir, options) => store.prepareHistoryForStorage(items, baseDir, options),
|
|
35
|
+
syncTextBlobs: (localDir, remoteDir) => store.syncBlobs(localDir, remoteDir),
|
|
36
|
+
removeLocalBlobIfUnreferenced: (item, items, baseDir) => store.removeLocalBlobIfUnreferenced(item, items, baseDir),
|
|
37
|
+
setInlineText,
|
|
38
|
+
};
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
if (process.platform !== 'win32') {
|
|
7
|
+
module.exports = {
|
|
8
|
+
getSequenceNumber() { return 0; },
|
|
9
|
+
readImageCandidate() { return null; },
|
|
10
|
+
dibToBmpBuffer,
|
|
11
|
+
};
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const koffi = require('koffi');
|
|
16
|
+
|
|
17
|
+
const user32 = koffi.load('user32.dll');
|
|
18
|
+
const kernel32 = koffi.load('kernel32.dll');
|
|
19
|
+
const shell32 = koffi.load('shell32.dll');
|
|
20
|
+
|
|
21
|
+
const OpenClipboard = user32.func('int __stdcall OpenClipboard(void *hWndNewOwner)');
|
|
22
|
+
const CloseClipboard = user32.func('int __stdcall CloseClipboard()');
|
|
23
|
+
const GetClipboardData = user32.func('void * __stdcall GetClipboardData(uint32 uFormat)');
|
|
24
|
+
const IsClipboardFormatAvailable = user32.func('int __stdcall IsClipboardFormatAvailable(uint32 format)');
|
|
25
|
+
const RegisterClipboardFormatW = user32.func('uint32 __stdcall RegisterClipboardFormatW(str16 lpszFormat)');
|
|
26
|
+
const GetClipboardSequenceNumber = user32.func('uint32 __stdcall GetClipboardSequenceNumber()');
|
|
27
|
+
|
|
28
|
+
const GlobalLock = kernel32.func('void * __stdcall GlobalLock(void *hMem)');
|
|
29
|
+
const GlobalUnlock = kernel32.func('int __stdcall GlobalUnlock(void *hMem)');
|
|
30
|
+
const GlobalSize = kernel32.func('uintptr_t __stdcall GlobalSize(void *hMem)');
|
|
31
|
+
|
|
32
|
+
const DragQueryFileW = shell32.func('uint32 __stdcall DragQueryFileW(void *hDrop, uint32 iFile, void *lpszFile, uint32 cch)');
|
|
33
|
+
|
|
34
|
+
const CF_BITMAP = 2;
|
|
35
|
+
const CF_DIB = 8;
|
|
36
|
+
const CF_HDROP = 15;
|
|
37
|
+
const MAX_IMAGE_FILE_BYTES = 100 * 1024 * 1024;
|
|
38
|
+
const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.bmp', '.gif', '.webp', '.tif', '.tiff', '.heic', '.heif']);
|
|
39
|
+
const registeredFormats = new Map();
|
|
40
|
+
|
|
41
|
+
function getSequenceNumber() {
|
|
42
|
+
try { return GetClipboardSequenceNumber() >>> 0; } catch { return 0; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function registeredFormat(name) {
|
|
46
|
+
if (!registeredFormats.has(name)) registeredFormats.set(name, RegisterClipboardFormatW(name) >>> 0);
|
|
47
|
+
return registeredFormats.get(name);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pointerToBuffer(ptr, size) {
|
|
51
|
+
const length = Number(size) || 0;
|
|
52
|
+
if (!ptr || length <= 0) return null;
|
|
53
|
+
try {
|
|
54
|
+
return Buffer.from(koffi.decode(ptr, 'uint8_t', length));
|
|
55
|
+
} catch { return null; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readGlobalMemory(format) {
|
|
59
|
+
if (!format || !IsClipboardFormatAvailable(format)) return null;
|
|
60
|
+
const handle = GetClipboardData(format);
|
|
61
|
+
if (!handle) return null;
|
|
62
|
+
const size = Number(GlobalSize(handle)) || 0;
|
|
63
|
+
if (size <= 0) return null;
|
|
64
|
+
const ptr = GlobalLock(handle);
|
|
65
|
+
if (!ptr) return null;
|
|
66
|
+
try {
|
|
67
|
+
return pointerToBuffer(ptr, size);
|
|
68
|
+
} finally {
|
|
69
|
+
try { GlobalUnlock(handle); } catch {}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function withOpenClipboard(fn) {
|
|
74
|
+
if (!OpenClipboard(null)) return null;
|
|
75
|
+
try { return fn(); } finally { try { CloseClipboard(); } catch {} }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function hasPngSignature(buffer) {
|
|
79
|
+
return Buffer.isBuffer(buffer)
|
|
80
|
+
&& buffer.length >= 8
|
|
81
|
+
&& buffer[0] === 0x89
|
|
82
|
+
&& buffer[1] === 0x50
|
|
83
|
+
&& buffer[2] === 0x4e
|
|
84
|
+
&& buffer[3] === 0x47
|
|
85
|
+
&& buffer[4] === 0x0d
|
|
86
|
+
&& buffer[5] === 0x0a
|
|
87
|
+
&& buffer[6] === 0x1a
|
|
88
|
+
&& buffer[7] === 0x0a;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function stringFromUtf16Buffer(buffer) {
|
|
92
|
+
if (!Buffer.isBuffer(buffer)) return '';
|
|
93
|
+
let end = 0;
|
|
94
|
+
while (end + 1 < buffer.length && buffer.readUInt16LE(end) !== 0) end += 2;
|
|
95
|
+
return buffer.toString('utf16le', 0, end).trim();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function stringFromAnsiBuffer(buffer) {
|
|
99
|
+
if (!Buffer.isBuffer(buffer)) return '';
|
|
100
|
+
const nul = buffer.indexOf(0);
|
|
101
|
+
return buffer.toString('latin1', 0, nul >= 0 ? nul : buffer.length).trim();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function isImagePath(filePath) {
|
|
105
|
+
if (!filePath || typeof filePath !== 'string') return false;
|
|
106
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
107
|
+
if (!IMAGE_EXTENSIONS.has(ext)) return false;
|
|
108
|
+
try {
|
|
109
|
+
const stat = fs.statSync(filePath);
|
|
110
|
+
return stat.isFile() && stat.size > 0 && stat.size <= MAX_IMAGE_FILE_BYTES;
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function readHdropPathsOpen() {
|
|
117
|
+
if (!IsClipboardFormatAvailable(CF_HDROP)) return [];
|
|
118
|
+
const hDrop = GetClipboardData(CF_HDROP);
|
|
119
|
+
if (!hDrop) return [];
|
|
120
|
+
const count = DragQueryFileW(hDrop, 0xffffffff, null, 0);
|
|
121
|
+
const paths = [];
|
|
122
|
+
for (let i = 0; i < count; i += 1) {
|
|
123
|
+
const chars = DragQueryFileW(hDrop, i, null, 0);
|
|
124
|
+
if (!chars) continue;
|
|
125
|
+
const buffer = Buffer.alloc((chars + 1) * 2);
|
|
126
|
+
const written = DragQueryFileW(hDrop, i, buffer, chars + 1);
|
|
127
|
+
if (written) paths.push(buffer.toString('utf16le', 0, written * 2));
|
|
128
|
+
}
|
|
129
|
+
return paths;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function readNamedFilePathsOpen() {
|
|
133
|
+
const paths = [];
|
|
134
|
+
const fileNameW = readGlobalMemory(registeredFormat('FileNameW'));
|
|
135
|
+
const fileNameA = readGlobalMemory(registeredFormat('FileName'));
|
|
136
|
+
const wide = stringFromUtf16Buffer(fileNameW);
|
|
137
|
+
const ansi = stringFromAnsiBuffer(fileNameA);
|
|
138
|
+
if (wide) paths.push(wide);
|
|
139
|
+
if (ansi) paths.push(ansi);
|
|
140
|
+
return paths;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function readImageFileCandidateOpen() {
|
|
144
|
+
const paths = [...readHdropPathsOpen(), ...readNamedFilePathsOpen()];
|
|
145
|
+
const imagePath = paths.find(isImagePath);
|
|
146
|
+
if (!imagePath) return null;
|
|
147
|
+
try {
|
|
148
|
+
return { buffer: fs.readFileSync(imagePath), source: 'win32-file', path: imagePath };
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readPngCandidateOpen() {
|
|
155
|
+
for (const name of ['PNG', 'image/png']) {
|
|
156
|
+
const buffer = readGlobalMemory(registeredFormat(name));
|
|
157
|
+
if (hasPngSignature(buffer)) return { buffer, source: `win32-${name}` };
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function dibToBmpBuffer(dib) {
|
|
163
|
+
if (!Buffer.isBuffer(dib) || dib.length < 40) return null;
|
|
164
|
+
const headerSize = dib.readUInt32LE(0);
|
|
165
|
+
if (headerSize < 12 || headerSize > dib.length) return null;
|
|
166
|
+
|
|
167
|
+
let bitCount = 0;
|
|
168
|
+
let compression = 0;
|
|
169
|
+
let colorsUsed = 0;
|
|
170
|
+
if (headerSize >= 40 && dib.length >= 36) {
|
|
171
|
+
bitCount = dib.readUInt16LE(14);
|
|
172
|
+
compression = dib.readUInt32LE(16);
|
|
173
|
+
colorsUsed = dib.readUInt32LE(32);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const paletteEntries = colorsUsed || (bitCount > 0 && bitCount <= 8 ? 2 ** bitCount : 0);
|
|
177
|
+
const bitfieldBytes = compression === 3 && headerSize === 40 ? 12 : 0;
|
|
178
|
+
const pixelOffset = 14 + headerSize + bitfieldBytes + paletteEntries * 4;
|
|
179
|
+
if (pixelOffset > 14 + dib.length) return null;
|
|
180
|
+
|
|
181
|
+
const fileHeader = Buffer.alloc(14);
|
|
182
|
+
fileHeader.write('BM', 0, 'ascii');
|
|
183
|
+
fileHeader.writeUInt32LE(fileHeader.length + dib.length, 2);
|
|
184
|
+
fileHeader.writeUInt32LE(0, 6);
|
|
185
|
+
fileHeader.writeUInt32LE(pixelOffset, 10);
|
|
186
|
+
return Buffer.concat([fileHeader, dib]);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readDibCandidateOpen() {
|
|
190
|
+
const dib = readGlobalMemory(CF_DIB);
|
|
191
|
+
const bmp = dibToBmpBuffer(dib);
|
|
192
|
+
return bmp ? { buffer: bmp, source: 'win32-dib' } : null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function readImageCandidate() {
|
|
196
|
+
return withOpenClipboard(() => (
|
|
197
|
+
readImageFileCandidateOpen()
|
|
198
|
+
|| readPngCandidateOpen()
|
|
199
|
+
|| readDibCandidateOpen()
|
|
200
|
+
|| (IsClipboardFormatAvailable(CF_BITMAP) ? { source: 'win32-bitmap-unreadable' } : null)
|
|
201
|
+
));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
getSequenceNumber,
|
|
206
|
+
readImageCandidate,
|
|
207
|
+
dibToBmpBuffer,
|
|
208
|
+
_private: {
|
|
209
|
+
hasPngSignature,
|
|
210
|
+
stringFromUtf16Buffer,
|
|
211
|
+
isImagePath,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Detach from a console this process merely INHERITED on Windows.
|
|
4
|
+
//
|
|
5
|
+
// electron.exe attaches to whatever console its parent had. Launched from an
|
|
6
|
+
// agent tool shell, a scheduled task or a helper cmd, the tray app then lives or
|
|
7
|
+
// dies with that console: when it is closed, Windows sends a console control
|
|
8
|
+
// event to every attached process and the whole tree stops within a second,
|
|
9
|
+
// with no crash record (2026-09-03: two "random crashes" 26 and 58 min after
|
|
10
|
+
// such launches; the Network Service child logged exit code 0xC000013A =
|
|
11
|
+
// STATUS_CONTROL_C_EXIT). FreeConsole() makes the process independent of that
|
|
12
|
+
// console for good.
|
|
13
|
+
//
|
|
14
|
+
// Policy (pure, unit-tested): if OTHER processes share the console it is
|
|
15
|
+
// somebody else's (a shell that may close it) -> detach. If we are the only
|
|
16
|
+
// process attached, the console is ours (the Startup VBS / start.bat path,
|
|
17
|
+
// where cmd has already exited) and harmless -> keep. BOARDCLIP_KEEP_CONSOLE=1
|
|
18
|
+
// keeps any console; a developer who wants `npx electron .` output in the
|
|
19
|
+
// terminal sets it. A "visible window" heuristic was tried first and is WRONG:
|
|
20
|
+
// an agent shell's hidden console still reports WS_VISIBLE. main.js guards
|
|
21
|
+
// process.stdout/stderr against write errors and logs only via logSafe, so a
|
|
22
|
+
// freed console never throws back into JS.
|
|
23
|
+
|
|
24
|
+
function decideConsoleDetach({ platform = process.platform, env = process.env, hwnd = null, attached = 0 } = {}) {
|
|
25
|
+
if (platform !== 'win32') return { action: 'none', reason: 'not-windows' };
|
|
26
|
+
if (env && env.BOARDCLIP_KEEP_CONSOLE === '1') return { action: 'kept', reason: 'env' };
|
|
27
|
+
if (!hwnd) return { action: 'none', reason: 'no-console' };
|
|
28
|
+
if (attached > 1) return { action: 'detach', reason: 'shared-console' };
|
|
29
|
+
return { action: 'kept', reason: 'own-console' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function loadNative() {
|
|
33
|
+
const koffi = require('koffi');
|
|
34
|
+
const kernel32 = koffi.load('kernel32.dll');
|
|
35
|
+
const getConsoleProcessList = kernel32.func('uint32 __stdcall GetConsoleProcessList(_Out_ uint32 *list, uint32 count)');
|
|
36
|
+
return {
|
|
37
|
+
getConsoleWindow: kernel32.func('void * __stdcall GetConsoleWindow()'),
|
|
38
|
+
freeConsole: kernel32.func('int __stdcall FreeConsole()'),
|
|
39
|
+
attachedProcessCount: () => {
|
|
40
|
+
const list = new Uint32Array(64);
|
|
41
|
+
return getConsoleProcessList(list, list.length);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Returns what was done, for the app.start diagnostic. Never throws.
|
|
47
|
+
function detachInheritedConsole({ platform = process.platform, env = process.env, native = null } = {}) {
|
|
48
|
+
if (platform !== 'win32') return { action: 'none', reason: 'not-windows' };
|
|
49
|
+
let api = native;
|
|
50
|
+
try {
|
|
51
|
+
if (!api) api = loadNative();
|
|
52
|
+
const hwnd = api.getConsoleWindow();
|
|
53
|
+
const attached = hwnd ? Number(api.attachedProcessCount()) || 0 : 0;
|
|
54
|
+
const decision = decideConsoleDetach({ platform, env, hwnd, attached });
|
|
55
|
+
if (decision.action !== 'detach') return { ...decision, attached };
|
|
56
|
+
const ok = !!api.freeConsole();
|
|
57
|
+
return { action: ok ? 'detached' : 'detach-failed', reason: decision.reason, attached };
|
|
58
|
+
} catch (error) {
|
|
59
|
+
return { action: 'error', reason: error && error.message };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { decideConsoleDetach, detachInheritedConsole, loadNative };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Windows only: PARK a window instead of hiding it. A parked window is cloaked
|
|
4
|
+
// (DWMWA_CLOAK): it stays "shown" to Windows and Chromium - so its content stays
|
|
5
|
+
// painted - but is not drawn, is not hit-tested (clicks go to the windows
|
|
6
|
+
// beneath) and is not in Alt-Tab. It also gets WS_EX_NOACTIVATE while parked,
|
|
7
|
+
// so Windows never hands it the focus when the foreground window closes or
|
|
8
|
+
// minimizes (keystrokes would vanish into an invisible window). Unparking clears
|
|
9
|
+
// both, and the window appears complete in ONE frame.
|
|
10
|
+
//
|
|
11
|
+
// Why (measured 2026-10-07 with a ~100 fps screen recording of the open): every
|
|
12
|
+
// ShowWindow of the acrylic popup drew the glass backdrop at full size at once
|
|
13
|
+
// while Windows scaled + faded the web CONTENT in ~130-200 ms later - the "opens
|
|
14
|
+
// twice" rim the owner kept seeing. Neither DWMWA_TRANSITIONS_FORCEDISABLED nor
|
|
15
|
+
// switching acrylic to mica changed it.
|
|
16
|
+
//
|
|
17
|
+
// Also here: the layered-window alpha a SOLID popup fades in with
|
|
18
|
+
// (setWindowAlpha, then clearLayered) - see setWindowAlpha below.
|
|
19
|
+
|
|
20
|
+
const DWMWA_CLOAK = 13;
|
|
21
|
+
const GWL_EXSTYLE = -20;
|
|
22
|
+
const WS_EX_NOACTIVATE = 0x08000000;
|
|
23
|
+
const WS_EX_LAYERED = 0x00080000;
|
|
24
|
+
const LWA_ALPHA = 0x2;
|
|
25
|
+
const RDW_INVALIDATE = 0x1;
|
|
26
|
+
const RDW_ERASE = 0x4;
|
|
27
|
+
const RDW_ALLCHILDREN = 0x80;
|
|
28
|
+
const RDW_FRAME = 0x400;
|
|
29
|
+
// Predefined HKEYs are sign-extended LONGs: 64-bit needs the full pointer value.
|
|
30
|
+
const HKEY_CURRENT_USER = process.arch === 'x64' || process.arch === 'arm64' ? 0xFFFFFFFF80000001n : 0x80000001;
|
|
31
|
+
const RRF_RT_REG_DWORD = 0x10;
|
|
32
|
+
let api = null;
|
|
33
|
+
|
|
34
|
+
function loadNative() {
|
|
35
|
+
const koffi = require('koffi');
|
|
36
|
+
const dwmapi = koffi.load('dwmapi.dll');
|
|
37
|
+
const user32 = koffi.load('user32.dll');
|
|
38
|
+
// 64-bit user32 has the *Ptr variants; 32-bit only exports the plain ones.
|
|
39
|
+
let getLong;
|
|
40
|
+
let setLong;
|
|
41
|
+
try {
|
|
42
|
+
getLong = user32.func('intptr_t __stdcall GetWindowLongPtrW(uintptr_t hwnd, int index)');
|
|
43
|
+
setLong = user32.func('intptr_t __stdcall SetWindowLongPtrW(uintptr_t hwnd, int index, intptr_t value)');
|
|
44
|
+
} catch {
|
|
45
|
+
getLong = user32.func('long __stdcall GetWindowLongW(uintptr_t hwnd, int index)');
|
|
46
|
+
setLong = user32.func('long __stdcall SetWindowLongW(uintptr_t hwnd, int index, long value)');
|
|
47
|
+
}
|
|
48
|
+
const setLayered = user32.func('int __stdcall SetLayeredWindowAttributes(uintptr_t hwnd, uint32 key, uint8 alpha, uint32 flags)');
|
|
49
|
+
const getLayered = user32.func('int __stdcall GetLayeredWindowAttributes(uintptr_t hwnd, _Out_ uint32 *key, _Out_ uint8 *alpha, _Out_ uint32 *flags)');
|
|
50
|
+
const redraw = user32.func('int __stdcall RedrawWindow(uintptr_t hwnd, void *rect, void *region, uint32 flags)');
|
|
51
|
+
const regGetValue = koffi.load('advapi32.dll').func('int32 __stdcall RegGetValueW(uintptr_t hkey, str16 subKey, str16 value, uint32 flags, void *type, _Out_ uint32 *data, _Inout_ uint32 *size)');
|
|
52
|
+
return {
|
|
53
|
+
setAttribute: dwmapi.func('int32 __stdcall DwmSetWindowAttribute(uintptr_t hwnd, uint32 attr, void *value, uint32 size)'),
|
|
54
|
+
getExStyle: (hwnd) => Number(getLong(hwnd, GWL_EXSTYLE)),
|
|
55
|
+
setExStyle: (hwnd, style) => setLong(hwnd, GWL_EXSTYLE, style),
|
|
56
|
+
setLayeredAlpha: (hwnd, alpha) => setLayered(hwnd, 0, alpha, LWA_ALPHA) !== 0,
|
|
57
|
+
getLayeredAlpha: (hwnd) => {
|
|
58
|
+
const key = [0];
|
|
59
|
+
const alpha = [0];
|
|
60
|
+
const flags = [0];
|
|
61
|
+
if (!getLayered(hwnd, key, alpha, flags)) return null;
|
|
62
|
+
return (flags[0] & LWA_ALPHA) ? alpha[0] : 255;
|
|
63
|
+
},
|
|
64
|
+
redraw: (hwnd) => redraw(hwnd, null, null, RDW_ERASE | RDW_INVALIDATE | RDW_FRAME | RDW_ALLCHILDREN) !== 0,
|
|
65
|
+
// HKCU DWORD value, or null when it is missing.
|
|
66
|
+
readDword: (key, name) => {
|
|
67
|
+
const data = [0];
|
|
68
|
+
const size = [4];
|
|
69
|
+
return regGetValue(HKEY_CURRENT_USER, key, name, RRF_RT_REG_DWORD, null, data, size) === 0 ? data[0] >>> 0 : null;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Electron's getNativeWindowHandle() is a Buffer holding the HWND value.
|
|
75
|
+
function hwndFromHandle(buf) {
|
|
76
|
+
if (!buf || !buf.length) return 0n;
|
|
77
|
+
return buf.length >= 8 ? buf.readBigUInt64LE(0) : BigInt(buf.readUInt32LE(0));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// -> { ok, hr?, noActivate?, reason? }. `ok` is the cloak result (the part that
|
|
81
|
+
// matters); the focus guard is best effort. Never throws: a caller that gets
|
|
82
|
+
// !ok falls back to an ordinary hide/show.
|
|
83
|
+
function setParked(win, parked, { platform = process.platform, native = null } = {}) {
|
|
84
|
+
if (platform !== 'win32') return { ok: false, reason: 'not-win32' };
|
|
85
|
+
if (!win || (typeof win.isDestroyed === 'function' && win.isDestroyed())) return { ok: false, reason: 'no-window' };
|
|
86
|
+
try {
|
|
87
|
+
const n = native || api || (api = loadNative());
|
|
88
|
+
const hwnd = hwndFromHandle(win.getNativeWindowHandle());
|
|
89
|
+
let noActivate = false;
|
|
90
|
+
try {
|
|
91
|
+
const style = n.getExStyle(hwnd);
|
|
92
|
+
n.setExStyle(hwnd, parked ? (style | WS_EX_NOACTIVATE) : (style & ~WS_EX_NOACTIVATE));
|
|
93
|
+
noActivate = true;
|
|
94
|
+
} catch {}
|
|
95
|
+
const value = Buffer.alloc(4);
|
|
96
|
+
value.writeInt32LE(parked ? 1 : 0, 0);
|
|
97
|
+
const hr = n.setAttribute(hwnd, DWMWA_CLOAK, value, 4);
|
|
98
|
+
return { ok: hr === 0, hr, noActivate };
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return { ok: false, reason: error && error.message };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The open fade of a SOLID window (plan I): a constant window alpha through
|
|
105
|
+
// WS_EX_LAYERED, done here rather than with BrowserWindow.setOpacity, which
|
|
106
|
+
// sets the style once and records it as set for good - after clearLayered()
|
|
107
|
+
// its next setOpacity would quietly do nothing. Electron never learns of the
|
|
108
|
+
// style, so its own opacity stays 1. Never on glass: a layered window drops
|
|
109
|
+
// the acrylic (recorded frame by frame 2026-10-07).
|
|
110
|
+
// -> { ok, alpha?, reason? }; alpha 0..1. Never throws.
|
|
111
|
+
function setWindowAlpha(win, alpha, { platform = process.platform, native = null } = {}) {
|
|
112
|
+
if (platform !== 'win32') return { ok: false, reason: 'not-win32' };
|
|
113
|
+
if (!win || (typeof win.isDestroyed === 'function' && win.isDestroyed())) return { ok: false, reason: 'no-window' };
|
|
114
|
+
try {
|
|
115
|
+
const n = native || api || (api = loadNative());
|
|
116
|
+
const hwnd = hwndFromHandle(win.getNativeWindowHandle());
|
|
117
|
+
const byte = Math.max(0, Math.min(255, Math.round((Number(alpha) || 0) * 255)));
|
|
118
|
+
const style = n.getExStyle(hwnd);
|
|
119
|
+
if (!(style & WS_EX_LAYERED)) n.setExStyle(hwnd, style | WS_EX_LAYERED);
|
|
120
|
+
return { ok: !!n.setLayeredAlpha(hwnd, byte), alpha: byte };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return { ok: false, reason: error && error.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Ends a fade: drops WS_EX_LAYERED (the alpha goes with it, the window is a
|
|
127
|
+
// normal opaque window again) and invalidates it, the documented way out of
|
|
128
|
+
// layering. -> { ok, wasLayered?, reason? }. Never throws.
|
|
129
|
+
function clearLayered(win, { platform = process.platform, native = null } = {}) {
|
|
130
|
+
if (platform !== 'win32') return { ok: false, reason: 'not-win32' };
|
|
131
|
+
if (!win || (typeof win.isDestroyed === 'function' && win.isDestroyed())) return { ok: false, reason: 'no-window' };
|
|
132
|
+
try {
|
|
133
|
+
const n = native || api || (api = loadNative());
|
|
134
|
+
const hwnd = hwndFromHandle(win.getNativeWindowHandle());
|
|
135
|
+
const style = n.getExStyle(hwnd);
|
|
136
|
+
if (!(style & WS_EX_LAYERED)) return { ok: true, wasLayered: false };
|
|
137
|
+
n.setExStyle(hwnd, style & ~WS_EX_LAYERED);
|
|
138
|
+
n.redraw(hwnd);
|
|
139
|
+
return { ok: !(n.getExStyle(hwnd) & WS_EX_LAYERED), wasLayered: true };
|
|
140
|
+
} catch (error) {
|
|
141
|
+
return { ok: false, reason: error && error.message };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// -> { layered, alpha (0-255; 255 when not layered) } or null. For QA checks.
|
|
146
|
+
function layeredState(win, { platform = process.platform, native = null } = {}) {
|
|
147
|
+
if (platform !== 'win32' || !win || (typeof win.isDestroyed === 'function' && win.isDestroyed())) return null;
|
|
148
|
+
try {
|
|
149
|
+
const n = native || api || (api = loadNative());
|
|
150
|
+
const hwnd = hwndFromHandle(win.getNativeWindowHandle());
|
|
151
|
+
const layered = !!(n.getExStyle(hwnd) & WS_EX_LAYERED);
|
|
152
|
+
return { layered, alpha: layered ? n.getLayeredAlpha(hwnd) : 255 };
|
|
153
|
+
} catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The Windows accent colour as Settings > Personalization > Colors shows it.
|
|
159
|
+
// Electron's systemPreferences.getAccentColor() returns DwmGetColorizationColor,
|
|
160
|
+
// a BLENDED title-bar colorization (measured 2026-10-08: 0xe3006fc4 for the
|
|
161
|
+
// 0078d4 accent the Settings palette shows), so the value Settings writes is
|
|
162
|
+
// read instead. The DWORDs are 0xAABBGGRR. -> '#rrggbb' | null. Never throws.
|
|
163
|
+
const ACCENT_VALUES = [
|
|
164
|
+
['Software\\Microsoft\\Windows\\DWM', 'AccentColor'],
|
|
165
|
+
['Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Accent', 'AccentColorMenu'],
|
|
166
|
+
];
|
|
167
|
+
function abgrToHex(dword) {
|
|
168
|
+
const n = Number(dword) >>> 0;
|
|
169
|
+
return `#${[n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
|
|
170
|
+
}
|
|
171
|
+
function readAccentColor({ platform = process.platform, native = null } = {}) {
|
|
172
|
+
if (platform !== 'win32') return null;
|
|
173
|
+
try {
|
|
174
|
+
const n = native || api || (api = loadNative());
|
|
175
|
+
for (const [key, name] of ACCENT_VALUES) {
|
|
176
|
+
const dword = n.readDword(key, name);
|
|
177
|
+
if (dword != null) return abgrToHex(dword);
|
|
178
|
+
}
|
|
179
|
+
} catch {}
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { setParked, setWindowAlpha, clearLayered, layeredState, readAccentColor, abgrToHex, hwndFromHandle, DWMWA_CLOAK, WS_EX_NOACTIVATE, WS_EX_LAYERED };
|