bosun 0.36.2 → 0.36.4
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/agent-prompts.mjs +95 -0
- package/analyze-agent-work-helpers.mjs +308 -0
- package/analyze-agent-work.mjs +926 -0
- package/autofix.mjs +2 -0
- package/bosun.schema.json +101 -3
- package/codex-shell.mjs +85 -10
- package/desktop/main.mjs +871 -48
- package/desktop/preload.mjs +54 -1
- package/desktop-shortcut.mjs +90 -11
- package/git-editor-fix.mjs +273 -0
- package/mcp-registry.mjs +579 -0
- package/meeting-workflow-service.mjs +631 -0
- package/monitor.mjs +18 -103
- package/package.json +21 -2
- package/primary-agent.mjs +32 -12
- package/session-tracker.mjs +68 -0
- package/setup-web-server.mjs +20 -10
- package/setup.mjs +376 -83
- package/startup-service.mjs +51 -6
- package/stream-resilience.mjs +17 -7
- package/ui/app.js +164 -4
- package/ui/components/agent-selector.js +145 -1
- package/ui/components/chat-view.js +161 -15
- package/ui/components/session-list.js +2 -2
- package/ui/components/shared.js +188 -15
- package/ui/modules/icons.js +13 -0
- package/ui/modules/utils.js +44 -0
- package/ui/modules/voice-client-sdk.js +733 -0
- package/ui/modules/voice-overlay.js +128 -15
- package/ui/modules/voice.js +15 -6
- package/ui/setup.html +281 -81
- package/ui/styles/components.css +99 -3
- package/ui/styles/sessions.css +122 -14
- package/ui/styles.css +14 -0
- package/ui/tabs/agents.js +1 -1
- package/ui/tabs/chat.js +123 -14
- package/ui/tabs/control.js +16 -22
- package/ui/tabs/dashboard.js +85 -8
- package/ui/tabs/library.js +113 -17
- package/ui/tabs/settings.js +116 -2
- package/ui/tabs/tasks.js +388 -39
- package/ui/tabs/telemetry.js +0 -1
- package/ui/tabs/workflows.js +4 -0
- package/ui-server.mjs +400 -22
- package/update-check.mjs +41 -13
- package/voice-action-dispatcher.mjs +844 -0
- package/voice-agents-sdk.mjs +664 -0
- package/voice-auth-manager.mjs +164 -0
- package/voice-relay.mjs +1194 -0
- package/voice-tools.mjs +914 -0
- package/workflow-templates/agents.mjs +6 -2
- package/workflow-templates/github.mjs +154 -12
- package/workflow-templates.mjs +3 -0
- package/github-reconciler.mjs +0 -506
- package/merge-strategy.mjs +0 -1210
- package/pr-cleanup-daemon.mjs +0 -992
- package/workspace-reaper.mjs +0 -405
package/desktop/main.mjs
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
app,
|
|
3
|
+
BrowserWindow,
|
|
4
|
+
dialog,
|
|
5
|
+
Menu,
|
|
6
|
+
shell,
|
|
7
|
+
Tray,
|
|
8
|
+
globalShortcut,
|
|
9
|
+
ipcMain,
|
|
10
|
+
session,
|
|
11
|
+
} from "electron";
|
|
2
12
|
import { dirname, join, resolve } from "node:path";
|
|
3
13
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
14
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -6,19 +16,47 @@ import { execFileSync, spawn } from "node:child_process";
|
|
|
6
16
|
import { request as httpRequest } from "node:http";
|
|
7
17
|
import { request as httpsRequest } from "node:https";
|
|
8
18
|
import { homedir } from "node:os";
|
|
19
|
+
import {
|
|
20
|
+
initShortcuts,
|
|
21
|
+
onShortcut,
|
|
22
|
+
getAllShortcuts,
|
|
23
|
+
getEffectiveAccelerator,
|
|
24
|
+
registerGlobalShortcuts,
|
|
25
|
+
unregisterGlobalShortcuts,
|
|
26
|
+
setShortcut,
|
|
27
|
+
resetShortcut,
|
|
28
|
+
resetAllShortcuts,
|
|
29
|
+
} from "./desktop-shortcuts.mjs";
|
|
9
30
|
|
|
10
31
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
32
|
|
|
12
33
|
process.title = "bosun-desktop";
|
|
13
34
|
|
|
14
35
|
let mainWindow = null;
|
|
36
|
+
let followWindow = null;
|
|
37
|
+
let tray = null;
|
|
38
|
+
let followWindowLaunchSignature = "";
|
|
15
39
|
let shuttingDown = false;
|
|
16
40
|
let uiServerStarted = false;
|
|
17
41
|
let uiOrigin = null;
|
|
18
42
|
let uiApi = null;
|
|
19
43
|
let runtimeConfigLoaded = false;
|
|
44
|
+
/** True when the app is running as a persistent background / tray resident. */
|
|
45
|
+
let trayMode = false;
|
|
46
|
+
/** True when the main window should start hidden (background mode). */
|
|
47
|
+
let startHidden = false;
|
|
20
48
|
const DEFAULT_TELEGRAM_UI_PORT = 3080;
|
|
21
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Shorthand: returns the effective accelerator for a shortcut ID.
|
|
52
|
+
* Used throughout buildAppMenu() and refreshTrayMenu() so the menu
|
|
53
|
+
* always reflects the user's current shortcut customizations.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} id Shortcut ID from DEFAULT_SHORTCUTS catalog.
|
|
56
|
+
* @returns {string|undefined} Accelerator string, or undefined if disabled.
|
|
57
|
+
*/
|
|
58
|
+
const acc = (id) => getEffectiveAccelerator(id) ?? undefined;
|
|
59
|
+
|
|
22
60
|
const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
|
|
23
61
|
|
|
24
62
|
// Local/private-network patterns — TLS cert bypass for the embedded UI server
|
|
@@ -34,6 +72,36 @@ function isLocalHost(hostname) {
|
|
|
34
72
|
return LOCAL_HOSTNAME_RE.some((re) => re.test(hostname));
|
|
35
73
|
}
|
|
36
74
|
|
|
75
|
+
/**
|
|
76
|
+
* Detect whether a graphical display environment is available.
|
|
77
|
+
* On Windows and macOS this is always true.
|
|
78
|
+
* On Linux we probe for an X11 / Wayland display server.
|
|
79
|
+
*/
|
|
80
|
+
function isGuiEnvironment() {
|
|
81
|
+
if (process.platform === "win32" || process.platform === "darwin") return true;
|
|
82
|
+
// Linux / BSD: check for a display server
|
|
83
|
+
if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return true;
|
|
84
|
+
// Running inside a desktop session without a forwarded $DISPLAY is possible
|
|
85
|
+
// (e.g. XDG_SESSION_TYPE=wayland without WAYLAND_DISPLAY being exported).
|
|
86
|
+
if (process.env.XDG_SESSION_TYPE && process.env.XDG_SESSION_TYPE !== "tty") return true;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Returns true when the app should run as a persistent tray resident.
|
|
92
|
+
* Opt-out: set BOSUN_DESKTOP_TRAY=0 / BOSUN_DESKTOP_NO_TRAY=1
|
|
93
|
+
* Explicit opt-in: BOSUN_DESKTOP_TRAY=1
|
|
94
|
+
* Default: enabled on any GUI environment.
|
|
95
|
+
*/
|
|
96
|
+
function isTrayModeEnabled() {
|
|
97
|
+
if (parseBoolEnv(process.env.BOSUN_DESKTOP_NO_TRAY, false)) return false;
|
|
98
|
+
const explicit = process.env.BOSUN_DESKTOP_TRAY;
|
|
99
|
+
if (explicit !== undefined && explicit !== "") {
|
|
100
|
+
return parseBoolEnv(explicit, true);
|
|
101
|
+
}
|
|
102
|
+
return isGuiEnvironment();
|
|
103
|
+
}
|
|
104
|
+
|
|
37
105
|
function parseBoolEnv(value, fallback) {
|
|
38
106
|
if (value === undefined || value === null) return fallback;
|
|
39
107
|
const normalized = String(value).trim().toLowerCase();
|
|
@@ -132,6 +200,248 @@ function resolveBosunRuntimePath(file) {
|
|
|
132
200
|
return resolve(resolveBosunRoot(), file);
|
|
133
201
|
}
|
|
134
202
|
|
|
203
|
+
function encodeFollowParam(value) {
|
|
204
|
+
const normalized = String(value || "").trim();
|
|
205
|
+
return normalized;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function buildFollowWindowUrl(baseUrl, detail = {}) {
|
|
209
|
+
const target = new URL(baseUrl);
|
|
210
|
+
target.searchParams.set("follow", "1");
|
|
211
|
+
target.searchParams.set("launch", "voice");
|
|
212
|
+
target.searchParams.set(
|
|
213
|
+
"call",
|
|
214
|
+
String(detail.call || "").trim().toLowerCase() === "video"
|
|
215
|
+
? "video"
|
|
216
|
+
: "voice",
|
|
217
|
+
);
|
|
218
|
+
const sessionId = encodeFollowParam(detail.sessionId);
|
|
219
|
+
const executor = encodeFollowParam(detail.executor);
|
|
220
|
+
const mode = encodeFollowParam(detail.mode);
|
|
221
|
+
const model = encodeFollowParam(detail.model);
|
|
222
|
+
const vision = encodeFollowParam(detail.initialVisionSource);
|
|
223
|
+
if (sessionId) target.searchParams.set("sessionId", sessionId);
|
|
224
|
+
if (executor) target.searchParams.set("executor", executor);
|
|
225
|
+
if (mode) target.searchParams.set("mode", mode);
|
|
226
|
+
if (model) target.searchParams.set("model", model);
|
|
227
|
+
if (vision) target.searchParams.set("vision", vision);
|
|
228
|
+
return target;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function setWindowVisible(win) {
|
|
232
|
+
if (!win || win.isDestroyed()) return;
|
|
233
|
+
if (win.isMinimized()) win.restore();
|
|
234
|
+
if (!win.isVisible()) win.show();
|
|
235
|
+
win.focus();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Navigate the main window's SPA to the given path.
|
|
240
|
+
* Falls back to a no-op if the window is not ready.
|
|
241
|
+
*/
|
|
242
|
+
function navigateMainWindow(path) {
|
|
243
|
+
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
244
|
+
setWindowVisible(mainWindow);
|
|
245
|
+
if (uiOrigin) {
|
|
246
|
+
const safePath = JSON.stringify(path);
|
|
247
|
+
mainWindow.webContents
|
|
248
|
+
.executeJavaScript(
|
|
249
|
+
`(function(){
|
|
250
|
+
if (window.history && window.history.pushState) {
|
|
251
|
+
window.history.pushState(null, '', ${safePath});
|
|
252
|
+
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
|
|
253
|
+
}
|
|
254
|
+
})()`,
|
|
255
|
+
)
|
|
256
|
+
.catch(() => {});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Build and return the application menu template.
|
|
262
|
+
* This is called once during bootstrap and can be refreshed when
|
|
263
|
+
* pack status or update state changes.
|
|
264
|
+
*/
|
|
265
|
+
function buildAppMenu() {
|
|
266
|
+
const isMac = process.platform === "darwin";
|
|
267
|
+
const isDev = !app.isPackaged;
|
|
268
|
+
|
|
269
|
+
const openUrl = (url) => shell.openExternal(url).catch(() => {});
|
|
270
|
+
|
|
271
|
+
/** @type {Electron.MenuItemConstructorOptions[]} */
|
|
272
|
+
const template = [
|
|
273
|
+
// ── macOS app menu ──────────────────────────────────────────────────────
|
|
274
|
+
...(isMac
|
|
275
|
+
? [
|
|
276
|
+
{
|
|
277
|
+
label: app.name,
|
|
278
|
+
submenu: [
|
|
279
|
+
{ role: /** @type {const} */ ("about") },
|
|
280
|
+
{ type: /** @type {const} */ ("separator") },
|
|
281
|
+
{ role: /** @type {const} */ ("services") },
|
|
282
|
+
{ type: /** @type {const} */ ("separator") },
|
|
283
|
+
{ role: /** @type {const} */ ("hide") },
|
|
284
|
+
{ role: /** @type {const} */ ("hideOthers") },
|
|
285
|
+
{ role: /** @type {const} */ ("unhide") },
|
|
286
|
+
{ type: /** @type {const} */ ("separator") },
|
|
287
|
+
{ role: /** @type {const} */ ("quit") },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
]
|
|
291
|
+
: []),
|
|
292
|
+
|
|
293
|
+
// ── File ────────────────────────────────────────────────────────────────
|
|
294
|
+
{
|
|
295
|
+
label: "File",
|
|
296
|
+
submenu: [
|
|
297
|
+
{
|
|
298
|
+
label: "New Chat",
|
|
299
|
+
accelerator: acc("app.newchat"),
|
|
300
|
+
click: () => navigateMainWindow("/"),
|
|
301
|
+
},
|
|
302
|
+
{ type: /** @type {const} */ ("separator") },
|
|
303
|
+
{
|
|
304
|
+
label: "Settings",
|
|
305
|
+
accelerator: acc("app.settings"),
|
|
306
|
+
click: () => navigateMainWindow("/settings"),
|
|
307
|
+
},
|
|
308
|
+
{ type: /** @type {const} */ ("separator") },
|
|
309
|
+
isMac
|
|
310
|
+
? { role: /** @type {const} */ ("close") }
|
|
311
|
+
: { role: /** @type {const} */ ("quit") },
|
|
312
|
+
],
|
|
313
|
+
},
|
|
314
|
+
|
|
315
|
+
// ── Edit ────────────────────────────────────────────────────────────────
|
|
316
|
+
{ role: /** @type {const} */ ("editMenu") },
|
|
317
|
+
|
|
318
|
+
// ── View ────────────────────────────────────────────────────────────────
|
|
319
|
+
{
|
|
320
|
+
label: "View",
|
|
321
|
+
submenu: [
|
|
322
|
+
{ role: /** @type {const} */ ("reload") },
|
|
323
|
+
{ role: /** @type {const} */ ("forceReload") },
|
|
324
|
+
{ type: /** @type {const} */ ("separator") },
|
|
325
|
+
{ role: /** @type {const} */ ("resetZoom") },
|
|
326
|
+
{ role: /** @type {const} */ ("zoomIn") },
|
|
327
|
+
{ role: /** @type {const} */ ("zoomOut") },
|
|
328
|
+
{ type: /** @type {const} */ ("separator") },
|
|
329
|
+
{ role: /** @type {const} */ ("togglefullscreen") },
|
|
330
|
+
...(isDev
|
|
331
|
+
? [
|
|
332
|
+
{ type: /** @type {const} */ ("separator") },
|
|
333
|
+
{ role: /** @type {const} */ ("toggleDevTools") },
|
|
334
|
+
]
|
|
335
|
+
: []),
|
|
336
|
+
],
|
|
337
|
+
},
|
|
338
|
+
|
|
339
|
+
// ── Bosun ───────────────────────────────────────────────────────────────
|
|
340
|
+
{
|
|
341
|
+
label: "Bosun",
|
|
342
|
+
submenu: [
|
|
343
|
+
{
|
|
344
|
+
label: "Show Main Window",
|
|
345
|
+
accelerator: acc("bosun.focus"),
|
|
346
|
+
click: () => setWindowVisible(mainWindow),
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
label: "Voice Call",
|
|
350
|
+
accelerator: acc("bosun.voice.call"),
|
|
351
|
+
click: () => openFollowWindow({ call: "voice" }).catch(() => {}),
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
label: "Video Call",
|
|
355
|
+
accelerator: acc("bosun.voice.video"),
|
|
356
|
+
click: () => openFollowWindow({ call: "video" }).catch(() => {}),
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
label: "Toggle Voice Companion",
|
|
360
|
+
accelerator: acc("bosun.voice.toggle"),
|
|
361
|
+
click: () => {
|
|
362
|
+
if (!restoreFollowWindow()) setWindowVisible(mainWindow);
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
{ type: /** @type {const} */ ("separator") },
|
|
366
|
+
{
|
|
367
|
+
label: "Dashboard",
|
|
368
|
+
accelerator: acc("bosun.navigate.home"),
|
|
369
|
+
click: () => navigateMainWindow("/"),
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
label: "Agents",
|
|
373
|
+
accelerator: acc("bosun.navigate.agents"),
|
|
374
|
+
click: () => navigateMainWindow("/agents"),
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
label: "Tasks",
|
|
378
|
+
accelerator: acc("bosun.navigate.tasks"),
|
|
379
|
+
click: () => navigateMainWindow("/tasks"),
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
label: "Logs",
|
|
383
|
+
accelerator: acc("bosun.navigate.logs"),
|
|
384
|
+
click: () => navigateMainWindow("/logs"),
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
label: "Settings",
|
|
388
|
+
accelerator: acc("bosun.navigate.settings"),
|
|
389
|
+
click: () => navigateMainWindow("/settings"),
|
|
390
|
+
},
|
|
391
|
+
{ type: /** @type {const} */ ("separator") },
|
|
392
|
+
{
|
|
393
|
+
label: "Quick New Chat",
|
|
394
|
+
accelerator: acc("bosun.quickchat"),
|
|
395
|
+
click: () => navigateMainWindow("/"),
|
|
396
|
+
},
|
|
397
|
+
{ type: /** @type {const} */ ("separator") },
|
|
398
|
+
{
|
|
399
|
+
label: "Check for Updates",
|
|
400
|
+
enabled: app.isPackaged,
|
|
401
|
+
click: () => maybeAutoUpdate().catch(() => {}),
|
|
402
|
+
},
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// ── Window ──────────────────────────────────────────────────────────────
|
|
407
|
+
{ role: /** @type {const} */ ("windowMenu") },
|
|
408
|
+
|
|
409
|
+
// ── Help ────────────────────────────────────────────────────────────────
|
|
410
|
+
{
|
|
411
|
+
role: /** @type {const} */ ("help"),
|
|
412
|
+
submenu: [
|
|
413
|
+
{
|
|
414
|
+
label: "Bosun Documentation",
|
|
415
|
+
click: () => openUrl("https://github.com/virtengine/bosun#readme"),
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
label: "GitHub Repository",
|
|
419
|
+
click: () => openUrl("https://github.com/virtengine/bosun"),
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
label: "Report an Issue",
|
|
423
|
+
click: () =>
|
|
424
|
+
openUrl("https://github.com/virtengine/bosun/issues/new"),
|
|
425
|
+
},
|
|
426
|
+
{ type: /** @type {const} */ ("separator") },
|
|
427
|
+
{
|
|
428
|
+
label: "Keyboard Shortcuts",
|
|
429
|
+
accelerator: acc("bosun.show.shortcuts"),
|
|
430
|
+
click: () => showShortcutsDialog(),
|
|
431
|
+
},
|
|
432
|
+
{ type: /** @type {const} */ ("separator") },
|
|
433
|
+
{
|
|
434
|
+
label: "Toggle Developer Tools",
|
|
435
|
+
accelerator: isMac ? "Alt+Cmd+I" : "Ctrl+Shift+I",
|
|
436
|
+
click: () => mainWindow?.webContents?.toggleDevTools(),
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
},
|
|
440
|
+
];
|
|
441
|
+
|
|
442
|
+
return Menu.buildFromTemplate(template);
|
|
443
|
+
}
|
|
444
|
+
|
|
135
445
|
async function loadBosunModule(file) {
|
|
136
446
|
const modulePath = resolveBosunRuntimePath(file);
|
|
137
447
|
return import(pathToFileURL(modulePath).href);
|
|
@@ -198,6 +508,9 @@ async function probeUiServer(url) {
|
|
|
198
508
|
});
|
|
199
509
|
}
|
|
200
510
|
|
|
511
|
+
/** Set to true if the daemon UI was not reachable at startup (offline mode). */
|
|
512
|
+
let bosunDaemonWasOffline = false;
|
|
513
|
+
|
|
201
514
|
async function resolveDaemonUiUrl() {
|
|
202
515
|
const useDaemon = parseBoolEnv(
|
|
203
516
|
process.env.BOSUN_DESKTOP_USE_DAEMON_UI,
|
|
@@ -210,49 +523,19 @@ async function resolveDaemonUiUrl() {
|
|
|
210
523
|
return ok ? base : null;
|
|
211
524
|
}
|
|
212
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Previously attempted to auto-start the bosun daemon from the Electron process.
|
|
528
|
+
* This behaviour has been intentionally removed: the desktop MUST NOT launch bosun.
|
|
529
|
+
*
|
|
530
|
+
* If the daemon is offline, buildUiUrl() sets bosunDaemonWasOffline=true and
|
|
531
|
+
* createMainWindow() injects an in-page banner with instructions on how to
|
|
532
|
+
* start bosun manually or configure auto-start via `bosun --setup`.
|
|
533
|
+
*
|
|
534
|
+
* Kept as a no-op so bootstrap() need not change its call site.
|
|
535
|
+
*/
|
|
213
536
|
async function ensureDaemonRunning() {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
false,
|
|
217
|
-
);
|
|
218
|
-
if (!autoStart) return;
|
|
219
|
-
|
|
220
|
-
const existing = getDaemonPid();
|
|
221
|
-
if (existing) return;
|
|
222
|
-
|
|
223
|
-
const ghosts = findGhostDaemonPids();
|
|
224
|
-
if (ghosts.length > 0) return;
|
|
225
|
-
|
|
226
|
-
const cliPath = resolveBosunRuntimePath("cli.mjs");
|
|
227
|
-
if (!existsSync(cliPath)) {
|
|
228
|
-
console.warn("[desktop] bosun CLI not found; daemon auto-start skipped");
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
try {
|
|
233
|
-
const setupModule = await loadBosunModule("setup.mjs");
|
|
234
|
-
if (setupModule?.shouldRunSetup?.()) {
|
|
235
|
-
console.warn(
|
|
236
|
-
"[desktop] setup required before daemon start; run: bosun --setup",
|
|
237
|
-
);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
} catch (err) {
|
|
241
|
-
console.warn(
|
|
242
|
-
"[desktop] unable to verify setup state; starting daemon anyway",
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const child = spawn(process.execPath, ["--run-as-node", cliPath, "--daemon"], {
|
|
247
|
-
detached: true,
|
|
248
|
-
stdio: "ignore",
|
|
249
|
-
env: { ...process.env, BOSUN_DESKTOP: "1" },
|
|
250
|
-
cwd: resolveBosunRoot(),
|
|
251
|
-
windowsHide: true,
|
|
252
|
-
});
|
|
253
|
-
child.unref();
|
|
254
|
-
|
|
255
|
-
await waitForDaemon(4000);
|
|
537
|
+
// Intentional no-op. Daemon auto-start from the desktop is permanently disabled.
|
|
538
|
+
// Detection happens in buildUiUrl(); the offline banner is shown in createMainWindow().
|
|
256
539
|
}
|
|
257
540
|
|
|
258
541
|
async function startUiServer() {
|
|
@@ -277,8 +560,18 @@ async function buildUiUrl() {
|
|
|
277
560
|
const daemonUrl = await resolveDaemonUiUrl();
|
|
278
561
|
if (daemonUrl) {
|
|
279
562
|
uiOrigin = new URL(daemonUrl).origin;
|
|
563
|
+
// Authenticate the initial WebView load against the separately-running
|
|
564
|
+
// daemon using the desktop API key (set during bootstrap).
|
|
565
|
+
const desktopKey = process.env.BOSUN_DESKTOP_API_KEY;
|
|
566
|
+
if (desktopKey) {
|
|
567
|
+
const daemonTarget = new URL(daemonUrl);
|
|
568
|
+
daemonTarget.searchParams.set("desktopKey", desktopKey);
|
|
569
|
+
return daemonTarget.toString();
|
|
570
|
+
}
|
|
280
571
|
return daemonUrl;
|
|
281
572
|
}
|
|
573
|
+
// Daemon is not reachable — flag it so the window can show an offline banner.
|
|
574
|
+
bosunDaemonWasOffline = true;
|
|
282
575
|
await startUiServer();
|
|
283
576
|
const api = await loadUiServerModule();
|
|
284
577
|
const uiServerUrl = api.getTelegramUiUrl();
|
|
@@ -287,9 +580,16 @@ async function buildUiUrl() {
|
|
|
287
580
|
}
|
|
288
581
|
const targetUrl = new URL(uiServerUrl);
|
|
289
582
|
uiOrigin = targetUrl.origin;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
583
|
+
// Prefer the non-expiring desktop API key over the TTL-based session token.
|
|
584
|
+
// Both result in the server setting a ve_session cookie and redirecting to /.
|
|
585
|
+
const desktopKey = process.env.BOSUN_DESKTOP_API_KEY;
|
|
586
|
+
if (desktopKey) {
|
|
587
|
+
targetUrl.searchParams.set("desktopKey", desktopKey);
|
|
588
|
+
} else {
|
|
589
|
+
const sessionToken = api.getSessionToken();
|
|
590
|
+
if (sessionToken) {
|
|
591
|
+
targetUrl.searchParams.set("token", sessionToken);
|
|
592
|
+
}
|
|
293
593
|
}
|
|
294
594
|
return targetUrl.toString();
|
|
295
595
|
}
|
|
@@ -306,6 +606,9 @@ async function createMainWindow() {
|
|
|
306
606
|
backgroundColor: "#0b0b0c",
|
|
307
607
|
...(existsSync(iconPath) ? { icon: iconPath } : {}),
|
|
308
608
|
show: false,
|
|
609
|
+
// In tray mode Windows/Linux should not show a taskbar button for the
|
|
610
|
+
// hidden state — the tray icon IS the taskbar presence.
|
|
611
|
+
skipTaskbar: Boolean(trayMode && startHidden),
|
|
309
612
|
webPreferences: {
|
|
310
613
|
contextIsolation: true,
|
|
311
614
|
nodeIntegration: false,
|
|
@@ -314,16 +617,474 @@ async function createMainWindow() {
|
|
|
314
617
|
},
|
|
315
618
|
});
|
|
316
619
|
|
|
620
|
+
mainWindow.on("close", (event) => {
|
|
621
|
+
// In tray mode, closing the window hides it to the tray instead of quitting.
|
|
622
|
+
if (trayMode && !shuttingDown) {
|
|
623
|
+
event.preventDefault();
|
|
624
|
+
mainWindow?.hide();
|
|
625
|
+
// On macOS, hide the dock icon when the window is hidden.
|
|
626
|
+
if (process.platform === "darwin") {
|
|
627
|
+
app.dock?.hide();
|
|
628
|
+
}
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
|
|
317
633
|
mainWindow.on("closed", () => {
|
|
318
634
|
mainWindow = null;
|
|
319
635
|
});
|
|
320
636
|
|
|
321
637
|
mainWindow.once("ready-to-show", () => {
|
|
322
|
-
|
|
638
|
+
if (!startHidden) {
|
|
639
|
+
mainWindow?.show();
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// When the window is shown again, make it appear in the taskbar on
|
|
644
|
+
// Windows / Linux and restore the macOS dock icon.
|
|
645
|
+
mainWindow.on("show", () => {
|
|
646
|
+
mainWindow?.setSkipTaskbar(false);
|
|
647
|
+
if (process.platform === "darwin") {
|
|
648
|
+
app.dock?.show();
|
|
649
|
+
}
|
|
323
650
|
});
|
|
324
651
|
|
|
325
652
|
const uiUrl = await buildUiUrl();
|
|
326
653
|
await mainWindow.loadURL(uiUrl);
|
|
654
|
+
|
|
655
|
+
// If the bosun daemon was not running when the desktop started, inject a
|
|
656
|
+
// non-blocking banner so the user knows and has clear instructions.
|
|
657
|
+
// The desktop NEVER auto-starts bosun — it is always a separate process.
|
|
658
|
+
if (bosunDaemonWasOffline) {
|
|
659
|
+
mainWindow.webContents.once("did-finish-load", () => {
|
|
660
|
+
mainWindow?.webContents
|
|
661
|
+
.executeJavaScript(
|
|
662
|
+
`(function() {
|
|
663
|
+
if (document.getElementById('bosun-offline-banner')) return;
|
|
664
|
+
const b = document.createElement('div');
|
|
665
|
+
b.id = 'bosun-offline-banner';
|
|
666
|
+
b.style.cssText = [
|
|
667
|
+
'position:fixed','top:0','left:0','right:0','z-index:99999',
|
|
668
|
+
'background:#7f1d1d','color:#fff','padding:10px 16px',
|
|
669
|
+
'font:13px/1.5 monospace','display:flex','gap:12px',
|
|
670
|
+
'align-items:center','justify-content:space-between',
|
|
671
|
+
].join(';');
|
|
672
|
+
b.innerHTML = [
|
|
673
|
+
'<span>',
|
|
674
|
+
'\u26a0\ufe0f <strong>Bosun daemon is not running.</strong>',
|
|
675
|
+
' This portal is in local mode — agents & tasks from your background service are unavailable.',
|
|
676
|
+
' Start it: ',
|
|
677
|
+
'<code style="background:rgba(255,255,255,.15);padding:2px 6px;border-radius:3px">bosun --daemon</code>',
|
|
678
|
+
' • Configure auto-start: ',
|
|
679
|
+
'<code style="background:rgba(255,255,255,.15);padding:2px 6px;border-radius:3px">bosun --setup</code>',
|
|
680
|
+
'</span>',
|
|
681
|
+
'<button onclick="document.getElementById(\'bosun-offline-banner\').remove()"',
|
|
682
|
+
' style="background:none;border:none;color:#fff;cursor:pointer;font-size:20px;line-height:1;padding:0 4px">×</button>',
|
|
683
|
+
].join(' ');
|
|
684
|
+
document.body.prepend(b);
|
|
685
|
+
})()`
|
|
686
|
+
)
|
|
687
|
+
.catch(() => {});
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function createFollowWindow() {
|
|
693
|
+
if (followWindow && !followWindow.isDestroyed()) return followWindow;
|
|
694
|
+
const iconPath = resolveBosunRuntimePath("logo.png");
|
|
695
|
+
followWindow = new BrowserWindow({
|
|
696
|
+
width: 460,
|
|
697
|
+
height: 720,
|
|
698
|
+
minWidth: 380,
|
|
699
|
+
minHeight: 520,
|
|
700
|
+
backgroundColor: "#0b0b0c",
|
|
701
|
+
...(existsSync(iconPath) ? { icon: iconPath } : {}),
|
|
702
|
+
show: false,
|
|
703
|
+
alwaysOnTop: true,
|
|
704
|
+
autoHideMenuBar: true,
|
|
705
|
+
skipTaskbar: false,
|
|
706
|
+
webPreferences: {
|
|
707
|
+
contextIsolation: true,
|
|
708
|
+
nodeIntegration: false,
|
|
709
|
+
sandbox: true,
|
|
710
|
+
backgroundThrottling: false,
|
|
711
|
+
preload: join(__dirname, "preload.mjs"),
|
|
712
|
+
},
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
followWindow.setAlwaysOnTop(true, "floating", 1);
|
|
716
|
+
|
|
717
|
+
followWindow.on("close", (event) => {
|
|
718
|
+
if (shuttingDown) return;
|
|
719
|
+
event.preventDefault();
|
|
720
|
+
followWindow?.hide();
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
followWindow.on("closed", () => {
|
|
724
|
+
followWindow = null;
|
|
725
|
+
followWindowLaunchSignature = "";
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
followWindow.once("ready-to-show", () => {
|
|
729
|
+
setWindowVisible(followWindow);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
return followWindow;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async function openFollowWindow(detail = {}) {
|
|
736
|
+
const win = await createFollowWindow();
|
|
737
|
+
const baseUiUrl = await buildUiUrl();
|
|
738
|
+
const target = buildFollowWindowUrl(baseUiUrl, detail);
|
|
739
|
+
const signature = target.toString();
|
|
740
|
+
if (!win.webContents.getURL() || followWindowLaunchSignature !== signature) {
|
|
741
|
+
followWindowLaunchSignature = signature;
|
|
742
|
+
await win.loadURL(signature);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
setWindowVisible(win);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function hideFollowWindow() {
|
|
749
|
+
if (!followWindow || followWindow.isDestroyed()) return false;
|
|
750
|
+
followWindow.hide();
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function restoreFollowWindow() {
|
|
755
|
+
if (!followWindow || followWindow.isDestroyed()) return false;
|
|
756
|
+
setWindowVisible(followWindow);
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Rebuild and apply the tray context menu (called after state changes). */
|
|
761
|
+
function refreshTrayMenu() {
|
|
762
|
+
if (!tray) return;
|
|
763
|
+
|
|
764
|
+
const openUrl = (url) => shell.openExternal(url).catch(() => {});
|
|
765
|
+
const isDev = !app.isPackaged;
|
|
766
|
+
|
|
767
|
+
const menu = Menu.buildFromTemplate([
|
|
768
|
+
// ── Identity header ──────────────────────────────────────────────────
|
|
769
|
+
{
|
|
770
|
+
label: "VirtEngine",
|
|
771
|
+
enabled: false,
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
label: "Open",
|
|
775
|
+
click: () => setWindowVisible(mainWindow),
|
|
776
|
+
},
|
|
777
|
+
{ type: /** @type {const} */ ("separator") },
|
|
778
|
+
|
|
779
|
+
// ── Launch Control ───────────────────────────────────────────────────
|
|
780
|
+
{
|
|
781
|
+
label: "Launch Control",
|
|
782
|
+
submenu: [
|
|
783
|
+
{
|
|
784
|
+
label: "Dashboard",
|
|
785
|
+
click: () => navigateMainWindow("/"),
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
label: "Agents",
|
|
789
|
+
click: () => navigateMainWindow("/agents"),
|
|
790
|
+
},
|
|
791
|
+
{
|
|
792
|
+
label: "Tasks",
|
|
793
|
+
click: () => navigateMainWindow("/tasks"),
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
label: "Logs",
|
|
797
|
+
click: () => navigateMainWindow("/logs"),
|
|
798
|
+
},
|
|
799
|
+
{ type: /** @type {const} */ ("separator") },
|
|
800
|
+
{
|
|
801
|
+
label: "Voice Companion",
|
|
802
|
+
accelerator: FOLLOW_RESTORE_SHORTCUT,
|
|
803
|
+
click: () => {
|
|
804
|
+
if (!restoreFollowWindow()) setWindowVisible(mainWindow);
|
|
805
|
+
},
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
label: "Restart to Apply Update",
|
|
811
|
+
enabled: app.isPackaged,
|
|
812
|
+
click: () => {
|
|
813
|
+
app.relaunch();
|
|
814
|
+
void shutdown("tray_restart_update");
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
label: "Preferences",
|
|
819
|
+
accelerator: "CmdOrCtrl+,",
|
|
820
|
+
click: () => navigateMainWindow("/settings"),
|
|
821
|
+
},
|
|
822
|
+
{ type: /** @type {const} */ ("separator") },
|
|
823
|
+
|
|
824
|
+
// ── Troubleshooting ──────────────────────────────────────────────────
|
|
825
|
+
{
|
|
826
|
+
label: "Troubleshooting",
|
|
827
|
+
submenu: [
|
|
828
|
+
{
|
|
829
|
+
label: "Reload UI",
|
|
830
|
+
click: () => mainWindow?.webContents?.reload(),
|
|
831
|
+
},
|
|
832
|
+
{
|
|
833
|
+
label: "Force Reload UI",
|
|
834
|
+
click: () => mainWindow?.webContents?.reloadIgnoringCache(),
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
label: "Clear Cache & Reload",
|
|
838
|
+
click: async () => {
|
|
839
|
+
try {
|
|
840
|
+
await session.defaultSession.clearCache();
|
|
841
|
+
} catch {
|
|
842
|
+
// best effort
|
|
843
|
+
}
|
|
844
|
+
mainWindow?.webContents?.reloadIgnoringCache();
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
{ type: /** @type {const} */ ("separator") },
|
|
848
|
+
{
|
|
849
|
+
label: "Toggle Developer Tools",
|
|
850
|
+
enabled: isDev || !app.isPackaged,
|
|
851
|
+
click: () => mainWindow?.webContents?.toggleDevTools(),
|
|
852
|
+
},
|
|
853
|
+
{ type: /** @type {const} */ ("separator") },
|
|
854
|
+
{
|
|
855
|
+
label: "View Logs",
|
|
856
|
+
click: () => navigateMainWindow("/logs"),
|
|
857
|
+
},
|
|
858
|
+
{
|
|
859
|
+
label: "Report an Issue",
|
|
860
|
+
click: () =>
|
|
861
|
+
openUrl("https://github.com/virtengine/bosun/issues/new"),
|
|
862
|
+
},
|
|
863
|
+
],
|
|
864
|
+
},
|
|
865
|
+
{ type: /** @type {const} */ ("separator") },
|
|
866
|
+
|
|
867
|
+
// ── Startup / login ──────────────────────────────────────────────
|
|
868
|
+
...( app.isPackaged
|
|
869
|
+
? [
|
|
870
|
+
{
|
|
871
|
+
label: "Start at Login",
|
|
872
|
+
type: /** @type {const} */ ("checkbox"),
|
|
873
|
+
checked: app.getLoginItemSettings().openAtLogin,
|
|
874
|
+
click: (item) => {
|
|
875
|
+
app.setLoginItemSettings({ openAtLogin: item.checked });
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
]
|
|
879
|
+
: []),
|
|
880
|
+
{ type: /** @type {const} */ ("separator") },
|
|
881
|
+
|
|
882
|
+
// ── Quit ───────────────────────────────────────────────────────────
|
|
883
|
+
{
|
|
884
|
+
label: "Quit",
|
|
885
|
+
accelerator: process.platform === "darwin" ? "Cmd+Q" : "Ctrl+Q",
|
|
886
|
+
click: () => {
|
|
887
|
+
void shutdown("tray_quit");
|
|
888
|
+
},
|
|
889
|
+
},
|
|
890
|
+
]);
|
|
891
|
+
|
|
892
|
+
tray.setContextMenu(menu);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function ensureTray() {
|
|
896
|
+
if (tray) return;
|
|
897
|
+
const iconPath = resolveBosunRuntimePath("logo.png");
|
|
898
|
+
if (!existsSync(iconPath)) return;
|
|
899
|
+
|
|
900
|
+
tray = new Tray(iconPath);
|
|
901
|
+
tray.setToolTip("Bosun — AI Control Center");
|
|
902
|
+
|
|
903
|
+
refreshTrayMenu();
|
|
904
|
+
|
|
905
|
+
// Single click: show/restore the main window (or follow window).
|
|
906
|
+
tray.on("click", () => {
|
|
907
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
908
|
+
if (mainWindow.isVisible() && mainWindow.isFocused()) {
|
|
909
|
+
mainWindow.hide();
|
|
910
|
+
if (process.platform === "darwin") app.dock?.hide();
|
|
911
|
+
} else {
|
|
912
|
+
setWindowVisible(mainWindow);
|
|
913
|
+
}
|
|
914
|
+
} else {
|
|
915
|
+
void createMainWindow();
|
|
916
|
+
}
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
// Double-click on Windows brings up the window unambiguously.
|
|
920
|
+
tray.on("double-click", () => {
|
|
921
|
+
setWindowVisible(mainWindow);
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* Display a native shortcuts reference dialog.
|
|
927
|
+
* Groups shortcuts by their group property so the list is easy to scan.
|
|
928
|
+
*/
|
|
929
|
+
function showShortcutsDialog() {
|
|
930
|
+
const shortcuts = getAllShortcuts();
|
|
931
|
+
|
|
932
|
+
// Group entries
|
|
933
|
+
/** @type {Map<string, typeof shortcuts>} */
|
|
934
|
+
const groups = new Map();
|
|
935
|
+
for (const s of shortcuts) {
|
|
936
|
+
const g = s.group || "Other";
|
|
937
|
+
if (!groups.has(g)) groups.set(g, []);
|
|
938
|
+
groups.get(g).push(s);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const isMac = process.platform === "darwin";
|
|
942
|
+
const modSymbol = isMac ? "⌘" : "Ctrl";
|
|
943
|
+
const shiftSym = isMac ? "⇧" : "+Shift";
|
|
944
|
+
|
|
945
|
+
const lines = [];
|
|
946
|
+
for (const [group, items] of groups) {
|
|
947
|
+
lines.push(`── ${group} ──`);
|
|
948
|
+
for (const s of items) {
|
|
949
|
+
const display = s.isDisabled
|
|
950
|
+
? "(disabled)"
|
|
951
|
+
: (s.accelerator ?? "(none)")
|
|
952
|
+
.replace(/CmdOrCtrl/g, modSymbol)
|
|
953
|
+
.replace(/CommandOrControl/g, modSymbol)
|
|
954
|
+
.replace(/Shift\+/g, `${shiftSym}+`);
|
|
955
|
+
const custom = s.isCustomized ? " ★" : "";
|
|
956
|
+
lines.push(` ${s.label.padEnd(30)} ${display}${custom}`);
|
|
957
|
+
}
|
|
958
|
+
lines.push("");
|
|
959
|
+
}
|
|
960
|
+
lines.push("★ = customized from default");
|
|
961
|
+
lines.push("");
|
|
962
|
+
lines.push("To customize shortcuts, edit:");
|
|
963
|
+
lines.push(` ${resolveDesktopConfigDir()}/desktop-shortcuts.json`);
|
|
964
|
+
|
|
965
|
+
dialog
|
|
966
|
+
.showMessageBox(mainWindow ?? undefined, {
|
|
967
|
+
type: "info",
|
|
968
|
+
title: "Bosun — Keyboard Shortcuts",
|
|
969
|
+
message: "Keyboard Shortcuts Reference",
|
|
970
|
+
detail: lines.join("\n"),
|
|
971
|
+
buttons: ["OK"],
|
|
972
|
+
})
|
|
973
|
+
.catch(() => {});
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Wire all shortcut action handlers and register global shortcuts.
|
|
978
|
+
* Called once during bootstrap, after the config dir is known.
|
|
979
|
+
*
|
|
980
|
+
* @param {string} configDir
|
|
981
|
+
*/
|
|
982
|
+
function initAndRegisterShortcuts(configDir) {
|
|
983
|
+
// Initialise the shortcuts manager (loads user customizations).
|
|
984
|
+
initShortcuts(configDir);
|
|
985
|
+
|
|
986
|
+
// ── Register action handlers ───────────────────────────────────────────
|
|
987
|
+
// Global: fire from anywhere on the desktop
|
|
988
|
+
onShortcut("bosun.focus", () => {
|
|
989
|
+
setWindowVisible(mainWindow);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
onShortcut("bosun.quickchat", () => {
|
|
993
|
+
setWindowVisible(mainWindow);
|
|
994
|
+
navigateMainWindow("/");
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
onShortcut("bosun.voice.call", () => {
|
|
998
|
+
openFollowWindow({ call: "voice" }).catch((err) =>
|
|
999
|
+
console.warn("[shortcuts] voice.call failed:", err?.message || err),
|
|
1000
|
+
);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
onShortcut("bosun.voice.video", () => {
|
|
1004
|
+
openFollowWindow({ call: "video" }).catch((err) =>
|
|
1005
|
+
console.warn("[shortcuts] voice.video failed:", err?.message || err),
|
|
1006
|
+
);
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
onShortcut("bosun.voice.toggle", () => {
|
|
1010
|
+
if (!restoreFollowWindow()) setWindowVisible(mainWindow);
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
// Local: navigation (also in menu, but registered here for completeness)
|
|
1014
|
+
onShortcut("bosun.navigate.home", () => navigateMainWindow("/"));
|
|
1015
|
+
onShortcut("bosun.navigate.agents", () => navigateMainWindow("/agents"));
|
|
1016
|
+
onShortcut("bosun.navigate.tasks", () => navigateMainWindow("/tasks"));
|
|
1017
|
+
onShortcut("bosun.navigate.logs", () => navigateMainWindow("/logs"));
|
|
1018
|
+
onShortcut("bosun.navigate.settings", () => navigateMainWindow("/settings"));
|
|
1019
|
+
onShortcut("app.newchat", () => navigateMainWindow("/"));
|
|
1020
|
+
onShortcut("app.settings", () => navigateMainWindow("/settings"));
|
|
1021
|
+
onShortcut("bosun.show.shortcuts", () => showShortcutsDialog());
|
|
1022
|
+
|
|
1023
|
+
// ── Register global shortcuts with the OS ────────────────────────────
|
|
1024
|
+
registerGlobalShortcuts();
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
function registerDesktopIpc() {
|
|
1028
|
+
ipcMain.handle("bosun:desktop:follow:open", async (_event, detail) => {
|
|
1029
|
+
await openFollowWindow(detail || {});
|
|
1030
|
+
return { ok: true };
|
|
1031
|
+
});
|
|
1032
|
+
ipcMain.handle("bosun:desktop:follow:hide", async () => {
|
|
1033
|
+
return { ok: hideFollowWindow() };
|
|
1034
|
+
});
|
|
1035
|
+
ipcMain.handle("bosun:desktop:follow:restore", async () => {
|
|
1036
|
+
return { ok: restoreFollowWindow() };
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// ── Shortcuts IPC ───────────────────────────────────────────────────
|
|
1040
|
+
/** Returns the full shortcuts catalog with effective accelerators. */
|
|
1041
|
+
ipcMain.handle("bosun:shortcuts:list", () => getAllShortcuts());
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Set a custom accelerator for a shortcut.
|
|
1045
|
+
* Payload: { id: string, accelerator: string | null }
|
|
1046
|
+
* Pass null to disable the shortcut.
|
|
1047
|
+
* Returns: { ok: boolean, error?: string }
|
|
1048
|
+
* Side-effects: re-registers global shortcuts + rebuilds the app menu.
|
|
1049
|
+
*/
|
|
1050
|
+
ipcMain.handle("bosun:shortcuts:set", (_event, { id, accelerator }) => {
|
|
1051
|
+
const result = setShortcut(id, accelerator);
|
|
1052
|
+
if (result.ok) {
|
|
1053
|
+
// Rebuild the menu so the new accelerator is reflected immediately.
|
|
1054
|
+
Menu.setApplicationMenu(buildAppMenu());
|
|
1055
|
+
refreshTrayMenu();
|
|
1056
|
+
}
|
|
1057
|
+
return result;
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Reset a single shortcut to its default.
|
|
1062
|
+
* Payload: { id: string }
|
|
1063
|
+
*/
|
|
1064
|
+
ipcMain.handle("bosun:shortcuts:reset", (_event, { id }) => {
|
|
1065
|
+
const result = resetShortcut(id);
|
|
1066
|
+
if (result.ok) {
|
|
1067
|
+
Menu.setApplicationMenu(buildAppMenu());
|
|
1068
|
+
refreshTrayMenu();
|
|
1069
|
+
}
|
|
1070
|
+
return result;
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
/** Reset ALL shortcuts to defaults. */
|
|
1074
|
+
ipcMain.handle("bosun:shortcuts:resetAll", () => {
|
|
1075
|
+
const result = resetAllShortcuts();
|
|
1076
|
+
if (result.ok) {
|
|
1077
|
+
Menu.setApplicationMenu(buildAppMenu());
|
|
1078
|
+
refreshTrayMenu();
|
|
1079
|
+
}
|
|
1080
|
+
return result;
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
/** Show the native keyboard shortcuts reference dialog. */
|
|
1084
|
+
ipcMain.handle("bosun:shortcuts:showDialog", () => {
|
|
1085
|
+
showShortcutsDialog();
|
|
1086
|
+
return { ok: true };
|
|
1087
|
+
});
|
|
327
1088
|
}
|
|
328
1089
|
|
|
329
1090
|
async function bootstrap() {
|
|
@@ -344,6 +1105,18 @@ async function bootstrap() {
|
|
|
344
1105
|
process.chdir(resolveBosunRoot());
|
|
345
1106
|
await loadRuntimeConfig();
|
|
346
1107
|
|
|
1108
|
+
// Provision (or reload) the dedicated Electron desktop API key.
|
|
1109
|
+
// The key is stored at {configDir}/desktop-api-key.json and is set in
|
|
1110
|
+
// process.env so that the in-process UI server can validate it without
|
|
1111
|
+
// importing the module directly (avoids circular dependency).
|
|
1112
|
+
try {
|
|
1113
|
+
const keyMod = await loadBosunModule("desktop-api-key.mjs");
|
|
1114
|
+
const desktopApiKey = keyMod.ensureDesktopApiKey(resolveDesktopConfigDir());
|
|
1115
|
+
process.env.BOSUN_DESKTOP_API_KEY = desktopApiKey;
|
|
1116
|
+
} catch (err) {
|
|
1117
|
+
console.warn("[desktop] could not load desktop-api-key module:", err?.message || err);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
347
1120
|
// Bypass TLS verification for the local embedded UI server.
|
|
348
1121
|
// setCertificateVerifyProc works at the OpenSSL level — it fires before
|
|
349
1122
|
// the higher-level `certificate-error` event and stops the repeated
|
|
@@ -357,7 +1130,44 @@ async function bootstrap() {
|
|
|
357
1130
|
});
|
|
358
1131
|
|
|
359
1132
|
await ensureDaemonRunning();
|
|
1133
|
+
|
|
1134
|
+
// Initialise shortcuts (loads user config) and register globals.
|
|
1135
|
+
// Must happen before buildAppMenu() so acc() returns correct values.
|
|
1136
|
+
initAndRegisterShortcuts(resolveDesktopConfigDir());
|
|
1137
|
+
|
|
1138
|
+
Menu.setApplicationMenu(buildAppMenu());
|
|
1139
|
+
|
|
1140
|
+
// Determine tray / background mode before creating any windows.
|
|
1141
|
+
trayMode = isTrayModeEnabled();
|
|
1142
|
+
// In tray mode the window starts hidden by default (background resident).
|
|
1143
|
+
// Set BOSUN_DESKTOP_START_HIDDEN=0 to open the window on every launch.
|
|
1144
|
+
startHidden = trayMode
|
|
1145
|
+
? parseBoolEnv(process.env.BOSUN_DESKTOP_START_HIDDEN, true)
|
|
1146
|
+
: false;
|
|
1147
|
+
|
|
1148
|
+
if (trayMode) {
|
|
1149
|
+
// Always create the tray icon first so the app has a presence even
|
|
1150
|
+
// before the window is ready.
|
|
1151
|
+
ensureTray();
|
|
1152
|
+
// On macOS, hide the dock icon in background mode unless the user has
|
|
1153
|
+
// explicitly disabled it.
|
|
1154
|
+
if (
|
|
1155
|
+
process.platform === "darwin"
|
|
1156
|
+
&& !parseBoolEnv(process.env.BOSUN_DESKTOP_SHOW_DOCK, false)
|
|
1157
|
+
) {
|
|
1158
|
+
app.dock?.hide();
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
registerDesktopIpc();
|
|
360
1163
|
await createMainWindow();
|
|
1164
|
+
|
|
1165
|
+
// In normal (non-background) mode the tray is still useful as an
|
|
1166
|
+
// indicator and quick-access — create it after the window is up.
|
|
1167
|
+
if (!trayMode) {
|
|
1168
|
+
ensureTray();
|
|
1169
|
+
}
|
|
1170
|
+
|
|
361
1171
|
await maybeAutoUpdate();
|
|
362
1172
|
} catch (error) {
|
|
363
1173
|
console.error("[desktop] startup failed", error);
|
|
@@ -403,6 +1213,13 @@ async function shutdown(reason) {
|
|
|
403
1213
|
|
|
404
1214
|
app.on("before-quit", () => {
|
|
405
1215
|
shuttingDown = true;
|
|
1216
|
+
// Unregister all custom global shortcuts before Electron's own cleanup.
|
|
1217
|
+
try { unregisterGlobalShortcuts(); } catch { /* ignore */ }
|
|
1218
|
+
globalShortcut.unregisterAll();
|
|
1219
|
+
if (tray) {
|
|
1220
|
+
tray.destroy();
|
|
1221
|
+
tray = null;
|
|
1222
|
+
}
|
|
406
1223
|
try {
|
|
407
1224
|
if (uiServerStarted && uiApi?.stopTelegramUiServer) {
|
|
408
1225
|
uiApi.stopTelegramUiServer();
|
|
@@ -430,12 +1247,18 @@ app.on(
|
|
|
430
1247
|
);
|
|
431
1248
|
|
|
432
1249
|
app.on("window-all-closed", () => {
|
|
1250
|
+
// In tray mode the app intentionally keeps running with no open windows.
|
|
1251
|
+
// Only shut down when quitting explicitly (e.g. tray menu Quit or Cmd+Q).
|
|
1252
|
+
if (trayMode) return;
|
|
433
1253
|
void shutdown("window_all_closed");
|
|
434
1254
|
});
|
|
435
1255
|
|
|
436
1256
|
app.on("activate", () => {
|
|
437
|
-
|
|
1257
|
+
// macOS: clicking the dock icon (re-)shows the app.
|
|
1258
|
+
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
438
1259
|
void createMainWindow();
|
|
1260
|
+
} else {
|
|
1261
|
+
setWindowVisible(mainWindow);
|
|
439
1262
|
}
|
|
440
1263
|
});
|
|
441
1264
|
|