@tobq/boardclip 0.0.0-stage → 2.1.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +170 -2
  3. package/assets/boardclip-icon.png +0 -0
  4. package/bin/boardclip.js +43 -0
  5. package/editor-preload.js +71 -0
  6. package/editor.html +220 -0
  7. package/icon.png +0 -0
  8. package/icon@2x.png +0 -0
  9. package/iconTemplate.png +0 -0
  10. package/iconTemplate@2x.png +0 -0
  11. package/index.html +1339 -0
  12. package/lib/appearance.js +183 -0
  13. package/lib/auto-update.js +306 -0
  14. package/lib/backup-worker.js +46 -0
  15. package/lib/backup.js +275 -0
  16. package/lib/blob-field-store.js +275 -0
  17. package/lib/blob-store.js +68 -0
  18. package/lib/build-info.js +89 -0
  19. package/lib/clip-blob-store.js +161 -0
  20. package/lib/clip-revision.js +81 -0
  21. package/lib/clipboard-capture.js +95 -0
  22. package/lib/clipboard-follow.js +98 -0
  23. package/lib/clipboard-model.js +1235 -0
  24. package/lib/cloud-accounts.js +250 -0
  25. package/lib/conflict-model.js +186 -0
  26. package/lib/control-client.js +87 -0
  27. package/lib/control-server.js +149 -0
  28. package/lib/diagnostics.js +121 -0
  29. package/lib/drag-files.js +48 -0
  30. package/lib/ensure-directory.js +56 -0
  31. package/lib/fork-names.js +35 -0
  32. package/lib/fs-probe.js +132 -0
  33. package/lib/history-feed.js +48 -0
  34. package/lib/hmac-auth.js +78 -0
  35. package/lib/macos-hotkey.js +297 -0
  36. package/lib/macos-paste.js +112 -0
  37. package/lib/mcp-core.js +270 -0
  38. package/lib/mcp-installers.js +284 -0
  39. package/lib/mcp-paths.js +99 -0
  40. package/lib/p2p-crypto.js +54 -0
  41. package/lib/p2p-discovery.js +191 -0
  42. package/lib/quick-paste.js +187 -0
  43. package/lib/retention.js +43 -0
  44. package/lib/sync-delta.js +227 -0
  45. package/lib/sync-journal.js +181 -0
  46. package/lib/sync-paths.js +73 -0
  47. package/lib/tailscale.js +80 -0
  48. package/lib/text-blob-store.js +38 -0
  49. package/lib/windows-clipboard.js +213 -0
  50. package/lib/windows-console.js +63 -0
  51. package/lib/windows-dwm.js +183 -0
  52. package/lib/windows-hook-worker.js +236 -0
  53. package/lib/windows-hook.js +91 -0
  54. package/lib/windows-paste.js +185 -0
  55. package/main.js +7540 -0
  56. package/mcp/boardclip-mcp.js +255 -0
  57. package/mcp-approval-preload.js +22 -0
  58. package/mcp-approval.html +218 -0
  59. package/package.json +55 -4
  60. package/preload.js +106 -0
  61. package/site/index.html +1624 -0
  62. package/site/shared/clip-search.js +2330 -0
  63. package/site/shared/clipboard-popup.css +1046 -0
  64. package/site/shared/clipboard-tokens.css +326 -0
  65. package/site/shared/clipboard-ui-core.js +6921 -0
  66. package/site/shared/clipboard-window.css +36 -0
  67. package/site/shared/vendor/cm5/README.md +52 -0
  68. package/site/shared/vendor/cm5/codemirror.css +344 -0
  69. package/site/shared/vendor/cm5/codemirror.js +9884 -0
  70. package/site/shared/vendor/cm5/diff-match-patch.js +2228 -0
  71. package/site/shared/vendor/cm5/merge.css +119 -0
  72. package/site/shared/vendor/cm5/merge.js +1182 -0
  73. package/site/shared/vendor/fonts/README.md +19 -0
  74. package/site/shared/vendor/fonts/material-symbols-rounded.woff2 +0 -0
  75. package/viewer-preload.js +51 -0
  76. package/viewer.html +129 -0
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // BoardClip MCP server (stdio).
5
+ //
6
+ // Spawned by an AI client (Claude Code, Codex, Claude Desktop, VS Code, …) over
7
+ // stdio - no HTTP, no port. It exposes the user's CURATED clipboard context and
8
+ // management actions:
9
+ //
10
+ // - Reads of clips in groups the user shared with AI are served directly from
11
+ // the data files (work even when the app is closed), filtered by the same
12
+ // opt-in group allowlist the app uses (lib/mcp-core).
13
+ // - Anything beyond the allowlist, any mutation, and any clipboard write is
14
+ // forwarded to the running BoardClip app over the local control channel,
15
+ // where it is gated behind the approval modal. If the app is not running,
16
+ // those tools return a clear "open BoardClip" message.
17
+ //
18
+ // All model/filtering logic is reused from lib/*; this file is the thin SDK glue.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { z } = require('zod');
23
+ const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
24
+ const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
25
+
26
+ const model = require('../lib/clipboard-model');
27
+ const textBlobStore = require('../lib/text-blob-store');
28
+ const mcpCore = require('../lib/mcp-core');
29
+ const mcpPaths = require('../lib/mcp-paths');
30
+ const controlClient = require('../lib/control-client');
31
+
32
+ // --- Data location -----------------------------------------------------------
33
+ const discovery = mcpPaths.readDiscovery();
34
+ const DATA_DIR = (discovery && discovery.dataDir) || mcpPaths.defaultDataDir();
35
+ const HISTORY_PATH = path.join(DATA_DIR, 'clipboard-history.json');
36
+ const SETTINGS_PATH = path.join(DATA_DIR, 'clipboard-settings.json');
37
+ const TEXT_DIR = path.join(DATA_DIR, textBlobStore.TEXT_BLOB_DIRNAME);
38
+
39
+ function readSettings() {
40
+ try {
41
+ return { ...model.DEFAULT_SETTINGS, ...JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')) };
42
+ } catch {
43
+ return { ...model.DEFAULT_SETTINGS };
44
+ }
45
+ }
46
+
47
+ function readHistory() {
48
+ try {
49
+ const loaded = JSON.parse(fs.readFileSync(HISTORY_PATH, 'utf8'));
50
+ const items = Array.isArray(loaded) ? loaded : [];
51
+ for (const item of items) { model.migrateItemPin(item); model.ensureItemId(item); }
52
+ return items;
53
+ } catch {
54
+ return [];
55
+ }
56
+ }
57
+
58
+ // --- Result helpers ----------------------------------------------------------
59
+ function jsonResult(value) {
60
+ return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
61
+ }
62
+
63
+ function errorResult(message) {
64
+ return { content: [{ type: 'text', text: message }], isError: true };
65
+ }
66
+
67
+ // Every clip mutation carries the rev it read; the app refuses a stale one.
68
+ const REV_NOTE = ' Requires expected_rev: the clip\'s `rev` from list_clips/search_clips/get_clip (or from your previous change\'s result). If the clip changed since you read it the change is refused - re-read it and retry. Returns the clip\'s new rev.';
69
+ const expectedRevSchema = () => z.string().min(1).describe('The clip\'s `rev` as you last read it; the change is refused if the clip changed since.');
70
+
71
+ function revisionConflictMessage(err) {
72
+ if (!err || !/revision_conflict:/.test(String(err.message || ''))) return null;
73
+ const d = err.details || {};
74
+ const code = err.code || String(err.message).split('revision_conflict:')[1];
75
+ if (code === 'stale_revision') return `Clip ${d.id} changed since you read it (your rev ${d.expectedRev}, now ${d.currentRev}), so nothing was changed. Re-read it with get_clip and retry with the new rev.`;
76
+ if (code === 'superseded') return `Clip ${d.id} was edited and is now clip ${d.currentId}, so nothing was changed. Re-read ${d.currentId} with get_clip and retry with its rev.`;
77
+ if (code === 'rev_required') return 'expected_rev is required: pass the clip\'s rev from list_clips/search_clips/get_clip.';
78
+ if (code === 'not_found') return `No clip with id "${d.id}" (it may have been deleted), so nothing was changed.`;
79
+ return `The clip changed (${code}), so nothing was changed. Re-read it and retry.`;
80
+ }
81
+
82
+ let clientLabel = 'an AI assistant';
83
+
84
+ // Forward a gated action/read to the running app and shape the response (or a
85
+ // friendly error) for the MCP client.
86
+ async function runForward(tool, args) {
87
+ try {
88
+ const result = await controlClient.request('action', '/action', { tool, args, client: clientLabel });
89
+ return jsonResult(result);
90
+ } catch (err) {
91
+ const conflict = revisionConflictMessage(err);
92
+ if (conflict) return errorResult(conflict);
93
+ if (err && err.code === 'app_not_running') {
94
+ return errorResult('BoardClip is not running. Open the BoardClip app so it can show the approval prompt and perform this action.');
95
+ }
96
+ if (err && /denied/i.test(err.message)) {
97
+ return errorResult('The user denied this action in the BoardClip approval prompt.');
98
+ }
99
+ if (err && /timed_out/i.test(err.message)) {
100
+ return errorResult('The approval prompt timed out (no response), so the action was not performed.');
101
+ }
102
+ return errorResult(err && err.message ? err.message : 'Action failed.');
103
+ }
104
+ }
105
+
106
+ // --- Server ------------------------------------------------------------------
107
+ const server = new McpServer({ name: 'boardclip', version: '1.0.0' });
108
+
109
+ // ---- Read tools (served locally from the data files) ----
110
+ server.registerTool('list_context', {
111
+ description: 'Summary of the user\'s clipboard organisation: groups (with which are shared with AI), pinned and numpad-slot clips, and totals. The starting point for understanding what is available.',
112
+ inputSchema: {},
113
+ }, async () => jsonResult(mcpCore.buildContext(readHistory(), readSettings())));
114
+
115
+ server.registerTool('list_clips', {
116
+ description: 'List clipboard clips the user shared with AI (most recent first, with text previews). Non-shared clips are excluded unless include_unshared_metadata is set, in which case they appear as metadata only (no text).',
117
+ inputSchema: {
118
+ limit: z.number().int().min(1).max(200).optional(),
119
+ group: z.string().optional().describe('Only clips in this group.'),
120
+ include_unshared_metadata: z.boolean().optional(),
121
+ },
122
+ }, async ({ limit, group, include_unshared_metadata }) => {
123
+ const res = mcpCore.listClips(readHistory(), readSettings(), {
124
+ limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
125
+ group: group || null,
126
+ includeNonShared: !!include_unshared_metadata,
127
+ });
128
+ return jsonResult(res);
129
+ });
130
+
131
+ server.registerTool('search_clips', {
132
+ description: 'Search shared clips with the BoardClip search language (the same text the app\'s search field takes). Plain words are literal, case-insensitive substrings, and every term must match (AND); "a quoted phrase" matches exactly; /pattern/ is a regular expression term (case-insensitive, . never crosses a line break; title:/re/ and text:/re/ too); a OR b matches either (OR binds tighter than AND, so x a OR b = x AND (a OR b)); ( ) groups at any depth; -term or -( ... ) excludes. Field scopes title:/text:/group: (short: t:/b:/g:), facets is:pinned|is:image|is:text|is:numpad|is:url|is:multiline|is:rich, num:1-9 (n:), since:/before: e.g. since:7d (s:/bf:), len:>100 or len:50-200 (l:), lines:>3 (ln:), words:<20 (wd:), sort:new|best (o:), id:. Every prefix has a short + long alias. Example: group:Work OR group:Ideas /inv(oice)?/ -draft. Returns matching shared clips with previews plus a count of how many NON-shared clips also matched. Set include_unshared to run the full search over everything - that requires the app and pops an approval prompt.',
133
+ inputSchema: {
134
+ query: z.string().min(1),
135
+ regex: z.boolean().optional().describe('Legacy: read the query\'s free text as ONE regular expression. Prefer /pattern/ terms in the query.'),
136
+ include_unshared: z.boolean().optional(),
137
+ limit: z.number().int().min(1).max(200).optional(),
138
+ },
139
+ }, async ({ query, regex, include_unshared, limit }) => {
140
+ if (include_unshared) {
141
+ return runForward('search_all', { query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT });
142
+ }
143
+ const res = mcpCore.searchClips(readHistory(), readSettings(), {
144
+ query, regex: !!regex, limit: limit || mcpCore.DEFAULT_LIST_LIMIT,
145
+ });
146
+ return jsonResult(res);
147
+ });
148
+
149
+ server.registerTool('get_clip', {
150
+ description: 'Get the full text of a clip by id. Shared clips return immediately. A non-shared clip requires the app and pops an approval prompt before the text is returned.',
151
+ inputSchema: { id: z.string() },
152
+ }, async ({ id }) => {
153
+ const settings = readSettings();
154
+ const resolved = mcpCore.resolveForRead(readHistory(), settings, id);
155
+ if (resolved.reason === 'not_found') return errorResult(`No clip with id "${id}".`);
156
+ if (resolved.reason === 'ok') {
157
+ const item = resolved.item;
158
+ const sharedSet = mcpCore.sharedGroupSet(settings);
159
+ if (item.type === 'image') return jsonResult(mcpCore.clipView(item, { sharedSet }));
160
+ textBlobStore.hydrateTextItem(item, TEXT_DIR);
161
+ return jsonResult(mcpCore.fullTextResult(item, sharedSet));
162
+ }
163
+ // not_shared -> approval-gated read through the app.
164
+ return runForward('read_clip', { id, reason: resolved.reason });
165
+ });
166
+
167
+ server.registerTool('get_image', {
168
+ description: 'Get metadata for an image clip (type, dimensions, group, timestamp). Set include_path to also get the local file path, which requires the app and pops an approval prompt.',
169
+ inputSchema: { id: z.string(), include_path: z.boolean().optional() },
170
+ }, async ({ id, include_path }) => {
171
+ if (include_path) return runForward('image_path', { id });
172
+ const settings = readSettings();
173
+ const resolved = mcpCore.resolveForRead(readHistory(), settings, id);
174
+ if (resolved.reason === 'not_found') return errorResult(`No clip with id "${id}".`);
175
+ if (resolved.item.type !== 'image') return errorResult(`Clip "${id}" is not an image.`);
176
+ // clipView returns metadata-only for a non-shared image; full meta for a shared one.
177
+ return jsonResult(mcpCore.clipView(resolved.item, { sharedSet: mcpCore.sharedGroupSet(settings) }));
178
+ });
179
+
180
+ // ---- Management tools (forwarded to the app; gated there) ----
181
+ server.registerTool('add_clip', {
182
+ description: 'Add a new text clip to history, optionally into a group. Pops an approval prompt unless you have allowed this action.',
183
+ inputSchema: { text: z.string().min(1), group: z.string().optional() },
184
+ }, async ({ text, group }) => runForward('add_clip', { text, group: group || null }));
185
+
186
+ server.registerTool('edit_clip', {
187
+ description: 'Replace (or append to) the text of an existing TEXT clip in place, keeping its pin, groups, and numpad slot. Text clips are content-addressed, so editing changes the clip id - the new id is returned in the result. Images cannot be edited. Pops an approval prompt.' + REV_NOTE,
188
+ inputSchema: {
189
+ id: z.string(),
190
+ expected_rev: expectedRevSchema(),
191
+ text: z.string().min(1),
192
+ title: z.string().optional().describe('Optional new title/name for the clip.'),
193
+ append: z.boolean().optional().describe('Append text to the existing content (newline-joined) instead of replacing it.'),
194
+ },
195
+ }, async ({ id, expected_rev, text, title, append }) => runForward('edit_clip', { id, expected_rev, text, title: title != null ? title : null, append: !!append }));
196
+
197
+ server.registerTool('pin_clip', {
198
+ description: 'Toggle the pin (star) on a clip by id.' + REV_NOTE,
199
+ inputSchema: { id: z.string(), expected_rev: expectedRevSchema() },
200
+ }, async ({ id, expected_rev }) => runForward('pin_clip', { id, expected_rev }));
201
+
202
+ server.registerTool('set_numpad', {
203
+ description: 'Assign a clip to a numpad quick-paste slot (1-9).' + REV_NOTE,
204
+ inputSchema: { id: z.string(), expected_rev: expectedRevSchema(), slot: z.number().int().min(1).max(9) },
205
+ }, async ({ id, expected_rev, slot }) => runForward('set_numpad', { id, expected_rev, slot }));
206
+
207
+ server.registerTool('assign_group', {
208
+ description: 'Toggle a clip\'s membership in a group (adds if absent, removes if present).' + REV_NOTE,
209
+ inputSchema: { id: z.string(), expected_rev: expectedRevSchema(), group: z.string().min(1) },
210
+ }, async ({ id, expected_rev, group }) => runForward('assign_group', { id, expected_rev, group }));
211
+
212
+ server.registerTool('create_group', {
213
+ description: 'Create a new group.',
214
+ inputSchema: { name: z.string().min(1) },
215
+ }, async ({ name }) => runForward('create_group', { name }));
216
+
217
+ server.registerTool('delete_group', {
218
+ description: 'Delete a group (clips stay in history, just lose this group label).',
219
+ inputSchema: { name: z.string().min(1) },
220
+ }, async ({ name }) => runForward('delete_group', { name }));
221
+
222
+ server.registerTool('delete_clip', {
223
+ description: 'Delete a clip from history. Always pops an approval prompt.' + REV_NOTE,
224
+ inputSchema: { id: z.string(), expected_rev: expectedRevSchema() },
225
+ }, async ({ id, expected_rev }) => runForward('delete_clip', { id, expected_rev }));
226
+
227
+ server.registerTool('copy_to_clipboard', {
228
+ description: 'Put a clip (by id) or literal text onto the user\'s system clipboard. Always pops an approval prompt.',
229
+ inputSchema: { id: z.string().optional(), text: z.string().optional() },
230
+ }, async ({ id, text }) => {
231
+ if (!id && !text) return errorResult('Provide either id or text.');
232
+ return runForward('copy_to_clipboard', { id: id || null, text: text != null ? text : null });
233
+ });
234
+
235
+ server.registerTool('paste_clip', {
236
+ description: 'Put a clip on the clipboard and paste it into the foreground app. Always pops an approval prompt.',
237
+ inputSchema: { id: z.string() },
238
+ }, async ({ id }) => runForward('paste_clip', { id }));
239
+
240
+ // --- Connect -----------------------------------------------------------------
241
+ async function main() {
242
+ const transport = new StdioServerTransport();
243
+ await server.connect(transport);
244
+ // Best-effort: label the client for the approval modal.
245
+ try {
246
+ const info = server.server.getClientVersion && server.server.getClientVersion();
247
+ if (info && info.name) clientLabel = info.version ? `${info.name} ${info.version}` : info.name;
248
+ } catch {}
249
+ }
250
+
251
+ main().catch(err => {
252
+ // stderr only - stdout is the JSON-RPC channel.
253
+ try { process.stderr.write(`boardclip-mcp fatal: ${err && err.stack || err}\n`); } catch {}
254
+ process.exit(1);
255
+ });
@@ -0,0 +1,22 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ // Bridge for the AI-action approval modal (mcp-approval.html). The main process
4
+ // pushes the request to render; the renderer sends back exactly one decision.
5
+ contextBridge.exposeInMainWorld('approval', {
6
+ onRequest: (callback) => {
7
+ ipcRenderer.on('approval-request', (_, request) => callback(request));
8
+ },
9
+ onSettings: (callback) => {
10
+ ipcRenderer.on('approval-settings', (_, s) => callback(s));
11
+ },
12
+ // Appearance (accent, density, corners, theme mode, this window's surface):
13
+ // one event whenever any of it changes - a setting, another device, the OS accent.
14
+ onAppearanceChanged: (callback) => {
15
+ const listener = (_, look) => callback(look || {});
16
+ ipcRenderer.on('appearance-changed', listener);
17
+ return () => ipcRenderer.removeListener('appearance-changed', listener);
18
+ },
19
+ decide: (id, choice) => ipcRenderer.send('approval-decide', id, choice),
20
+ hold: (id, held, remainingSec) => ipcRenderer.send('approval-hold', id, held, remainingSec),
21
+ resize: (height) => ipcRenderer.send('approval-resize', height),
22
+ });
@@ -0,0 +1,218 @@
1
+ <!DOCTYPE html>
2
+ <html><head><meta charset="utf-8"><title>Approve AI action</title>
3
+ <script>
4
+ document.documentElement.dataset.theme = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
5
+ </script>
6
+ <link rel="stylesheet" href="site/shared/clipboard-popup.css">
7
+ <link rel="stylesheet" href="site/shared/clipboard-window.css">
8
+ <!-- The shared core: its applyTheme / applyAppearance render the appearance
9
+ payload exactly as every other window does. -->
10
+ <script src="site/shared/clip-search.js"></script>
11
+ <script src="site/shared/clipboard-ui-core.js"></script>
12
+ <style>
13
+ /* Tokens, the .mi icon spec, the .overline label, the .btn set, the focus
14
+ ring and the scrollbar come from the shared clipboard-popup.css; the
15
+ window base (reset, font, glass scrim) from clipboard-window.css, so the
16
+ modal can never drift from the app. Only the modal's own layout lives here.
17
+ Layout answers, top to bottom: WHO is asking, WHAT happens (title + one
18
+ plain sentence with the consequence), WHY it is asking, WHICH clip (facts),
19
+ the text only as a labelled preview, then the decision: [Deny] [Allow
20
+ once], with "for this session" / "always" as quiet options above it. */
21
+ body { user-select:none; }
22
+ /* The bar is the shared window bar (.bc-bar: drag region, --bar-h, the title in sentence case). */
23
+ /* Two tones, as in every window: the request on the canvas, the decision
24
+ (countdown, the session / always options, Deny / Allow once) on a foot
25
+ band like the editor's, held at the bottom. */
26
+ body { display:flex; flex-direction:column; }
27
+ .wrap { padding:var(--sp-5) var(--gutter) var(--sp-6); display:flex; flex-direction:column; gap:var(--sp-4); }
28
+ .foot { margin-top:auto; padding:var(--sp-5) var(--gutter); display:flex; flex-direction:column; gap:var(--sp-4);
29
+ background:var(--surface); border-top:1px solid var(--line); }
30
+ .who { font-size:var(--fs-meta); line-height:var(--lh-ui); color:var(--text-dim); }
31
+ .who b { color:var(--text); font-weight:var(--fw-strong); }
32
+ .head { display:flex; gap:var(--sp-5); align-items:center; }
33
+ .badge { width:var(--ctl-lg); height:var(--ctl-lg); border-radius:var(--r-panel); flex-shrink:0; display:flex; align-items:center; justify-content:center;
34
+ background:var(--accent-bg); color:var(--accent); }
35
+ .badge.danger { background:var(--red-bg); color:var(--red); }
36
+ h1 { font-size:var(--fs-ui); font-weight:var(--fw-strong); line-height:var(--lh-ui); }
37
+ .explain { font-size:var(--fs-ui); line-height:var(--lh-text); color:var(--text); }
38
+ .why { font-size:var(--fs-meta); line-height:var(--lh-ui); color:var(--text-dim); display:flex; gap:var(--sp-2); align-items:flex-start; }
39
+ .why .mi { flex-shrink:0; }
40
+ .facts { display:grid; grid-template-columns:auto 1fr; gap:var(--sp-1) var(--sp-4); font-size:var(--fs-meta); }
41
+ .facts dt { color:var(--text-dim); }
42
+ .facts dd { color:var(--text); word-break:break-word; }
43
+ .card { background:var(--surface); border-radius:var(--r-panel); padding:var(--sp-4) var(--sp-5); }
44
+ .card .overline { margin-bottom:var(--sp-2); }
45
+ .detail { font-family:var(--font-mono); font-size:var(--fs-ui); line-height:var(--lh-text); color:var(--text);
46
+ white-space:pre-wrap; word-break:break-word; max-height:88px; overflow:auto; }
47
+ /* The countdown is information, not a warning: neutral, whatever the action. */
48
+ .meter { height:3px; border-radius:var(--r-chip); background:var(--line); overflow:hidden; }
49
+ .meter > span { display:block; height:100%; width:100%; background:var(--text-dim); transition:width 1s linear; }
50
+ .meter.paused > span { opacity:.45; }
51
+ .countdown { font-size:var(--fs-meta); color:var(--text-dim); display:flex; justify-content:space-between; margin-top:var(--sp-3); }
52
+ .later { display:flex; flex-wrap:wrap; gap:var(--sp-2); margin:0 calc(-1 * var(--sp-3)); }
53
+ .acts { display:flex; justify-content:flex-end; gap:var(--sp-4); }
54
+ </style></head>
55
+ <body>
56
+ <div class="bc-bar"><span class="bc-bar-title">AI action</span></div>
57
+ <div class="wrap" id="wrap">
58
+ <div class="who" id="who" title="Asked through BoardClip's AI access (its MCP connection). You can turn AI access off in Settings."><b id="client">An AI assistant</b> wants BoardClip to:</div>
59
+ <div class="head">
60
+ <div class="badge" id="badge"><span class="mi lg" id="badgeIcon">smart_toy</span></div>
61
+ <h1 id="title">Act on your clips</h1>
62
+ </div>
63
+ <div class="explain" id="explain"></div>
64
+ <div class="why" id="whyRow"><span class="mi sm">info</span><span id="why"></span></div>
65
+ <dl class="facts" id="facts"></dl>
66
+ <div class="card" id="detailCard">
67
+ <div class="label overline" id="detailLabel">Clip text</div>
68
+ <div class="detail" id="detail"></div>
69
+ </div>
70
+ </div>
71
+ <div class="foot" id="foot">
72
+ <div>
73
+ <div class="meter" id="meter"><span id="meterBar"></span></div>
74
+ <div class="countdown"><span id="countLabel">Denies automatically if you don't respond</span><span id="countNum"></span></div>
75
+ </div>
76
+ <div class="later">
77
+ <button type="button" class="btn quiet sm" id="allowSession" data-allow>Allow for this session</button>
78
+ <button type="button" class="btn quiet sm" id="allowAlways" data-allow>Always allow</button>
79
+ </div>
80
+ <div class="acts">
81
+ <button type="button" class="btn" id="deny" title="Deny (Enter or Esc)">Deny</button>
82
+ <button type="button" class="btn primary" id="allowOnce" data-allow>Allow once</button>
83
+ </div>
84
+ </div>
85
+
86
+ <script>
87
+ const Core = window.BoardClipCore;
88
+ const $ = id => document.getElementById(id);
89
+ let current = null, remaining = 60, timer = null, decided = false, paused = false, armedAt = Infinity;
90
+ // Glyphs match the ones the app uses for the same action (star = pin, sell =
91
+ // group, edit_note = edit).
92
+ const ICONS = {
93
+ delete_clip:'delete', edit_clip:'edit_note', copy_to_clipboard:'content_copy', paste_clip:'content_paste_go',
94
+ add_clip:'add', pin_clip:'star', set_numpad:'tag', assign_group:'sell',
95
+ create_group:'new_label', delete_group:'label_off', read_clip:'visibility',
96
+ search_all:'search', image_path:'image',
97
+ };
98
+ const KIND = {
99
+ delete_clip:'deleting clips', edit_clip:'editing clip text', copy_to_clipboard:'writing your clipboard', paste_clip:'pasting into apps',
100
+ add_clip:'adding clips', pin_clip:'pinning', set_numpad:'numpad slots', assign_group:'group changes',
101
+ create_group:'group changes', delete_group:'group changes', read_clip:'reading private clips',
102
+ search_all:'searching private clips', image_path:'revealing image paths',
103
+ };
104
+ // The prompt never takes focus (main shows it inactive) and never allows by
105
+ // accident: Enter and Esc deny; allowing takes a click, or Ctrl+Enter
106
+ // (Cmd+Enter on macOS) once the window has focus. The allow buttons arm a
107
+ // moment after the prompt appears, so a click already on its way to
108
+ // whatever was under the pointer cannot land on them.
109
+ const ARM_MS = 800;
110
+ const MOD = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
111
+ const armed = () => Date.now() >= armedAt;
112
+ const allowButtons = () => [...document.querySelectorAll('[data-allow]')];
113
+
114
+ // The appearance payload every window gets (main's windowAppearance): with
115
+ // the request (plus main's resolved theme), then again on any change.
116
+ function applyLook(look) {
117
+ const root = document.documentElement;
118
+ if (look.theme === 'light' || look.theme === 'dark') root.dataset.theme = look.theme;
119
+ else if (look.themeMode) Core.applyTheme(root, look.themeMode, matchMedia('(prefers-color-scheme: dark)').matches);
120
+ Core.applyAppearance(root, look);
121
+ }
122
+ if (window.approval.onSettings) window.approval.onSettings(applyLook);
123
+ if (window.approval.onAppearanceChanged) window.approval.onAppearanceChanged(applyLook);
124
+
125
+ function fit() {
126
+ // The natural height of bar + request + foot (the foot's margin-top:auto
127
+ // only takes up slack, so it never feeds back into the measure).
128
+ const h = ['.bc-bar', '#wrap', '#foot'].reduce((sum, sel) => { const el = document.querySelector(sel); return sum + (el ? el.getBoundingClientRect().height : 0); }, 0);
129
+ if (window.approval && window.approval.resize) window.approval.resize(Math.ceil(h));
130
+ }
131
+ function decide(choice) {
132
+ if (decided || !current) return;
133
+ if (choice !== 'deny' && choice !== 'timeout' && !armed()) return;
134
+ decided = true;
135
+ if (timer) clearInterval(timer);
136
+ window.approval.decide(current.id, choice);
137
+ }
138
+ // The countdown stops while the mouse is over the prompt so it can be read
139
+ // at leisure; main is told so its safety timer (and the waiting helper's
140
+ // keepalive) follow. Leaving the window resumes from the same second.
141
+ function setPaused(next) {
142
+ if (decided || !current || paused === next) return;
143
+ paused = next;
144
+ $('countLabel').textContent = paused ? 'Paused while your mouse is over this' : "Denies automatically if you don't respond";
145
+ $('countNum').textContent = paused ? 'paused' : remaining + 's';
146
+ $('meter').classList.toggle('paused', paused);
147
+ if (window.approval.hold) window.approval.hold(current.id, paused, remaining);
148
+ }
149
+ document.documentElement.addEventListener('mouseenter', () => setPaused(true));
150
+ document.documentElement.addEventListener('mouseleave', () => setPaused(false));
151
+ function tick() {
152
+ if (paused) return;
153
+ remaining -= 1;
154
+ $('countNum').textContent = remaining + 's';
155
+ $('meterBar').style.width = Math.max(0, (remaining / (current.timeoutSec || 60)) * 100) + '%';
156
+ if (remaining <= 0) decide('timeout');
157
+ }
158
+
159
+ window.approval.onRequest(req => {
160
+ current = req;
161
+ remaining = req.timeoutSec || 60;
162
+ const danger = !!req.danger;
163
+ const kind = KIND[req.tool] || req.tool;
164
+ $('title').textContent = req.title || 'Act on your clips';
165
+ $('client').textContent = req.client || 'An AI assistant';
166
+ $('badgeIcon').textContent = ICONS[req.tool] || 'smart_toy';
167
+ $('badge').classList.toggle('danger', danger);
168
+ $('allowOnce').classList.toggle('danger', danger);
169
+ $('allowOnce').classList.toggle('primary', !danger);
170
+ $('allowOnce').title = `Allow once (${MOD}+Enter). Enter and Esc deny.`;
171
+ $('allowSession').title = `Allow ${kind} until BoardClip restarts`;
172
+ $('allowAlways').textContent = `Always allow ${kind}`;
173
+ $('allowAlways').title = `Never ask again before ${kind} (Settings > AI access lists what is always allowed)`;
174
+ $('explain').textContent = req.explain || req.summary || '';
175
+ $('why').textContent = req.why || '';
176
+ $('whyRow').style.display = req.why ? '' : 'none';
177
+ const facts = Array.isArray(req.facts) ? req.facts : [];
178
+ $('facts').innerHTML = '';
179
+ for (const [k, v] of facts) {
180
+ const dt = document.createElement('dt'); dt.textContent = k;
181
+ const dd = document.createElement('dd'); dd.textContent = v;
182
+ $('facts').append(dt, dd);
183
+ }
184
+ $('facts').style.display = facts.length ? '' : 'none';
185
+ const preview = req.preview && req.preview.text ? req.preview : (req.detail ? { label: 'Clip text', text: req.detail } : null);
186
+ if (preview) { $('detailLabel').textContent = preview.label || 'Clip text'; $('detail').textContent = preview.text.trim(); $('detailCard').style.display = ''; }
187
+ else { $('detailCard').style.display = 'none'; }
188
+ $('countNum').textContent = remaining + 's';
189
+ timer = setInterval(tick, 1000);
190
+ for (const b of allowButtons()) b.disabled = true;
191
+ armedAt = Date.now() + ARM_MS;
192
+ setTimeout(() => { if (!decided) for (const b of allowButtons()) b.disabled = false; }, ARM_MS);
193
+ requestAnimationFrame(fit);
194
+ // Opened under a resting cursor: no mouseenter fires, so ask directly.
195
+ requestAnimationFrame(() => { if (document.documentElement.matches(':hover')) setPaused(true); });
196
+ });
197
+
198
+ $('allowOnce').onclick = () => decide('once');
199
+ $('allowSession').onclick = () => decide('session');
200
+ $('allowAlways').onclick = () => decide('always');
201
+ $('deny').onclick = () => decide('deny');
202
+ // Capture phase, so a focused button never gets the key first: Enter on a
203
+ // focused Allow still denies, and Space cannot press an allow button.
204
+ window.addEventListener('keydown', e => {
205
+ if (e.key === 'Escape') { e.preventDefault(); decide('deny'); return; }
206
+ if (e.key === 'Enter') {
207
+ e.preventDefault();
208
+ if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey) decide('once');
209
+ else decide('deny');
210
+ return;
211
+ }
212
+ if (e.key === ' ' && e.target && e.target.closest && e.target.closest('[data-allow]')) e.preventDefault();
213
+ }, true);
214
+ window.addEventListener('keyup', e => {
215
+ if (e.key === ' ' && e.target && e.target.closest && e.target.closest('[data-allow]')) e.preventDefault();
216
+ }, true);
217
+ </script>
218
+ </body></html>
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "@tobq/boardclip",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "productName": "BoardClip",
4
+ "version": "2.1.108",
5
+ "description": "Cross-platform clipboard history tray app with numpad quick-paste and native cloud sync",
6
+ "keywords": [
7
+ "clipboard",
8
+ "clipboard-manager",
9
+ "clipboard-history",
10
+ "electron",
11
+ "tray",
12
+ "windows",
13
+ "macos",
14
+ "linux",
15
+ "mcp"
16
+ ],
17
+ "homepage": "https://boardclip.app",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/tobq/boardclip.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "BoardClip contributors",
24
+ "main": "main.js",
25
+ "bin": {
26
+ "boardclip": "bin/boardclip.js"
27
+ },
28
+ "dependencies": {
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
30
+ "koffi": "^2.15.6",
31
+ "zod": "^4.4.3",
32
+ "electron": "^34.0.0"
33
+ },
34
+ "files": [
35
+ "main.js",
36
+ "preload.js",
37
+ "index.html",
38
+ "mcp-approval.html",
39
+ "mcp-approval-preload.js",
40
+ "lib/**",
41
+ "mcp/**",
42
+ "site/shared/**",
43
+ "assets/boardclip-icon.png",
44
+ "editor.html",
45
+ "editor-preload.js",
46
+ "viewer.html",
47
+ "viewer-preload.js",
48
+ "iconTemplate.png",
49
+ "iconTemplate@2x.png",
50
+ "icon.png",
51
+ "icon@2x.png",
52
+ "bin/**"
53
+ ],
54
+ "engines": {
55
+ "node": ">=18"
56
+ }
57
+ }