backdraw 0.0.0-stage → 2.1.0
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 +606 -2
- package/README.tr.md +624 -0
- package/bin/backdraw.js +56 -0
- package/cli.js +105 -0
- package/doctor.js +174 -0
- package/extension/_locales/en/messages.json +3 -0
- package/extension/_locales/tr/messages.json +3 -0
- package/extension/aspect.js +25 -0
- package/extension/background.js +1015 -0
- package/extension/collection.html +45 -0
- package/extension/collection.js +62 -0
- package/extension/collections.js +409 -0
- package/extension/i18n.js +359 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon32.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/icons/mascot-192.png +0 -0
- package/extension/icons/mascot-96.png +0 -0
- package/extension/inpage.js +624 -0
- package/extension/manifest.json +20 -0
- package/extension/menus.js +50 -0
- package/extension/popup.css +90 -0
- package/extension/popup.html +63 -0
- package/extension/popup.js +376 -0
- package/extension/settings.js +35 -0
- package/extension/setup.js +57 -0
- package/extension/sidepanel.css +113 -0
- package/extension/sidepanel.html +31 -0
- package/extension/sidepanel.js +433 -0
- package/extension/theme.css +22 -0
- package/host.js +664 -0
- package/identity.js +21 -0
- package/install.js +427 -0
- package/lib/args.js +30 -0
- package/lib/aspect.js +85 -0
- package/lib/browsers.js +74 -0
- package/lib/collection-store.js +288 -0
- package/lib/collections.js +192 -0
- package/lib/doctor.js +143 -0
- package/lib/extension-calls.js +59 -0
- package/lib/gallery.js +63 -0
- package/lib/limits.js +18 -0
- package/lib/mcp-clients.js +178 -0
- package/lib/naming.js +25 -0
- package/lib/png.js +24 -0
- package/lib/projects.js +56 -0
- package/lib/prompt.js +25 -0
- package/lib/protocol.js +50 -0
- package/lib/results.js +170 -0
- package/lib/term.js +22 -0
- package/lib/toml.js +248 -0
- package/mcp-server.js +437 -0
- package/package.json +54 -4
- package/platform/index.js +14 -0
- package/platform/win32.js +268 -0
- package/setup.js +212 -0
- package/shared.js +118 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Windows implementation: named pipe, native host registration for each Chromium browser
|
|
3
|
+
// (registry), user folders, installed browsers, and where the Claude Code CLI, the Claude desktop
|
|
4
|
+
// app and their configs live.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { execFileSync, spawn } = require('child_process');
|
|
9
|
+
const identity = require('../identity');
|
|
10
|
+
const browsers = require('../lib/browsers');
|
|
11
|
+
|
|
12
|
+
const USER_TAG = (os.userInfo().username || 'user').replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
13
|
+
|
|
14
|
+
function pipePath() {
|
|
15
|
+
return `\\\\.\\pipe\\${identity.PIPE_PREFIX}-${USER_TAG}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// In the home folder, not under AppData: the Claude desktop app is an MSIX package, and AppData
|
|
19
|
+
// writes made from its processes are redirected into the package's private storage, where Chrome
|
|
20
|
+
// can't see them. The home folder itself is not redirected.
|
|
21
|
+
// home: the user's home folder; install.js passes it so its tests can use a temporary one.
|
|
22
|
+
function installDir(home = os.homedir()) {
|
|
23
|
+
return path.join(home, identity.INSTALL_DIR_NAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function legacyInstallDir(home = os.homedir()) {
|
|
27
|
+
return path.join(home, ...identity.LEGACY.INSTALL_DIR);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function claudeConfigFile(home = os.homedir()) {
|
|
31
|
+
return path.join(home, '.claude.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Chat MCP config of the Claude desktop app: the MSIX package's private copy and the classic one.
|
|
35
|
+
function desktopConfigFiles() {
|
|
36
|
+
const files = [];
|
|
37
|
+
const packages = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'Packages');
|
|
38
|
+
try {
|
|
39
|
+
for (const dir of fs.readdirSync(packages)) {
|
|
40
|
+
if (/^Claude_/i.test(dir)) files.push(path.join(packages, dir, 'LocalCache', 'Roaming', 'Claude', 'claude_desktop_config.json'));
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
43
|
+
files.push(path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json'));
|
|
44
|
+
return files.filter(f => fs.existsSync(f));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Reads the output of the process query below: one executable path per line, '?' when a path
|
|
48
|
+
// can't be read. Claude Code's own claude.exe has the same process name but lives elsewhere and
|
|
49
|
+
// doesn't count; an unreadable path counts as the desktop app, to be safe.
|
|
50
|
+
function parseClaudeProcesses(out) {
|
|
51
|
+
return String(out || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)
|
|
52
|
+
.some(p => p === '?' || /\\WindowsApps\\|\\AnthropicClaude\\/i.test(p));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// True while the Claude desktop app runs (it rewrites its config on exit), false when it doesn't,
|
|
56
|
+
// null when that can't be told (PowerShell failed). With no claude process Get-Process reports an
|
|
57
|
+
// error even when silenced, so the script collects the result and exits 0 explicitly.
|
|
58
|
+
function desktopAppRunning() {
|
|
59
|
+
let out;
|
|
60
|
+
try {
|
|
61
|
+
out = execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
62
|
+
"[Console]::OutputEncoding = [Text.Encoding]::UTF8; $p = @(Get-Process -Name claude -ErrorAction SilentlyContinue | ForEach-Object { if ($_.Path) { $_.Path } else { '?' } }); $p; exit 0"],
|
|
63
|
+
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
return parseClaudeProcesses(out);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// key: an id of browsers.HOST_KEYS (chrome, edge, chromium); Chrome's when left out.
|
|
71
|
+
const regSubkey = (hostName, key = 'chrome') => `${browsers.HOST_KEYS[key]}\\${hostName}`;
|
|
72
|
+
const regKey = (hostName, key) => `HKCU\\${regSubkey(hostName, key)}`;
|
|
73
|
+
|
|
74
|
+
// Same MSIX caveat for the registry: HKCU writes from inside the package go to a private copy.
|
|
75
|
+
// WMI's registry provider runs outside the package, so it writes the HKCU that Chrome reads.
|
|
76
|
+
function realRegistry(subkey, action, value = '') {
|
|
77
|
+
const ps = `$ErrorActionPreference = 'Stop'
|
|
78
|
+
$hk = [uint32]2147483649
|
|
79
|
+
$k = '${subkey}'
|
|
80
|
+
function Reg($m, $a) { Invoke-CimMethod -Namespace root/default -ClassName StdRegProv -MethodName $m -Arguments $a }
|
|
81
|
+
if ('${action}' -eq 'delete') { [void](Reg 'DeleteKey' @{ hDefKey = $hk; sSubKeyName = $k }); exit 0 }
|
|
82
|
+
[void](Reg 'CreateKey' @{ hDefKey = $hk; sSubKeyName = $k })
|
|
83
|
+
$r = Reg 'SetStringValue' @{ hDefKey = $hk; sSubKeyName = $k; sValueName = ''; sValue = $env:BACKDRAW_VALUE }
|
|
84
|
+
if ($r.ReturnValue -ne 0) { throw "SetStringValue returned $($r.ReturnValue)" }
|
|
85
|
+
(Reg 'GetStringValue' @{ hDefKey = $hk; sSubKeyName = $k; sValueName = '' }).sValue`;
|
|
86
|
+
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps],
|
|
87
|
+
{ encoding: 'utf8', env: { ...process.env, BACKDRAW_VALUE: value } }).trim();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Chrome starts the host through this launcher when the extension connects (native messaging).
|
|
91
|
+
function writeHostLauncher(dir, nodePath) {
|
|
92
|
+
const bat = path.join(dir, 'host.bat');
|
|
93
|
+
fs.writeFileSync(bat, `@echo off\r\n"${nodePath}" "%~dp0host.js" %*\r\n`);
|
|
94
|
+
return bat;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Returns where the registration lives, for display.
|
|
98
|
+
function registerNativeHost(hostName, manifestPath, key = 'chrome') {
|
|
99
|
+
const full = regKey(hostName, key);
|
|
100
|
+
try { execFileSync('reg.exe', ['delete', full, '/f'], { stdio: 'ignore' }); } catch {} // stale private copy, if any
|
|
101
|
+
try {
|
|
102
|
+
if (realRegistry(regSubkey(hostName, key), 'set', manifestPath) === manifestPath) return full;
|
|
103
|
+
} catch {}
|
|
104
|
+
execFileSync('reg.exe', ['add', full, '/ve', '/t', 'REG_SZ', '/d', manifestPath, '/f'], { stdio: 'ignore' });
|
|
105
|
+
return full;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Where a host's registration lives, for display (e.g. the uninstall dry run).
|
|
109
|
+
function nativeHostEntry(hostName, key = 'chrome') {
|
|
110
|
+
return regKey(hostName, key);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function unregisterNativeHost(hostName, key = 'chrome') {
|
|
114
|
+
try { realRegistry(regSubkey(hostName, key), 'delete'); } catch {}
|
|
115
|
+
try { execFileSync('reg.exe', ['delete', regKey(hostName, key), '/f'], { stdio: 'ignore' }); } catch {}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// For `backdraw doctor`: in one PowerShell run, through WMI (the real registry, also from inside an
|
|
119
|
+
// MSIX package), the registration of `hostName` under every host key, and each browser's native
|
|
120
|
+
// messaging policies from HKLM and HKCU (lists are policy subkeys with numbered string values).
|
|
121
|
+
// Returns the raw JSON text; parseBrowserRegistry() reads it.
|
|
122
|
+
function readBrowserRegistry(hostName) {
|
|
123
|
+
const query = {
|
|
124
|
+
hosts: Object.keys(browsers.HOST_KEYS).map(id => ({ id, key: regSubkey(hostName, id) })),
|
|
125
|
+
policies: browsers.BROWSERS.map(b => ({ id: b.id, policyKey: b.policyKey })),
|
|
126
|
+
};
|
|
127
|
+
const ps = `$ErrorActionPreference = 'Stop'
|
|
128
|
+
[Console]::OutputEncoding = [Text.Encoding]::UTF8
|
|
129
|
+
function Reg($m, $a) { Invoke-CimMethod -Namespace root/default -ClassName StdRegProv -MethodName $m -Arguments $a }
|
|
130
|
+
$hives = @(@('hklm', [uint32]2147483650), @('hkcu', [uint32]2147483649))
|
|
131
|
+
$q = $env:BACKDRAW_QUERY | ConvertFrom-Json
|
|
132
|
+
$hosts = @{}
|
|
133
|
+
foreach ($h in $q.hosts) { $hosts[$h.id] = (Reg 'GetStringValue' @{ hDefKey = [uint32]2147483649; sSubKeyName = $h.key; sValueName = '' }).sValue }
|
|
134
|
+
$policies = @{}
|
|
135
|
+
foreach ($b in $q.policies) {
|
|
136
|
+
$policy = @{}
|
|
137
|
+
foreach ($h in $hives) {
|
|
138
|
+
$d = Reg 'GetDWORDValue' @{ hDefKey = $h[1]; sSubKeyName = $b.policyKey; sValueName = 'NativeMessagingUserLevelHosts' }
|
|
139
|
+
$p = @{ userLevelHosts = $(if ($d.ReturnValue -eq 0) { [int]$d.uValue } else { $null }) }
|
|
140
|
+
foreach ($l in 'NativeMessagingBlocklist', 'NativeMessagingAllowlist') {
|
|
141
|
+
$sub = "$($b.policyKey)\\$l"
|
|
142
|
+
$e = Reg 'EnumValues' @{ hDefKey = $h[1]; sSubKeyName = $sub }
|
|
143
|
+
$vals = @()
|
|
144
|
+
if ($e.ReturnValue -eq 0 -and $e.sNames) { foreach ($n in $e.sNames) { $vals += [string](Reg 'GetStringValue' @{ hDefKey = $h[1]; sSubKeyName = $sub; sValueName = $n }).sValue } }
|
|
145
|
+
$p[$l] = $vals
|
|
146
|
+
}
|
|
147
|
+
$policy[$h[0]] = $p
|
|
148
|
+
}
|
|
149
|
+
$policies[$b.id] = $policy
|
|
150
|
+
}
|
|
151
|
+
ConvertTo-Json -InputObject @{ hosts = $hosts; policies = $policies } -Depth 6 -Compress`;
|
|
152
|
+
return execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', ps],
|
|
153
|
+
{ encoding: 'utf8', env: { ...process.env, BACKDRAW_QUERY: JSON.stringify(query) }, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// { hosts: { chrome: manifest path or null, edge, chromium },
|
|
157
|
+
// policies: { <browser id>: { userLevelHosts, blocklist, allowlist } } }.
|
|
158
|
+
// A machine (HKLM) policy wins over the same user (HKCU) policy.
|
|
159
|
+
function parseBrowserRegistry(text) {
|
|
160
|
+
const raw = JSON.parse(String(text).replace(/^\uFEFF/, '').trim() || '{}');
|
|
161
|
+
const list = v => [].concat(v === null || v === undefined ? [] : v).map(String);
|
|
162
|
+
const hosts = {};
|
|
163
|
+
for (const id of Object.keys(browsers.HOST_KEYS)) hosts[id] = (raw.hosts || {})[id] || null;
|
|
164
|
+
const policies = {};
|
|
165
|
+
for (const [id, policy] of Object.entries(raw.policies || {})) {
|
|
166
|
+
const pick = (name, read) => {
|
|
167
|
+
for (const hive of ['hklm', 'hkcu']) {
|
|
168
|
+
const v = read(((policy || {})[hive] || {})[name]);
|
|
169
|
+
if (v !== undefined) return v;
|
|
170
|
+
}
|
|
171
|
+
return undefined;
|
|
172
|
+
};
|
|
173
|
+
policies[id] = {
|
|
174
|
+
userLevelHosts: pick('userLevelHosts', v => (v === null || v === undefined ? undefined : Number(v) !== 0)),
|
|
175
|
+
blocklist: pick('NativeMessagingBlocklist', v => (list(v).length ? list(v) : undefined)) || [],
|
|
176
|
+
allowlist: pick('NativeMessagingAllowlist', v => (list(v).length ? list(v) : undefined)) || [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return { hosts, policies };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Program Files, Program Files (x86) and LocalAppData: where browsers install themselves.
|
|
183
|
+
function programRoots() {
|
|
184
|
+
const env = process.env;
|
|
185
|
+
return [env.ProgramFiles || 'C:\\Program Files', env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)',
|
|
186
|
+
env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local')];
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// The executable of a browser (its first channel that is there), or null when it isn't installed.
|
|
190
|
+
function browserExe(browser) {
|
|
191
|
+
for (const rel of browsers.byId(browser).exe) {
|
|
192
|
+
for (const root of programRoots()) {
|
|
193
|
+
const exe = path.join(root, rel);
|
|
194
|
+
if (fs.existsSync(exe)) return exe;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function detectBrowsers() {
|
|
201
|
+
return browsers.BROWSERS.filter(b => browserExe(b.id)).map(b => b.id);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Opens a browser page (e.g. its extensions page) in that browser, if it is installed.
|
|
205
|
+
function openInBrowser(browser, url) {
|
|
206
|
+
const exe = browserExe(browser);
|
|
207
|
+
if (!exe) return false;
|
|
208
|
+
const child = spawn(exe, [url], { detached: true, stdio: 'ignore' });
|
|
209
|
+
child.on('error', () => {});
|
|
210
|
+
child.unref();
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function openFolder(dir) {
|
|
215
|
+
const child = spawn('explorer.exe', [dir], { detached: true, stdio: 'ignore' });
|
|
216
|
+
child.on('error', () => {});
|
|
217
|
+
child.unref();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Through PowerShell (clip.exe mangles non-ASCII paths). Returns whether it worked.
|
|
221
|
+
function copyToClipboard(text) {
|
|
222
|
+
try {
|
|
223
|
+
execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Set-Clipboard -Value $env:BACKDRAW_CLIP'],
|
|
224
|
+
{ stdio: 'ignore', env: { ...process.env, BACKDRAW_CLIP: text } });
|
|
225
|
+
return true;
|
|
226
|
+
} catch { return false; }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The first match of a command on PATH, or null.
|
|
230
|
+
function which(cmd) {
|
|
231
|
+
try { return execFileSync('where.exe', [cmd], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).split(/\r?\n/)[0].trim() || null; } catch { return null; }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function appDataDir() {
|
|
235
|
+
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function picturesDir() {
|
|
239
|
+
try {
|
|
240
|
+
const out = execFileSync('powershell.exe', ['-NoProfile', '-Command',
|
|
241
|
+
"[Console]::OutputEncoding = [Text.Encoding]::UTF8; [Environment]::GetFolderPath('MyPictures')"], { encoding: 'utf8' }).trim();
|
|
242
|
+
if (out && fs.existsSync(out)) return out;
|
|
243
|
+
} catch {}
|
|
244
|
+
return path.join(os.homedir(), 'Pictures');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function claudeCli() {
|
|
248
|
+
const local = path.join(os.homedir(), '.local', 'bin', 'claude.exe');
|
|
249
|
+
if (fs.existsSync(local)) return local;
|
|
250
|
+
return which('claude');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Opens Explorer with the file selected. No shell: explorer.exe is started directly. Its own parser
|
|
254
|
+
// splits "/select," arguments at commas, so the path is always quoted (a Windows path never holds ").
|
|
255
|
+
function revealInFolder(file, run = spawn) {
|
|
256
|
+
if (typeof file !== 'string' || !path.win32.isAbsolute(file) || file.includes('"')) throw new Error('Not an absolute file path.');
|
|
257
|
+
const child = run('explorer.exe', ['/select,', `"${file}"`], { detached: true, stdio: 'ignore', windowsVerbatimArguments: true });
|
|
258
|
+
child.on('error', () => {});
|
|
259
|
+
child.unref();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
pipePath, installDir, legacyInstallDir, claudeConfigFile, desktopConfigFiles, desktopAppRunning, parseClaudeProcesses,
|
|
264
|
+
writeHostLauncher, registerNativeHost, unregisterNativeHost, nativeHostEntry, readBrowserRegistry, parseBrowserRegistry,
|
|
265
|
+
detectBrowsers, browserExe, openInBrowser, openFolder, copyToClipboard, which, appDataDir,
|
|
266
|
+
picturesDir, claudeCli,
|
|
267
|
+
revealInFolder,
|
|
268
|
+
};
|
package/setup.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `backdraw setup` (npx backdraw@latest setup): installs Backdraw into %USERPROFILE%\.backdraw (npx's
|
|
3
|
+
// own copy is temporary), registers the native host for the installed browsers, adds Backdraw to
|
|
4
|
+
// the MCP clients the user picks, then walks through loading the extension and waits for it.
|
|
5
|
+
// --yes no questions: every client that was found
|
|
6
|
+
// --clients=a,b exactly these (ids: see CLIENT_IDS)
|
|
7
|
+
// --browser=edge the browser to load the extension in (default: the first one found)
|
|
8
|
+
// --no-wait don't wait for the extension to connect
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const installer = require('./install');
|
|
12
|
+
const browsers = require('./lib/browsers');
|
|
13
|
+
const mcpClients = require('./lib/mcp-clients');
|
|
14
|
+
const { useColor, painter, line } = require('./lib/term');
|
|
15
|
+
|
|
16
|
+
const SELF = require('./package.json').version;
|
|
17
|
+
const CLIENT_IDS = ['claude-code', 'claude-desktop', ...mcpClients.FILE_CLIENTS.map(c => c.id)];
|
|
18
|
+
const WAIT_MS = 180000;
|
|
19
|
+
const EXAMPLE = 'Generate an image with ChatGPT: a cozy reading nook by a rainy window, warm lamp light.';
|
|
20
|
+
|
|
21
|
+
// Every client setup knows, and whether it is on this PC.
|
|
22
|
+
function detectClients(c) {
|
|
23
|
+
const p = c.platform;
|
|
24
|
+
return [
|
|
25
|
+
{ id: 'claude-code', label: 'Claude Code', detected: !!c.claudeCli || fs.existsSync(p.claudeConfigFile(c.home)) },
|
|
26
|
+
{ id: 'claude-desktop', label: 'Claude desktop app', detected: p.desktopConfigFiles().length > 0 },
|
|
27
|
+
...mcpClients.FILE_CLIENTS.map(client => ({ id: client.id, label: client.label, detected: client.detect(c.clientEnv) })),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseClientList(value) {
|
|
32
|
+
if (value === true || value === '') throw new Error(`--clients needs a list, e.g. --clients=claude-code,cursor (one of: ${CLIENT_IDS.join(', ')}).`);
|
|
33
|
+
const ids = String(value).split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
|
34
|
+
const unknown = ids.filter(id => !CLIENT_IDS.includes(id));
|
|
35
|
+
if (unknown.length) throw new Error(`Unknown client ${unknown.join(', ')}; use: ${CLIENT_IDS.join(', ')}.`);
|
|
36
|
+
return [...new Set(ids)];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The answer to the client question: '' = the found ones, "none"/"0", or numbers like "1,3".
|
|
40
|
+
// Returns the ids, or null for an answer that doesn't parse.
|
|
41
|
+
function parseChoice(answer, found) {
|
|
42
|
+
const a = String(answer || '').trim().toLowerCase();
|
|
43
|
+
if (!a) return found.filter(f => f.detected).map(f => f.id);
|
|
44
|
+
if (a === 'none' || a === '0') return [];
|
|
45
|
+
const nums = a.split(/[\s,]+/).filter(Boolean).map(Number);
|
|
46
|
+
if (nums.some(n => !Number.isInteger(n) || n < 1 || n > found.length)) return null;
|
|
47
|
+
return [...new Set(nums.map(n => found[n - 1].id))];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function chooseClients(found, ask, log) {
|
|
51
|
+
log('MCP clients on this PC:');
|
|
52
|
+
found.forEach((f, i) => log(` ${i + 1}. ${f.label.padEnd(20)} ${f.detected ? 'found' : 'not found'}`));
|
|
53
|
+
const dflt = found.map((f, i) => (f.detected ? i + 1 : null)).filter(Boolean);
|
|
54
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
55
|
+
const ids = parseChoice(await ask(`Add Backdraw to which? Enter = the found ones (${dflt.join(', ') || 'none'}), numbers like 1,3, or "none": `), found);
|
|
56
|
+
if (ids) return ids;
|
|
57
|
+
log(`Please answer with numbers from 1 to ${found.length}, or "none".`);
|
|
58
|
+
}
|
|
59
|
+
throw new Error('No valid answer; run setup again, or pass --clients=… or --yes.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function addToDesktop(c, server) {
|
|
63
|
+
const p = c.platform;
|
|
64
|
+
const files = p.desktopConfigFiles();
|
|
65
|
+
if (!files.length) return [['warn', 'no Claude desktop config was found; start the app once, then run: npx backdraw@latest setup --clients=claude-desktop']];
|
|
66
|
+
const running = p.desktopAppRunning();
|
|
67
|
+
if (running !== false) {
|
|
68
|
+
return [['warn', `${running ? 'Claude is running' : 'couldn\'t tell whether Claude is running'}, so its config was left alone. Quit Claude completely (also from the system tray), then run: npx backdraw@latest setup --clients=claude-desktop`]];
|
|
69
|
+
}
|
|
70
|
+
return files.map(file => {
|
|
71
|
+
try {
|
|
72
|
+
const backup = installer.updateDesktopConfig(file, installer.desktopEntry(server));
|
|
73
|
+
return ['ok', backup ? `added (${file}; backup ${path.basename(backup)})` : 'already set up'];
|
|
74
|
+
} catch (e) { return ['fail', `${file}: ${e.message}`]; }
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function addToFileClient(c, id, server) {
|
|
79
|
+
try {
|
|
80
|
+
const r = mcpClients.addEntry(mcpClients.byId(id), c.clientEnv, installer.NODE, server);
|
|
81
|
+
if (r.status === 'unchanged') return ['ok', `already set up (${r.file})`];
|
|
82
|
+
return ['ok', `${r.status} (${r.file}${r.backup ? `; backup ${path.basename(r.backup)}` : ''})`];
|
|
83
|
+
} catch (e) { return ['fail', e.message]; }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Polls the bridge until the extension is connected (and, with `version`, runs that version).
|
|
87
|
+
// Resolves to its status, or null on a timeout or when skipped (Ctrl+C).
|
|
88
|
+
function waitForExtension(call, { timeoutMs = WAIT_MS, intervalMs = 2000, onSigint = true, version = null } = {}) {
|
|
89
|
+
return new Promise(resolve => {
|
|
90
|
+
let done = false;
|
|
91
|
+
let timer = null;
|
|
92
|
+
const finish = value => {
|
|
93
|
+
if (done) return;
|
|
94
|
+
done = true;
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
if (onSigint) process.removeListener('SIGINT', skip);
|
|
97
|
+
resolve(value);
|
|
98
|
+
};
|
|
99
|
+
const skip = () => finish(null);
|
|
100
|
+
if (onSigint) process.on('SIGINT', skip);
|
|
101
|
+
const deadline = Date.now() + timeoutMs;
|
|
102
|
+
// A Ctrl+C can arrive while a call is still out: then nothing may poll again.
|
|
103
|
+
const poll = async () => {
|
|
104
|
+
if (done) return;
|
|
105
|
+
try {
|
|
106
|
+
const status = await call('status', {}, 3000);
|
|
107
|
+
if (status && status.extension && (!version || status.extension.version === version)) return finish(status);
|
|
108
|
+
} catch {}
|
|
109
|
+
if (done) return;
|
|
110
|
+
if (Date.now() >= deadline) return finish(null);
|
|
111
|
+
timer = setTimeout(poll, intervalMs);
|
|
112
|
+
};
|
|
113
|
+
poll();
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function connectedStatus(call) {
|
|
118
|
+
try {
|
|
119
|
+
const status = await call('status', {}, 3000);
|
|
120
|
+
return status && status.extension ? status : null;
|
|
121
|
+
} catch { return null; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// deps (tests): ctx (install.js context), call (bridge), ask (question -> answer; none = no TTY),
|
|
125
|
+
// log, color, waitMs.
|
|
126
|
+
async function setup(opts = {}, deps = {}) {
|
|
127
|
+
const log = deps.log || (text => console.log(text));
|
|
128
|
+
const paint = painter(deps.color !== undefined ? deps.color : useColor());
|
|
129
|
+
const say = (status, label, detail) => log(line(paint, status, label, detail, 20));
|
|
130
|
+
const call = deps.call || require('./shared').call;
|
|
131
|
+
const base = { ...(deps.ctx || {}), report: false };
|
|
132
|
+
const c = installer.context(base);
|
|
133
|
+
const p = c.platform;
|
|
134
|
+
|
|
135
|
+
let browser = null;
|
|
136
|
+
if (opts.browser !== undefined) {
|
|
137
|
+
browser = browsers.byId(String(opts.browser).toLowerCase());
|
|
138
|
+
if (!browser) throw new Error(`--browser takes one of: ${browsers.BROWSERS.map(b => b.id).join(', ')}.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
log(paint('bold', `Backdraw ${SELF} setup`));
|
|
142
|
+
log('');
|
|
143
|
+
const found = detectClients(c);
|
|
144
|
+
let chosen;
|
|
145
|
+
if (opts.clients !== undefined) chosen = parseClientList(opts.clients);
|
|
146
|
+
else if (opts.yes || !deps.ask) chosen = found.filter(f => f.detected).map(f => f.id);
|
|
147
|
+
else { chosen = await chooseClients(found, deps.ask, log); log(''); }
|
|
148
|
+
|
|
149
|
+
const r = await installer.install({ ...base, clients: chosen, browsers: browser ? [browser.id] : [] });
|
|
150
|
+
say('ok', 'Installed', `${r.dest} (${r.version})`);
|
|
151
|
+
for (const m of r.migration.filter(x => x.startsWith('copied '))) say('ok', 'Migrated', m.replace(/^copied /, ''));
|
|
152
|
+
if (r.hasLegacy) say('info', 'Old folder', `${r.legacyDir} is no longer used; delete it by hand once Backdraw works`);
|
|
153
|
+
say('ok', 'Native host', `registered for ${r.registered.map(b => browsers.byId(b).name).join(', ')}`);
|
|
154
|
+
|
|
155
|
+
const labels = Object.fromEntries(found.map(f => [f.id, f.label]));
|
|
156
|
+
for (const id of chosen) {
|
|
157
|
+
if (id === 'claude-code') say('ok', labels[id], 'added (user scope)');
|
|
158
|
+
else if (id === 'claude-desktop') for (const [status, detail] of addToDesktop(c, r.server)) say(status, labels[id], detail);
|
|
159
|
+
else {
|
|
160
|
+
const [status, detail] = addToFileClient(c, id, r.server);
|
|
161
|
+
say(status, labels[id], detail);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!chosen.length) say('warn', 'MCP clients', `none chosen; add one later with: npx backdraw@latest setup --clients=${CLIENT_IDS[0]}`);
|
|
165
|
+
|
|
166
|
+
// A reloaded extension needs a few seconds to start the new host; until then the old one may
|
|
167
|
+
// still answer with the old version.
|
|
168
|
+
let status = r.reloaded
|
|
169
|
+
? await waitForExtension(call, { timeoutMs: deps.reloadWaitMs || 20000, intervalMs: deps.intervalMs || 1000, onSigint: false, version: r.version })
|
|
170
|
+
|| await connectedStatus(call)
|
|
171
|
+
: await connectedStatus(call);
|
|
172
|
+
if (status && status.extension.version !== r.version) {
|
|
173
|
+
say('warn', 'Extension', `${status.extension.version} is still running; reload Backdraw at chrome://extensions to start ${r.version}`);
|
|
174
|
+
} else if (status) say('ok', 'Extension', `${status.extension.version} connected${r.reloaded ? ' (reloaded with the new files)' : ''}`);
|
|
175
|
+
|
|
176
|
+
if (!status) {
|
|
177
|
+
// The one named with --browser, else the first one installed (Chrome comes first).
|
|
178
|
+
if (!browser) browser = browsers.byId(p.detectBrowsers()[0] || 'chrome');
|
|
179
|
+
const folder = path.join(r.dest, 'extension');
|
|
180
|
+
try { p.openFolder(folder); } catch {}
|
|
181
|
+
const copied = p.copyToClipboard(folder);
|
|
182
|
+
const opened = p.openInBrowser(browser.id, browser.page);
|
|
183
|
+
log('');
|
|
184
|
+
log(paint('bold', `Load the extension in ${browser.name} (once):`));
|
|
185
|
+
log(` 1. ${opened ? `${browser.page} is opening; turn` : `Open ${browser.page} and turn`} on "Developer mode" (top right).`);
|
|
186
|
+
log(' 2. Click "Load unpacked".');
|
|
187
|
+
log(` 3. Pick the folder ${folder}${copied ? ' (the path is on your clipboard: paste it with Ctrl+V)' : ''}.`);
|
|
188
|
+
if (opts.wait === false) {
|
|
189
|
+
log('');
|
|
190
|
+
say('info', 'Extension', 'not waiting (--no-wait); check later with: npx backdraw@latest doctor');
|
|
191
|
+
} else {
|
|
192
|
+
log('');
|
|
193
|
+
log(`Waiting for the extension to connect (up to ${Math.round((deps.waitMs || WAIT_MS) / 60000)} min; Ctrl+C skips)…`);
|
|
194
|
+
status = await waitForExtension(call, { timeoutMs: deps.waitMs || WAIT_MS, intervalMs: deps.intervalMs, onSigint: !deps.call });
|
|
195
|
+
if (status) say('ok', 'Extension', `${status.extension.version} connected`);
|
|
196
|
+
else say('warn', 'Extension', 'not connected yet; once it is loaded, check with: npx backdraw@latest doctor');
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
log('');
|
|
201
|
+
if (status) {
|
|
202
|
+
log(paint('ok', '✓ Ready.'));
|
|
203
|
+
if (!status.project) log('One more step: click the Backdraw icon in the toolbar and pick a ChatGPT Project (its checklist shows how).');
|
|
204
|
+
}
|
|
205
|
+
if (chosen.length) {
|
|
206
|
+
log('Start a new session in your MCP client (Backdraw is loaded when a session starts) and say:');
|
|
207
|
+
log(` "${EXAMPLE}"`);
|
|
208
|
+
}
|
|
209
|
+
return { result: r, chosen, status };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { CLIENT_IDS, detectClients, parseClientList, parseChoice, waitForExtension, setup };
|
package/shared.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Paths, small file helpers and the named-pipe client shared by the MCP server and the CLI.
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const identity = require('./identity');
|
|
7
|
+
const platform = require('./platform');
|
|
8
|
+
const { MAX_REFS, MAX_REF_BYTES, MAX_REFS_TOTAL_BYTES } = require('./lib/limits');
|
|
9
|
+
|
|
10
|
+
const ROOT = __dirname;
|
|
11
|
+
// Single version source; install.js copies package.json next to these files.
|
|
12
|
+
const VERSION = require('./package.json').version;
|
|
13
|
+
const DATA_DIR = path.join(ROOT, 'data');
|
|
14
|
+
const LOG_DIR = path.join(ROOT, 'logs');
|
|
15
|
+
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
16
|
+
const JOBS_FILE = path.join(DATA_DIR, 'jobs.json');
|
|
17
|
+
const PREVIEW_DIR = path.join(DATA_DIR, 'previews');
|
|
18
|
+
|
|
19
|
+
// Resolved on first use, so loading this module works on every OS. BACKDRAW_PIPE override: tests only.
|
|
20
|
+
let pipePath = null;
|
|
21
|
+
function getPipePath() { return pipePath || (pipePath = process.env[identity.PIPE_ENV] || platform.pipePath()); }
|
|
22
|
+
|
|
23
|
+
const NOT_RUNNING =
|
|
24
|
+
'The ChatGPT bridge is not running: Google Chrome is closed, or the browser extension ' +
|
|
25
|
+
'is not loaded/enabled. Ask the user to open Chrome; the extension connects by itself.';
|
|
26
|
+
|
|
27
|
+
function readJson(file, fallback) {
|
|
28
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeJson(file, value) {
|
|
32
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
33
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
34
|
+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
35
|
+
fs.renameSync(tmp, file);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class BridgeError extends Error {
|
|
39
|
+
constructor(message, code) { super(message); this.code = code; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One request per connection: write a JSON line, read one JSON line back.
|
|
43
|
+
function call(method, params = {}, timeoutMs = 30000) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const sock = net.connect(getPipePath());
|
|
46
|
+
let buf = '';
|
|
47
|
+
let done = false;
|
|
48
|
+
const finish = (err, value) => {
|
|
49
|
+
if (done) return;
|
|
50
|
+
done = true;
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
sock.destroy();
|
|
53
|
+
if (err) reject(err); else resolve(value);
|
|
54
|
+
};
|
|
55
|
+
const timer = setTimeout(
|
|
56
|
+
() => finish(new BridgeError(`The bridge did not answer "${method}" within ${Math.round(timeoutMs / 1000)}s.`, 'TIMEOUT')),
|
|
57
|
+
timeoutMs);
|
|
58
|
+
sock.setEncoding('utf8');
|
|
59
|
+
sock.on('connect', () => sock.write(JSON.stringify({ id: 1, method, params }) + '\n'));
|
|
60
|
+
sock.on('data', chunk => {
|
|
61
|
+
buf += chunk;
|
|
62
|
+
const nl = buf.indexOf('\n');
|
|
63
|
+
if (nl < 0) return;
|
|
64
|
+
let msg;
|
|
65
|
+
try { msg = JSON.parse(buf.slice(0, nl)); } catch (e) { return finish(e); }
|
|
66
|
+
if (msg.error) finish(new BridgeError(msg.error.message, msg.error.code));
|
|
67
|
+
else finish(null, msg.result);
|
|
68
|
+
});
|
|
69
|
+
sock.on('error', e => finish(['ENOENT', 'ECONNREFUSED', 'EPIPE'].includes(e.code) ? new BridgeError(NOT_RUNNING, 'NOT_RUNNING') : e));
|
|
70
|
+
sock.on('close', () => finish(new BridgeError('The bridge closed the connection unexpectedly.', 'CLOSED')));
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
75
|
+
|
|
76
|
+
// Reads reference images in the caller's own context (relative paths = its working folder).
|
|
77
|
+
function readReferences(list) {
|
|
78
|
+
if (!list) return [];
|
|
79
|
+
if (!Array.isArray(list)) list = [list];
|
|
80
|
+
if (list.length > MAX_REFS) throw new Error(`At most ${MAX_REFS} reference images per request.`);
|
|
81
|
+
let total = 0;
|
|
82
|
+
return list.map(p => {
|
|
83
|
+
const file = path.resolve(String(p));
|
|
84
|
+
const mime = IMAGE_TYPES[path.extname(file).toLowerCase()];
|
|
85
|
+
if (!mime) throw new Error(`Reference is not a png/jpg/webp/gif image: ${file}`);
|
|
86
|
+
let buf;
|
|
87
|
+
try { buf = fs.readFileSync(file); } catch (e) { throw new Error(`Cannot read reference image ${file} (${e.code || e.message}).`); }
|
|
88
|
+
if (buf.length > MAX_REF_BYTES) throw new Error(`Reference image is larger than ${MAX_REF_BYTES / 1048576} MB: ${file}`);
|
|
89
|
+
if ((total += buf.length) > MAX_REFS_TOTAL_BYTES) throw new Error(`The reference images together are larger than ${MAX_REFS_TOTAL_BYTES / 1048576} MB.`);
|
|
90
|
+
return { name: path.basename(file), mime, data: buf.toString('base64') };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Copies a finished job's images into the folder the caller asked for (once), from the caller's
|
|
95
|
+
// own process so the copy lands where the caller can see it.
|
|
96
|
+
async function copyResults(job) {
|
|
97
|
+
if (job.status !== 'succeeded' || !job.copyTo || (job.copies && job.copies.length)) return job;
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(job.copyTo, { recursive: true });
|
|
100
|
+
const copies = job.images.map(im => {
|
|
101
|
+
const ext = path.extname(im.path);
|
|
102
|
+
const base = path.basename(im.path, ext);
|
|
103
|
+
let dest = path.join(job.copyTo, `${base}${ext}`);
|
|
104
|
+
for (let n = 2; fs.existsSync(dest); n++) dest = path.join(job.copyTo, `${base}_${n}${ext}`);
|
|
105
|
+
fs.copyFileSync(im.path, dest);
|
|
106
|
+
return dest;
|
|
107
|
+
});
|
|
108
|
+
return await call('mark_copied', { jobId: job.id, paths: copies });
|
|
109
|
+
} catch (e) {
|
|
110
|
+
return { ...job, copyError: e.message };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports = {
|
|
115
|
+
VERSION, ROOT, DATA_DIR, LOG_DIR, CONFIG_FILE, JOBS_FILE, PREVIEW_DIR,
|
|
116
|
+
NOT_RUNNING, BridgeError, readJson, writeJson, call, readReferences, copyResults,
|
|
117
|
+
get PIPE_PATH() { return getPipePath(); },
|
|
118
|
+
};
|