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
package/identity.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Every name the outside world sees, in one place. The extension can't load this file, so
|
|
3
|
+
// extension/background.js repeats HOST_NAME (a test keeps the two in sync).
|
|
4
|
+
module.exports = {
|
|
5
|
+
PRODUCT: 'Backdraw',
|
|
6
|
+
HOST_NAME: 'io.github.muhammedtalha04.backdraw',
|
|
7
|
+
HOST_DESCRIPTION: 'Backdraw native host',
|
|
8
|
+
MCP_NAME: 'backdraw',
|
|
9
|
+
PIPE_PREFIX: 'backdraw', // the pipe is named <prefix>-<user>
|
|
10
|
+
PIPE_ENV: 'BACKDRAW_PIPE', // pipe path override, tests only
|
|
11
|
+
INSTALL_DIR_NAME: '.backdraw', // in the user's home folder
|
|
12
|
+
IMAGES_DIR_NAME: 'Backdraw', // in the user's Pictures folder, for new installs
|
|
13
|
+
COLLECTIONS_DIR_NAME: 'References', // reference collections, inside IMAGES_DIR_NAME
|
|
14
|
+
|
|
15
|
+
// Names used before the rename; install.js migrates from them and cleans them up.
|
|
16
|
+
LEGACY: {
|
|
17
|
+
HOST_NAME: 'com.claude.chatgpt_image_bridge',
|
|
18
|
+
MCP_NAME: 'chatgpt-images',
|
|
19
|
+
INSTALL_DIR: ['.claude', 'chatgpt-image-bridge'], // relative to the home folder
|
|
20
|
+
},
|
|
21
|
+
};
|
package/install.js
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Installs Backdraw for the current user (no admin rights needed). Windows only for now; the
|
|
3
|
+
// OS-specific steps live in platform/.
|
|
4
|
+
// node install.js install or update; migrates an older chatgpt-image-bridge install
|
|
5
|
+
// node install.js --desktop add Backdraw to the Claude desktop app's chat config (quit Claude first)
|
|
6
|
+
// node install.js --uninstall remove the Chrome host registration and the MCP entries
|
|
7
|
+
// node install.js --uninstall --dry-run only show what --uninstall would remove
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { execFileSync } = require('child_process');
|
|
13
|
+
const identity = require('./identity');
|
|
14
|
+
const platform = require('./platform');
|
|
15
|
+
const browsers = require('./lib/browsers');
|
|
16
|
+
const mcpClients = require('./lib/mcp-clients');
|
|
17
|
+
|
|
18
|
+
const { LEGACY } = identity;
|
|
19
|
+
const SRC = __dirname;
|
|
20
|
+
const FILES = ['package.json', 'LICENSE', 'identity.js', 'shared.js', 'host.js', 'mcp-server.js', 'cli.js', 'install.js', 'setup.js', 'doctor.js',
|
|
21
|
+
'README.md', 'README.tr.md', 'SECURITY.md', 'CHANGELOG.md', 'bin', 'extension', 'platform', 'lib'];
|
|
22
|
+
const NODE = process.execPath;
|
|
23
|
+
const MCP_NAMES = [identity.MCP_NAME, LEGACY.MCP_NAME];
|
|
24
|
+
|
|
25
|
+
function readJson(file, fallback) {
|
|
26
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function samePath(a, b) { return path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase(); }
|
|
30
|
+
|
|
31
|
+
const quoted = names => names.map(n => `"${n}"`).join(', ');
|
|
32
|
+
|
|
33
|
+
function extensionId(manifest) {
|
|
34
|
+
const hex = crypto.createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32);
|
|
35
|
+
return [...hex].map(c => String.fromCharCode(97 + parseInt(c, 16))).join('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Everything install() and uninstall() reach outside their own folder; the tests pass a fake platform,
|
|
39
|
+
// a temporary home folder and a recording claudeCli instead.
|
|
40
|
+
// platform the OS layer (platform/) home the user's home folder
|
|
41
|
+
// src the folder that is installed log prints the report
|
|
42
|
+
// claudeCli runs `claude <args>`, or null when the Claude Code CLI is not installed
|
|
43
|
+
// reload asks a running extension to reload itself (resolves to true when it did)
|
|
44
|
+
// clients (setup) the MCP clients to register in; without it, Claude Code only, as before
|
|
45
|
+
// report false: install() returns its result without printing the report (setup prints its own)
|
|
46
|
+
// browsers (setup --browser) browser ids to register for even when they aren't found
|
|
47
|
+
function context(ctx = {}) {
|
|
48
|
+
const p = ctx.platform || platform;
|
|
49
|
+
let claudeCli = ctx.claudeCli;
|
|
50
|
+
if (claudeCli === undefined) {
|
|
51
|
+
const exe = p.claudeCli();
|
|
52
|
+
claudeCli = exe ? args => execFileSync(exe, args, { stdio: 'ignore' }) : null;
|
|
53
|
+
}
|
|
54
|
+
const home = ctx.home || os.homedir();
|
|
55
|
+
return {
|
|
56
|
+
platform: p, home, src: ctx.src || SRC, log: ctx.log || (text => console.log(text)),
|
|
57
|
+
claudeCli, reload: ctx.reload || reloadRunningExtension, dryRun: !!ctx.dryRun,
|
|
58
|
+
clients: ctx.clients || null, report: ctx.report !== false, browsers: ctx.browsers || [],
|
|
59
|
+
clientEnv: { home, appData: p.appDataDir(home), which: cmd => p.which(cmd) },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// The browsers to register the native host for: Chrome always (every Chrome channel and a copy the
|
|
64
|
+
// check can't see read its key, and the others fall back to it), the installed ones, and any the
|
|
65
|
+
// user named (setup --browser).
|
|
66
|
+
function targetBrowsers(p, extra = []) {
|
|
67
|
+
return [...new Set(['chrome', ...p.detectBrowsers(), ...extra])];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Copies the settings and job history of an older install. Never overwrites; returns a report.
|
|
71
|
+
function migrateData(fromDir, toDir) {
|
|
72
|
+
const report = [];
|
|
73
|
+
for (const name of ['config.json', 'jobs.json']) {
|
|
74
|
+
const from = path.join(fromDir, 'data', name);
|
|
75
|
+
const to = path.join(toDir, 'data', name);
|
|
76
|
+
if (!fs.existsSync(from)) continue;
|
|
77
|
+
if (fs.existsSync(to)) { report.push(`kept the existing ${to} (did not overwrite it with ${from})`); continue; }
|
|
78
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
79
|
+
fs.copyFileSync(from, to);
|
|
80
|
+
report.push(`copied ${from} -> ${to}`);
|
|
81
|
+
}
|
|
82
|
+
return report;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Folders a config without them gets; the user's own settings are kept. pictures() is only asked
|
|
86
|
+
// when needed (it starts PowerShell).
|
|
87
|
+
function configDefaults(cfg, pictures) {
|
|
88
|
+
const out = { ...cfg };
|
|
89
|
+
if (!out.outputDir) out.outputDir = path.join(pictures(), identity.IMAGES_DIR_NAME);
|
|
90
|
+
if (!out.collectionsDir) out.collectionsDir = path.join(pictures(), identity.IMAGES_DIR_NAME, identity.COLLECTIONS_DIR_NAME);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------- Claude Code
|
|
95
|
+
|
|
96
|
+
function removeLegacyMcpFromFile(file) {
|
|
97
|
+
const json = readJson(file, null);
|
|
98
|
+
if (!json || !json.mcpServers || !(LEGACY.MCP_NAME in json.mcpServers)) return false;
|
|
99
|
+
delete json.mcpServers[LEGACY.MCP_NAME];
|
|
100
|
+
fs.writeFileSync(file, JSON.stringify(json, null, 2));
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Returns [where Backdraw was registered, whether the old entry was removed].
|
|
105
|
+
function registerMcp(c, serverPath) {
|
|
106
|
+
const entry = { type: 'stdio', command: NODE, args: [serverPath], env: {} };
|
|
107
|
+
if (c.claudeCli) {
|
|
108
|
+
let legacyRemoved = false;
|
|
109
|
+
try { c.claudeCli(['mcp', 'remove', LEGACY.MCP_NAME, '-s', 'user']); legacyRemoved = true; } catch {}
|
|
110
|
+
try { c.claudeCli(['mcp', 'remove', identity.MCP_NAME, '-s', 'user']); } catch {}
|
|
111
|
+
c.claudeCli(['mcp', 'add-json', identity.MCP_NAME, JSON.stringify(entry), '-s', 'user']);
|
|
112
|
+
return [`"${identity.MCP_NAME}" (claude mcp, user scope)`, legacyRemoved];
|
|
113
|
+
}
|
|
114
|
+
const file = c.platform.claudeConfigFile(c.home);
|
|
115
|
+
const legacyRemoved = removeLegacyMcpFromFile(file);
|
|
116
|
+
const json = readJson(file, {});
|
|
117
|
+
json.mcpServers = { ...(json.mcpServers || {}), [identity.MCP_NAME]: entry };
|
|
118
|
+
fs.writeFileSync(file, JSON.stringify(json, null, 2));
|
|
119
|
+
return [`"${identity.MCP_NAME}" (${file})`, legacyRemoved];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Without the CLI: the entries ~/.claude.json has.
|
|
123
|
+
function mcpEntriesInFile(c) {
|
|
124
|
+
const json = readJson(c.platform.claudeConfigFile(c.home), null);
|
|
125
|
+
return json && json.mcpServers ? MCP_NAMES.filter(n => n in json.mcpServers) : [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Returns the entries that were removed (with the CLI: the ones it could remove).
|
|
129
|
+
function unregisterMcp(c) {
|
|
130
|
+
if (c.claudeCli) {
|
|
131
|
+
return MCP_NAMES.filter(name => { try { c.claudeCli(['mcp', 'remove', name, '-s', 'user']); return true; } catch { return false; } });
|
|
132
|
+
}
|
|
133
|
+
const found = mcpEntriesInFile(c);
|
|
134
|
+
if (found.length) {
|
|
135
|
+
const file = c.platform.claudeConfigFile(c.home);
|
|
136
|
+
const json = readJson(file, {});
|
|
137
|
+
for (const name of found) delete json.mcpServers[name];
|
|
138
|
+
fs.writeFileSync(file, JSON.stringify(json, null, 2));
|
|
139
|
+
}
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------- Claude desktop app (chat)
|
|
144
|
+
|
|
145
|
+
const desktopEntry = serverPath => ({ command: NODE, args: [serverPath] });
|
|
146
|
+
|
|
147
|
+
function desktopServers(file, entry, remove) {
|
|
148
|
+
const json = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
149
|
+
const before = json.mcpServers || {};
|
|
150
|
+
const servers = { ...before };
|
|
151
|
+
delete servers[LEGACY.MCP_NAME];
|
|
152
|
+
if (remove) delete servers[identity.MCP_NAME];
|
|
153
|
+
else servers[identity.MCP_NAME] = entry;
|
|
154
|
+
return { json, servers, changed: JSON.stringify(servers) !== JSON.stringify(before) };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Drops the old entry and adds Backdraw (or, with remove, drops both) in one desktop config file.
|
|
158
|
+
// A timestamped .bak copy is written first. Returns the backup path, or null if nothing changed.
|
|
159
|
+
// Throws on unreadable JSON without touching the file.
|
|
160
|
+
function updateDesktopConfig(file, entry, { remove = false } = {}) {
|
|
161
|
+
const { json, servers, changed } = desktopServers(file, entry, remove);
|
|
162
|
+
if (!changed) return null;
|
|
163
|
+
const backup = `${file}.${new Date().toISOString().replace(/[:.]/g, '-')}.bak`;
|
|
164
|
+
fs.copyFileSync(file, backup);
|
|
165
|
+
json.mcpServers = servers;
|
|
166
|
+
fs.writeFileSync(file, JSON.stringify(json, null, 2));
|
|
167
|
+
return backup;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function desktopNeedsUpdate(file, entry, remove = false) {
|
|
171
|
+
try { return desktopServers(file, entry, remove).changed; } catch { return false; }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The Backdraw entries (new and old name) in a desktop config file.
|
|
175
|
+
function desktopEntriesIn(file) {
|
|
176
|
+
const servers = (readJson(file, {}) || {}).mcpServers || {};
|
|
177
|
+
return MCP_NAMES.filter(n => n in servers);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function desktopCommand(dest, flag) {
|
|
181
|
+
return `node "${path.join(dest, 'install.js')}" ${flag}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function desktop() {
|
|
185
|
+
const dest = platform.installDir();
|
|
186
|
+
const server = path.join(dest, 'mcp-server.js');
|
|
187
|
+
if (!fs.existsSync(server)) throw new Error(`Backdraw is not installed in ${dest} yet; run node install.js first.`);
|
|
188
|
+
const files = platform.desktopConfigFiles();
|
|
189
|
+
if (!files.length) return console.log('No Claude desktop app config was found; nothing to change.');
|
|
190
|
+
const running = platform.desktopAppRunning();
|
|
191
|
+
if (running !== false) {
|
|
192
|
+
console.log(`${running === null
|
|
193
|
+
? 'Could not tell whether the Claude desktop app is running (PowerShell failed), so its config was not changed.'
|
|
194
|
+
: 'The Claude desktop app is running, so its config was not changed (Claude rewrites it on exit).'}
|
|
195
|
+
Quit Claude completely (also from the system tray), then run this from PowerShell:
|
|
196
|
+
${desktopCommand(dest, '--desktop')}`);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const file of files) {
|
|
201
|
+
try {
|
|
202
|
+
const backup = updateDesktopConfig(file, desktopEntry(server));
|
|
203
|
+
console.log(backup ? `Updated ${file}\n backup: ${backup}` : `Already up to date: ${file}`);
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.log(`Skipped ${file}: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
console.log(`"${identity.MCP_NAME}" is available in new Claude desktop chats after Claude starts again.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---------------------------------------------------------------- install
|
|
212
|
+
|
|
213
|
+
async function reloadRunningExtension(dest) {
|
|
214
|
+
try {
|
|
215
|
+
const shared = require(path.join(dest, 'shared.js'));
|
|
216
|
+
await shared.call('reload_extension', {}, 3000);
|
|
217
|
+
return true;
|
|
218
|
+
} catch { return false; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// What is left to do about the Chrome extension after the files were copied: nothing when the
|
|
222
|
+
// running extension was reloaded. An unpacked extension is read from disk when Chrome starts or when
|
|
223
|
+
// it is turned on; one that can't be reached is not running, or (after a migration) may still be
|
|
224
|
+
// loaded from the old folder.
|
|
225
|
+
function extensionSteps({ dest, legacyDir, hasLegacy, wasInstalled, reloaded }) {
|
|
226
|
+
const folder = path.join(dest, 'extension');
|
|
227
|
+
const check = `Check the connection: node "${path.join(dest, 'cli.js')}" status`;
|
|
228
|
+
if (reloaded) return [];
|
|
229
|
+
if (hasLegacy) {
|
|
230
|
+
return [`Open Chrome, or turn Backdraw on at chrome://extensions; it connects by itself.
|
|
231
|
+
If chrome://extensions still shows the old "Claude ChatGPT Görsel Köprüsü" extension (loaded from
|
|
232
|
+
${path.join(legacyDir, 'extension')}), remove it, click "Load unpacked" and select ${folder}`, check];
|
|
233
|
+
}
|
|
234
|
+
if (wasInstalled) {
|
|
235
|
+
return [`Open Chrome, or turn Backdraw on at chrome://extensions: Chrome loads the new version by itself.
|
|
236
|
+
If Backdraw was never loaded: turn on "Developer mode" there, click "Load unpacked" and select ${folder}`, check];
|
|
237
|
+
}
|
|
238
|
+
return [`Load the Chrome extension once: open chrome://extensions, turn on "Developer mode" (top right),
|
|
239
|
+
click "Load unpacked" and select ${folder}`,
|
|
240
|
+
'Open the Extensions menu (the puzzle icon) in Chrome\'s toolbar, pin Backdraw, click it and follow its setup checklist (ChatGPT sign-in, project).'];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function desktopStep(dest, running) {
|
|
244
|
+
const command = desktopCommand(dest, '--desktop');
|
|
245
|
+
if (running === false) return `Claude desktop app (chat): add Backdraw to its config with ${command}`;
|
|
246
|
+
return `Claude desktop app (chat): ${running ? 'Claude is running' : 'could not tell whether Claude is running'}, so its config was left alone.
|
|
247
|
+
Quit Claude completely (also from the system tray), then run this from PowerShell: ${command}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const numbered = steps => steps.map((s, i) => ` ${i + 1}. ${s.replace(/\n/g, '\n ')}`);
|
|
251
|
+
|
|
252
|
+
function installReport(r) {
|
|
253
|
+
const lines = [`Backdraw ${r.version} is installed.`,
|
|
254
|
+
` Folder : ${r.dest}`,
|
|
255
|
+
` Images folder : ${r.cfg.outputDir}`,
|
|
256
|
+
` References folder : ${r.cfg.collectionsDir}`,
|
|
257
|
+
` Extension ID : ${r.id}`,
|
|
258
|
+
` Browsers : ${r.registered.map(b => browsers.byId(b).name).join(', ')}`,
|
|
259
|
+
` Claude Code MCP : ${r.mcp}`];
|
|
260
|
+
const migrated = [...r.migration.filter(m => m.startsWith('copied ')),
|
|
261
|
+
...(r.legacyMcpRemoved ? [`removed the old "${LEGACY.MCP_NAME}" Claude Code MCP entry`] : [])];
|
|
262
|
+
if (migrated.length) lines.push('', `Migrated from the old "${LEGACY.INSTALL_DIR[LEGACY.INSTALL_DIR.length - 1]}" install:`, ...migrated.map(m => ` - ${m}`));
|
|
263
|
+
if (r.hasLegacy) lines.push('', `The old folder ${r.legacyDir} is no longer used; delete it by hand once Backdraw works.`);
|
|
264
|
+
const steps = extensionSteps(r);
|
|
265
|
+
if (r.desktopStale) steps.push(desktopStep(r.dest, r.desktopRunning));
|
|
266
|
+
if (r.reloaded) lines.push('', 'The running Chrome extension was reloaded with the new version.');
|
|
267
|
+
if (steps.length) lines.push('', 'Next steps:', ...numbered(steps));
|
|
268
|
+
lines.push('', `The "${identity.MCP_NAME}" tools are ready in new Claude Code sessions.`);
|
|
269
|
+
return lines.join('\n');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function install(ctx) {
|
|
273
|
+
const c = context(ctx);
|
|
274
|
+
const p = c.platform;
|
|
275
|
+
const dest = p.installDir(c.home);
|
|
276
|
+
const legacyDir = p.legacyInstallDir(c.home);
|
|
277
|
+
const hasLegacy = fs.existsSync(legacyDir) && !samePath(legacyDir, dest);
|
|
278
|
+
const wasInstalled = fs.existsSync(path.join(dest, 'extension', 'manifest.json'));
|
|
279
|
+
if (!samePath(c.src, dest)) {
|
|
280
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
281
|
+
for (const f of FILES) {
|
|
282
|
+
if (fs.existsSync(path.join(c.src, f))) fs.cpSync(path.join(c.src, f), path.join(dest, f), { recursive: true, force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const migration = hasLegacy ? migrateData(legacyDir, dest) : [];
|
|
286
|
+
const { id, browsers: registered } = registerHost(c, dest);
|
|
287
|
+
|
|
288
|
+
const cfgFile = path.join(dest, 'data', 'config.json');
|
|
289
|
+
let pictures = null;
|
|
290
|
+
const cfg = configDefaults(readJson(cfgFile, {}), () => pictures || (pictures = p.picturesDir()));
|
|
291
|
+
fs.mkdirSync(path.dirname(cfgFile), { recursive: true });
|
|
292
|
+
fs.writeFileSync(cfgFile, JSON.stringify(cfg, null, 2));
|
|
293
|
+
fs.mkdirSync(cfg.outputDir, { recursive: true });
|
|
294
|
+
|
|
295
|
+
const server = path.join(dest, 'mcp-server.js');
|
|
296
|
+
const claudeCode = !c.clients || c.clients.includes('claude-code');
|
|
297
|
+
const [mcp, legacyMcpRemoved] = claudeCode ? registerMcp(c, server) : ['not selected', false];
|
|
298
|
+
const reloaded = await c.reload(dest);
|
|
299
|
+
const desktopStale = p.desktopConfigFiles().some(f => desktopNeedsUpdate(f, desktopEntry(server)));
|
|
300
|
+
const result = {
|
|
301
|
+
version: readJson(path.join(dest, 'package.json'), {}).version || '?', dest, cfg, id, mcp, legacyDir, hasLegacy, migration,
|
|
302
|
+
legacyMcpRemoved, wasInstalled, reloaded, desktopStale, desktopRunning: desktopStale ? p.desktopAppRunning() : false,
|
|
303
|
+
server, registered,
|
|
304
|
+
};
|
|
305
|
+
if (c.report) c.log(installReport(result));
|
|
306
|
+
return result;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Writes the launcher and the host manifest, and registers the host under the key each installed
|
|
310
|
+
// browser reads first (all of them load the same unpacked extension, so they share one extension
|
|
311
|
+
// ID and one manifest). Also what `backdraw doctor --fix` repairs.
|
|
312
|
+
// Returns { id, manifest, browsers: [ids], keys: [registry keys written] }.
|
|
313
|
+
function registerHost(c, dest) {
|
|
314
|
+
const p = c.platform;
|
|
315
|
+
const id = extensionId(readJson(path.join(dest, 'extension', 'manifest.json'), {}));
|
|
316
|
+
const launcher = p.writeHostLauncher(dest, NODE);
|
|
317
|
+
const manifest = path.join(dest, `${identity.HOST_NAME}.json`);
|
|
318
|
+
fs.writeFileSync(manifest, JSON.stringify({
|
|
319
|
+
name: identity.HOST_NAME, description: identity.HOST_DESCRIPTION, path: launcher, type: 'stdio',
|
|
320
|
+
allowed_origins: [`chrome-extension://${id}/`],
|
|
321
|
+
}, null, 2));
|
|
322
|
+
p.unregisterNativeHost(LEGACY.HOST_NAME, 'chrome');
|
|
323
|
+
const targets = targetBrowsers(p, c.browsers);
|
|
324
|
+
const keys = browsers.keysFor(targets).map(key => p.registerNativeHost(identity.HOST_NAME, manifest, key));
|
|
325
|
+
return { id, manifest, browsers: targets, keys };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------- uninstall
|
|
329
|
+
|
|
330
|
+
// What --uninstall leaves alone (the folders that are there): the user's pictures and the settings/history folder.
|
|
331
|
+
function keptLines(dest, cfg) {
|
|
332
|
+
return [['Images folder ', cfg.outputDir], ['References folder ', cfg.collectionsDir], ['Settings, history ', path.join(dest, 'data')]]
|
|
333
|
+
.filter(([, dir]) => dir && fs.existsSync(dir)).map(([label, dir]) => ` ${label}: ${dir}`);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Every registry key a host registration can have: Backdraw's under each browser key, and 1.x's
|
|
337
|
+
// under Chrome's.
|
|
338
|
+
const hostEntries = () => [...Object.keys(browsers.HOST_KEYS).map(key => [identity.HOST_NAME, key]), [LEGACY.HOST_NAME, 'chrome']];
|
|
339
|
+
|
|
340
|
+
// The other MCP clients that have a Backdraw entry: [{ client, file }]. A file Backdraw can't read
|
|
341
|
+
// is listed with its error.
|
|
342
|
+
function fileClientEntries(c) {
|
|
343
|
+
return mcpClients.FILE_CLIENTS.map(client => {
|
|
344
|
+
try {
|
|
345
|
+
const { file, entry } = mcpClients.readEntry(client, c.clientEnv);
|
|
346
|
+
if (!entry) return null;
|
|
347
|
+
if (client.format === 'json') mcpClients.readJsonConfig(file); // one with comments can't be rewritten
|
|
348
|
+
return { client, file };
|
|
349
|
+
} catch (e) { return { client, file: client.file(c.clientEnv), error: e.message }; }
|
|
350
|
+
}).filter(Boolean);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Lists the targets without changing anything (no registry, CLI or file writes).
|
|
354
|
+
function dryRunReport(c, { dest, folders, desktopFiles, cfg, others }) {
|
|
355
|
+
const configFile = c.platform.claudeConfigFile(c.home);
|
|
356
|
+
const inFile = c.claudeCli ? [] : mcpEntriesInFile(c);
|
|
357
|
+
return ['Dry run: nothing was changed. "backdraw uninstall" would remove:',
|
|
358
|
+
' Browser host entries (where present):',
|
|
359
|
+
...hostEntries().map(([n, b]) => ` ${c.platform.nativeHostEntry(n, b)}`),
|
|
360
|
+
' Claude Code MCP entries:',
|
|
361
|
+
c.claudeCli ? ` ${quoted(MCP_NAMES)} with "claude mcp remove <name> -s user" (where registered)`
|
|
362
|
+
: inFile.length ? ` ${quoted(inFile)} from ${configFile}` : ` none (${configFile} has no Backdraw entry)`,
|
|
363
|
+
' Claude desktop app chat config (a backup is made first; only while Claude is closed):',
|
|
364
|
+
...(desktopFiles.length ? desktopFiles.map(f => ` ${quoted(desktopEntriesIn(f))} from ${f}`) : [' none (no Backdraw entry)']),
|
|
365
|
+
' Other MCP clients (a backup is made first):',
|
|
366
|
+
...(others.length ? others.map(o => ` ${o.client.label}: "${mcpClients.NAME}" from ${o.file}${o.error ? ` (can't be changed: ${o.error})` : ''}`)
|
|
367
|
+
: [' none (no Backdraw entry)']),
|
|
368
|
+
'It keeps:', ...keptLines(dest, cfg),
|
|
369
|
+
...folders.map(d => ` The folder ${d} itself (delete it by hand)`),
|
|
370
|
+
' The Chrome extension (remove it at chrome://extensions)',
|
|
371
|
+
].join('\n');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function uninstall(ctx) {
|
|
375
|
+
const c = context(ctx);
|
|
376
|
+
const p = c.platform;
|
|
377
|
+
const dest = p.installDir(c.home);
|
|
378
|
+
const legacyDir = p.legacyInstallDir(c.home);
|
|
379
|
+
const cfg = readJson(path.join(dest, 'data', 'config.json'), {}) || {};
|
|
380
|
+
const desktopFiles = p.desktopConfigFiles().filter(f => desktopEntriesIn(f).length);
|
|
381
|
+
const folders = [dest, legacyDir].filter(d => fs.existsSync(d));
|
|
382
|
+
const others = fileClientEntries(c);
|
|
383
|
+
if (c.dryRun) return c.log(dryRunReport(c, { dest, folders, desktopFiles, cfg, others }));
|
|
384
|
+
|
|
385
|
+
for (const [name, browser] of hostEntries()) p.unregisterNativeHost(name, browser);
|
|
386
|
+
const mcpRemoved = unregisterMcp(c);
|
|
387
|
+
const otherLines = others.map(({ client, file, error }) => {
|
|
388
|
+
if (error) return `${client.label}: left alone (${error})`;
|
|
389
|
+
try { return `${client.label}: removed "${mcpClients.NAME}" from ${file} (backup: ${mcpClients.removeEntry(client, c.clientEnv).backup})`; } catch (e) { return `${client.label}: left alone (${e.message})`; }
|
|
390
|
+
});
|
|
391
|
+
const desktopRunning = desktopFiles.length ? p.desktopAppRunning() : false;
|
|
392
|
+
const desktopLines = [];
|
|
393
|
+
if (desktopRunning === false) {
|
|
394
|
+
for (const file of desktopFiles) {
|
|
395
|
+
try { desktopLines.push(`updated ${file} (backup: ${updateDesktopConfig(file, null, { remove: true })})`); } catch (e) { desktopLines.push(`skipped ${file}: ${e.message}`); }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const steps = ['Remove the Backdraw extension at chrome://extensions.'];
|
|
399
|
+
if (desktopRunning !== false) {
|
|
400
|
+
steps.push(`Claude desktop app (chat): ${desktopRunning ? 'Claude is running' : 'could not tell whether Claude is running'}, so its config was left alone.
|
|
401
|
+
Quit Claude completely (also from the system tray), then run this again from PowerShell: ${desktopCommand(fs.existsSync(path.join(dest, 'install.js')) ? dest : c.src, '--uninstall')}`);
|
|
402
|
+
}
|
|
403
|
+
if (folders.length) steps.push(`Optionally delete ${folders.join(' and ')} by hand (that deletes the settings and job history too).`);
|
|
404
|
+
const kept = keptLines(dest, cfg);
|
|
405
|
+
c.log(['Backdraw was uninstalled.',
|
|
406
|
+
` Browser host entries: removed ${identity.HOST_NAME} (Chrome, Edge and Chromium keys; Brave reads the last two) and ${LEGACY.HOST_NAME} (where present)`,
|
|
407
|
+
` Claude Code MCP : ${mcpRemoved.length ? `removed ${quoted(mcpRemoved)}` : 'no entry was registered'}`,
|
|
408
|
+
...(desktopLines.length ? desktopLines.map(l => ` Claude desktop app : ${l}`)
|
|
409
|
+
: [` Claude desktop app : ${desktopFiles.length ? 'left alone (see the next steps)' : 'no Backdraw entry'}`]),
|
|
410
|
+
...otherLines.map(l => ` ${l}`),
|
|
411
|
+
...(kept.length ? ['Kept:', ...kept] : []),
|
|
412
|
+
'', 'Next steps:', ...numbered(steps)].join('\n'));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (require.main === module) {
|
|
416
|
+
const args = process.argv.slice(2);
|
|
417
|
+
const dryRun = args.includes('--dry-run');
|
|
418
|
+
const run = args.includes('--uninstall') ? () => uninstall({ dryRun })
|
|
419
|
+
: dryRun ? () => { throw new Error('--dry-run only works together with --uninstall.'); }
|
|
420
|
+
: args.includes('--desktop') ? desktop : () => install();
|
|
421
|
+
Promise.resolve().then(run).catch(e => { console.error(`Setup failed: ${e.message}`); process.exitCode = 1; });
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
module.exports = {
|
|
425
|
+
FILES, NODE, context, extensionId, migrateData, updateDesktopConfig, desktopNeedsUpdate, desktopEntry, desktopEntriesIn, desktopCommand,
|
|
426
|
+
extensionSteps, configDefaults, registerHost, registerMcp, targetBrowsers, install, uninstall,
|
|
427
|
+
};
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Argument helpers for the MCP tools and the CLI.
|
|
3
|
+
|
|
4
|
+
// A whole number within lo..hi. Only a number or a numeric string counts: anything else (missing,
|
|
5
|
+
// null, '', a boolean) gets the default, so a client that sends null for an omitted wait still waits.
|
|
6
|
+
function intArg(v, lo, hi, dflt) {
|
|
7
|
+
const n = typeof v === 'number' || (typeof v === 'string' && v.trim()) ? Number(v) : NaN;
|
|
8
|
+
return Math.min(hi, Math.max(lo, Number.isFinite(n) ? Math.round(n) : dflt));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Positionals plus --flags. A flag in `booleans` never takes a value, so it can't swallow the next
|
|
12
|
+
// word; any other flag takes the next word unless that is another flag (or missing: then it is true).
|
|
13
|
+
// Flags in `lists` collect every value (--ref a --ref b); for the others the last one wins.
|
|
14
|
+
// --flag=value works too.
|
|
15
|
+
function parseArgs(argv, { booleans = [], lists = [] } = {}) {
|
|
16
|
+
const pos = [];
|
|
17
|
+
const flags = {};
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (!a.startsWith('--')) { pos.push(a); continue; }
|
|
21
|
+
const eq = a.indexOf('=');
|
|
22
|
+
const key = eq > 2 ? a.slice(2, eq) : a.slice(2);
|
|
23
|
+
const value = eq > 2 ? a.slice(eq + 1)
|
|
24
|
+
: !booleans.includes(key) && i + 1 < argv.length && !argv[i + 1].startsWith('--') ? argv[++i] : true;
|
|
25
|
+
flags[key] = lists.includes(key) && key in flags ? [].concat(flags[key], value) : value;
|
|
26
|
+
}
|
|
27
|
+
return { pos, flags };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { intArg, parseArgs };
|
package/lib/aspect.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Aspect ratios (pure): reading the caller's W:H and telling ChatGPT how to draw it. ChatGPT makes
|
|
3
|
+
// any ratio from 1:3 to 3:1 itself (about 1.57 MP in the asked shape). Up to 1:4 / 4:1 is accepted:
|
|
4
|
+
// those are drawn at 1:3 / 3:1 and cropped from the centre afterwards (extension/aspect.js).
|
|
5
|
+
|
|
6
|
+
const TOLERANCE = 0.01; // within 1% of the target counts as exact; extension/aspect.js uses the same
|
|
7
|
+
const NATIVE = 3; // ChatGPT draws 1:3 .. 3:1
|
|
8
|
+
const LIMIT = 4; // accepted 1:4 .. 4:1
|
|
9
|
+
const MAX_TERM = 100;
|
|
10
|
+
const ALIASES = { square: '1:1', landscape: '3:2', portrait: '2:3', wide: '16:9', tall: '9:16', 'discord-banner': '5:2' };
|
|
11
|
+
|
|
12
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
13
|
+
const orientation = (w, h) => (w === h ? 'square' : w > h ? 'landscape' : 'portrait');
|
|
14
|
+
const pct = x => `${Math.round(x * 1000) / 10}%`;
|
|
15
|
+
|
|
16
|
+
// "16:9", "16x9", "16/9", "1920:1080" (reduced to 16:9) or an alias. Throws with a hint on anything else.
|
|
17
|
+
function parseAspect(input) {
|
|
18
|
+
const text = String(input === undefined || input === null ? '' : input).trim().toLowerCase();
|
|
19
|
+
const alias = text.replace(/[\s_]+/g, '-');
|
|
20
|
+
const m = (Object.hasOwn(ALIASES, alias) ? ALIASES[alias] : text).match(/^(\d{1,5}(?:\.\d{1,3})?)\s*[:x×/]\s*(\d{1,5}(?:\.\d{1,3})?)$/);
|
|
21
|
+
if (!m) throw new Error(`aspect_ratio "${input}" is not a ratio: use W:H with whole numbers, e.g. 16:9, 4:5 or 1:1 (or ${Object.keys(ALIASES).join(', ')}).`);
|
|
22
|
+
const decimals = Math.max(...[m[1], m[2]].map(s => (s.split('.')[1] || '').length));
|
|
23
|
+
let w = Math.round(Number(m[1]) * 10 ** decimals);
|
|
24
|
+
let h = Math.round(Number(m[2]) * 10 ** decimals);
|
|
25
|
+
if (!w || !h) throw new Error(`aspect_ratio "${input}": both sides must be at least 1.`);
|
|
26
|
+
const g = gcd(w, h);
|
|
27
|
+
w /= g;
|
|
28
|
+
h /= g;
|
|
29
|
+
// Before the decimals hint, which would otherwise suggest a whole-number ratio that is refused too.
|
|
30
|
+
if (w > LIMIT * h || h > LIMIT * w) {
|
|
31
|
+
throw new Error(`aspect_ratio ${w}:${h} is outside the supported range 1:${LIMIT} to ${LIMIT}:1 (ChatGPT itself draws 1:${NATIVE} to ${NATIVE}:1).`);
|
|
32
|
+
}
|
|
33
|
+
if (decimals) throw new Error(`aspect_ratio "${input}" must use whole numbers${w <= MAX_TERM && h <= MAX_TERM ? `: use ${w}:${h} instead` : ', e.g. 16:9'}.`);
|
|
34
|
+
if (w > MAX_TERM || h > MAX_TERM) throw new Error(`aspect_ratio "${input}" (${w}:${h}) is too precise: use a simpler ratio with numbers up to ${MAX_TERM}, e.g. 16:9 or 21:9.`);
|
|
35
|
+
return { w, h, ratio: w / h, label: `${w}:${h}`, orientation: orientation(w, h) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// What to ask ChatGPT for: the target itself, or for a ratio it can't draw the range bound on the
|
|
39
|
+
// same side (cropped to the target afterwards).
|
|
40
|
+
function plan(aspect) {
|
|
41
|
+
const target = { w: aspect.w, h: aspect.h, label: aspect.label };
|
|
42
|
+
const native = aspect.w <= NATIVE * aspect.h && aspect.h <= NATIVE * aspect.w;
|
|
43
|
+
const generate = native ? { ...target } : aspect.w > aspect.h ? { w: NATIVE, h: 1, label: `${NATIVE}:1` } : { w: 1, h: NATIVE, label: `1:${NATIVE}` };
|
|
44
|
+
return { target, native, generate };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For a cropped ratio: the side that gets cut and the share of it the centre crop keeps (4:1 from
|
|
48
|
+
// 3:1 keeps the middle 75% of the height). null when nothing is cut.
|
|
49
|
+
function safeBand(p) {
|
|
50
|
+
if (p.native) return null;
|
|
51
|
+
const want = p.target.w / p.target.h;
|
|
52
|
+
const made = p.generate.w / p.generate.h;
|
|
53
|
+
return want > made ? { axis: 'height', share: made / want } : { axis: 'width', share: want / made };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The lines added to the ChatGPT message for a job's aspect plan (or null) and transparent flag.
|
|
57
|
+
function promptLines(p, transparent) {
|
|
58
|
+
const lines = [];
|
|
59
|
+
if (p) {
|
|
60
|
+
const shape = `${p.generate.label} (${orientation(p.generate.w, p.generate.h)})`;
|
|
61
|
+
const band = safeBand(p);
|
|
62
|
+
// "Fill the whole frame" would ask for the very background a transparent image must not have.
|
|
63
|
+
const fill = transparent ? 'Compose the subject to suit this frame; everything around it stays transparent, with no borders.' : null;
|
|
64
|
+
if (!band) {
|
|
65
|
+
lines.push(`Aspect ratio: ${shape}. ${fill || 'Fill the whole frame, no borders or letterboxing.'}`);
|
|
66
|
+
} else {
|
|
67
|
+
const edges = band.axis === 'height' ? 'top and bottom' : 'left and right';
|
|
68
|
+
lines.push(`Aspect ratio: make the image ${shape}. It will be cropped from the centre to exactly ${p.target.label} afterwards, ` +
|
|
69
|
+
`so keep every important element (subjects, faces, text, logos) inside the middle ${pct(band.share)} of the ${band.axis}; ` +
|
|
70
|
+
`the ${edges} ${pct((1 - band.share) / 2)} may be cut off. ${fill || 'Fill the whole frame, no borders.'}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (transparent) {
|
|
74
|
+
lines.push('Background: fully transparent (PNG with an alpha channel). Show only the subject: no background colour, no scenery, no floor shadow, no white or checkerboard box.');
|
|
75
|
+
}
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// A job's aspect_ratio argument as a plan, or null when none was given. Throws on an invalid one.
|
|
80
|
+
function aspectOption(value) {
|
|
81
|
+
if (value === undefined || value === null || String(value).trim() === '') return null;
|
|
82
|
+
return plan(parseAspect(value));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { TOLERANCE, ALIASES, pct, parseAspect, plan, safeBand, promptLines, aspectOption };
|
package/lib/browsers.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The Chromium browsers Backdraw registers its native host for (Windows). A browser looks a per-user
|
|
3
|
+
// host up under HKCU\<key>\<host name> (default value = path of the host manifest), trying its keys
|
|
4
|
+
// in order and using the first one that is there:
|
|
5
|
+
// Chrome Google\Chrome (developer.chrome.com, native messaging)
|
|
6
|
+
// Edge Microsoft\Edge, Chromium, Chrome (learn.microsoft.com, Edge native messaging)
|
|
7
|
+
// Chromium Chromium, Chrome (chromium launch_context_win.cc, CHROMIUM_BRANDING)
|
|
8
|
+
// Brave Chromium, Chrome (a Chromium-branded build; it has no key of its own)
|
|
9
|
+
// Backdraw writes each target browser's first key. Policies live under HKLM and HKCU\<policyKey>.
|
|
10
|
+
// exe where its executable can be (each channel), relative to Program Files, Program Files (x86)
|
|
11
|
+
// or LocalAppData
|
|
12
|
+
// page its extensions page
|
|
13
|
+
// Chrome's key is always written: every Chrome channel (Beta, Dev, Canary, a portable copy) reads
|
|
14
|
+
// it, and the others fall back to it.
|
|
15
|
+
const HOST_KEYS = {
|
|
16
|
+
chrome: 'Software\\Google\\Chrome\\NativeMessagingHosts',
|
|
17
|
+
edge: 'Software\\Microsoft\\Edge\\NativeMessagingHosts',
|
|
18
|
+
chromium: 'Software\\Chromium\\NativeMessagingHosts',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const BROWSERS = [
|
|
22
|
+
{
|
|
23
|
+
id: 'chrome', name: 'Google Chrome', keys: ['chrome'],
|
|
24
|
+
policyKey: 'SOFTWARE\\Policies\\Google\\Chrome',
|
|
25
|
+
exe: ['Chrome', 'Chrome Beta', 'Chrome Dev', 'Chrome SxS'].map(c => `Google\\${c}\\Application\\chrome.exe`),
|
|
26
|
+
page: 'chrome://extensions',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'edge', name: 'Microsoft Edge', keys: ['edge', 'chromium', 'chrome'],
|
|
30
|
+
policyKey: 'SOFTWARE\\Policies\\Microsoft\\Edge',
|
|
31
|
+
exe: ['Edge', 'Edge Beta', 'Edge Dev', 'Edge SxS'].map(c => `Microsoft\\${c}\\Application\\msedge.exe`),
|
|
32
|
+
page: 'edge://extensions',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: 'brave', name: 'Brave', keys: ['chromium', 'chrome'],
|
|
36
|
+
policyKey: 'SOFTWARE\\Policies\\BraveSoftware\\Brave',
|
|
37
|
+
exe: ['', '-Beta', '-Dev', '-Nightly'].map(c => `BraveSoftware\\Brave-Browser${c}\\Application\\brave.exe`),
|
|
38
|
+
page: 'brave://extensions',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'chromium', name: 'Chromium', keys: ['chromium', 'chrome'],
|
|
42
|
+
policyKey: 'SOFTWARE\\Policies\\Chromium',
|
|
43
|
+
exe: ['Chromium\\Application\\chrome.exe'],
|
|
44
|
+
page: 'chrome://extensions',
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const byId = id => BROWSERS.find(b => b.id === id);
|
|
49
|
+
|
|
50
|
+
// The registry keys (ids of HOST_KEYS) to write for these browsers, once each.
|
|
51
|
+
const keysFor = browserIds => [...new Set(browserIds.map(id => byId(id).keys[0]))];
|
|
52
|
+
|
|
53
|
+
// The manifest path a browser will use: the value of the first of its keys that is set.
|
|
54
|
+
// values: { chrome: path | null, edge: …, chromium: … }
|
|
55
|
+
function effectiveHost(browser, values) {
|
|
56
|
+
for (const key of browser.keys) if (values[key]) return { key, path: values[key] };
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Whether a browser's policies stop it from starting a per-user (HKCU) native host:
|
|
61
|
+
// policies: { userLevelHosts: true | false | undefined, blocklist: [...], allowlist: [...] }
|
|
62
|
+
// The allowlist only matters for a host the blocklist names (by name or "*"). Returns null when
|
|
63
|
+
// nothing blocks it, or the reason.
|
|
64
|
+
function policyProblem(policies, hostName) {
|
|
65
|
+
if (!policies) return null;
|
|
66
|
+
if (policies.userLevelHosts === false) {
|
|
67
|
+
return 'the NativeMessagingUserLevelHosts policy is off, so per-user hosts (like Backdraw\'s) are ignored';
|
|
68
|
+
}
|
|
69
|
+
const blocked = (policies.blocklist || []).some(n => n === '*' || n === hostName);
|
|
70
|
+
if (blocked && !(policies.allowlist || []).includes(hostName)) return 'the NativeMessagingBlocklist policy blocks Backdraw\'s host';
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { HOST_KEYS, BROWSERS, byId, keysFor, effectiveHost, policyProblem };
|