groove-dev 0.27.107 → 0.27.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/TRAINING_DATA.md +9 -0
  2. package/moe-training/client/envelope-builder.js +5 -0
  3. package/moe-training/client/scrubber.js +1 -1
  4. package/moe-training/client/step-classifier.js +22 -6
  5. package/moe-training/client/trajectory-capture.js +15 -4
  6. package/moe-training/shared/constants.js +2 -0
  7. package/moe-training/shared/envelope-schema.js +1 -1
  8. package/moe-training/test/client/envelope-builder.test.js +32 -0
  9. package/moe-training/test/client/scrubber.test.js +13 -0
  10. package/moe-training/test/client/step-classifier.test.js +96 -7
  11. package/moe-training/test/client/trajectory-capture.test.js +53 -6
  12. package/node_modules/@groove-dev/cli/package.json +1 -1
  13. package/node_modules/@groove-dev/daemon/package.json +1 -1
  14. package/node_modules/@groove-dev/daemon/src/api.js +29 -2
  15. package/node_modules/@groove-dev/daemon/src/process.js +5 -5
  16. package/node_modules/@groove-dev/gui/dist/assets/{index-DkAGIluW.js → index-CEgtSfbG.js} +1748 -1745
  17. package/node_modules/@groove-dev/gui/dist/assets/{index-QwgLRN8B.css → index-_3cJS_UG.css} +1 -1
  18. package/node_modules/@groove-dev/gui/dist/index.html +2 -2
  19. package/node_modules/@groove-dev/gui/package.json +1 -1
  20. package/node_modules/@groove-dev/gui/src/components/layout/command-palette.jsx +2 -1
  21. package/node_modules/@groove-dev/gui/src/components/layout/status-bar.jsx +9 -3
  22. package/node_modules/@groove-dev/gui/src/components/settings/federation-panel.jsx +2 -2
  23. package/node_modules/@groove-dev/gui/src/components/settings/federation-peers.jsx +14 -2
  24. package/node_modules/@groove-dev/gui/src/components/settings/quick-connect.jsx +9 -0
  25. package/node_modules/@groove-dev/gui/src/stores/groove.js +10 -0
  26. package/node_modules/@groove-dev/gui/src/views/federation.jsx +56 -15
  27. package/node_modules/moe-training/client/envelope-builder.js +5 -0
  28. package/node_modules/moe-training/client/scrubber.js +1 -1
  29. package/node_modules/moe-training/client/step-classifier.js +22 -6
  30. package/node_modules/moe-training/client/trajectory-capture.js +15 -4
  31. package/node_modules/moe-training/shared/constants.js +2 -0
  32. package/node_modules/moe-training/shared/envelope-schema.js +1 -1
  33. package/node_modules/moe-training/test/client/envelope-builder.test.js +32 -0
  34. package/node_modules/moe-training/test/client/scrubber.test.js +13 -0
  35. package/node_modules/moe-training/test/client/step-classifier.test.js +96 -7
  36. package/node_modules/moe-training/test/client/trajectory-capture.test.js +53 -6
  37. package/package.json +1 -1
  38. package/packages/cli/package.json +1 -1
  39. package/packages/daemon/package.json +1 -1
  40. package/packages/daemon/src/api.js +29 -2
  41. package/packages/daemon/src/process.js +5 -5
  42. package/packages/gui/dist/assets/{index-DkAGIluW.js → index-CEgtSfbG.js} +1748 -1745
  43. package/packages/gui/dist/assets/{index-QwgLRN8B.css → index-_3cJS_UG.css} +1 -1
  44. package/packages/gui/dist/index.html +2 -2
  45. package/packages/gui/package.json +1 -1
  46. package/packages/gui/src/components/layout/command-palette.jsx +2 -1
  47. package/packages/gui/src/components/layout/status-bar.jsx +9 -3
  48. package/packages/gui/src/components/settings/federation-panel.jsx +2 -2
  49. package/packages/gui/src/components/settings/federation-peers.jsx +14 -2
  50. package/packages/gui/src/components/settings/quick-connect.jsx +9 -0
  51. package/packages/gui/src/stores/groove.js +10 -0
  52. package/packages/gui/src/views/federation.jsx +56 -15
  53. package/ssh/main.js +2253 -0
