flying-lobster 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/PRD.md ADDED
@@ -0,0 +1,76 @@
1
+ # Flying Lobster 🦞 — PRD v1
2
+
3
+ ## What
4
+ Lightweight, always-on-top desktop chat window for talking to OpenClaw bot gateway instances. One hotkey away, anywhere on your computer, even over fullscreen apps.
5
+
6
+ ## Tech Stack
7
+ - **Electron** (cross-platform: Mac, Windows, Linux)
8
+ - **electron-store** for persistent config (JSON on disk)
9
+ - **BrowserWindow/WebView** embedding OpenClaw web UI (chat only)
10
+
11
+ ## Core Features (MVP)
12
+
13
+ ### 1. Global Hotkey
14
+ - Default: `Cmd+Shift+L` (Mac) / `Ctrl+Shift+L` (Win)
15
+ - User-customizable in settings
16
+ - Toggles window show/hide
17
+
18
+ ### 2. Always-on-Top Window
19
+ - Floats above ALL windows including fullscreen apps
20
+ - Small footprint (~400x600, resizable)
21
+ - Remembers position/size across restarts
22
+ - Hide: hotkey, Esc, or click outside
23
+ - Show: hotkey or tray icon click
24
+
25
+ ### 3. System Tray
26
+ - Tray icon (🦞 or lobster icon)
27
+ - Click to toggle window
28
+ - Right-click menu: show, settings, quit
29
+
30
+ ### 4. Gateway Management
31
+ - Add/edit/delete gateway configs (name + URL)
32
+ - Switch between gateways via dropdown in app
33
+ - Connection status indicator (green/red dot)
34
+ - Persisted via electron-store
35
+
36
+ ### 5. Chat-Only UI
37
+ - Loads OpenClaw gateway web UI in webview
38
+ - CSS injected to hide header, sidebar, nav — only chat visible
39
+ - Auto-loads active gateway on startup
40
+
41
+ ### 6. Settings
42
+ - Gateway list management
43
+ - Hotkey customization
44
+ - Window behavior preferences
45
+
46
+ ## Config Schema (electron-store)
47
+ ```json
48
+ {
49
+ "gateways": [
50
+ { "id": "uuid", "name": "Rooty", "url": "http://localhost:19002" }
51
+ ],
52
+ "activeGateway": "uuid",
53
+ "hotkey": "CommandOrControl+Shift+L",
54
+ "windowBounds": { "x": 100, "y": 100, "width": 400, "height": 600 }
55
+ }
56
+ ```
57
+
58
+ ## Architecture
59
+ ```
60
+ System Tray 🦞
61
+
62
+ Electron Main Process
63
+ ├── Global hotkey listener (globalShortcut)
64
+ ├── Window manager (show/hide/position)
65
+ ├── Config store (electron-store)
66
+ └── BrowserWindow
67
+ ├── Gateway selector bar (top)
68
+ └── WebView → OpenClaw UI (CSS-injected, chat only)
69
+ ```
70
+
71
+ ## Out of Scope (v2+)
72
+ - Native chat UI (replace webview)
73
+ - Notifications/badges
74
+ - File drag & drop
75
+ - Multi-window
76
+ - Auto-discovery of local gateways
package/bin/cli.js ADDED
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { spawn, execSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+
8
+ const APP_NAME = 'Flying Lobster.app';
9
+ const INSTALL_PATH = '/Applications';
10
+
11
+ function log(msg) {
12
+ console.log(`🦞 ${msg}`);
13
+ }
14
+
15
+ function error(msg) {
16
+ console.error(`❌ ${msg}`);
17
+ process.exit(1);
18
+ }
19
+
20
+ function runCommand(cmd, args = [], options = {}) {
21
+ return new Promise((resolve, reject) => {
22
+ const child = spawn(cmd, args, {
23
+ stdio: 'inherit',
24
+ shell: true,
25
+ ...options
26
+ });
27
+ child.on('close', (code) => {
28
+ if (code === 0) resolve();
29
+ else reject(new Error(`Command failed with code ${code}`));
30
+ });
31
+ child.on('error', reject);
32
+ });
33
+ }
34
+
35
+ function findBuiltApp(projectRoot) {
36
+ const distDir = path.join(projectRoot, 'dist');
37
+
38
+ // Check possible build output directories in order of preference
39
+ const possibleDirs = [
40
+ 'mac-universal',
41
+ 'mac-arm64',
42
+ 'mac-x64',
43
+ 'mac'
44
+ ];
45
+
46
+ for (const dir of possibleDirs) {
47
+ const appPath = path.join(distDir, dir, APP_NAME);
48
+ if (fs.existsSync(appPath)) {
49
+ return appPath;
50
+ }
51
+ }
52
+
53
+ return null;
54
+ }
55
+
56
+ async function install() {
57
+ const projectRoot = path.resolve(__dirname, '..');
58
+ const destPath = path.join(INSTALL_PATH, APP_NAME);
59
+
60
+ log('Building Flying Lobster...');
61
+
62
+ // Install dependencies if needed
63
+ if (!fs.existsSync(path.join(projectRoot, 'node_modules'))) {
64
+ log('Installing dependencies...');
65
+ await runCommand('npm', ['install'], { cwd: projectRoot });
66
+ }
67
+
68
+ // Build the app (dir target is faster than DMG for install)
69
+ try {
70
+ await runCommand('npm', ['run', 'build:dir'], { cwd: projectRoot });
71
+ } catch (e) {
72
+ error('Build failed. Make sure you have the right permissions and dependencies.');
73
+ }
74
+
75
+ // Find the built app
76
+ const appPath = findBuiltApp(projectRoot);
77
+ if (!appPath) {
78
+ error('Built app not found. Build may have failed.');
79
+ }
80
+
81
+ log(`Found built app at: ${path.basename(path.dirname(appPath))}`);
82
+
83
+ // Remove existing installation
84
+ if (fs.existsSync(destPath)) {
85
+ log('Removing existing installation...');
86
+ try {
87
+ execSync(`rm -rf "${destPath}"`);
88
+ } catch (e) {
89
+ error(`Failed to remove existing app. Try: sudo rm -rf "${destPath}"`);
90
+ }
91
+ }
92
+
93
+ // Copy to Applications
94
+ log('Installing to /Applications...');
95
+ try {
96
+ execSync(`cp -R "${appPath}" "${INSTALL_PATH}/"`);
97
+ } catch (e) {
98
+ error(`Failed to copy app. Try running with sudo or copy manually from:\n ${appPath}`);
99
+ }
100
+
101
+ // Open the app
102
+ log('Installation complete! ✨');
103
+ log(`Installed to: ${destPath}`);
104
+ log('');
105
+ log('Starting Flying Lobster...');
106
+
107
+ spawn('open', [destPath], { detached: true, stdio: 'ignore' }).unref();
108
+
109
+ console.log('');
110
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
111
+ console.log('');
112
+ console.log(' 🦞 Flying Lobster is now installed!');
113
+ console.log('');
114
+ console.log(' Quick tips:');
115
+ console.log(' • Press Cmd+Shift+L to toggle the chat window');
116
+ console.log(' • Click the 🦞 tray icon to show/hide');
117
+ console.log(' • Add your OpenClaw gateway in Settings');
118
+ console.log(' • Cmd+Shift+Left/Right to switch gateways');
119
+ console.log('');
120
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
121
+ console.log('');
122
+ }
123
+
124
+ async function uninstall() {
125
+ const destPath = path.join(INSTALL_PATH, APP_NAME);
126
+
127
+ if (!fs.existsSync(destPath)) {
128
+ log('Flying Lobster is not installed.');
129
+ return;
130
+ }
131
+
132
+ log('Uninstalling Flying Lobster...');
133
+
134
+ // Quit the app if running
135
+ try {
136
+ execSync('osascript -e \'quit app "Flying Lobster"\' 2>/dev/null || true');
137
+ } catch (e) {
138
+ // Ignore errors
139
+ }
140
+
141
+ // Remove the app
142
+ try {
143
+ execSync(`rm -rf "${destPath}"`);
144
+ log('Successfully uninstalled Flying Lobster.');
145
+ } catch (e) {
146
+ error(`Failed to remove app. Try: sudo rm -rf "${destPath}"`);
147
+ }
148
+ }
149
+
150
+ function showHelp() {
151
+ console.log(`
152
+ 🦞 Flying Lobster CLI
153
+
154
+ Usage:
155
+ flying-lobster <command>
156
+
157
+ Commands:
158
+ install Build and install Flying Lobster to /Applications
159
+ uninstall Remove Flying Lobster from /Applications
160
+ help Show this help message
161
+
162
+ Examples:
163
+ npx flying-lobster install
164
+ flying-lobster uninstall
165
+ `);
166
+ }
167
+
168
+ async function main() {
169
+ const command = process.argv[2];
170
+
171
+ if (os.platform() !== 'darwin') {
172
+ error('Flying Lobster CLI install is currently macOS only.');
173
+ }
174
+
175
+ switch (command) {
176
+ case 'install':
177
+ await install();
178
+ break;
179
+ case 'uninstall':
180
+ await uninstall();
181
+ break;
182
+ case 'help':
183
+ case '--help':
184
+ case '-h':
185
+ case undefined:
186
+ showHelp();
187
+ break;
188
+ default:
189
+ console.error(`Unknown command: ${command}`);
190
+ showHelp();
191
+ process.exit(1);
192
+ }
193
+ }
194
+
195
+ main().catch((e) => {
196
+ console.error(e.message);
197
+ process.exit(1);
198
+ });
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "flying-lobster",
3
+ "version": "0.1.0",
4
+ "description": "Always-on-top chat window for OpenClaw gateways 🦞",
5
+ "author": "Rootlab.ai",
6
+ "license": "MIT",
7
+ "main": "src/main/index.js",
8
+ "bin": {
9
+ "flying-lobster": "./bin/cli.js"
10
+ },
11
+ "scripts": {
12
+ "dev": "electron .",
13
+ "start": "electron .",
14
+ "build": "electron-builder --mac",
15
+ "build:dir": "electron-builder --mac --dir",
16
+ "install-app": "node bin/cli.js install"
17
+ },
18
+ "devDependencies": {
19
+ "electron": "^33.0.0",
20
+ "electron-builder": "^25.0.0"
21
+ },
22
+ "dependencies": {
23
+ "electron-store": "^8.2.0",
24
+ "uuid": "^13.0.0"
25
+ },
26
+ "build": {
27
+ "appId": "ai.rootlab.flying-lobster",
28
+ "productName": "Flying Lobster",
29
+ "files": [
30
+ "src/**/*",
31
+ "assets/**/*"
32
+ ],
33
+ "mac": {
34
+ "category": "public.app-category.productivity",
35
+ "target": [
36
+ {
37
+ "target": "dmg",
38
+ "arch": ["universal"]
39
+ },
40
+ {
41
+ "target": "dir",
42
+ "arch": ["universal"]
43
+ }
44
+ ]
45
+ },
46
+ "dmg": {
47
+ "title": "Flying Lobster",
48
+ "contents": [
49
+ {
50
+ "x": 130,
51
+ "y": 220
52
+ },
53
+ {
54
+ "x": 410,
55
+ "y": 220,
56
+ "type": "link",
57
+ "path": "/Applications"
58
+ }
59
+ ]
60
+ }
61
+ }
62
+ }
@@ -0,0 +1,358 @@
1
+ const { app, BrowserWindow, globalShortcut, Tray, Menu, nativeImage, ipcMain, webContents, nativeTheme } = require('electron');
2
+ const path = require('path');
3
+ const store = require('./store');
4
+ const { randomUUID } = require('crypto');
5
+
6
+ // CSS to inject into OpenClaw webview to create chat-only view
7
+ const OPENCLAW_CSS = `
8
+ :root, :host {
9
+ --shell-nav-width: 0px !important;
10
+ --shell-topbar-height: 0px !important;
11
+ }
12
+ .shell {
13
+ --shell-nav-width: 0px !important;
14
+ --shell-topbar-height: 0px !important;
15
+ grid-template-columns: 0px minmax(0,1fr) !important;
16
+ grid-template-rows: 0px 1fr !important;
17
+ grid-template-areas: "content" "content" !important;
18
+ }
19
+ .nav { display: none !important; }
20
+ .topbar { display: none !important; }
21
+ .content-header { display: none !important; }
22
+ .content {
23
+ padding: 0 4px !important;
24
+ gap: 0 !important;
25
+ grid-area: 1 / 1 / -1 / -1 !important;
26
+ }
27
+ .chat-controls { display: none !important; }
28
+ .chat-compose { padding: 8px 4px 4px !important; }
29
+ .chat-compose__row { gap: 6px !important; }
30
+ .chat-compose__field textarea {
31
+ min-height: 40px !important;
32
+ width: 100% !important;
33
+ }
34
+ .chat-compose__actions .btn:not(.primary) { display: none !important; }
35
+ .chat-compose__actions .btn .btn-kbd { display: none !important; }
36
+ .chat-compose__actions .btn { padding: 0 10px !important; }
37
+ .chat-group-messages { max-width: 100% !important; }
38
+ .chat-group { margin-right: 4px !important; margin-left: 4px !important; }
39
+ `;
40
+
41
+ const OPENCLAW_JS = `
42
+ (function() {
43
+ const css = ${JSON.stringify(OPENCLAW_CSS)};
44
+ function injectCSS(root) {
45
+ if (!root || root.querySelector('[data-fl]')) return;
46
+ const s = document.createElement('style');
47
+ s.setAttribute('data-fl', '1');
48
+ s.textContent = css;
49
+ root.appendChild(s);
50
+ }
51
+ function inject() {
52
+ injectCSS(document.head);
53
+ document.documentElement.style.setProperty('--shell-nav-width', '0px', 'important');
54
+ document.documentElement.style.setProperty('--shell-topbar-height', '0px', 'important');
55
+ const app = document.querySelector('openclaw-app');
56
+ if (app && app.shadowRoot) {
57
+ injectCSS(app.shadowRoot);
58
+ const shell = app.shadowRoot.querySelector('.shell');
59
+ if (shell) shell.classList.add('shell--nav-collapsed');
60
+ app.shadowRoot.querySelectorAll('*').forEach(el => {
61
+ if (el.shadowRoot) injectCSS(el.shadowRoot);
62
+ });
63
+ }
64
+ }
65
+ inject();
66
+ let n = 0;
67
+ const iv = setInterval(() => { inject(); if (++n > 60) clearInterval(iv); }, 300);
68
+ const ob = new MutationObserver(inject);
69
+ if (document.body) ob.observe(document.body, { childList: true, subtree: true });
70
+ else document.addEventListener('DOMContentLoaded', () => ob.observe(document.body, { childList: true, subtree: true }));
71
+ setTimeout(() => { ob.disconnect(); clearInterval(iv); }, 30000);
72
+ })();
73
+ `;
74
+
75
+ let mainWindow = null;
76
+ let settingsWindow = null;
77
+ let tray = null;
78
+
79
+ // ── IPC Handlers ──────────────────────────────────────────────
80
+
81
+ ipcMain.handle('get-gateways', () => store.get('gateways'));
82
+
83
+ ipcMain.handle('add-gateway', (_e, { name, url, token }) => {
84
+ const gateways = store.get('gateways');
85
+ const gw = { id: randomUUID(), name, url, token: token || '' };
86
+ gateways.push(gw);
87
+ store.set('gateways', gateways);
88
+ // If first gateway, auto-activate
89
+ if (!store.get('activeGateway')) store.set('activeGateway', gw.id);
90
+ return gw;
91
+ });
92
+
93
+ ipcMain.handle('update-gateway', (_e, { id, name, url, token }) => {
94
+ const gateways = store.get('gateways').map(g =>
95
+ g.id === id ? { ...g, name, url, token: token || '' } : g
96
+ );
97
+ store.set('gateways', gateways);
98
+ return gateways;
99
+ });
100
+
101
+ ipcMain.handle('delete-gateway', (_e, id) => {
102
+ const gateways = store.get('gateways').filter(g => g.id !== id);
103
+ store.set('gateways', gateways);
104
+ if (store.get('activeGateway') === id) {
105
+ store.set('activeGateway', gateways.length ? gateways[0].id : null);
106
+ }
107
+ return gateways;
108
+ });
109
+
110
+ ipcMain.handle('get-active-gateway', () => store.get('activeGateway'));
111
+
112
+ ipcMain.handle('set-active-gateway', (_e, id) => {
113
+ store.set('activeGateway', id);
114
+ return id;
115
+ });
116
+
117
+ ipcMain.handle('get-settings', () => ({
118
+ hotkey: store.get('hotkey'),
119
+ }));
120
+
121
+ ipcMain.handle('update-settings', (_e, settings) => {
122
+ if (settings.hotkey && settings.hotkey !== store.get('hotkey')) {
123
+ store.set('hotkey', settings.hotkey);
124
+ registerHotkey();
125
+ }
126
+ return { hotkey: store.get('hotkey') };
127
+ });
128
+
129
+ ipcMain.handle('open-settings', () => {
130
+ openSettings();
131
+ });
132
+
133
+ ipcMain.handle('ping-gateway', async (_e, url) => {
134
+ try {
135
+ const ctrl = new AbortController();
136
+ const t = setTimeout(() => ctrl.abort(), 3000);
137
+ const res = await fetch(url, { signal: ctrl.signal, method: 'HEAD' });
138
+ clearTimeout(t);
139
+ return res.ok || res.status < 500;
140
+ } catch {
141
+ return false;
142
+ }
143
+ });
144
+
145
+ // ── Theme Support ─────────────────────────────────────────────
146
+
147
+ ipcMain.handle('get-theme', () => {
148
+ return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
149
+ });
150
+
151
+ // Notify all windows when theme changes
152
+ nativeTheme.on('updated', () => {
153
+ const theme = nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
154
+ if (mainWindow && !mainWindow.isDestroyed()) {
155
+ mainWindow.webContents.send('theme-changed', theme);
156
+ }
157
+ if (settingsWindow && !settingsWindow.isDestroyed()) {
158
+ settingsWindow.webContents.send('theme-changed', theme);
159
+ }
160
+ });
161
+
162
+ // ── Main Window ───────────────────────────────────────────────
163
+
164
+ function createWindow() {
165
+ const bounds = store.get('windowBounds');
166
+
167
+ mainWindow = new BrowserWindow({
168
+ width: bounds.width || 400,
169
+ height: bounds.height || 600,
170
+ x: bounds.x,
171
+ y: bounds.y,
172
+ frame: false,
173
+ alwaysOnTop: true,
174
+ skipTaskbar: true,
175
+ resizable: true,
176
+ show: false,
177
+ transparent: false,
178
+ visibleOnAllWorkspaces: true,
179
+ webPreferences: {
180
+ nodeIntegration: false,
181
+ contextIsolation: true,
182
+ preload: path.join(__dirname, 'preload.js'),
183
+ webviewTag: true,
184
+ }
185
+ });
186
+
187
+ if (process.platform === 'darwin') {
188
+ mainWindow.setAlwaysOnTop(true, 'screen-saver');
189
+ mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
190
+ } else {
191
+ mainWindow.setAlwaysOnTop(true, 'screen-saver');
192
+ }
193
+
194
+ mainWindow.loadFile(path.join(__dirname, '..', 'renderer', 'index.html'));
195
+
196
+ // Inject CSS/JS into any webview that loads inside this window
197
+ mainWindow.webContents.on('did-attach-webview', (event, wvWebContents) => {
198
+ wvWebContents.on('dom-ready', () => {
199
+ wvWebContents.insertCSS(OPENCLAW_CSS).catch(e => console.error('Main insertCSS failed:', e));
200
+ wvWebContents.executeJavaScript(OPENCLAW_JS).catch(e => console.error('Main executeJS failed:', e));
201
+ });
202
+ });
203
+
204
+ const saveBounds = () => {
205
+ if (mainWindow && !mainWindow.isDestroyed()) {
206
+ store.set('windowBounds', mainWindow.getBounds());
207
+ }
208
+ };
209
+ mainWindow.on('move', saveBounds);
210
+ mainWindow.on('resize', saveBounds);
211
+
212
+ mainWindow.on('blur', () => {
213
+ if (mainWindow && mainWindow.isVisible()) {
214
+ mainWindow.hide();
215
+ }
216
+ });
217
+
218
+ mainWindow.once('ready-to-show', () => {
219
+ mainWindow.show();
220
+ });
221
+
222
+ mainWindow.webContents.on('before-input-event', (event, input) => {
223
+ if (input.key === 'Escape') {
224
+ mainWindow.hide();
225
+ }
226
+ });
227
+
228
+ mainWindow.on('closed', () => {
229
+ mainWindow = null;
230
+ });
231
+ }
232
+
233
+ // ── Settings Window ───────────────────────────────────────────
234
+
235
+ function openSettings() {
236
+ if (settingsWindow && !settingsWindow.isDestroyed()) {
237
+ settingsWindow.focus();
238
+ return;
239
+ }
240
+
241
+ settingsWindow = new BrowserWindow({
242
+ width: 520,
243
+ height: 560,
244
+ resizable: false,
245
+ frame: false,
246
+ webPreferences: {
247
+ nodeIntegration: false,
248
+ contextIsolation: true,
249
+ preload: path.join(__dirname, 'preload-settings.js'),
250
+ }
251
+ });
252
+
253
+ settingsWindow.loadFile(path.join(__dirname, '..', 'renderer', 'settings.html'));
254
+
255
+ settingsWindow.on('closed', () => {
256
+ settingsWindow = null;
257
+ // Notify main window to refresh gateway list
258
+ if (mainWindow && !mainWindow.isDestroyed()) {
259
+ mainWindow.webContents.send('gateways-updated');
260
+ }
261
+ });
262
+ }
263
+
264
+ // ── Agent Switching Shortcuts ─────────────────────────────────
265
+
266
+ function cycleGateway(direction) {
267
+ const gateways = store.get('gateways');
268
+ if (gateways.length < 2) return; // Nothing to cycle
269
+
270
+ const activeId = store.get('activeGateway');
271
+ const currentIndex = gateways.findIndex(g => g.id === activeId);
272
+
273
+ let newIndex;
274
+ if (direction === 'next') {
275
+ newIndex = (currentIndex + 1) % gateways.length;
276
+ } else {
277
+ newIndex = (currentIndex - 1 + gateways.length) % gateways.length;
278
+ }
279
+
280
+ const newGateway = gateways[newIndex];
281
+ store.set('activeGateway', newGateway.id);
282
+
283
+ // Notify main window to switch gateway
284
+ if (mainWindow && !mainWindow.isDestroyed()) {
285
+ mainWindow.webContents.send('switch-gateway', newGateway.id);
286
+ }
287
+ }
288
+
289
+ // ── Toggle / Tray / Hotkey ────────────────────────────────────
290
+
291
+ function toggleWindow() {
292
+ if (!mainWindow || mainWindow.isDestroyed()) {
293
+ createWindow();
294
+ return;
295
+ }
296
+ if (mainWindow.isVisible()) {
297
+ mainWindow.hide();
298
+ } else {
299
+ mainWindow.show();
300
+ mainWindow.focus();
301
+ }
302
+ }
303
+
304
+ function createTray() {
305
+ const icon = nativeImage.createFromDataURL(
306
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAARElEQVQ4T2P8z8BQz0BAwMBAAGBhYGD4T4whBgYGRmIMkG4Ao2uQGECyC0h2wWgYjIYBVcKAZC+QnJBIdgHJXiDZAABhvBAR2MfUzgAAAABJRU5ErkJggg=='
307
+ );
308
+
309
+ tray = new Tray(icon.resize({ width: 16, height: 16 }));
310
+ tray.setToolTip('Flying Lobster 🦞');
311
+
312
+ const contextMenu = Menu.buildFromTemplate([
313
+ { label: 'Show/Hide', click: toggleWindow },
314
+ { type: 'separator' },
315
+ { label: 'Settings', click: openSettings },
316
+ { type: 'separator' },
317
+ { label: 'Quit', click: () => app.quit() }
318
+ ]);
319
+
320
+ tray.setContextMenu(contextMenu);
321
+ tray.on('click', toggleWindow);
322
+ }
323
+
324
+ function registerHotkey() {
325
+ const hotkey = store.get('hotkey');
326
+ globalShortcut.unregisterAll();
327
+
328
+ // Main toggle hotkey
329
+ const registered = globalShortcut.register(hotkey, toggleWindow);
330
+ if (!registered) {
331
+ console.error(`Failed to register hotkey: ${hotkey}`);
332
+ }
333
+
334
+ // Agent switching shortcuts (Cmd+Shift+Right/Left)
335
+ const nextRegistered = globalShortcut.register('CommandOrControl+Shift+Right', () => cycleGateway('next'));
336
+ const prevRegistered = globalShortcut.register('CommandOrControl+Shift+Left', () => cycleGateway('prev'));
337
+
338
+ if (!nextRegistered) console.error('Failed to register Cmd+Shift+Right');
339
+ if (!prevRegistered) console.error('Failed to register Cmd+Shift+Left');
340
+ }
341
+
342
+ if (process.platform === 'darwin') {
343
+ app.dock.hide();
344
+ }
345
+
346
+ app.whenReady().then(() => {
347
+ createWindow();
348
+ createTray();
349
+ registerHotkey();
350
+ });
351
+
352
+ app.on('will-quit', () => {
353
+ globalShortcut.unregisterAll();
354
+ });
355
+
356
+ app.on('window-all-closed', (e) => {
357
+ e.preventDefault?.();
358
+ });
@@ -0,0 +1,13 @@
1
+ const { contextBridge, ipcRenderer } = require('electron');
2
+
3
+ contextBridge.exposeInMainWorld('api', {
4
+ getGateways: () => ipcRenderer.invoke('get-gateways'),
5
+ addGateway: (gateway) => ipcRenderer.invoke('add-gateway', gateway),
6
+ updateGateway: (gateway) => ipcRenderer.invoke('update-gateway', gateway),
7
+ deleteGateway: (id) => ipcRenderer.invoke('delete-gateway', id),
8
+ getSettings: () => ipcRenderer.invoke('get-settings'),
9
+ updateSettings: (settings) => ipcRenderer.invoke('update-settings', settings),
10
+ // Theme support
11
+ getTheme: () => ipcRenderer.invoke('get-theme'),
12
+ onThemeChanged: (cb) => ipcRenderer.on('theme-changed', (_event, theme) => cb(theme)),
13
+ });