clawmoney 0.17.47 → 0.18.1

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.
Binary file
Binary file
@@ -0,0 +1,645 @@
1
+ // ClawMoney companion — Electron main process.
2
+ //
3
+ // A tiny menu-bar (tray) app that gives CLI-only users a persistent icon +
4
+ // a dashboard window, WITHOUT installing the full Tauri desktop app.
5
+ // The CLI (client) talks to this process over a filesystem IPC bridge
6
+ // (see ../src/ui/ipc-client.ts), the same pattern Coinbase's `awal` uses.
7
+ //
8
+ // Plain CommonJS on purpose (Electron main is happiest that way).
9
+
10
+ const { app, BrowserWindow, Tray, Menu, nativeImage, shell, ipcMain } = require('electron');
11
+ const fs = require('node:fs');
12
+ const path = require('node:path');
13
+ const os = require('node:os');
14
+ const http = require('node:http');
15
+ const { spawn, spawnSync } = require('node:child_process');
16
+
17
+ // Rebrand from "Electron" — sets the macOS menu-bar app name (and userData path).
18
+ app.setName('ClawMoney');
19
+
20
+ // Single shared bridge dir, must match src/ui/ipc-client.ts.
21
+ const BRIDGE = path.join(os.tmpdir(), 'clawmoney-ui-bridge');
22
+ const REQ_DIR = path.join(BRIDGE, 'requests');
23
+ const RES_DIR = path.join(BRIDGE, 'responses');
24
+ const PID_FILE = path.join(BRIDGE, 'companion.pid');
25
+
26
+ // The CLI passes the resolved dashboard URL (with auth token) via env.
27
+ const DASHBOARD_URL = process.env.CLAWMONEY_DASHBOARD_URL || 'https://clawmoney.ai/dashboard';
28
+
29
+ // Verbose log so the CLI side can diagnose render failures (white screen etc).
30
+ const LOG_FILE = path.join(__dirname, 'companion.log');
31
+ function log(msg) {
32
+ const line = `[${new Date().toISOString()}] ${msg}`;
33
+ try { fs.appendFileSync(LOG_FILE, line + '\n'); } catch {}
34
+ console.error(line);
35
+ }
36
+
37
+ // Local static server for the bundled desktop UI. We serve over HTTP (not
38
+ // file://) because the UI references public assets by absolute path, e.g.
39
+ // "/brand/logo-icon.png" — file:// would resolve those against the filesystem
40
+ // root and 404. A server root makes them resolve correctly (like Tauri does).
41
+ const MIME = {
42
+ '.html': 'text/html; charset=utf-8',
43
+ '.js': 'text/javascript; charset=utf-8',
44
+ '.css': 'text/css; charset=utf-8',
45
+ '.json': 'application/json; charset=utf-8',
46
+ '.svg': 'image/svg+xml',
47
+ '.png': 'image/png',
48
+ '.jpg': 'image/jpeg',
49
+ '.jpeg': 'image/jpeg',
50
+ '.gif': 'image/gif',
51
+ '.webp': 'image/webp',
52
+ '.ico': 'image/x-icon',
53
+ '.woff': 'font/woff',
54
+ '.woff2': 'font/woff2',
55
+ '.ttf': 'font/ttf',
56
+ };
57
+
58
+ let serverPort = null;
59
+
60
+ function startUIServer() {
61
+ const root = path.join(__dirname, 'ui');
62
+ if (!fs.existsSync(path.join(root, 'index.html'))) {
63
+ log('ui/index.html missing — UI server not started');
64
+ return Promise.resolve(null);
65
+ }
66
+ return new Promise((resolve) => {
67
+ const server = http.createServer((req, res) => {
68
+ let urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
69
+ if (urlPath === '/') urlPath = '/index.html';
70
+ const safe = path.normalize(urlPath).replace(/^(\.\.[/\\])+/, '');
71
+ let filePath = path.join(root, safe);
72
+ if (!filePath.startsWith(root) || !fs.existsSync(filePath) || fs.statSync(filePath).isDirectory()) {
73
+ filePath = path.join(root, 'index.html'); // SPA fallback
74
+ }
75
+ res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream' });
76
+ fs.createReadStream(filePath).pipe(res);
77
+ });
78
+ server.on('error', (e) => { log(`UI server error: ${e.message}`); resolve(null); });
79
+ server.listen(0, '127.0.0.1', () => {
80
+ serverPort = server.address().port;
81
+ log(`UI server on http://127.0.0.1:${serverPort}`);
82
+ resolve(serverPort);
83
+ });
84
+ });
85
+ }
86
+
87
+ // ── Tauri invoke bridge ─────────────────────────────────────────────────────
88
+ // preload.js injects window.__TAURI_INTERNALS__.invoke -> ipcRenderer -> here.
89
+ // We implement the desktop UI's commands against clawmoney config + backend so
90
+ // the UI runs in real (isTauri) mode. Mirrors the Rust commands in
91
+ // clawmoney-desktop/src-tauri/src/main.rs (which mostly call the same backend
92
+ // or `clawmoney` CLI).
93
+ const API_BASE = process.env.CLAWMONEY_API_BASE || 'https://api.bnbot.ai';
94
+ const CONFIG_PATH = path.join(os.homedir(), '.clawmoney', 'config.yaml');
95
+ const EXTENSION_URL = 'https://clawmoney.ai/extension';
96
+
97
+ // Electron GUI apps inherit a minimal PATH; extend it so `bnbot` resolves.
98
+ const CLI_PATH = [
99
+ '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
100
+ path.join(os.homedir(), '.npm-global', 'bin'),
101
+ process.env.PATH || '',
102
+ ].join(':');
103
+
104
+ // Mirror Rust service_status: read the daemon pid file, check the process is alive.
105
+ function readPidStatus(name, pidFile) {
106
+ const pidPath = path.join(os.homedir(), '.clawmoney', pidFile);
107
+ try {
108
+ const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim(), 10) || null;
109
+ if (pid) process.kill(pid, 0); // throws if the process isn't alive
110
+ return { name, running: !!pid, pid, pidFile: pidPath };
111
+ } catch {
112
+ return { name, running: false, pid: null, pidFile: pidPath };
113
+ }
114
+ }
115
+
116
+ // Mirror Rust get_extension_status: `bnbot status`; output with "extension" +
117
+ // "connected" => connected; command missing/failed => not installed.
118
+ function getExtensionStatus() {
119
+ return new Promise((resolve) => {
120
+ let done = false;
121
+ let out = '';
122
+ const finish = (installed, connected, status) => {
123
+ if (done) return;
124
+ done = true;
125
+ resolve({ installed, connected, status, installUrl: EXTENSION_URL, detail: out.trim() });
126
+ };
127
+ try {
128
+ const p = spawn('bnbot', ['status'], { env: { ...process.env, PATH: CLI_PATH } });
129
+ const timer = setTimeout(() => { try { p.kill(); } catch { /* ignore */ } finish(false, false, 'missing'); }, 12000);
130
+ p.stdout.on('data', (d) => { out += d; });
131
+ p.stderr.on('data', (d) => { out += d; });
132
+ p.on('error', () => { clearTimeout(timer); finish(false, false, 'missing'); });
133
+ p.on('close', (code) => {
134
+ clearTimeout(timer);
135
+ if (code !== 0) return finish(false, false, 'missing');
136
+ const o = out.toLowerCase();
137
+ const connected = o.includes('extension') && o.includes('connected');
138
+ finish(true, connected, connected ? 'connected' : 'not_connected');
139
+ });
140
+ } catch {
141
+ finish(false, false, 'missing');
142
+ }
143
+ });
144
+ }
145
+
146
+ function readClawConfig() {
147
+ try {
148
+ const txt = fs.readFileSync(CONFIG_PATH, 'utf8');
149
+ const get = (k) => {
150
+ const m = txt.match(new RegExp('^' + k + ':\\s*(.+)$', 'm'));
151
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : undefined;
152
+ };
153
+ return {
154
+ api_key: get('api_key'),
155
+ agent_id: get('agent_id'),
156
+ agent_slug: get('agent_slug'),
157
+ email: get('email'),
158
+ wallet_address: get('wallet_address'),
159
+ };
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
165
+ async function apiGet(p, apiKey) {
166
+ try {
167
+ const r = await fetch(API_BASE + p, {
168
+ headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
169
+ });
170
+ const data = await r.json().catch(() => null);
171
+ return { ok: r.ok, status: r.status, data };
172
+ } catch (e) {
173
+ return { ok: false, status: 0, data: null, error: String(e) };
174
+ }
175
+ }
176
+
177
+ // Mirror desktop's ensure_provider_running: when the dashboard loads and the
178
+ // user is logged in but the Market provider isn't running, start it — so the
179
+ // companion auto-takes orders on open, just like the desktop app does.
180
+ // Uses ELECTRON_RUN_AS_NODE so the companion's own node runs the CLI entry
181
+ // (no dependency on a global `clawmoney` or system node on PATH).
182
+ function ensureMarketRunning() {
183
+ const entry = process.env.CLAWMONEY_CLI_ENTRY;
184
+ if (!entry || !fs.existsSync(entry)) return;
185
+ const cfg = readClawConfig();
186
+ if (!cfg || !cfg.api_key) return; // not logged in → don't take orders
187
+ if (readPidStatus('market', 'provider.pid').running) return; // already up
188
+ try {
189
+ // Use system `node` (not process.execPath). process.execPath is the ClawMoney
190
+ // electron binary; even with ELECTRON_RUN_AS_NODE, the CLI's daemon inherits
191
+ // that execPath and re-launches as a GUI electron window → single-instance
192
+ // clash → the companion flickers/relaunches. `node` keeps the daemon headless.
193
+ const child = spawn('node', [entry, 'market', 'start'], {
194
+ env: { ...process.env, PATH: CLI_PATH },
195
+ detached: true,
196
+ stdio: 'ignore',
197
+ });
198
+ child.unref();
199
+ log('ensureMarketRunning: started `node clawmoney market start`');
200
+ } catch (e) {
201
+ log(`ensureMarketRunning failed: ${e.message}`);
202
+ }
203
+ }
204
+
205
+ async function loadDashboard() {
206
+ ensureMarketRunning();
207
+ const cfg = readClawConfig();
208
+ const configured = !!(cfg && cfg.api_key);
209
+ const dash = {
210
+ ok: true,
211
+ apiBase: API_BASE,
212
+ configPath: CONFIG_PATH,
213
+ configured,
214
+ config: configured
215
+ ? { agent_slug: cfg.agent_slug, email: cfg.email, wallet_address: cfg.wallet_address }
216
+ : null,
217
+ account: { ok: false },
218
+ wallet: { ok: false },
219
+ extension: { installed: false, connected: false },
220
+ services: { market: readPidStatus('market', 'provider.pid'), surf: readPidStatus('surf', 'relay.pid') },
221
+ history: { ok: true, orders: [], escrow: [], orderCount: 0, escrowCount: 0 },
222
+ orderEarnings: { ok: false },
223
+ skills: [],
224
+ providerLog: [],
225
+ connections: { platforms: [] },
226
+ cliAvailable: true,
227
+ };
228
+ dash.extension = await getExtensionStatus(); // local check, independent of config
229
+ if (!configured) return dash;
230
+ const key = cfg.api_key;
231
+ const [account, wBase, wBsc, skills, orders, escrow, earnings] = await Promise.all([
232
+ apiGet('/api/v1/claw-agents/me', key),
233
+ apiGet('/api/v1/claw-agents/me/wallet/balance?asset=usdc&network=base', key),
234
+ apiGet('/api/v1/claw-agents/me/wallet/balance?asset=usdc&network=bsc', key),
235
+ apiGet('/api/v1/market/skills/mine?active_only=false', key),
236
+ apiGet('/api/v1/market/orders/mine?role=provider&limit=50', key),
237
+ apiGet('/api/v1/market/escrow/assigned?limit=12', key),
238
+ apiGet('/api/v1/market/orders/mine/earnings', key),
239
+ ]);
240
+
241
+ if (account.ok) dash.account = { ok: true, data: account.data };
242
+
243
+ const baseAmt = parseFloat((wBase.data && wBase.data.amount) || '0') || 0;
244
+ const bscAmt = parseFloat((wBsc.data && wBsc.data.amount) || '0') || 0;
245
+ dash.wallet = {
246
+ ok: true,
247
+ address: cfg.wallet_address || (wBase.data && wBase.data.address) || null,
248
+ balance: baseAmt + bscAmt,
249
+ base: baseAmt,
250
+ bsc: bscAmt,
251
+ };
252
+
253
+ if (skills.ok) dash.skills = (skills.data && skills.data.data) || [];
254
+
255
+ const orderList = (orders.data && orders.data.data) || [];
256
+ const escrowList = (escrow.data && escrow.data.data) || [];
257
+ dash.history = {
258
+ ok: true,
259
+ orders: orderList,
260
+ escrow: escrowList,
261
+ orderCount: orderList.length,
262
+ escrowCount: escrowList.length,
263
+ };
264
+
265
+ if (earnings.ok) dash.orderEarnings = { ok: true, ...(earnings.data || {}) };
266
+
267
+ return dash;
268
+ }
269
+
270
+ // LLM subscriptions that can be resold via the relay (mirrors desktop LLM_SPECS).
271
+ const LLM_SPECS = [
272
+ { cli: 'codex', package: '@openai/codex', token: '.codex/auth.json', model: 'gpt-5.5' },
273
+ { cli: 'chatgpt-web', package: '@jackwener/opencli', token: '', model: 'gpt-5.2' },
274
+ { cli: 'gemini', package: '@google/gemini-cli', token: '.gemini/oauth_creds.json', model: 'gemini-2.5-flash' },
275
+ ];
276
+ const RELAY_RESALE_PATH = path.join(os.homedir(), '.clawmoney', 'relay-resale.json');
277
+
278
+ function commandExists(bin) {
279
+ try {
280
+ return spawnSync('which', [bin], { env: { ...process.env, PATH: CLI_PATH }, encoding: 'utf8' }).status === 0;
281
+ } catch { return false; }
282
+ }
283
+ function llmProbeBinary(cli) { return cli === 'chatgpt-web' ? 'opencli' : cli; }
284
+ function llmReady(cli, token) {
285
+ // chatgpt-web has no token file; treat opencli's presence as the ready signal.
286
+ if (cli === 'chatgpt-web') return commandExists('opencli');
287
+ return token ? fs.existsSync(path.join(os.homedir(), ...token.split('/'))) : false;
288
+ }
289
+ function readRelaySettings() {
290
+ try {
291
+ if (fs.existsSync(RELAY_RESALE_PATH)) return JSON.parse(fs.readFileSync(RELAY_RESALE_PATH, 'utf8'));
292
+ } catch { /* ignore */ }
293
+ return {};
294
+ }
295
+
296
+ // Run a process async (Promise) so the Electron main thread never blocks —
297
+ // mirrors desktop's run_cli on a spawn_blocking thread. The UI stays responsive
298
+ // while market/relay daemons start/stop or npm installs run.
299
+ function runProc(cmd, procArgs, timeoutMs = 25000) {
300
+ return new Promise((resolve) => {
301
+ let child;
302
+ try {
303
+ child = spawn(cmd, procArgs, { env: { ...process.env, PATH: CLI_PATH } });
304
+ } catch (e) {
305
+ resolve({ ok: false, error: String(e) });
306
+ return;
307
+ }
308
+ let out = '';
309
+ let err = '';
310
+ const timer = setTimeout(() => {
311
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
312
+ resolve({ ok: false, error: 'timeout' });
313
+ }, timeoutMs);
314
+ child.stdout?.on('data', (d) => { out += d; });
315
+ child.stderr?.on('data', (d) => { err += d; });
316
+ child.on('close', (code) => {
317
+ clearTimeout(timer);
318
+ resolve({ ok: code === 0, stdout: out.trim(), stderr: err.trim() });
319
+ });
320
+ child.on('error', (e) => {
321
+ clearTimeout(timer);
322
+ resolve({ ok: false, error: String(e) });
323
+ });
324
+ });
325
+ }
326
+
327
+ // Run a clawmoney CLI subcommand via system `node` (not process.execPath, which
328
+ // is the electron binary — see ensureMarketRunning). Returns a Promise.
329
+ function runCli(cliArgs, timeoutMs = 25000) {
330
+ const entry = process.env.CLAWMONEY_CLI_ENTRY;
331
+ if (!entry || !fs.existsSync(entry)) return Promise.resolve({ ok: false, error: 'CLI entry not found' });
332
+ return runProc('node', [entry, ...cliArgs], timeoutMs);
333
+ }
334
+
335
+ async function handleTauriCommand(cmd, args) {
336
+ log(`invoke: ${cmd}`);
337
+ try {
338
+ switch (cmd) {
339
+ case 'load_dashboard':
340
+ return await loadDashboard();
341
+ case 'load_provider_log':
342
+ return { ok: true, providerLog: [] };
343
+ // Local-service controls → drive the clawmoney CLI daemons.
344
+ case 'start_market': return await runCli(['market', 'start']);
345
+ case 'stop_market': return await runCli(['market', 'stop']);
346
+ case 'start_surf': return await runCli(['relay', 'start']);
347
+ case 'stop_surf': return await runCli(['relay', 'stop']);
348
+ case 'launch_chrome':
349
+ try { spawn('open', ['-a', 'Google Chrome'], { detached: true, stdio: 'ignore' }).unref(); } catch { /* ignore */ }
350
+ return { ok: true };
351
+ case 'open_external':
352
+ if (args && args.url) shell.openExternal(String(args.url));
353
+ return { ok: true };
354
+ case 'install_extension':
355
+ shell.openExternal(EXTENSION_URL);
356
+ return { ok: true };
357
+ case 'logout': {
358
+ // Clear api_key so the UI returns to the signed-out state.
359
+ try {
360
+ const txt = fs.readFileSync(CONFIG_PATH, 'utf8');
361
+ fs.writeFileSync(CONFIG_PATH, txt.replace(/^api_key:.*$/m, '').replace(/\n{3,}/g, '\n\n'));
362
+ } catch { /* ignore */ }
363
+ return { ok: true };
364
+ }
365
+ // ── Agent 上架 (relay resale of LLM subscriptions) ──
366
+ case 'llm_detect': {
367
+ const providers = LLM_SPECS.map((s) => ({
368
+ cli: s.cli,
369
+ package: s.package,
370
+ installed: commandExists(llmProbeBinary(s.cli)),
371
+ loggedIn: llmReady(s.cli, s.token),
372
+ defaultModel: s.model,
373
+ }));
374
+ return { ok: true, providers };
375
+ }
376
+ case 'llm_install': {
377
+ const spec = LLM_SPECS.find((s) => s.cli === (args && args.cli));
378
+ if (!spec) return { ok: false, error: `unknown cli ${args && args.cli}` };
379
+ return await runProc('npm', ['install', '-g', spec.package], 180000);
380
+ }
381
+ case 'llm_login': {
382
+ const cli = args && args.cli;
383
+ if (cli === 'gemini') {
384
+ try {
385
+ spawn('osascript', ['-e', 'tell application "Terminal"\nactivate\ndo script "gemini"\nend tell'],
386
+ { detached: true, stdio: 'ignore' }).unref();
387
+ } catch { /* ignore */ }
388
+ return { ok: true, message: '已打开终端 — 在终端里选 "Login with Google" 登录,完成后回来' };
389
+ }
390
+ const bin = llmProbeBinary(cli);
391
+ if (!commandExists(bin)) return { ok: false, error: `${cli} 未安装` };
392
+ try {
393
+ spawn(bin, ['login'], { env: { ...process.env, PATH: CLI_PATH }, detached: true, stdio: 'ignore' }).unref();
394
+ } catch (e) { return { ok: false, error: String(e) }; }
395
+ return { ok: true };
396
+ }
397
+ case 'llm_set_enabled': {
398
+ const cli = args && args.cli;
399
+ const enabled = !!(args && args.enabled);
400
+ try {
401
+ const s = readRelaySettings();
402
+ const disabled = new Set(Array.isArray(s.disabledClis) ? s.disabledClis : []);
403
+ if (enabled) disabled.delete(cli); else disabled.add(cli);
404
+ s.disabledClis = [...disabled];
405
+ fs.mkdirSync(path.dirname(RELAY_RESALE_PATH), { recursive: true });
406
+ fs.writeFileSync(RELAY_RESALE_PATH, JSON.stringify(s, null, 2));
407
+ if (enabled && s.online) {
408
+ const spec = LLM_SPECS.find((x) => x.cli === cli);
409
+ if (spec && llmReady(spec.cli, spec.token)) {
410
+ await runCli(['relay', 'register', '--cli', spec.cli, '--model', spec.model, '--concurrency', String(s.concurrency || 2)], 30000);
411
+ }
412
+ }
413
+ } catch (e) { return { ok: false, error: String(e) }; }
414
+ return { ok: true };
415
+ }
416
+ case 'relay_settings_get':
417
+ return readRelaySettings();
418
+ case 'relay_settings_set': {
419
+ const settings = (args && args.settings) ? args.settings : (args || {});
420
+ try {
421
+ fs.mkdirSync(path.dirname(RELAY_RESALE_PATH), { recursive: true });
422
+ fs.writeFileSync(RELAY_RESALE_PATH, JSON.stringify(settings, null, 2));
423
+ } catch (e) { log(`relay_settings_set write: ${e.message}`); }
424
+ if (settings.online === true) {
425
+ const conc = String(settings.concurrency || 2);
426
+ const disabled = Array.isArray(settings.disabledClis) ? settings.disabledClis : [];
427
+ let registered = 0;
428
+ for (const spec of LLM_SPECS) {
429
+ if (disabled.includes(spec.cli)) continue;
430
+ if (llmReady(spec.cli, spec.token)) {
431
+ await runCli(['relay', 'register', '--cli', spec.cli, '--model', spec.model, '--concurrency', conc], 30000);
432
+ registered++;
433
+ }
434
+ }
435
+ return { ok: true, registered, applied: await runCli(['relay', 'start']) };
436
+ }
437
+ return { ok: true, applied: await runCli(['relay', 'stop']) };
438
+ }
439
+ default:
440
+ // window/event plugin calls (plugin:window|*) + not-yet-wired commands
441
+ // (llm_upload etc) — no-op so the UI doesn't error.
442
+ if (typeof cmd === 'string' && cmd.startsWith('plugin:')) return {};
443
+ return { ok: true };
444
+ }
445
+ } catch (e) {
446
+ log(`invoke ${cmd} failed: ${e.message}`);
447
+ return { ok: false, error: String(e) };
448
+ }
449
+ }
450
+
451
+ ipcMain.handle('tauri:invoke', (_e, cmd, args) => handleTauriCommand(cmd, args));
452
+
453
+ let win = null;
454
+ let tray = null;
455
+
456
+ // --- single instance: never two icons / two windows --------------------------
457
+ if (!app.requestSingleInstanceLock()) {
458
+ app.quit();
459
+ process.exit(0);
460
+ }
461
+ app.on('second-instance', () => showWindow());
462
+
463
+ function ensureDirs() {
464
+ for (const d of [BRIDGE, REQ_DIR, RES_DIR]) {
465
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true, mode: 0o700 });
466
+ }
467
+ }
468
+
469
+ function loadUI() {
470
+ if (serverPort) {
471
+ const url = `http://127.0.0.1:${serverPort}/`;
472
+ log(`loading desktop UI: ${url}`);
473
+ win.loadURL(url);
474
+ } else {
475
+ log(`UI server unavailable, loading ${DASHBOARD_URL}`);
476
+ win.loadURL(DASHBOARD_URL);
477
+ }
478
+ }
479
+
480
+ function createWindow() {
481
+ win = new BrowserWindow({
482
+ width: 1080,
483
+ height: 720,
484
+ minWidth: 920,
485
+ minHeight: 640,
486
+ show: false,
487
+ // Match the Tauri desktop window: transparent + frameless. The desktop UI
488
+ // draws its own rounded "floating card" + traffic lights, so a native frame
489
+ // or opaque background would double the chrome and leak a black border
490
+ // around the card (exactly what the user saw).
491
+ transparent: true,
492
+ frame: false,
493
+ hasShadow: false,
494
+ backgroundColor: '#00000000',
495
+ webPreferences: {
496
+ preload: path.join(__dirname, 'preload.js'),
497
+ contextIsolation: false,
498
+ nodeIntegration: false,
499
+ },
500
+ });
501
+ // Diagnostics: surface render failures / renderer console into companion.log.
502
+ win.webContents.on('did-fail-load', (_e, code, desc, url) => log(`did-fail-load ${code} ${desc} ${url}`));
503
+ win.webContents.on('console-message', (e) => log(`[renderer] ${e.message} (${e.sourceId}:${e.lineNumber})`));
504
+ win.webContents.on('did-finish-load', () => {
505
+ log('did-finish-load OK');
506
+ // The desktop UI only goes transparent / fills edge-to-edge when isTauri() is
507
+ // true. We keep isTauri() false (so data stays on mock for this step), and
508
+ // instead force the Tauri look via CSS: kill the fake macOS desktop bg, the
509
+ // body color, the .stage padding, and the letterbox scale transform.
510
+ win.webContents.insertCSS(
511
+ 'html,body{background:transparent !important;}' +
512
+ '#desktop{display:none !important;}' +
513
+ '.stage{padding:0 !important;}' +
514
+ '#app{transform:none !important;border-radius:22px !important;}' +
515
+ // Frameless-window dragging: desktop UI uses Tauri JS dragging (no-op in
516
+ // Electron), so make the top bars draggable via CSS app-region; keep all
517
+ // interactive elements clickable.
518
+ '.page-header,.sidebar-header{-webkit-app-region:drag;}' +
519
+ '.page-actions,.page-actions *,button,a,input,select,[role=button]{-webkit-app-region:no-drag;}'
520
+ ).catch((e) => log(`insertCSS failed: ${e.message}`));
521
+ win.show();
522
+ });
523
+ loadUI();
524
+ // Open external links in the real browser, not inside the app.
525
+ win.webContents.setWindowOpenHandler(({ url }) => {
526
+ shell.openExternal(url);
527
+ return { action: 'deny' };
528
+ });
529
+ // Closing the window just hides it — the tray icon stays put (use Quit to exit).
530
+ win.on('close', (e) => {
531
+ if (!app.isQuitting) {
532
+ e.preventDefault();
533
+ win.hide();
534
+ }
535
+ });
536
+ }
537
+
538
+ function showWindow() {
539
+ if (!win) createWindow();
540
+ win.show();
541
+ win.focus();
542
+ }
543
+
544
+ function createTray() {
545
+ let icon = nativeImage.createFromPath(path.join(__dirname, 'tray-icon.png'));
546
+ if (process.platform === 'darwin') icon = icon.resize({ width: 18, height: 18 });
547
+ tray = new Tray(icon);
548
+ tray.setToolTip('ClawMoney');
549
+ tray.setContextMenu(
550
+ Menu.buildFromTemplate([
551
+ { label: 'Open ClawMoney', click: showWindow },
552
+ { type: 'separator' },
553
+ {
554
+ label: 'Quit',
555
+ click: () => {
556
+ app.isQuitting = true;
557
+ cleanup();
558
+ app.exit(0);
559
+ },
560
+ },
561
+ ])
562
+ );
563
+ tray.on('click', showWindow);
564
+ }
565
+
566
+ // --- filesystem IPC server (CLI -> companion) --------------------------------
567
+ // CLI writes requests/<id>.json; we handle and write responses/<id>.json.
568
+ function handleRequestFile(file) {
569
+ const reqPath = path.join(REQ_DIR, file);
570
+ let req;
571
+ try {
572
+ req = JSON.parse(fs.readFileSync(reqPath, 'utf8'));
573
+ } catch {
574
+ return; // half-written file; the next watch event will catch it
575
+ }
576
+ try { fs.unlinkSync(reqPath); } catch {}
577
+
578
+ let result;
579
+ switch (req.channel) {
580
+ case 'ping':
581
+ result = { ok: true, pid: process.pid };
582
+ break;
583
+ case 'show':
584
+ showWindow();
585
+ result = { ok: true };
586
+ break;
587
+ case 'navigate':
588
+ if (win && req.data && req.data.url) win.loadURL(req.data.url);
589
+ showWindow();
590
+ result = { ok: true };
591
+ break;
592
+ case 'quit':
593
+ result = { ok: true };
594
+ setTimeout(() => { app.isQuitting = true; cleanup(); app.exit(0); }, 50);
595
+ break;
596
+ default:
597
+ result = { error: `unknown channel: ${req.channel}` };
598
+ }
599
+
600
+ try {
601
+ fs.writeFileSync(
602
+ path.join(RES_DIR, `${req.id}.json`),
603
+ JSON.stringify({ id: req.id, result }),
604
+ { mode: 0o600 }
605
+ );
606
+ } catch {}
607
+ }
608
+
609
+ function watchRequests() {
610
+ // fs.watch misses files written before the watcher attaches — drain any
611
+ // requests that arrived during startup first (fixes the IPC "show" timeout).
612
+ try {
613
+ for (const f of fs.readdirSync(REQ_DIR)) {
614
+ if (f.endsWith('.json')) handleRequestFile(f);
615
+ }
616
+ } catch {}
617
+ fs.watch(REQ_DIR, (_event, file) => {
618
+ if (file && file.endsWith('.json')) handleRequestFile(file);
619
+ });
620
+ }
621
+
622
+ function cleanup() {
623
+ try { fs.unlinkSync(PID_FILE); } catch {}
624
+ }
625
+
626
+ app.whenReady().then(async () => {
627
+ ensureDirs();
628
+ fs.writeFileSync(PID_FILE, String(process.pid), { mode: 0o600 });
629
+ // Dock icon — dev Electron otherwise shows the default atom icon.
630
+ if (process.platform === 'darwin' && app.dock) {
631
+ try {
632
+ app.dock.setIcon(nativeImage.createFromPath(path.join(__dirname, 'icon.png')));
633
+ } catch (e) {
634
+ log(`dock.setIcon failed: ${e.message}`);
635
+ }
636
+ }
637
+ await startUIServer();
638
+ createTray();
639
+ createWindow();
640
+ watchRequests();
641
+ });
642
+
643
+ // Tray app: don't quit when the window closes.
644
+ app.on('window-all-closed', () => {});
645
+ app.on('before-quit', () => { app.isQuitting = true; cleanup(); });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "clawmoney-companion",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "description": "ClawMoney CLI companion — menu-bar tray icon + dashboard window (Electron). Auto-installed on first `clawmoney ui` / `market start --ui`.",
6
+ "main": "main.js",
7
+ "dependencies": {
8
+ "electron": "^37.2.1"
9
+ }
10
+ }