package/ssh/main.js ADDED
@@ -0,0 +1,2253 @@
1
+ // FSL-1.1-Apache-2.0 — see LICENSE
2
+ import { app, BrowserWindow, Tray, Menu, shell, nativeImage, dialog, ipcMain, safeStorage } from 'electron';
3
+ import pkg from 'electron-updater';
4
+ const { autoUpdater } = pkg;
5
+ import { createHash, randomBytes } from 'crypto';
6
+ import { fork, spawn, execFileSync } from 'child_process';
7
+ import { basename, dirname, join, resolve } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, renameSync, readdirSync, rmSync } from 'fs';
10
+ import { execSync } from 'child_process';
11
+ import { createServer, createConnection } from 'net';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const IS_MAC = process.platform === 'darwin';
15
+ const STUDIO_URL = 'https://studio.groovedev.ai';
16
+ const SUBSCRIPTION_POLL_MS = 5 * 60 * 1000;
17
+ let _lastSubCheck = 0;
18
+
19
+ function getAvailablePort() {
20
+ return new Promise((resolve, reject) => {
21
+ const srv = createServer();
22
+ srv.listen(0, '127.0.0.1', () => {
23
+ const { port } = srv.address();
24
+ srv.close(() => resolve(port));
25
+ });
26
+ srv.on('error', reject);
27
+ });
28
+ }
29
+
30
+ // macOS Electron apps launched from Finder inherit a minimal PATH missing user
31
+ // shell additions. Resolve the real PATH and API key env vars once at startup
32
+ // so forked daemons can find CLI tools and use API keys as fallback.
33
+ (function fixElectronEnv() {
34
+ if (!IS_MAC) return;
35
+ const shell = process.env.SHELL || '/bin/zsh';
36
+ const apiKeyVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'ELEVENLABS_API_KEY'];
37
+ const envVars = ['SSH_AUTH_SOCK'];
38
+ try {
39
+ const allVars = [...apiKeyVars, ...envVars];
40
+ const printCmd = ['echo "PATH=$PATH"', ...allVars.map(v => `echo "${v}=$${v}"`)].join('; ');
41
+ const output = execSync(`${shell} -ilc '${printCmd}'`, { encoding: 'utf8', timeout: 5000 }).trim();
42
+ for (const line of output.split('\n')) {
43
+ const eq = line.indexOf('=');
44
+ if (eq < 1) continue;
45
+ const key = line.slice(0, eq);
46
+ const val = line.slice(eq + 1);
47
+ if (key === 'PATH' && val) { process.env.PATH = val; }
48
+ else if (apiKeyVars.includes(key) && val && !process.env[key]) { process.env[key] = val; }
49
+ else if (envVars.includes(key) && val) { process.env[key] = val; }
50
+ }
51
+ } catch {
52
+ const home = app.getPath('home');
53
+ const extra = [
54
+ '/usr/local/bin', '/opt/homebrew/bin', `${home}/.local/bin`,
55
+ `${home}/.npm-global/bin`,
56
+ ];
57
+ const cur = process.env.PATH || '';
58
+ const toAdd = extra.filter(p => !cur.split(':').includes(p));
59
+ if (toAdd.length) process.env.PATH = [...toAdd, cur].join(':');
60
+ }
61
+ })();
62
+
63
+ let tray = null;
64
+ let isQuitting = false;
65
+ let pendingAuthState = null;
66
+ let subscriptionTimer = null;
67
+ let workspaces = null;
68
+
69
+ function resolveResourcePath(...segments) {
70
+ if (app.isPackaged) {
71
+ return join(process.resourcesPath, ...segments);
72
+ }
73
+ return resolve(__dirname, '..', ...segments);
74
+ }
75
+
76
+ // --- WorkspaceManager: one daemon + one window per project ---
77
+
78
+ class WorkspaceManager {
79
+ constructor() {
80
+ this.instances = new Map();
81
+ this._daemonProcesses = new Map();
82
+ this.recentProjects = this._loadRecents();
83
+ this.sshConnections = this._loadSSH();
84
+ this._sshTunnels = new Map();
85
+ this._homeWindow = null;
86
+ }
87
+
88
+ _instanceId(projectDir) {
89
+ return createHash('sha256').update(projectDir).digest('hex').slice(0, 8);
90
+ }
91
+
92
+ _rejectIfUnsafe(projectDir) {
93
+ if (!projectDir || typeof projectDir !== 'string') return 'Invalid folder path.';
94
+ const dir = resolve(projectDir);
95
+ const home = app.getPath('home');
96
+ const forbidden = new Set([home, '/', '/Users', '/Applications', '/System', '/Library', '/private', '/var', '/tmp', '/etc', '/usr', '/opt', '/bin', '/sbin', app.getPath('desktop'), app.getPath('documents'), app.getPath('downloads')]);
97
+ if (forbidden.has(dir)) {
98
+ return `Groove cannot open "${dir}" as a project — it's a system or top-level folder. Choose a specific project directory instead.`;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ _loadRecents() {
104
+ try {
105
+ const p = join(app.getPath('userData'), 'recent-projects.json');
106
+ return JSON.parse(readFileSync(p, 'utf8'));
107
+ } catch { return []; }
108
+ }
109
+
110
+ _saveRecents() {
111
+ const p = join(app.getPath('userData'), 'recent-projects.json');
112
+ writeFileSync(p, JSON.stringify(this.recentProjects.slice(0, 20)), { mode: 0o600 });
113
+ }
114
+
115
+ _touchRecent(projectDir, name) {
116
+ this.recentProjects = this.recentProjects.filter(r => r.dir !== projectDir);
117
+ this.recentProjects.unshift({
118
+ dir: projectDir,
119
+ name: name || basename(projectDir),
120
+ lastOpened: new Date().toISOString(),
121
+ });
122
+ this._saveRecents();
123
+ }
124
+
125
+ async open(projectDir, options = {}) {
126
+ const forbidden = this._rejectIfUnsafe(projectDir);
127
+ if (forbidden) {
128
+ if (options.showDialogs !== false) dialog.showErrorBox('Cannot open this folder', forbidden);
129
+ throw new Error(forbidden);
130
+ }
131
+ const id = this._instanceId(projectDir);
132
+
133
+ if (this.instances.has(id)) {
134
+ const inst = this.instances.get(id);
135
+ if (inst.window && !inst.window.isDestroyed()) {
136
+ inst.window.show();
137
+ inst.window.focus();
138
+ return inst;
139
+ }
140
+ }
141
+
142
+ let port;
143
+ try {
144
+ port = await this._startDaemon(id, projectDir);
145
+ } catch (err) {
146
+ if (options.showDialogs !== false) {
147
+ dialog.showErrorBox('Failed to open project',
148
+ `${err.message}\n\nTry reinstalling Groove from groovedev.ai or rebuild with ./promote-local.sh`);
149
+ }
150
+ throw err;
151
+ }
152
+ const name = basename(projectDir);
153
+ const window = this._createWindow(id, port, projectDir);
154
+
155
+ const inst = { id, port, projectDir, name, daemon: this._getDaemon(id), window };
156
+ this.instances.set(id, inst);
157
+ this._touchRecent(projectDir, name);
158
+ this._updateTrayMenu();
159
+
160
+ return inst;
161
+ }
162
+
163
+ openRemote(localPort, name) {
164
+ const id = `remote-${localPort}`;
165
+
166
+ if (this.instances.has(id)) {
167
+ const inst = this.instances.get(id);
168
+ if (inst.window && !inst.window.isDestroyed()) {
169
+ inst.window.show();
170
+ inst.window.focus();
171
+ return inst;
172
+ }
173
+ }
174
+
175
+ const win = new BrowserWindow({
176
+ width: 1440,
177
+ height: 900,
178
+ minWidth: 900,
179
+ minHeight: 600,
180
+ titleBarStyle: IS_MAC ? 'hiddenInset' : 'default',
181
+ backgroundColor: '#0a0a0a',
182
+ title: `${name} — Groove (Remote)`,
183
+ show: false,
184
+ webPreferences: {
185
+ preload: join(__dirname, 'preload.cjs'),
186
+ contextIsolation: true,
187
+ nodeIntegration: false,
188
+ sandbox: true,
189
+ backgroundThrottling: false,
190
+ },
191
+ });
192
+
193
+ win.webContents.session.setPermissionRequestHandler((wc, permission, callback) => {
194
+ const url = wc.getURL();
195
+ const isLocal = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1');
196
+ if (isLocal && (permission === 'media' || permission === 'microphone' || permission === 'audio-capture')) {
197
+ callback(true);
198
+ return;
199
+ }
200
+ if (!isLocal) { callback(false); return; }
201
+ callback(true);
202
+ });
203
+
204
+ win.webContents.session.setPermissionCheckHandler((wc, permission) => {
205
+ if (!wc) return false;
206
+ const url = wc.getURL();
207
+ const isLocal = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1');
208
+ if (isLocal && (permission === 'media' || permission === 'microphone' || permission === 'audio-capture')) {
209
+ return true;
210
+ }
211
+ return isLocal;
212
+ });
213
+
214
+ const remoteUrl = `http://localhost:${localPort}?instance=${encodeURIComponent(name)}`;
215
+
216
+ const guiErrorHtml = 'data:text/html,' + encodeURIComponent([
217
+ '<!DOCTYPE html><html><head><style>',
218
+ '*{margin:0;padding:0;box-sizing:border-box}',
219
+ 'body{background:#0f1115;color:#e6e6e6;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;gap:16px}',
220
+ '.icon{width:64px;height:64px;border-radius:50%;background:rgba(251,191,36,0.1);display:flex;align-items:center;justify-content:center;margin-bottom:8px}',
221
+ 'h2{font-size:18px;font-weight:600}',
222
+ 'p{font-size:13px;color:#6e7681;max-width:400px;text-align:center;line-height:1.5}',
223
+ '.hint{font-size:12px;color:#505862}',
224
+ 'button{margin-top:8px;padding:10px 24px;border-radius:8px;border:1px solid rgba(51,175,188,0.4);background:rgba(51,175,188,0.1);color:#33afbc;font-size:13px;font-weight:500;cursor:pointer}',
225
+ 'button:hover{background:rgba(51,175,188,0.2)}',
226
+ '</style></head><body>',
227
+ '<div class="icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></div>',
228
+ '<h2>Remote GUI Not Available</h2>',
229
+ '<p>The remote Groove daemon is running but its GUI files are missing. This usually means the remote version needs to be updated.</p>',
230
+ '<p class="hint">Try disconnecting and reconnecting — Groove will automatically update the remote.</p>',
231
+ `<button onclick="location.href='${remoteUrl.replace(/'/g, "\\'")}'">Retry</button>`,
232
+ '<p class="hint" style="margin-top:12px">If retry doesn\'t work, close this window and click the server again in the welcome screen to reconnect.</p>',
233
+ '</body></html>',
234
+ ].join(''));
235
+
236
+ win.webContents.on('did-finish-load', () => {
237
+ const loadedUrl = win.webContents.getURL();
238
+ if (loadedUrl.startsWith('data:')) return;
239
+ win.webContents.executeJavaScript('(function(){ var el = document.querySelector("pre"); return el ? el.textContent : null; })()')
240
+ .then(text => {
241
+ if (!text) return;
242
+ try {
243
+ const json = JSON.parse(text);
244
+ if (json.error) win.webContents.loadURL(guiErrorHtml);
245
+ } catch {}
246
+ })
247
+ .catch(() => {});
248
+ });
249
+
250
+ win.webContents.on('did-fail-load', (_e, code, desc) => {
251
+ if (code === -3) return;
252
+ const failHtml = 'data:text/html,' + encodeURIComponent([
253
+ '<!DOCTYPE html><html><head><style>',
254
+ '*{margin:0;padding:0;box-sizing:border-box}',
255
+ 'body{background:#0f1115;color:#e6e6e6;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;flex-direction:column;gap:16px}',
256
+ 'h2{font-size:18px;font-weight:600}',
257
+ 'p{font-size:13px;color:#6e7681;max-width:400px;text-align:center;line-height:1.5}',
258
+ 'button{margin-top:8px;padding:10px 24px;border-radius:8px;border:1px solid rgba(51,175,188,0.4);background:rgba(51,175,188,0.1);color:#33afbc;font-size:13px;font-weight:500;cursor:pointer}',
259
+ 'button:hover{background:rgba(51,175,188,0.2)}',
260
+ '</style></head><body>',
261
+ '<h2>Connection Failed</h2>',
262
+ `<p>${(desc || 'Could not reach the remote Groove daemon.').replace(/[<>"&]/g, '')}</p>`,
263
+ `<button onclick="location.href='${remoteUrl.replace(/'/g, "\\'")}'">Retry</button>`,
264
+ '</body></html>',
265
+ ].join(''));
266
+ win.webContents.loadURL(failHtml);
267
+ });
268
+
269
+ // Clear HTTP cache before loading remote GUI — prevents stale bundles after npm update
270
+ win.webContents.session.clearCache().then(() => {
271
+ win.loadURL(remoteUrl);
272
+ });
273
+ win.once('ready-to-show', () => win.show());
274
+
275
+ win.webContents.setWindowOpenHandler(({ url }) => {
276
+ try {
277
+ const parsed = new URL(url);
278
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
279
+ shell.openExternal(url);
280
+ }
281
+ } catch {}
282
+ return { action: 'deny' };
283
+ });
284
+
285
+ win.on('close', (e) => {
286
+ if (IS_MAC && !isQuitting) {
287
+ e.preventDefault();
288
+ win.hide();
289
+ this._showHomeIfNeeded(win);
290
+ }
291
+ });
292
+
293
+ win.on('closed', () => {
294
+ this.instances.delete(id);
295
+ this._updateTrayMenu();
296
+ });
297
+
298
+ const inst = { id, port: localPort, projectDir: null, name, daemon: null, window: win, remote: true };
299
+ this.instances.set(id, inst);
300
+ this._closeHomeWindow();
301
+ this._updateTrayMenu();
302
+
303
+ return inst;
304
+ }
305
+
306
+ async close(id) {
307
+ const inst = this.instances.get(id);
308
+ if (!inst) return;
309
+ if (inst.window && !inst.window.isDestroyed()) inst.window.close();
310
+ if (inst.daemon && !inst.daemon.killed) {
311
+ inst.daemon.kill('SIGTERM');
312
+ await new Promise(r => {
313
+ const t = setTimeout(() => {
314
+ try { inst.daemon.kill('SIGKILL'); } catch {}
315
+ r();
316
+ }, 2000);
317
+ inst.daemon.on('exit', () => { clearTimeout(t); r(); });
318
+ });
319
+ }
320
+ this.instances.delete(id);
321
+ this._updateTrayMenu();
322
+ }
323
+
324
+ async shutdownAll() {
325
+ await Promise.all([...this.instances.keys()].map(id => this.close(id)));
326
+ }
327
+
328
+ getAll() {
329
+ return Array.from(this.instances.values());
330
+ }
331
+
332
+ // --- SSH Connections (stored in Electron userData, independent of any daemon) ---
333
+
334
+ _loadSSH() {
335
+ try {
336
+ const connections = JSON.parse(readFileSync(join(app.getPath('userData'), 'ssh-connections.json'), 'utf8'));
337
+ let migrated = false;
338
+ for (const c of connections) {
339
+ if (c.keyPath && !c.sshKeyPath) {
340
+ c.sshKeyPath = c.keyPath;
341
+ delete c.keyPath;
342
+ migrated = true;
343
+ }
344
+ }
345
+ if (migrated) {
346
+ writeFileSync(
347
+ join(app.getPath('userData'), 'ssh-connections.json'),
348
+ JSON.stringify(connections, null, 2),
349
+ { mode: 0o600 },
350
+ );
351
+ }
352
+ return connections;
353
+ } catch { return []; }
354
+ }
355
+
356
+ _saveSSH() {
357
+ writeFileSync(
358
+ join(app.getPath('userData'), 'ssh-connections.json'),
359
+ JSON.stringify(this.sshConnections, null, 2),
360
+ { mode: 0o600 },
361
+ );
362
+ }
363
+
364
+ addSSH(config) {
365
+ const id = createHash('sha256').update(`${config.user}@${config.host}:${config.port || 22}`).digest('hex').slice(0, 8);
366
+ const existing = this.sshConnections.find(c => c.id === id);
367
+ if (existing) {
368
+ Object.assign(existing, config, { id });
369
+ if (existing.keyPath && !existing.sshKeyPath) { existing.sshKeyPath = existing.keyPath; delete existing.keyPath; }
370
+ this._saveSSH();
371
+ return existing;
372
+ }
373
+ const entry = { id, ...config, port: config.port || 22, createdAt: new Date().toISOString() };
374
+ if (entry.keyPath && !entry.sshKeyPath) { entry.sshKeyPath = entry.keyPath; delete entry.keyPath; }
375
+ this.sshConnections.unshift(entry);
376
+ this._saveSSH();
377
+ return entry;
378
+ }
379
+
380
+ removeSSH(id) {
381
+ this.sshConnections = this.sshConnections.filter(c => c.id !== id);
382
+ this._saveSSH();
383
+ }
384
+
385
+ syncSSHFromDaemon(port) {
386
+ fetch(`http://localhost:${port}/api/tunnels`)
387
+ .then(r => r.json())
388
+ .then(tunnels => {
389
+ if (!Array.isArray(tunnels)) return;
390
+ const ids = new Set(this.sshConnections.map(c => c.id));
391
+ let changed = false;
392
+ for (const t of tunnels) {
393
+ if (!ids.has(t.id)) {
394
+ this.sshConnections.push({
395
+ id: t.id, name: t.name, host: t.host, user: t.user,
396
+ port: t.port || 22, sshKeyPath: t.sshKeyPath,
397
+ createdAt: t.createdAt,
398
+ });
399
+ changed = true;
400
+ }
401
+ }
402
+ if (changed) this._saveSSH();
403
+ })
404
+ .catch(() => {});
405
+ }
406
+
407
+ async connectSSH(id) {
408
+ const conn = this.sshConnections.find(c => c.id === id);
409
+ if (!conn) throw new Error('Connection not found');
410
+
411
+ const localPort = await getAvailablePort();
412
+ const knownHostsPath = join(app.getPath('userData'), 'ssh-known-hosts');
413
+ const rawKey = conn.sshKeyPath || conn.keyPath;
414
+ let sshKey = rawKey || null;
415
+ if (sshKey) {
416
+ sshKey = sshKey.replace(/^~(?=[/\\]|$)/, app.getPath('home'));
417
+ if (!existsSync(sshKey)) {
418
+ throw new Error(`SSH key file not found: ${sshKey}`);
419
+ }
420
+ }
421
+ const keyArgs = sshKey ? ['-i', sshKey] : [];
422
+
423
+ const spawnTunnel = () => {
424
+ const sshArgs = [
425
+ '-N', '-L', `${localPort}:localhost:31415`,
426
+ ...keyArgs,
427
+ '-p', String(conn.port || 22),
428
+ '-o', 'StrictHostKeyChecking=accept-new',
429
+ '-o', 'GSSAPIAuthentication=no',
430
+ '-o', `UserKnownHostsFile=${knownHostsPath}`,
431
+ '-o', 'ConnectTimeout=15',
432
+ '-o', 'ServerAliveInterval=30',
433
+ '-o', 'ServerAliveCountMax=3',
434
+ '-o', 'ExitOnForwardFailure=yes',
435
+ '-o', 'BatchMode=yes',
436
+ `${conn.user}@${conn.host}`,
437
+ ];
438
+ const p = spawn('ssh', sshArgs, { stdio: ['ignore', 'pipe', 'pipe'], detached: true, windowsHide: true, shell: process.platform === 'win32' });
439
+ const state = { proc: p, exited: false, exitCode: null, stderr: '' };
440
+ p.stderr.on('data', (chunk) => { state.stderr += chunk.toString(); });
441
+ p.on('exit', (code) => { state.exited = true; state.exitCode = code; });
442
+ return state;
443
+ };
444
+
445
+ const removeStaleHostKey = () => {
446
+ try {
447
+ const hostSpec = (conn.port && conn.port !== 22)
448
+ ? `[${conn.host}]:${conn.port}`
449
+ : conn.host;
450
+ execFileSync('ssh-keygen', ['-R', hostSpec, '-f', knownHostsPath], {
451
+ stdio: 'pipe', timeout: 5000, shell: process.platform === 'win32',
452
+ });
453
+ return true;
454
+ } catch { return false; }
455
+ };
456
+
457
+ const isPortInUse = (port) => new Promise((resolve) => {
458
+ const sock = createConnection({ host: '127.0.0.1', port }, () => {
459
+ sock.destroy();
460
+ resolve(true);
461
+ });
462
+ sock.on('error', () => resolve(false));
463
+ sock.setTimeout(500, () => { sock.destroy(); resolve(false); });
464
+ });
465
+
466
+ const waitForTunnel = async (state) => {
467
+ for (let elapsed = 0; elapsed < 20000; elapsed += 500) {
468
+ await new Promise(r => setTimeout(r, 500));
469
+ if (state.exited) return false;
470
+ if (await isPortInUse(localPort)) return true;
471
+ }
472
+ return false;
473
+ };
474
+
475
+ const enhancePermissionError = (errText) => {
476
+ if (/permission denied/i.test(errText)) {
477
+ if (sshKey) {
478
+ return `${errText} — the SSH key "${rawKey}" was rejected by the server. Verify this is the correct key for ${conn.user}@${conn.host}.`;
479
+ }
480
+ return `${errText} — no SSH key configured for this connection. Edit the connection and add your SSH key, or ensure your SSH agent has the key loaded.`;
481
+ }
482
+ return errText;
483
+ };
484
+
485
+ let tunnel = spawnTunnel();
486
+ let tunnelUp = await waitForTunnel(tunnel);
487
+
488
+ if (!tunnelUp && tunnel.exited) {
489
+ const errText = tunnel.stderr.trim();
490
+ if (/host key verification failed|remote host identification has changed/i.test(errText)) {
491
+ if (removeStaleHostKey()) {
492
+ tunnel = spawnTunnel();
493
+ tunnelUp = await waitForTunnel(tunnel);
494
+ if (!tunnelUp) {
495
+ if (tunnel.exited) {
496
+ const retryErr = tunnel.stderr.trim() || 'unknown SSH error';
497
+ throw new Error(`SSH tunnel failed after clearing stale host key: ${enhancePermissionError(retryErr)}`);
498
+ }
499
+ try { process.kill(tunnel.proc.pid); } catch {}
500
+ throw new Error(`SSH tunnel started but port forward not active${tunnel.stderr.trim() ? ': ' + tunnel.stderr.trim() : ''}`);
501
+ }
502
+ } else {
503
+ throw new Error(`SSH host key verification failed and could not clear stale entry: ${errText}`);
504
+ }
505
+ } else {
506
+ const detail = enhancePermissionError(errText) || `exit code ${tunnel.exitCode}`;
507
+ throw new Error(`SSH tunnel failed to establish: ${detail}`);
508
+ }
509
+ } else if (!tunnelUp) {
510
+ try { process.kill(tunnel.proc.pid); } catch {}
511
+ throw new Error(`SSH tunnel started but port forward not active${tunnel.stderr.trim() ? ': ' + tunnel.stderr.trim() : ''}`);
512
+ }
513
+
514
+ const proc = tunnel.proc;
515
+ proc.unref();
516
+
517
+ const sshExec = (cmd, timeout = 60000) => {
518
+ const escaped = cmd.replace(/'/g, "'\\''");
519
+ return execFileSync('ssh', [
520
+ ...keyArgs, '-p', String(conn.port || 22),
521
+ '-o', 'ConnectTimeout=10', '-o', 'BatchMode=yes',
522
+ '-o', `UserKnownHostsFile=${knownHostsPath}`,
523
+ `${conn.user}@${conn.host}`, `bash -lc '${escaped}'`,
524
+ ], { timeout, stdio: 'pipe', shell: process.platform === 'win32' }).toString().trim();
525
+ };
526
+
527
+ const checkHealth = async (attempts = 4, delay = 2000) => {
528
+ for (let i = 0; i < attempts; i++) {
529
+ try {
530
+ const resp = await fetch(`http://localhost:${localPort}/api/health`, { signal: AbortSignal.timeout(3000) });
531
+ if (resp.ok) return true;
532
+ } catch {}
533
+ if (i < attempts - 1) await new Promise(r => setTimeout(r, delay));
534
+ }
535
+ return false;
536
+ };
537
+
538
+ const emitProgress = (msg) => {
539
+ if (this._homeWindow && !this._homeWindow.isDestroyed()) {
540
+ this._homeWindow.webContents.executeJavaScript(
541
+ `document.getElementById('loading-text').textContent = ${JSON.stringify(msg)}`
542
+ ).catch(() => {});
543
+ }
544
+ };
545
+
546
+ let healthy = await checkHealth();
547
+
548
+ if (!healthy) {
549
+ let grooveInstalled = false;
550
+ try {
551
+ sshExec('groove --version', 10000);
552
+ grooveInstalled = true;
553
+ } catch {}
554
+
555
+ if (!grooveInstalled) {
556
+ emitProgress('Checking remote environment...');
557
+ try {
558
+ const envCheck = sshExec('which node && which npm || echo __NO_NODE__', 20000);
559
+ if (envCheck.includes('__NO_NODE__')) {
560
+ proc.kill();
561
+ throw new Error('Node.js and npm are not installed on the remote server. Install Node.js 20+ first, then retry.');
562
+ }
563
+ } catch (envErr) {
564
+ if (envErr.message.includes('not installed on the remote')) throw envErr;
565
+ proc.kill();
566
+ throw new Error(`Failed to check remote environment: ${envErr.message}`);
567
+ }
568
+
569
+ emitProgress('Installing Groove on remote server...');
570
+ const isRoot = conn.user === 'root';
571
+ const localVer = app.getVersion();
572
+ const pinnedPkg = `groove-dev@${localVer}`;
573
+ const latestPkg = 'groove-dev';
574
+ const installCmd = (pkg) => isRoot ? `npm i -g ${pkg}` : `sudo npm i -g ${pkg}`;
575
+ try {
576
+ sshExec(installCmd(pinnedPkg), 120000);
577
+ } catch (e) {
578
+ emitProgress('Pinned version failed — trying latest...');
579
+ try {
580
+ sshExec(installCmd(latestPkg), 120000);
581
+ } catch (e2) {
582
+ proc.kill();
583
+ throw new Error(`Failed to install Groove on remote server: ${e2.message || 'npm install failed'}`);
584
+ }
585
+ }
586
+ }
587
+
588
+ emitProgress('Starting remote daemon...');
589
+ try { sshExec('groove start -d', 30000); } catch {}
590
+
591
+ await new Promise(r => setTimeout(r, 5000));
592
+ healthy = await checkHealth(3);
593
+ }
594
+
595
+ if (!healthy) {
596
+ proc.kill();
597
+ throw new Error('Remote daemon started but not responding on port 31415 — check firewall settings or try again');
598
+ }
599
+
600
+ const localVersion = app.getVersion();
601
+ try {
602
+ const resp = await fetch(`http://localhost:${localPort}/api/status`, { signal: AbortSignal.timeout(3000) });
603
+ if (resp.ok) {
604
+ const status = await resp.json();
605
+ const remoteVersion = status.version;
606
+ if (remoteVersion && remoteVersion !== localVersion) {
607
+ emitProgress(`Updating remote Groove ${remoteVersion} → ${localVersion}...`);
608
+ const upgradeCmd = (pkg) => conn.user === 'root' ? `npm i -g ${pkg}` : `sudo npm i -g ${pkg}`;
609
+ try {
610
+ sshExec(upgradeCmd(`groove-dev@${localVersion}`), 120000);
611
+ } catch (e) {
612
+ console.error('[ssh] Remote upgrade failed:', e.message);
613
+ emitProgress('Pinned upgrade failed — trying latest...');
614
+ try {
615
+ sshExec(upgradeCmd('groove-dev'), 120000);
616
+ } catch (e2) {
617
+ console.error('[ssh] Unpinned upgrade also failed:', e2.message);
618
+ emitProgress('Remote upgrade failed — running older version');
619
+ }
620
+ }
621
+ try { sshExec('groove stop', 10000); } catch {}
622
+ await new Promise(r => setTimeout(r, 1000));
623
+ try { sshExec('groove start -d', 30000); } catch {}
624
+ await new Promise(r => setTimeout(r, 5000));
625
+ if (!await checkHealth(3)) {
626
+ emitProgress('Remote updated but daemon slow to restart — retrying...');
627
+ await new Promise(r => setTimeout(r, 5000));
628
+ await checkHealth(3);
629
+ }
630
+ }
631
+ }
632
+ } catch {}
633
+
634
+ this._sshTunnels = this._sshTunnels || new Map();
635
+ this._sshTunnels.set(id, proc);
636
+
637
+ conn.lastConnected = new Date().toISOString();
638
+ this._saveSSH();
639
+
640
+ return { localPort, name: conn.name };
641
+ }
642
+
643
+ _startDaemon(id, projectDir) {
644
+ return new Promise((resolve, reject) => {
645
+ const bridgePath = join(__dirname, 'daemon-bridge.js');
646
+ const guiPath = app.isPackaged ? resolveResourcePath('gui') : resolveResourcePath('gui', 'dist');
647
+ const daemonPath = resolveResourcePath('daemon', 'src', 'index.js');
648
+
649
+ const proc = fork(bridgePath, [projectDir], {
650
+ execArgv: ['--max-old-space-size=2048', '--max-semi-space-size=128'],
651
+ stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
652
+ env: {
653
+ ...process.env,
654
+ GROOVE_ELECTRON: '1',
655
+ GROOVE_GUI_PATH: guiPath,
656
+ GROOVE_DAEMON_PATH: daemonPath,
657
+ },
658
+ });
659
+
660
+ this._daemonProcesses.set(id, proc);
661
+
662
+ const timeout = setTimeout(() => reject(new Error('Daemon failed to start within 15 seconds')), 15000);
663
+
664
+ proc.on('message', (msg) => {
665
+ if (msg.type === 'ready') {
666
+ clearTimeout(timeout);
667
+ const token = loadStoredToken();
668
+ if (token) proc.send({ type: 'auth-token', token });
669
+ resolve(msg.port);
670
+ } else if (msg.type === 'error') {
671
+ clearTimeout(timeout);
672
+ reject(new Error(msg.message));
673
+ }
674
+ });
675
+
676
+ proc.stderr.on('data', (data) => process.stderr.write(`[daemon:${id}] ${data}`));
677
+
678
+ proc.on('exit', (code) => {
679
+ clearTimeout(timeout);
680
+ this._daemonProcesses.delete(id);
681
+ const inst = this.instances.get(id);
682
+ if (inst && !isQuitting) {
683
+ if (inst.window && !inst.window.isDestroyed()) {
684
+ inst.window.webContents.send('daemon-crashed', { code });
685
+ }
686
+ }
687
+ });
688
+ });
689
+ }
690
+
691
+ _getDaemon(id) {
692
+ return this._daemonProcesses.get(id) || null;
693
+ }
694
+
695
+ _createWindow(id, port, projectDir) {
696
+ const name = basename(projectDir);
697
+ const win = new BrowserWindow({
698
+ width: 1440,
699
+ height: 900,
700
+ minWidth: 900,
701
+ minHeight: 600,
702
+ titleBarStyle: IS_MAC ? 'hiddenInset' : 'default',
703
+ backgroundColor: '#0a0a0a',
704
+ title: `${name} — Groove`,
705
+ show: false,
706
+ webPreferences: {
707
+ preload: join(__dirname, 'preload.cjs'),
708
+ contextIsolation: true,
709
+ nodeIntegration: false,
710
+ sandbox: true,
711
+ backgroundThrottling: false,
712
+ },
713
+ });
714
+
715
+ win.webContents.session.setPermissionRequestHandler((wc, permission, callback) => {
716
+ const url = wc.getURL();
717
+ const isLocal = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1');
718
+ if (isLocal && (permission === 'media' || permission === 'microphone' || permission === 'audio-capture')) {
719
+ callback(true);
720
+ return;
721
+ }
722
+ if (!isLocal) { callback(false); return; }
723
+ callback(true);
724
+ });
725
+
726
+ win.webContents.session.setPermissionCheckHandler((wc, permission) => {
727
+ if (!wc) return false;
728
+ const url = wc.getURL();
729
+ const isLocal = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1');
730
+ if (isLocal && (permission === 'media' || permission === 'microphone' || permission === 'audio-capture')) {
731
+ return true;
732
+ }
733
+ return isLocal;
734
+ });
735
+
736
+ win.loadURL(`http://localhost:${port}?instance=${encodeURIComponent(name)}`);
737
+ win.once('ready-to-show', () => win.show());
738
+ win.webContents.on('console-message', (event) => {
739
+ const { level, message, lineNumber, sourceId } = event;
740
+ if (level === 'error' || level === 'warning') {
741
+ process.stderr.write(`[renderer:${level}] ${message} (${sourceId}:${lineNumber})\n`);
742
+ }
743
+ });
744
+ win.webContents.on('render-process-gone', (_e, details) => {
745
+ process.stderr.write(`[renderer-gone] ${JSON.stringify(details)}\n`);
746
+ });
747
+
748
+ win.webContents.setWindowOpenHandler(({ url }) => {
749
+ try {
750
+ const parsed = new URL(url);
751
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
752
+ shell.openExternal(url);
753
+ }
754
+ } catch {}
755
+ return { action: 'deny' };
756
+ });
757
+
758
+ win.on('close', (e) => {
759
+ if (IS_MAC && !isQuitting) {
760
+ e.preventDefault();
761
+ win.hide();
762
+ this._showHomeIfNeeded(win);
763
+ }
764
+ });
765
+
766
+ win.on('closed', () => {
767
+ const inst = this.instances.get(id);
768
+ if (inst) inst.window = null;
769
+ });
770
+
771
+ win.on('focus', () => {
772
+ const now = Date.now();
773
+ if (now - _lastSubCheck < 60_000) return;
774
+ _lastSubCheck = now;
775
+ if (loadStoredToken()) checkSubscription();
776
+ });
777
+
778
+ return win;
779
+ }
780
+
781
+ _updateTrayMenu() {
782
+ if (!tray) return;
783
+ const instances = this.getAll();
784
+ const instanceItems = instances.map(inst => ({
785
+ label: inst.name || basename(inst.projectDir),
786
+ click: () => {
787
+ if (inst.window && !inst.window.isDestroyed()) {
788
+ inst.window.show();
789
+ inst.window.focus();
790
+ }
791
+ },
792
+ }));
793
+
794
+ const recentItems = this.recentProjects
795
+ .filter(r => !instances.some(i => i.projectDir === r.dir))
796
+ .slice(0, 5)
797
+ .map(r => ({
798
+ label: r.name || basename(r.dir),
799
+ click: () => this.open(r.dir),
800
+ }));
801
+
802
+ const template = [
803
+ ...instanceItems,
804
+ ...(instanceItems.length > 0 ? [{ type: 'separator' }] : []),
805
+ { label: 'Open Folder...', click: () => this._openFolderDialog() },
806
+ ...(recentItems.length > 0 ? [
807
+ { type: 'separator' },
808
+ { label: 'Recent Projects', enabled: false },
809
+ ...recentItems,
810
+ ] : []),
811
+ { type: 'separator' },
812
+ { label: 'Quit Groove', click: () => { isQuitting = true; app.quit(); } },
813
+ ];
814
+
815
+ tray.setContextMenu(Menu.buildFromTemplate(template));
816
+ }
817
+
818
+ async _openFolderDialog(parentWindow = null) {
819
+ const result = await dialog.showOpenDialog(parentWindow || BrowserWindow.getFocusedWindow(), {
820
+ properties: ['openDirectory'],
821
+ title: 'Open Project Folder',
822
+ });
823
+ if (!result.canceled && result.filePaths.length) {
824
+ await this.open(result.filePaths[0]);
825
+ }
826
+ }
827
+
828
+ _createHomeWindow() {
829
+ if (this._homeWindow && !this._homeWindow.isDestroyed()) {
830
+ this._homeWindow.show();
831
+ this._homeWindow.focus();
832
+ return;
833
+ }
834
+
835
+ const htmlPath = join(app.getPath('userData'), 'welcome.html');
836
+ writeFileSync(htmlPath, getWelcomeHtml(), 'utf8');
837
+
838
+ const win = new BrowserWindow({
839
+ width: 1440,
840
+ height: 900,
841
+ minWidth: 900,
842
+ minHeight: 600,
843
+ resizable: true,
844
+ titleBarStyle: IS_MAC ? 'hiddenInset' : 'default',
845
+ backgroundColor: '#0a0a0a',
846
+ title: 'Groove',
847
+ show: false,
848
+ webPreferences: {
849
+ preload: join(__dirname, 'preload.cjs'),
850
+ contextIsolation: true,
851
+ nodeIntegration: false,
852
+ sandbox: true,
853
+ backgroundThrottling: false,
854
+ },
855
+ });
856
+
857
+ win.loadFile(htmlPath);
858
+ win.once('ready-to-show', () => win.show());
859
+
860
+ win.on('close', (e) => {
861
+ if (IS_MAC && !isQuitting) {
862
+ e.preventDefault();
863
+ win.hide();
864
+ }
865
+ });
866
+
867
+ win.on('closed', () => {
868
+ this._homeWindow = null;
869
+ });
870
+
871
+ this._homeWindow = win;
872
+ }
873
+
874
+ _closeHomeWindow() {
875
+ if (this._homeWindow && !this._homeWindow.isDestroyed()) {
876
+ this._homeWindow.destroy();
877
+ }
878
+ this._homeWindow = null;
879
+ }
880
+
881
+ _showHomeIfNeeded(hiddenWin) {
882
+ if (!IS_MAC || isQuitting) return;
883
+ const hasVisible = [...this.instances.values()].some(
884
+ i => i.window && !i.window.isDestroyed() && i.window.isVisible() && i.window !== hiddenWin
885
+ );
886
+ if (!hasVisible && (!this._homeWindow || this._homeWindow.isDestroyed())) {
887
+ this._createHomeWindow();
888
+ }
889
+ }
890
+ }
891
+
892
+ function getWelcomeHtml() {
893
+ return `<!DOCTYPE html>
894
+ <html lang="en">
895
+ <head>
896
+ <meta charset="utf-8">
897
+ <meta http-equiv="Content-Security-Policy"
898
+ content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:">
899
+ <title>Groove</title>
900
+ <style>
901
+ * { margin: 0; padding: 0; box-sizing: border-box; }
902
+ html, body { height: 100%; }
903
+ body {
904
+ background: radial-gradient(ellipse at 50% 30%, rgba(51,175,188,0.04) 0%, transparent 70%) #0f1115;
905
+ color: #e6e6e6;
906
+ font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
907
+ overflow: hidden; user-select: none;
908
+ display: flex; flex-direction: column;
909
+ }
910
+
911
+ .titlebar {
912
+ -webkit-app-region: drag;
913
+ height: 38px; flex-shrink: 0;
914
+ display: flex; align-items: center; justify-content: center;
915
+ }
916
+ .titlebar-label {
917
+ font-size: 11px; color: #505862; font-weight: 600;
918
+ letter-spacing: 1.5px;
919
+ }
920
+
921
+ .page {
922
+ flex: 1; display: flex; flex-direction: column;
923
+ align-items: center; justify-content: center; overflow-y: auto;
924
+ padding: 24px 0; -webkit-app-region: no-drag;
925
+ }
926
+ .page::-webkit-scrollbar { width: 6px; }
927
+ .page::-webkit-scrollbar-track { background: transparent; }
928
+ .page::-webkit-scrollbar-thumb { background: #2c313a; border-radius: 3px; }
929
+ .page::-webkit-scrollbar-thumb:hover { background: #3e4451; }
930
+ .content {
931
+ max-width: 720px; width: 100%;
932
+ padding: 0 48px;
933
+ margin: auto 0;
934
+ display: flex; flex-direction: column; align-items: center;
935
+ }
936
+
937
+ .hero {
938
+ display: flex; flex-direction: column; align-items: center;
939
+ margin-bottom: 32px;
940
+ }
941
+ .hero-icon {
942
+ width: 80px; height: 80px; border-radius: 50%;
943
+ background: radial-gradient(circle at center, rgba(51,175,188,0.25) 0%, rgba(51,175,188,0.08) 55%, transparent 100%);
944
+ display: flex; align-items: center; justify-content: center;
945
+ position: relative; margin-bottom: 18px;
946
+ }
947
+ .hero-icon::before {
948
+ content: ''; position: absolute; inset: 6px;
949
+ border-radius: 50%; border: 1px solid rgba(51,175,188,0.4);
950
+ box-shadow: 0 0 24px rgba(51,175,188,0.18), inset 0 0 14px rgba(51,175,188,0.1);
951
+ }
952
+ .hero-icon svg { position: relative; color: #33afbc; }
953
+ .hero h1 {
954
+ font-size: 26px; font-weight: 700; letter-spacing: -0.5px;
955
+ color: #e6e6e6; margin-bottom: 6px;
956
+ }
957
+ .hero .sub { font-size: 13px; color: #6e7681; }
958
+
959
+ .error-msg {
960
+ display: none; width: 100%; margin-bottom: 16px;
961
+ padding: 11px 14px; border-radius: 8px;
962
+ background: rgba(251,191,36,0.08); border: 1px solid rgba(251,191,36,0.3);
963
+ color: #fbbf24; font-size: 12px;
964
+ }
965
+ .error-msg.active { display: block; }
966
+
967
+ .actions {
968
+ width: 100%; max-width: 520px;
969
+ display: flex; flex-direction: column; gap: 10px;
970
+ }
971
+
972
+ .action-primary {
973
+ position: relative; display: flex; align-items: center; gap: 16px;
974
+ padding: 18px 20px; border-radius: 12px;
975
+ background: linear-gradient(135deg, rgba(51,175,188,0.18) 0%, rgba(51,175,188,0.08) 100%), #1a2127;
976
+ border: 1px solid rgba(51,175,188,0.55);
977
+ cursor: pointer; -webkit-app-region: no-drag;
978
+ transition: transform 0.15s, background 0.15s, border-color 0.15s;
979
+ font-family: inherit; color: inherit; text-align: left; width: 100%;
980
+ box-shadow: 0 0 0 1px rgba(51,175,188,0.08), 0 8px 24px rgba(0,0,0,0.25), 0 0 20px rgba(51,175,188,0.1);
981
+ }
982
+ .action-primary:hover {
983
+ background: linear-gradient(135deg, rgba(51,175,188,0.25) 0%, rgba(51,175,188,0.12) 100%), #24282f;
984
+ border-color: rgba(51,175,188,0.75);
985
+ transform: translateY(-1px);
986
+ }
987
+ .ap-ic {
988
+ width: 44px; height: 44px; border-radius: 11px;
989
+ background: rgba(51,175,188,0.12); color: #33afbc;
990
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
991
+ }
992
+ .ap-text { flex: 1; min-width: 0; }
993
+ .ap-title { font-size: 14px; font-weight: 600; color: #e6e6e6; }
994
+ .ap-sub { font-size: 11px; color: #6e7681; margin-top: 2px; }
995
+ .ap-tag {
996
+ font-size: 10px; font-weight: 600; color: #6e7681;
997
+ background: #2c313a; padding: 4px 8px; border-radius: 5px;
998
+ letter-spacing: 0.5px; font-family: ui-monospace, 'SF Mono', Monaco, monospace;
999
+ }
1000
+
1001
+ .actions-row {
1002
+ display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
1003
+ }
1004
+ .action-card {
1005
+ display: flex; align-items: center; gap: 12px;
1006
+ padding: 14px 16px; border-radius: 10px;
1007
+ background: #1e2127; border: 1px solid #3e4451;
1008
+ cursor: pointer; -webkit-app-region: no-drag;
1009
+ transition: background 0.15s, border-color 0.15s;
1010
+ font-family: inherit; color: inherit; text-align: left; width: 100%;
1011
+ }
1012
+ .action-card:hover {
1013
+ background: #24282f; border-color: #33afbc;
1014
+ }
1015
+ .ac-ic {
1016
+ width: 34px; height: 34px; border-radius: 9px;
1017
+ background: #2c313a; color: #6e7681;
1018
+ display: flex; align-items: center; justify-content: center; flex-shrink: 0;
1019
+ transition: color 0.15s, background 0.15s;
1020
+ }
1021
+ .action-card:hover .ac-ic { color: #33afbc; background: rgba(51,175,188,0.1); }
1022
+ .ac-text { flex: 1; min-width: 0; }
1023
+ .ac-title { font-size: 13px; font-weight: 600; color: #e6e6e6; }
1024
+ .ac-sub { font-size: 11px; color: #6e7681; margin-top: 2px; }
1025
+
1026
+ .section-title {
1027
+ font-size: 10px; font-weight: 600; text-transform: uppercase;
1028
+ letter-spacing: 1px; color: #6e7681;
1029
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
1030
+ margin-bottom: 8px;
1031
+ }
1032
+
1033
+ .recents-area {
1034
+ width: 100%; max-width: 520px;
1035
+ margin-top: 24px;
1036
+ display: grid; grid-template-columns: 1fr 1fr; gap: 32px;
1037
+ }
1038
+ .list-row {
1039
+ display: flex; align-items: center;
1040
+ padding: 5px 0;
1041
+ cursor: pointer; transition: color 0.12s;
1042
+ -webkit-app-region: no-drag; position: relative;
1043
+ }
1044
+ .list-row:hover .list-name { color: #e6e6e6; }
1045
+ .list-info { flex: 1; min-width: 0; }
1046
+ .list-name {
1047
+ font-size: 13px; font-weight: 500; color: #b0b8c4;
1048
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1049
+ line-height: 1.4; transition: color 0.12s;
1050
+ }
1051
+ .list-delete {
1052
+ opacity: 0; width: 20px; height: 20px; border: none;
1053
+ background: transparent; color: #6e7681; cursor: pointer;
1054
+ display: flex; align-items: center; justify-content: center;
1055
+ border-radius: 4px; flex-shrink: 0;
1056
+ transition: opacity 0.12s, color 0.12s;
1057
+ font-family: inherit; -webkit-app-region: no-drag;
1058
+ }
1059
+ .list-row:hover .list-delete { opacity: 1; }
1060
+ .list-delete:hover { color: #ef4444; }
1061
+
1062
+ .empty-text {
1063
+ font-size: 12px; color: #505862; padding: 8px 10px;
1064
+ }
1065
+
1066
+ .add-link {
1067
+ display: inline-flex; align-items: center; gap: 5px;
1068
+ font-size: 12px; color: #33afbc; cursor: pointer;
1069
+ padding: 6px 10px; border: none; background: none;
1070
+ font-family: inherit; transition: opacity 0.12s;
1071
+ -webkit-app-region: no-drag;
1072
+ }
1073
+ .add-link:hover { opacity: 0.8; }
1074
+
1075
+ .ssh-form-wrap {
1076
+ display: none; position: fixed; left: 0; right: 0; bottom: 48px;
1077
+ background: #1e2127; border-top: 1px solid #3e4451;
1078
+ padding: 20px 36px; z-index: 50;
1079
+ -webkit-app-region: no-drag;
1080
+ }
1081
+ .ssh-form-wrap.active { display: block; }
1082
+ .ssh-form-inner { max-width: 600px; margin: 0 auto; }
1083
+ .ssh-form-head {
1084
+ font-size: 13px; font-weight: 600; color: #e6e6e6;
1085
+ margin-bottom: 16px; display: flex; align-items: center; gap: 10px;
1086
+ }
1087
+ .ssh-form-ic {
1088
+ width: 28px; height: 28px; border-radius: 8px;
1089
+ background: rgba(51,175,188,0.12); color: #33afbc;
1090
+ display: flex; align-items: center; justify-content: center;
1091
+ }
1092
+ .ssh-form-wrap label {
1093
+ display: block; font-size: 11px; color: #6e7681;
1094
+ margin-bottom: 5px; margin-top: 12px; font-weight: 500;
1095
+ letter-spacing: 0.2px;
1096
+ }
1097
+ .ssh-form-wrap label:first-of-type { margin-top: 0; }
1098
+ .ssh-input {
1099
+ width: 100%; padding: 9px 12px; border-radius: 8px;
1100
+ border: 1px solid #3e4451; background: #1a1e25;
1101
+ color: #e6e6e6; font-size: 12px; font-family: inherit;
1102
+ outline: none; transition: border-color 0.12s;
1103
+ }
1104
+ .ssh-input:focus { border-color: #33afbc; }
1105
+ .ssh-input-row { display: flex; gap: 8px; }
1106
+ .ssh-input-row .ssh-input { flex: 1; }
1107
+ .ssh-input-sm { width: 90px; flex: none !important; }
1108
+ .btn-pick-key {
1109
+ padding: 9px 14px; border-radius: 8px;
1110
+ border: 1px solid #3e4451; background: #24282f;
1111
+ color: #6e7681; font-size: 11px; cursor: pointer;
1112
+ transition: all 0.12s; font-family: inherit; white-space: nowrap;
1113
+ -webkit-app-region: no-drag;
1114
+ }
1115
+ .btn-pick-key:hover { border-color: #33afbc; color: #e6e6e6; }
1116
+ .ssh-form-actions { display: flex; gap: 8px; margin-top: 18px; }
1117
+ .btn-save-ssh {
1118
+ flex: 1; padding: 10px; border-radius: 8px; border: none;
1119
+ background: #33afbc; color: #0a0a0a; font-size: 13px; font-weight: 600;
1120
+ cursor: pointer; transition: opacity 0.12s; font-family: inherit;
1121
+ -webkit-app-region: no-drag;
1122
+ }
1123
+ .btn-save-ssh:hover { opacity: 0.9; }
1124
+ .btn-save-ssh:disabled { opacity: 0.4; cursor: default; }
1125
+ .btn-cancel-ssh {
1126
+ padding: 10px 16px; border-radius: 8px;
1127
+ border: 1px solid #3e4451; background: transparent;
1128
+ color: #6e7681; font-size: 13px; cursor: pointer;
1129
+ transition: all 0.12s; font-family: inherit;
1130
+ -webkit-app-region: no-drag;
1131
+ }
1132
+ .btn-cancel-ssh:hover { border-color: #33afbc; color: #e6e6e6; }
1133
+
1134
+ .update-banner {
1135
+ display: none; position: fixed; left: 0; right: 0; bottom: 48px;
1136
+ padding: 14px 36px; z-index: 40;
1137
+ background: rgba(30,33,39,0.97);
1138
+ border-top: 1px solid rgba(51,175,188,0.25);
1139
+ -webkit-app-region: no-drag;
1140
+ }
1141
+ .update-banner.active { display: block; }
1142
+
1143
+ .footer {
1144
+ display: flex; align-items: center; justify-content: center; gap: 16px;
1145
+ padding: 12px 24px 14px;
1146
+ border-top: 1px solid rgba(62,68,81,0.35);
1147
+ flex-shrink: 0; -webkit-app-region: no-drag;
1148
+ }
1149
+ .kbd-hint {
1150
+ display: inline-flex; align-items: center; gap: 6px;
1151
+ font-size: 11px; color: #6e7681;
1152
+ }
1153
+ .kbd-hint kbd {
1154
+ display: inline-block; padding: 2px 6px; border-radius: 4px;
1155
+ background: #24282f; border: 1px solid #3e4451; color: #e6e6e6;
1156
+ font-family: ui-monospace, 'SF Mono', Monaco, monospace;
1157
+ font-size: 10px; font-weight: 500;
1158
+ }
1159
+ .footer-sep { width: 1px; height: 12px; background: #3e4451; }
1160
+ .version { font-size: 11px; color: #505862; font-weight: 500; }
1161
+
1162
+ .loading-full {
1163
+ display: none; position: fixed; inset: 0;
1164
+ background: rgba(26,30,37,0.96); backdrop-filter: blur(8px);
1165
+ flex-direction: column; align-items: center; justify-content: center; gap: 18px;
1166
+ z-index: 100;
1167
+ }
1168
+ .loading-full.active { display: flex; }
1169
+ .spinner {
1170
+ width: 32px; height: 32px; border: 3px solid #2c313a;
1171
+ border-top-color: #33afbc; border-radius: 50%;
1172
+ animation: spin 0.8s linear infinite;
1173
+ }
1174
+ @keyframes spin { to { transform: rotate(360deg); } }
1175
+ .loading-text { font-size: 13px; color: #e6e6e6; font-weight: 500; }
1176
+
1177
+ .update-inner {
1178
+ display: flex; align-items: center; gap: 12px;
1179
+ max-width: 600px; margin: 0 auto;
1180
+ }
1181
+ .update-title { font-size: 13px; font-weight: 600; color: #e6e6e6; }
1182
+ .update-detail { font-size: 11px; color: #6e7681; margin-top: 2px; }
1183
+ .update-info { flex: 1; min-width: 0; }
1184
+ .update-action {
1185
+ display: none; font-size: 11px; font-weight: 600; color: #33afbc;
1186
+ background: rgba(51,175,188,0.15); padding: 6px 14px; border-radius: 6px;
1187
+ white-space: nowrap; cursor: pointer;
1188
+ }
1189
+ .update-progress-bar {
1190
+ margin-top: 10px; height: 3px; border-radius: 2px;
1191
+ background: rgba(51,175,188,0.12); overflow: hidden;
1192
+ max-width: 600px; margin-left: auto; margin-right: auto;
1193
+ }
1194
+ .update-progress-fill {
1195
+ height: 100%; width: 0%; border-radius: 2px;
1196
+ background: #33afbc; transition: width 0.4s ease-out;
1197
+ }
1198
+ </style>
1199
+ </head>
1200
+ <body>
1201
+ <div class="titlebar"><span class="titlebar-label">GROOVE</span></div>
1202
+ <div class="page">
1203
+ <div class="content">
1204
+ <div class="hero">
1205
+ <div class="hero-icon">
1206
+ <svg width="48" height="24" viewBox="0 6 24 12" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 12c-2-2.67-4-4-6-4a4 4 0 1 0 0 8c2 0 4-1.33 6-4Zm0 0c2 2.67 4 4 6 4a4 4 0 0 0 0-8c-2 0-4 1.33-6 4Z"/></svg>
1207
+ </div>
1208
+ <h1>Welcome to Groove</h1>
1209
+ <div class="sub">Your AI coding team, ready in minutes</div>
1210
+ </div>
1211
+
1212
+ <div class="error-msg" id="error"></div>
1213
+
1214
+ <div class="actions">
1215
+ <button class="action-primary" id="open-folder">
1216
+ <div class="ap-ic">
1217
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
1218
+ </div>
1219
+ <div class="ap-text">
1220
+ <div class="ap-title">Open Project</div>
1221
+ <div class="ap-sub">Select a local folder to start a team</div>
1222
+ </div>
1223
+ <div class="ap-tag" id="kbd-open">\u2318O</div>
1224
+ </button>
1225
+
1226
+ <div class="actions-row">
1227
+ <button class="action-card" id="btn-connect-remote">
1228
+ <div class="ac-ic">
1229
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15 15 0 0 1 4 10 15 15 0 0 1-4 10 15 15 0 0 1-4-10 15 15 0 0 1 4-10z"/></svg>
1230
+ </div>
1231
+ <div class="ac-text">
1232
+ <div class="ac-title">Add Remote Server</div>
1233
+ <div class="ac-sub">Save SSH details for later</div>
1234
+ </div>
1235
+ </button>
1236
+ <button class="action-card" id="btn-docs">
1237
+ <div class="ac-ic">
1238
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>
1239
+ </div>
1240
+ <div class="ac-text">
1241
+ <div class="ac-title">Read the Docs</div>
1242
+ <div class="ac-sub">Learn how teams work</div>
1243
+ </div>
1244
+ </button>
1245
+ </div>
1246
+ </div>
1247
+
1248
+ <div class="recents-area">
1249
+ <div id="recents-section" style="display:none">
1250
+ <div class="section-title">Recent Projects</div>
1251
+ <div id="recents"></div>
1252
+ </div>
1253
+
1254
+ <div id="ssh-section">
1255
+ <div class="section-title">SSH Connections</div>
1256
+ <div id="ssh-list"></div>
1257
+ <div class="ssh-hint" style="font-size:11px;color:#505862;padding:4px 10px;">Open a project, then connect from Settings</div>
1258
+ <button class="add-link" id="btn-add-ssh">
1259
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
1260
+ Add Server
1261
+ </button>
1262
+ </div>
1263
+ </div>
1264
+
1265
+ <div id="empty-state" style="display:none">
1266
+ <div class="empty-text">No recent activity</div>
1267
+ </div>
1268
+ </div>
1269
+ </div>
1270
+
1271
+ <div class="ssh-form-wrap" id="ssh-form">
1272
+ <div class="ssh-form-inner">
1273
+ <div class="ssh-form-head">
1274
+ <div class="ssh-form-ic">
1275
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><circle cx="6" cy="6" r="1"/><circle cx="6" cy="18" r="1"/></svg>
1276
+ </div>
1277
+ New SSH Connection
1278
+ </div>
1279
+ <label>Name</label>
1280
+ <input class="ssh-input" id="ssh-name" placeholder="My Server" autocomplete="off">
1281
+ <label>Host</label>
1282
+ <div class="ssh-input-row">
1283
+ <input class="ssh-input" id="ssh-host" placeholder="192.168.1.100">
1284
+ <input class="ssh-input ssh-input-sm" id="ssh-port" placeholder="Port" value="22">
1285
+ </div>
1286
+ <label>Username</label>
1287
+ <input class="ssh-input" id="ssh-user" placeholder="ubuntu">
1288
+ <label>SSH Key (optional)</label>
1289
+ <div class="ssh-input-row">
1290
+ <input class="ssh-input" id="ssh-key" placeholder="~/.ssh/id_rsa" readonly>
1291
+ <button class="btn-pick-key" id="btn-pick-key">Browse</button>
1292
+ </div>
1293
+ <div class="ssh-form-actions">
1294
+ <button class="btn-cancel-ssh" id="btn-cancel-ssh">Cancel</button>
1295
+ <button class="btn-save-ssh" id="btn-save-ssh">Save</button>
1296
+ </div>
1297
+ </div>
1298
+ </div>
1299
+
1300
+ <div class="update-banner" id="update-btn">
1301
+ <div class="update-inner">
1302
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#33afbc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m16 12-4-4-4 4"/><path d="M12 16V8"/></svg>
1303
+ <div class="update-info">
1304
+ <div class="update-title" id="update-title">Update Available</div>
1305
+ <div class="update-detail" id="update-detail">Downloading...</div>
1306
+ </div>
1307
+ <div class="update-action" id="update-action">Update &amp; Restart</div>
1308
+ </div>
1309
+ <div class="update-progress-bar" id="update-progress-bar">
1310
+ <div class="update-progress-fill" id="update-progress-fill"></div>
1311
+ </div>
1312
+ </div>
1313
+
1314
+ <div class="footer">
1315
+ <span class="kbd-hint"><kbd id="kbd-footer">\u2318O</kbd> Open Project</span>
1316
+ <span class="footer-sep"></span>
1317
+ <span class="version" id="version"></span>
1318
+ </div>
1319
+ <div class="loading-full" id="loading">
1320
+ <div class="spinner"></div>
1321
+ <div class="loading-text" id="loading-text">Starting Groove...</div>
1322
+ </div>
1323
+ <script>
1324
+ (function() {
1325
+ var X_IC = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
1326
+ ' stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg>';
1327
+
1328
+ function esc(s) {
1329
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
1330
+ .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
1331
+ }
1332
+
1333
+ function shortenPath(p) {
1334
+ var m = p.match(/^(\\/Users\\/[^/]+|\\/home\\/[^/]+)/);
1335
+ return m ? '~' + p.slice(m[0].length) : p;
1336
+ }
1337
+
1338
+ function setLoading(on, text) {
1339
+ document.getElementById('loading').className = on ? 'loading-full active' : 'loading-full';
1340
+ if (text) document.getElementById('loading-text').textContent = text;
1341
+ }
1342
+
1343
+ function showError(msg) {
1344
+ var el = document.getElementById('error');
1345
+ el.textContent = msg;
1346
+ el.className = 'error-msg active';
1347
+ }
1348
+
1349
+ function hideError() {
1350
+ document.getElementById('error').className = 'error-msg';
1351
+ }
1352
+
1353
+ function openProject(dir) {
1354
+ setLoading(true, 'Opening ' + dir.split(/[\\/]/).pop() + '...');
1355
+ hideError();
1356
+ window.groove.home.openRecent(dir).catch(function(err) {
1357
+ setLoading(false);
1358
+ showError(err.message || 'Failed to open project');
1359
+ });
1360
+ }
1361
+
1362
+ if (window.groove.platform !== 'darwin') {
1363
+ var k1 = document.getElementById('kbd-open');
1364
+ var k2 = document.getElementById('kbd-footer');
1365
+ if (k1) k1.textContent = 'Ctrl+O';
1366
+ if (k2) k2.textContent = 'Ctrl+O';
1367
+ }
1368
+
1369
+ document.getElementById('open-folder').addEventListener('click', function() {
1370
+ hideError();
1371
+ window.groove.home.openFolder().then(function(dir) {
1372
+ if (dir) openProject(dir);
1373
+ }).catch(function(err) {
1374
+ showError(err.message || 'Failed to open folder');
1375
+ });
1376
+ });
1377
+
1378
+ document.getElementById('btn-docs').addEventListener('click', function() {
1379
+ if (window.groove.openExternal) {
1380
+ window.groove.openExternal('https://docs.groovedev.ai');
1381
+ }
1382
+ });
1383
+
1384
+ window.groove.getVersion().then(function(v) {
1385
+ document.getElementById('version').textContent = 'v' + v;
1386
+ }).catch(function() {});
1387
+
1388
+ if (window.groove.update) {
1389
+ var updateBtn = document.getElementById('update-btn');
1390
+ var updateTitle = document.getElementById('update-title');
1391
+ var updateDetail = document.getElementById('update-detail');
1392
+ var updateAction = document.getElementById('update-action');
1393
+ var progressFill = document.getElementById('update-progress-fill');
1394
+ var progressBar = document.getElementById('update-progress-bar');
1395
+
1396
+ if (window.groove.update.onUpdateProgress) {
1397
+ window.groove.update.onUpdateProgress(function(data) {
1398
+ updateBtn.classList.add('active');
1399
+ updateDetail.textContent = 'Downloading\u2026 ' + (data.percent || 0) + '%';
1400
+ progressFill.style.width = (data.percent || 0) + '%';
1401
+ });
1402
+ }
1403
+ window.groove.update.onUpdateDownloaded(function(data) {
1404
+ updateBtn.classList.add('active');
1405
+ updateTitle.textContent = 'v' + data.version + ' Ready';
1406
+ updateDetail.textContent = 'Restart to apply the update';
1407
+ progressBar.style.display = 'none';
1408
+ updateAction.style.display = 'block';
1409
+ updateAction.onclick = function(e) {
1410
+ e.stopPropagation();
1411
+ window.groove.update.installUpdate();
1412
+ };
1413
+ updateBtn.onclick = function() { window.groove.update.installUpdate(); };
1414
+ });
1415
+ }
1416
+
1417
+ // --- Recents ---
1418
+ var recentsData = [];
1419
+ var recentsEl = document.getElementById('recents');
1420
+ var recentsSec = document.getElementById('recents-section');
1421
+ var emptyEl = document.getElementById('empty-state');
1422
+
1423
+ function renderRecents(recents) {
1424
+ recentsData = recents || [];
1425
+ if (!recentsData.length) {
1426
+ recentsSec.style.display = 'none';
1427
+ checkEmpty();
1428
+ return;
1429
+ }
1430
+ recentsSec.style.display = '';
1431
+ var items = recentsData.slice(0, 3);
1432
+ recentsEl.innerHTML = items.map(function(r) {
1433
+ return '<div class="list-row" data-dir="' + esc(r.dir) + '" title="' + esc(r.dir) + '">' +
1434
+ '<div class="list-info">' +
1435
+ '<div class="list-name">' + esc(r.name || r.dir.split(/[\\/]/).pop()) + '</div>' +
1436
+ '</div>' +
1437
+ '<button class="list-delete" data-del-dir="' + esc(r.dir) + '" title="Remove from recents">' + X_IC + '</button>' +
1438
+ '</div>';
1439
+ }).join('');
1440
+ recentsEl.querySelectorAll('.list-row').forEach(function(el) {
1441
+ el.addEventListener('click', function(e) {
1442
+ if (e.target.closest('.list-delete')) return;
1443
+ openProject(el.getAttribute('data-dir'));
1444
+ });
1445
+ });
1446
+ recentsEl.querySelectorAll('.list-delete').forEach(function(btn) {
1447
+ btn.addEventListener('click', function(e) {
1448
+ e.stopPropagation();
1449
+ var dir = btn.getAttribute('data-del-dir');
1450
+ window.groove.home.removeRecent(dir).then(function(updated) {
1451
+ renderRecents(updated);
1452
+ }).catch(function() {
1453
+ recentsData = recentsData.filter(function(r) { return r.dir !== dir; });
1454
+ renderRecents(recentsData);
1455
+ });
1456
+ });
1457
+ });
1458
+ checkEmpty();
1459
+ }
1460
+
1461
+ window.groove.home.getRecents().then(renderRecents).catch(function(err) {
1462
+ showError('Failed to load recent projects: ' + err.message);
1463
+ });
1464
+
1465
+ // --- SSH ---
1466
+ var sshData = [];
1467
+ var sshListEl = document.getElementById('ssh-list');
1468
+ var sshFormEl = document.getElementById('ssh-form');
1469
+ var sshSection = document.getElementById('ssh-section');
1470
+
1471
+ function checkEmpty() {
1472
+ var hasRecents = recentsData.length > 0;
1473
+ var hasSSH = sshData.length > 0;
1474
+ emptyEl.style.display = (!hasRecents && !hasSSH) ? '' : 'none';
1475
+ }
1476
+
1477
+ function renderSSH(connections) {
1478
+ sshData = connections || [];
1479
+ if (!sshData.length) {
1480
+ sshListEl.innerHTML = '';
1481
+ checkEmpty();
1482
+ return;
1483
+ }
1484
+ sshListEl.innerHTML = sshData.slice(0, 3).map(function(c) {
1485
+ return '<div class="list-row" data-id="' + esc(c.id) + '" style="cursor:default">' +
1486
+ '<div class="list-info">' +
1487
+ '<div class="list-name">' + esc(c.name || c.host) + '</div>' +
1488
+ '</div>' +
1489
+ '<button class="list-delete" data-del-id="' + esc(c.id) + '" title="Remove server">' + X_IC + '</button>' +
1490
+ '</div>';
1491
+ }).join('');
1492
+ sshListEl.querySelectorAll('.list-delete').forEach(function(btn) {
1493
+ btn.addEventListener('click', function(e) {
1494
+ e.stopPropagation();
1495
+ var id = btn.getAttribute('data-del-id');
1496
+ window.groove.home.removeSSH(id).then(function() {
1497
+ window.groove.home.getSSH().then(renderSSH).catch(function() { renderSSH([]); });
1498
+ }).catch(function() {});
1499
+ });
1500
+ });
1501
+ checkEmpty();
1502
+ }
1503
+
1504
+ window.groove.home.getSSH().then(renderSSH).catch(function() { renderSSH([]); });
1505
+
1506
+ function openSSHForm() {
1507
+ sshFormEl.classList.add('active');
1508
+ setTimeout(function() {
1509
+ document.getElementById('ssh-name').focus();
1510
+ }, 40);
1511
+ }
1512
+
1513
+ document.getElementById('btn-add-ssh').addEventListener('click', openSSHForm);
1514
+ document.getElementById('btn-connect-remote').addEventListener('click', openSSHForm);
1515
+
1516
+ document.getElementById('btn-cancel-ssh').addEventListener('click', function() {
1517
+ sshFormEl.classList.remove('active');
1518
+ });
1519
+ document.getElementById('btn-pick-key').addEventListener('click', function() {
1520
+ window.groove.home.pickKeyFile().then(function(p) {
1521
+ if (p) document.getElementById('ssh-key').value = p;
1522
+ }).catch(function() {});
1523
+ });
1524
+ document.getElementById('btn-save-ssh').addEventListener('click', function() {
1525
+ var host = document.getElementById('ssh-host').value.trim();
1526
+ var user = document.getElementById('ssh-user').value.trim() || 'root';
1527
+ if (!host) { showError('Host is required'); return; }
1528
+ var config = {
1529
+ name: document.getElementById('ssh-name').value.trim() || host,
1530
+ host: host,
1531
+ port: parseInt(document.getElementById('ssh-port').value) || 22,
1532
+ user: user,
1533
+ sshKeyPath: document.getElementById('ssh-key').value.trim() || undefined
1534
+ };
1535
+ document.getElementById('btn-save-ssh').disabled = true;
1536
+ window.groove.home.addSSH(config).then(function() {
1537
+ sshFormEl.classList.remove('active');
1538
+ document.getElementById('ssh-name').value = '';
1539
+ document.getElementById('ssh-host').value = '';
1540
+ document.getElementById('ssh-port').value = '22';
1541
+ document.getElementById('ssh-user').value = '';
1542
+ document.getElementById('ssh-key').value = '';
1543
+ document.getElementById('btn-save-ssh').disabled = false;
1544
+ window.groove.home.getSSH().then(renderSSH);
1545
+ }).catch(function(err) {
1546
+ document.getElementById('btn-save-ssh').disabled = false;
1547
+ showError(err.message || 'Failed to save');
1548
+ });
1549
+ });
1550
+
1551
+ document.addEventListener('keydown', function(e) {
1552
+ var mod = e.metaKey || e.ctrlKey;
1553
+ if (mod && (e.key === 'o' || e.key === 'O')) {
1554
+ e.preventDefault();
1555
+ document.getElementById('open-folder').click();
1556
+ }
1557
+ });
1558
+ })();
1559
+ </script>
1560
+ </body>
1561
+ </html>`;
1562
+ }
1563
+
1564
+ // --- Instance lookup helper ---
1565
+
1566
+ function getInstanceForEvent(event) {
1567
+ const win = BrowserWindow.fromWebContents(event.sender);
1568
+ if (!win || !workspaces) return null;
1569
+ for (const inst of workspaces.instances.values()) {
1570
+ if (inst.window === win) return inst;
1571
+ }
1572
+ return null;
1573
+ }
1574
+
1575
+ function broadcastToAllWindows(channel, data) {
1576
+ if (!workspaces) return;
1577
+ for (const inst of workspaces.instances.values()) {
1578
+ if (inst.window && !inst.window.isDestroyed()) {
1579
+ inst.window.webContents.send(channel, data);
1580
+ }
1581
+ }
1582
+ if (workspaces._homeWindow && !workspaces._homeWindow.isDestroyed()) {
1583
+ workspaces._homeWindow.webContents.send(channel, data);
1584
+ }
1585
+ }
1586
+
1587
+ // --- IPC Handlers ---
1588
+
1589
+ ipcMain.on('app-quit', () => {
1590
+ isQuitting = true;
1591
+ app.quit();
1592
+ });
1593
+
1594
+ ipcMain.handle('get-version', () => app.getVersion());
1595
+ ipcMain.handle('install-update', async () => {
1596
+ isQuitting = true;
1597
+
1598
+ if (workspaces) {
1599
+ for (const proc of workspaces._daemonProcesses.values()) {
1600
+ try { proc.disconnect(); } catch {}
1601
+ try { proc.kill('SIGKILL'); } catch {}
1602
+ }
1603
+ workspaces._daemonProcesses.clear();
1604
+ }
1605
+
1606
+ if (IS_MAC) {
1607
+ const downloadedFile = autoUpdater.downloadedUpdateHelper?.file;
1608
+ if (!downloadedFile || !existsSync(downloadedFile)) {
1609
+ console.error('[updater] Downloaded file not found, falling back');
1610
+ autoUpdater.quitAndInstall();
1611
+ return;
1612
+ }
1613
+
1614
+ try {
1615
+ const appBundlePath = resolve(dirname(process.execPath), '..', '..');
1616
+ if (!appBundlePath.endsWith('.app')) throw new Error('Cannot resolve .app bundle path');
1617
+
1618
+ const targetVersion = autoUpdater.downloadedUpdateHelper?.versionInfo?.version || 'unknown';
1619
+ writeFileSync(join(app.getPath('userData'), 'update-pending.json'), JSON.stringify({ version: targetVersion, at: Date.now() }), { mode: 0o600 });
1620
+
1621
+ const extractDir = join(app.getPath('temp'), `groove-update-${Date.now()}`);
1622
+ execFileSync('/usr/bin/ditto', ['-xk', downloadedFile, extractDir], { timeout: 60000 });
1623
+
1624
+ const entries = readdirSync(extractDir);
1625
+ const newAppName = entries.find(e => e.endsWith('.app'));
1626
+ if (!newAppName) throw new Error('No .app found in update zip');
1627
+
1628
+ const newAppPath = join(extractDir, newAppName);
1629
+ const backupPath = appBundlePath + '.bak';
1630
+
1631
+ try { rmSync(backupPath, { recursive: true, force: true }); } catch {}
1632
+
1633
+ renameSync(appBundlePath, backupPath);
1634
+ try {
1635
+ renameSync(newAppPath, appBundlePath);
1636
+ } catch (moveErr) {
1637
+ try { renameSync(backupPath, appBundlePath); } catch (rollbackErr) {
1638
+ console.error('[updater] Rollback also failed:', rollbackErr.message);
1639
+ }
1640
+ throw moveErr;
1641
+ }
1642
+
1643
+ try { rmSync(backupPath, { recursive: true, force: true }); } catch {}
1644
+ try { rmSync(extractDir, { recursive: true, force: true }); } catch {}
1645
+
1646
+ spawn('/bin/sh', ['-c', 'sleep 1 && open "$1"', '--', appBundlePath], {
1647
+ detached: true, stdio: 'ignore'
1648
+ }).unref();
1649
+
1650
+ app.exit(0);
1651
+ } catch (err) {
1652
+ console.error('[updater] Manual update failed:', err.message);
1653
+ autoUpdater.quitAndInstall();
1654
+ }
1655
+ } else {
1656
+ autoUpdater.quitAndInstall();
1657
+ }
1658
+ });
1659
+
1660
+ ipcMain.handle('check-for-update', async () => {
1661
+ if (!app.isPackaged) return { updateAvailable: false };
1662
+ try {
1663
+ const result = await autoUpdater.checkForUpdates();
1664
+ return { updateAvailable: !!result?.updateInfo };
1665
+ } catch { return { updateAvailable: false }; }
1666
+ });
1667
+
1668
+ autoUpdater.on('update-available', (info) => broadcastToAllWindows('update-available', { version: info.version }));
1669
+ autoUpdater.on('download-progress', (info) => broadcastToAllWindows('update-progress', { percent: Math.round(info.percent) }));
1670
+ autoUpdater.on('update-downloaded', (info) => broadcastToAllWindows('update-downloaded', { version: info.version }));
1671
+ autoUpdater.on('error', (err) => console.error('[auto-updater]', err.message));
1672
+
1673
+ ipcMain.handle('get-instance-info', (event) => {
1674
+ const inst = getInstanceForEvent(event);
1675
+ if (!inst) return null;
1676
+ return { id: inst.id, name: inst.name, projectDir: inst.projectDir, port: inst.port };
1677
+ });
1678
+
1679
+ ipcMain.handle('open-folder', async (event) => {
1680
+ const win = BrowserWindow.fromWebContents(event.sender);
1681
+ await workspaces?._openFolderDialog(win);
1682
+ });
1683
+
1684
+ ipcMain.handle('subscription-check', async (event) => {
1685
+ await checkSubscription();
1686
+ const inst = getInstanceForEvent(event);
1687
+ if (!inst) return null;
1688
+ try {
1689
+ const resp = await fetch(`http://localhost:${inst.port}/api/subscription/status`);
1690
+ return await resp.json();
1691
+ } catch { return null; }
1692
+ });
1693
+
1694
+ ipcMain.handle('open-external', (_event, url) => {
1695
+ try {
1696
+ const parsed = new URL(url);
1697
+ if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return;
1698
+ const allowed = ['groovedev.ai', 'studio.groovedev.ai', 'github.com', 'checkout.stripe.com', 'billing.stripe.com', 'appleid.apple.com', 'localhost'];
1699
+ if (!allowed.some(d => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) return;
1700
+ return shell.openExternal(url);
1701
+ } catch {
1702
+ return;
1703
+ }
1704
+ });
1705
+
1706
+ ipcMain.handle('select-folder', async (event, options = {}) => {
1707
+ const win = BrowserWindow.fromWebContents(event.sender);
1708
+ if (!win) return null;
1709
+ let defaultPath = app.getPath('home');
1710
+ if (options.defaultPath && typeof options.defaultPath === 'string') {
1711
+ const resolved = resolve(options.defaultPath);
1712
+ if (!resolved.includes('..')) {
1713
+ defaultPath = resolved;
1714
+ }
1715
+ }
1716
+ const result = await dialog.showOpenDialog(win, {
1717
+ title: options.title || 'Select Folder',
1718
+ defaultPath,
1719
+ properties: ['openDirectory', 'createDirectory'],
1720
+ });
1721
+ if (result.canceled || !result.filePaths.length) return null;
1722
+ return result.filePaths[0];
1723
+ });
1724
+
1725
+ ipcMain.handle('set-project-dir', async (event, dir) => {
1726
+ if (!dir || typeof dir !== 'string') return { error: 'dir required' };
1727
+ const inst = getInstanceForEvent(event);
1728
+ if (!inst) return { error: 'no instance' };
1729
+ try {
1730
+ const res = await fetch(`http://localhost:${inst.port}/api/project-dir`, {
1731
+ method: 'POST',
1732
+ headers: { 'Content-Type': 'application/json' },
1733
+ body: JSON.stringify({ dir }),
1734
+ });
1735
+ return await res.json();
1736
+ } catch (err) {
1737
+ return { error: err.message };
1738
+ }
1739
+ });
1740
+
1741
+ // --- Remote window IPC ---
1742
+
1743
+ ipcMain.handle('open-remote-window', (_event, port, name) => {
1744
+ if (!workspaces || !port || !name) return { error: 'Invalid params' };
1745
+ try {
1746
+ workspaces.openRemote(Number(port), String(name));
1747
+ return { ok: true };
1748
+ } catch (err) {
1749
+ return { error: err.message };
1750
+ }
1751
+ });
1752
+
1753
+ ipcMain.handle('close-remote-window', (event) => {
1754
+ const win = BrowserWindow.fromWebContents(event.sender);
1755
+ if (win && !win.isDestroyed()) win.close();
1756
+ });
1757
+
1758
+ ipcMain.handle('close-remote-by-port', (_event, port) => {
1759
+ if (!workspaces || !port) return;
1760
+ const id = `remote-${port}`;
1761
+ const inst = workspaces.instances.get(id);
1762
+ if (inst?.window && !inst.window.isDestroyed()) inst.window.close();
1763
+ });
1764
+
1765
+ // --- Home window IPC ---
1766
+
1767
+ ipcMain.handle('home-get-recents', () => {
1768
+ return workspaces?.recentProjects || [];
1769
+ });
1770
+
1771
+ ipcMain.handle('home-open-recent', async (_event, dir) => {
1772
+ if (!dir || typeof dir !== 'string') throw new Error('Invalid directory');
1773
+ const forbidden = workspaces._rejectIfUnsafe(dir);
1774
+ if (forbidden) throw new Error(forbidden);
1775
+
1776
+ const id = workspaces._instanceId(dir);
1777
+
1778
+ if (workspaces.instances.has(id)) {
1779
+ const existing = workspaces.instances.get(id);
1780
+ if (existing.window && !existing.window.isDestroyed()) {
1781
+ existing.window.show();
1782
+ existing.window.focus();
1783
+ workspaces._closeHomeWindow();
1784
+ return { ok: true };
1785
+ }
1786
+ }
1787
+
1788
+ let port;
1789
+ try {
1790
+ port = await workspaces._startDaemon(id, dir);
1791
+ } catch (err) {
1792
+ dialog.showErrorBox('Failed to open project',
1793
+ `${err.message}\n\nTry reinstalling Groove from groovedev.ai or rebuild with ./promote-local.sh`);
1794
+ return { error: err.message };
1795
+ }
1796
+
1797
+ setTimeout(() => workspaces.syncSSHFromDaemon(port), 3000);
1798
+
1799
+ const name = basename(dir);
1800
+ const win = workspaces._homeWindow;
1801
+ if (!win || win.isDestroyed()) throw new Error('Window was closed');
1802
+
1803
+ win.setTitle(`${name} — Groove`);
1804
+
1805
+ win.webContents.setWindowOpenHandler(({ url }) => {
1806
+ try {
1807
+ const parsed = new URL(url);
1808
+ if (parsed.protocol === 'https:' || parsed.protocol === 'http:') {
1809
+ shell.openExternal(url);
1810
+ }
1811
+ } catch {}
1812
+ return { action: 'deny' };
1813
+ });
1814
+
1815
+ win.webContents.removeAllListeners('console-message');
1816
+ win.webContents.removeAllListeners('render-process-gone');
1817
+ win.webContents.on('console-message', (event) => {
1818
+ const { level, message, lineNumber, sourceId } = event;
1819
+ if (level === 'error' || level === 'warning') {
1820
+ process.stderr.write(`[renderer:${level}] ${message} (${sourceId}:${lineNumber})\n`);
1821
+ }
1822
+ });
1823
+ win.webContents.on('render-process-gone', (_e, details) => {
1824
+ process.stderr.write(`[renderer-gone] ${JSON.stringify(details)}\n`);
1825
+ });
1826
+
1827
+ win.removeAllListeners('close');
1828
+ win.removeAllListeners('closed');
1829
+ win.on('close', (e) => {
1830
+ if (IS_MAC && !isQuitting) {
1831
+ e.preventDefault();
1832
+ win.hide();
1833
+ workspaces._showHomeIfNeeded(win);
1834
+ }
1835
+ });
1836
+ win.on('closed', () => {
1837
+ const inst = workspaces.instances.get(id);
1838
+ if (inst) inst.window = null;
1839
+ });
1840
+ win.on('focus', () => {
1841
+ const now = Date.now();
1842
+ if (now - _lastSubCheck < 60_000) return;
1843
+ _lastSubCheck = now;
1844
+ if (loadStoredToken()) checkSubscription();
1845
+ });
1846
+
1847
+ const inst = { id, port, projectDir: dir, name, daemon: workspaces._getDaemon(id), window: win };
1848
+ workspaces.instances.set(id, inst);
1849
+ workspaces._touchRecent(dir, name);
1850
+ workspaces._homeWindow = null;
1851
+ workspaces._updateTrayMenu();
1852
+
1853
+ win.webContents.session.setPermissionRequestHandler((wc, permission, callback) => {
1854
+ const url = wc.getURL();
1855
+ const isLocal = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1');
1856
+ if (isLocal && (permission === 'media' || permission === 'microphone' || permission === 'audio-capture')) {
1857
+ callback(true);
1858
+ return;
1859
+ }
1860
+ if (!isLocal) { callback(false); return; }
1861
+ callback(true);
1862
+ });
1863
+
1864
+ win.webContents.session.setPermissionCheckHandler((wc, permission) => {
1865
+ if (!wc) return false;
1866
+ const url = wc.getURL();
1867
+ const isLocal = url.startsWith('http://localhost') || url.startsWith('http://127.0.0.1');
1868
+ if (isLocal && (permission === 'media' || permission === 'microphone' || permission === 'audio-capture')) {
1869
+ return true;
1870
+ }
1871
+ return isLocal;
1872
+ });
1873
+
1874
+ win.loadURL(`http://localhost:${port}?instance=${encodeURIComponent(name)}`);
1875
+ return { ok: true };
1876
+ });
1877
+
1878
+ ipcMain.handle('home-open-folder', async () => {
1879
+ const parentWin = workspaces?._homeWindow && !workspaces._homeWindow.isDestroyed()
1880
+ ? workspaces._homeWindow : null;
1881
+ const result = await dialog.showOpenDialog(parentWin, {
1882
+ properties: ['openDirectory'],
1883
+ title: 'Open Project Folder',
1884
+ });
1885
+ if (result.canceled || !result.filePaths.length) return null;
1886
+ return result.filePaths[0];
1887
+ });
1888
+
1889
+ // --- Home SSH IPC ---
1890
+
1891
+ ipcMain.handle('home-get-ssh', () => {
1892
+ return workspaces?.sshConnections || [];
1893
+ });
1894
+
1895
+ ipcMain.handle('home-add-ssh', (_event, config) => {
1896
+ if (!workspaces || !config?.host || !config?.user) return { error: 'Invalid config' };
1897
+ const entry = workspaces.addSSH(config);
1898
+ return entry;
1899
+ });
1900
+
1901
+ ipcMain.handle('home-remove-recent', (_event, dir) => {
1902
+ workspaces.recentProjects = workspaces.recentProjects.filter(r => r.dir !== dir);
1903
+ workspaces._saveRecents();
1904
+ return workspaces.recentProjects;
1905
+ });
1906
+
1907
+ ipcMain.handle('home-remove-ssh', (_event, id) => {
1908
+ if (!workspaces || !id) return { error: 'Invalid id' };
1909
+ workspaces.removeSSH(id);
1910
+ return { ok: true };
1911
+ });
1912
+
1913
+ ipcMain.handle('home-connect-ssh', async (_event, id) => {
1914
+ if (!workspaces || !id) throw new Error('Invalid id');
1915
+ const { localPort, name } = await workspaces.connectSSH(id);
1916
+ return { ok: true, localPort, name };
1917
+ });
1918
+
1919
+ ipcMain.handle('home-pick-key', async () => {
1920
+ const parentWin = workspaces?._homeWindow && !workspaces._homeWindow.isDestroyed()
1921
+ ? workspaces._homeWindow : null;
1922
+ const result = await dialog.showOpenDialog(parentWin, {
1923
+ title: 'Select SSH Key',
1924
+ defaultPath: join(app.getPath('home'), '.ssh'),
1925
+ properties: ['openFile', 'showHiddenFiles'],
1926
+ filters: [{ name: 'All Files', extensions: ['*'] }],
1927
+ });
1928
+ if (result.canceled || !result.filePaths.length) return null;
1929
+ return result.filePaths[0];
1930
+ });
1931
+
1932
+ ipcMain.handle('home-get-cached-sub', async () => {
1933
+ // Primary source of truth: the daemon's subscription cache, written by the
1934
+ // daemon's authenticated poll of the backend. When a project is open we
1935
+ // query it live; otherwise we read the last value it wrote to disk.
1936
+ //
1937
+ // Splash-screen fallback: when no daemon is running and the disk cache is
1938
+ // stale/missing, we call the backend directly so a just-returning Pro user
1939
+ // isn't mis-classified as community until they open a project.
1940
+ const token = loadStoredToken();
1941
+ let sub = getCachedSubscription();
1942
+
1943
+ let daemonQueried = false;
1944
+ try {
1945
+ const instances = workspaces?.getAll() || [];
1946
+ const inst = instances.find(i => i.daemon && !i.daemon.killed && i.port);
1947
+ if (inst) {
1948
+ daemonQueried = true;
1949
+ const resp = await fetch(`http://localhost:${inst.port}/api/subscription/status`, {
1950
+ signal: AbortSignal.timeout(3_000),
1951
+ });
1952
+ if (resp.ok) {
1953
+ const data = await resp.json();
1954
+ sub = {
1955
+ plan: data.plan || 'community',
1956
+ active: data.active === true || data.status === 'active' || data.status === 'trialing',
1957
+ features: data.features || [],
1958
+ seats: data.seats || 1,
1959
+ validatedAt: Date.now(),
1960
+ };
1961
+ cacheSubscription(sub);
1962
+ }
1963
+ }
1964
+ } catch {}
1965
+
1966
+ const CACHE_TTL_MS = 60 * 60 * 1000;
1967
+ const cacheStale = !sub?.validatedAt || (Date.now() - sub.validatedAt) > CACHE_TTL_MS;
1968
+ if (token && !daemonQueried && cacheStale) {
1969
+ try {
1970
+ const resp = await fetch('https://docs.groovedev.ai/api/v1/subscription/status', {
1971
+ headers: { Authorization: `Bearer ${token}` },
1972
+ signal: AbortSignal.timeout(5_000),
1973
+ });
1974
+ if (resp.ok) {
1975
+ const data = await resp.json();
1976
+ sub = {
1977
+ plan: data.plan || 'community',
1978
+ active: data.active === true || data.status === 'active' || data.status === 'trialing',
1979
+ features: data.features || [],
1980
+ seats: data.seats || 1,
1981
+ validatedAt: Date.now(),
1982
+ };
1983
+ cacheSubscription(sub);
1984
+ }
1985
+ } catch {}
1986
+ }
1987
+
1988
+ return {
1989
+ authenticated: !!token,
1990
+ plan: sub?.plan || 'community',
1991
+ active: sub?.active || false,
1992
+ };
1993
+ });
1994
+
1995
+ // --- Auth flow ---
1996
+
1997
+ app.setAsDefaultProtocolClient('groove');
1998
+
1999
+ function tokenPath() {
2000
+ return join(app.getPath('userData'), 'auth-token');
2001
+ }
2002
+
2003
+ function storeToken(jwt) {
2004
+ if (!safeStorage.isEncryptionAvailable()) {
2005
+ console.error('[security] Cannot store token — encryption unavailable');
2006
+ return;
2007
+ }
2008
+ writeFileSync(tokenPath(), safeStorage.encryptString(jwt), { mode: 0o600 });
2009
+ if (workspaces) {
2010
+ for (const inst of workspaces.instances.values()) {
2011
+ if (inst.daemon && inst.daemon.connected) {
2012
+ inst.daemon.send({ type: 'auth-token', token: jwt });
2013
+ }
2014
+ }
2015
+ }
2016
+ }
2017
+
2018
+ function loadStoredToken() {
2019
+ try {
2020
+ if (!safeStorage.isEncryptionAvailable()) {
2021
+ console.error('[security] Cannot load token — encryption unavailable');
2022
+ return null;
2023
+ }
2024
+ const buf = readFileSync(tokenPath());
2025
+ return safeStorage.decryptString(buf);
2026
+ } catch { return null; }
2027
+ }
2028
+
2029
+ function clearStoredToken() {
2030
+ try { unlinkSync(tokenPath()); } catch {}
2031
+ if (workspaces) {
2032
+ for (const inst of workspaces.instances.values()) {
2033
+ if (inst.daemon && inst.daemon.connected) {
2034
+ inst.daemon.send({ type: 'auth-token', token: null });
2035
+ }
2036
+ }
2037
+ }
2038
+ }
2039
+
2040
+ function cacheSubscription(data) {
2041
+ try {
2042
+ writeFileSync(
2043
+ join(app.getPath('userData'), 'subscription-cache.json'),
2044
+ JSON.stringify(data), { mode: 0o600 },
2045
+ );
2046
+ } catch {}
2047
+ }
2048
+
2049
+ function getCachedSubscription() {
2050
+ try {
2051
+ return JSON.parse(readFileSync(join(app.getPath('userData'), 'subscription-cache.json'), 'utf8'));
2052
+ } catch { return null; }
2053
+ }
2054
+
2055
+ ipcMain.handle('auth-login', () => {
2056
+ pendingAuthState = randomBytes(32).toString('hex');
2057
+ setTimeout(() => { if (pendingAuthState) pendingAuthState = null; }, 10 * 60 * 1000);
2058
+ const url = `${STUDIO_URL}/#/login?return=electron&outer_state=${pendingAuthState}`;
2059
+ shell.openExternal(url);
2060
+ return { state: pendingAuthState };
2061
+ });
2062
+
2063
+ ipcMain.handle('auth-logout', () => {
2064
+ clearStoredToken();
2065
+ stopSubscriptionPoll();
2066
+ return { ok: true };
2067
+ });
2068
+
2069
+ ipcMain.handle('auth-status', () => {
2070
+ const token = loadStoredToken();
2071
+ return { authenticated: !!token };
2072
+ });
2073
+
2074
+ ipcMain.handle('integration-oauth-start', async (_event, oauthUrl) => {
2075
+ try {
2076
+ shell.openExternal(oauthUrl);
2077
+ return { ok: true };
2078
+ } catch (err) {
2079
+ return { error: err.message };
2080
+ }
2081
+ });
2082
+
2083
+ function handleAuthCallback(url) {
2084
+ try {
2085
+ const parsed = new URL(url);
2086
+ const token = parsed.searchParams.get('token');
2087
+ const state = parsed.searchParams.get('state');
2088
+ if (!token || !state) return;
2089
+ if (state !== pendingAuthState) {
2090
+ console.error('[auth] state mismatch — possible CSRF, rejecting callback');
2091
+ return;
2092
+ }
2093
+ pendingAuthState = null;
2094
+ storeToken(token);
2095
+ startSubscriptionPoll();
2096
+ broadcastToAllWindows('auth-changed', { authenticated: true });
2097
+ const instances = workspaces?.getAll() || [];
2098
+ const visible = instances.find(i => i.window && !i.window.isDestroyed());
2099
+ if (visible) {
2100
+ visible.window.show();
2101
+ visible.window.focus();
2102
+ }
2103
+ } catch (err) { console.error('[auth] Callback failed:', err.message); }
2104
+ }
2105
+
2106
+ app.on('open-url', (event, url) => {
2107
+ event.preventDefault();
2108
+ if (url.startsWith('groove://auth/callback')) {
2109
+ handleAuthCallback(url);
2110
+ } else if (url.startsWith('groove://activate')) {
2111
+ checkSubscription();
2112
+ }
2113
+ });
2114
+
2115
+ // --- Subscription polling ---
2116
+
2117
+ async function checkSubscription() {
2118
+ const instances = workspaces?.getAll() || [];
2119
+ const inst = instances.find(i => i.daemon && !i.daemon.killed);
2120
+ if (!inst) return;
2121
+ try {
2122
+ const resp = await fetch(`http://localhost:${inst.port}/api/subscription/status`);
2123
+ const data = await resp.json();
2124
+ const sub = {
2125
+ active: data.active || false,
2126
+ plan: data.plan || 'community',
2127
+ features: data.features || [],
2128
+ seats: data.seats || 1,
2129
+ };
2130
+ cacheSubscription(sub);
2131
+ broadcastToAllWindows('subscription-status', sub);
2132
+ } catch {}
2133
+ }
2134
+
2135
+ function startSubscriptionPoll() {
2136
+ stopSubscriptionPoll();
2137
+ checkSubscription();
2138
+ subscriptionTimer = setInterval(checkSubscription, SUBSCRIPTION_POLL_MS);
2139
+ }
2140
+
2141
+ function stopSubscriptionPoll() {
2142
+ if (subscriptionTimer) {
2143
+ clearInterval(subscriptionTimer);
2144
+ subscriptionTimer = null;
2145
+ }
2146
+ }
2147
+
2148
+ // --- Tray ---
2149
+
2150
+ function createTray() {
2151
+ const trayIconPath = join(__dirname, 'assets', 'tray-icon.svg');
2152
+ const icon = nativeImage.createFromPath(trayIconPath).resize({ width: 22, height: 22 });
2153
+ tray = new Tray(icon);
2154
+ tray.setToolTip('Groove');
2155
+ tray.on('click', () => {
2156
+ const instances = workspaces?.getAll() || [];
2157
+ const visible = instances.find(i => i.window && !i.window.isDestroyed());
2158
+ if (visible) {
2159
+ visible.window.show();
2160
+ visible.window.focus();
2161
+ } else if (workspaces?._homeWindow && !workspaces._homeWindow.isDestroyed()) {
2162
+ workspaces._homeWindow.show();
2163
+ workspaces._homeWindow.focus();
2164
+ }
2165
+ });
2166
+ workspaces?._updateTrayMenu();
2167
+ }
2168
+
2169
+ // --- App lifecycle ---
2170
+
2171
+ const gotTheLock = app.requestSingleInstanceLock();
2172
+ if (!gotTheLock) {
2173
+ app.quit();
2174
+ } else {
2175
+
2176
+ app.on('second-instance', () => {
2177
+ if (!workspaces) return;
2178
+ const instances = workspaces.getAll();
2179
+ const visible = instances.find(i => i.window && !i.window.isDestroyed() && i.window.isVisible());
2180
+ if (visible) {
2181
+ if (visible.window.isMinimized()) visible.window.restore();
2182
+ visible.window.show();
2183
+ visible.window.focus();
2184
+ } else if (workspaces._homeWindow && !workspaces._homeWindow.isDestroyed()) {
2185
+ if (workspaces._homeWindow.isMinimized()) workspaces._homeWindow.restore();
2186
+ workspaces._homeWindow.show();
2187
+ workspaces._homeWindow.focus();
2188
+ } else {
2189
+ workspaces._createHomeWindow();
2190
+ }
2191
+ });
2192
+
2193
+ app.whenReady().then(async () => {
2194
+ workspaces = new WorkspaceManager();
2195
+ createTray();
2196
+ const stored = loadStoredToken();
2197
+ if (stored) {
2198
+ storeToken(stored);
2199
+ startSubscriptionPoll();
2200
+ }
2201
+ workspaces._createHomeWindow();
2202
+
2203
+ // Auto-updater setup — skip in dev mode
2204
+ if (app.isPackaged) {
2205
+ autoUpdater.autoDownload = true;
2206
+ autoUpdater.autoInstallOnAppQuit = true;
2207
+ autoUpdater.logger = { info: (...a) => console.log('[updater]', ...a), warn: (...a) => console.warn('[updater]', ...a), error: (...a) => console.error('[updater]', ...a) };
2208
+
2209
+ let skipAutoCheck = false;
2210
+ try {
2211
+ const marker = JSON.parse(readFileSync(join(app.getPath('userData'), 'update-pending.json'), 'utf8'));
2212
+ unlinkSync(join(app.getPath('userData'), 'update-pending.json'));
2213
+ if (marker.version !== app.getVersion()) {
2214
+ console.error('[updater] Update to ' + marker.version + ' failed, still on ' + app.getVersion());
2215
+ skipAutoCheck = true;
2216
+ }
2217
+ } catch {}
2218
+
2219
+ if (!skipAutoCheck) {
2220
+ autoUpdater.checkForUpdates().catch(() => {});
2221
+ }
2222
+ setInterval(() => autoUpdater.checkForUpdates().catch(() => {}), 4 * 60 * 60 * 1000);
2223
+ }
2224
+ });
2225
+
2226
+ app.on('activate', () => {
2227
+ const instances = workspaces?.getAll() || [];
2228
+ const visible = instances.find(i => i.window && !i.window.isDestroyed());
2229
+ if (visible) {
2230
+ visible.window.show();
2231
+ visible.window.focus();
2232
+ } else if (workspaces?._homeWindow && !workspaces._homeWindow.isDestroyed()) {
2233
+ workspaces._homeWindow.show();
2234
+ workspaces._homeWindow.focus();
2235
+ } else {
2236
+ workspaces?._createHomeWindow();
2237
+ }
2238
+ });
2239
+
2240
+ app.on('window-all-closed', () => {
2241
+ if (!IS_MAC) {
2242
+ isQuitting = true;
2243
+ app.quit();
2244
+ }
2245
+ });
2246
+
2247
+ app.on('before-quit', async () => {
2248
+ isQuitting = true;
2249
+ stopSubscriptionPoll();
2250
+ if (workspaces) await workspaces.shutdownAll();
2251
+ });
2252
+
2253
+ } // end single-instance lock