aegis-desktop 0.3.1 → 0.4.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/main.js CHANGED
@@ -20,6 +20,8 @@
20
20
  'use strict';
21
21
 
22
22
  const path = require('node:path');
23
+ const fs = require('node:fs');
24
+ const { pathToFileURL } = require('node:url');
23
25
 
24
26
  // Dev / CI / smoke-test layout: desktop/ lives inside the repo, so the shared
25
27
  // client resolves via ../client/aegis.js. In the packaged app the client is
@@ -53,6 +55,9 @@ const ollama = require('./lib/local/ollama.js');
53
55
  const providers = require('./lib/local/providers.js');
54
56
  const sessionStore = require('./lib/sync/sessions.js');
55
57
  const memoryQueue = require('./lib/sync/memory-queue.js');
58
+ const windowState = require('./lib/window-state.js');
59
+ const deepLink = require('./lib/deep-link.js');
60
+ const quickLauncherLib = require('./lib/quick-launcher.js');
56
61
  // Foreign-memory scanner (shared with client/foreign-memory.js). Same
57
62
  // resolution rule as the transport above: the canonical file inside the repo,
58
63
  // the predist-staged copy in a packaged app. Never forked logic — so it needs
@@ -73,6 +78,7 @@ const IPC_PREFIX = 'aegis:';
73
78
  const MODEL_PREFIX = 'model:';
74
79
  const SYNC_PREFIX = 'sync:';
75
80
  const TOOLS_PREFIX = 'tools:';
81
+ const QUICK_PREFIX = 'quick:';
76
82
 
77
83
  /**
78
84
  * Main -> renderer push channel for chat SSE deltas (D2.1 streaming render).
@@ -81,6 +87,34 @@ const TOOLS_PREFIX = 'tools:';
81
87
  */
82
88
  const CHAT_DELTA_CHANNEL = `${IPC_PREFIX}chatDelta`;
83
89
 
90
+ /** Main -> renderer pushes for native menu accelerators (Cmd/Ctrl+N,
91
+ * Cmd/Ctrl+K) that have no Electron role to bind to — the renderer owns
92
+ * "new chat" and "open search" behaviour, so the menu just asks for it. */
93
+ const MENU_NEW_CHAT_CHANNEL = `${IPC_PREFIX}menuNewChat`;
94
+ const MENU_SEARCH_CHANNEL = `${IPC_PREFIX}menuSearch`;
95
+ /** File > Save as… / Export session — same "menu has no state of its own,
96
+ * renderer does the work" pattern as the two channels above: the renderer
97
+ * knows the open session id, main.js just pings it. */
98
+ const MENU_EXPORT_MARKDOWN_CHANNEL = `${IPC_PREFIX}menuExportMarkdown`;
99
+ const MENU_EXPORT_JSON_CHANNEL = `${IPC_PREFIX}menuExportJson`;
100
+
101
+ /**
102
+ * Main -> renderer push for a resolved aegis:// deep link (D? deep linking).
103
+ * Delivered once per link, after the target window has finished loading
104
+ * (see sendDeepLinkToWindow) so the renderer's listener is always attached
105
+ * before it arrives.
106
+ */
107
+ const DEEP_LINK_CHANNEL = `${IPC_PREFIX}deepLink`;
108
+
109
+ /**
110
+ * Main -> renderer push for a quick-launcher answer the user chose to keep
111
+ * (D? quick launcher §4). Carries `{ prompt, response, model }`; the main
112
+ * window's renderer already owns "add a turn to the open thread" (send()'s
113
+ * addMessage + sync.append), so — same "menu pings, renderer acts" pattern as
114
+ * MENU_NEW_CHAT_CHANNEL etc. — this just delivers the payload.
115
+ */
116
+ const QUICK_LAUNCHER_PUSH_CHANNEL = `${IPC_PREFIX}quickLauncherPush`;
117
+
84
118
  /**
85
119
  * Address one SSE delta to the stream that produced it (D2.2 multi-stream).
86
120
  *
@@ -293,11 +327,27 @@ async function importForeignMemory(aegis, dir, payload) {
293
327
  };
294
328
  }
295
329
 
330
+ /** Only http/https may be opened externally — file://, javascript:, etc.
331
+ * would hand the OS shell an arbitrary URI straight from model output. */
332
+ function isSafeExternalUrl(url) {
333
+ if (typeof url !== 'string' || !url) return false;
334
+ try {
335
+ const parsed = new URL(url);
336
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:';
337
+ } catch {
338
+ return false;
339
+ }
340
+ }
341
+
296
342
  /**
297
343
  * Pure mapping: IPC payload -> shared-client call. No Electron types here, so
298
- * tests can drive it with a stub client and a fake ipcMain.
344
+ * tests can drive it with a stub client and a fake ipcMain. `openExternal` is
345
+ * the one exception — it is itself just a function (default a harmless
346
+ * reject), injected by bootstrap() so this module still needs no `electron`
347
+ * import to stay unit-testable.
299
348
  */
300
- function createIpcDispatch(aegis, dir, persistApiKey) {
349
+ function createIpcDispatch(aegis, dir, persistApiKey, openExternal) {
350
+ const openExternalFn = openExternal || (() => Promise.reject(new Error('no opener configured')));
301
351
  const dispatch = {
302
352
  status: () => ({
303
353
  appVersion: APP_VERSION,
@@ -363,15 +413,45 @@ function createIpcDispatch(aegis, dir, persistApiKey) {
363
413
  importForeignMemory(aegis, dir, payload),
364
414
  importConversation: (payload) =>
365
415
  aegis.importConversation(payload || {}),
416
+
417
+ // Links inside rendered markdown must never navigate the app's own
418
+ // BrowserWindow (that would point the chat UI at an arbitrary model-
419
+ // supplied origin) — they open in the OS default browser instead.
420
+ openExternal: (payload) => {
421
+ const url = payload && payload.url;
422
+ if (!isSafeExternalUrl(url)) {
423
+ return Promise.resolve({ ok: false, reason: 'unsupported URL scheme' });
424
+ }
425
+ return Promise.resolve(openExternalFn(url)).then(
426
+ () => ({ ok: true }),
427
+ (err) => ({ ok: false, reason: errorText(err) })
428
+ );
429
+ },
366
430
  };
367
431
  return dispatch;
368
432
  }
369
433
 
434
+ /** Chain `onReplyFinished(event, result)` onto a dispatch promise without
435
+ * changing what it resolves/rejects with — the notifier is best-effort
436
+ * (native-notification side effect) and optional (undefined in every
437
+ * headless test path, where it is simply never called). */
438
+ function withReplyNotify(promise, event, onReplyFinished) {
439
+ if (!onReplyFinished) return promise;
440
+ return promise.then((result) => {
441
+ onReplyFinished(event, result);
442
+ return result;
443
+ });
444
+ }
445
+
370
446
  /** Register every dispatch method as `aegis:<name>` on ipcMain. `dir` (the
371
447
  * user-data dir) is optional and threaded through only for the memorySave
372
- * offline-queue fallback — see saveMemoryWithQueue(). */
373
- function registerIpc(ipcMain, aegis, dir, persistApiKey) {
374
- const dispatch = createIpcDispatch(aegis, dir, persistApiKey);
448
+ * offline-queue fallback — see saveMemoryWithQueue(). `openExternal` is the
449
+ * real electron.shell.openExternal, injected by bootstrap(); omitted in
450
+ * tests, where the safe no-op default in createIpcDispatch takes over.
451
+ * `onReplyFinished` is likewise bootstrap()-only: it fires the native
452
+ * "reply ready" notification when the window is unfocused/hidden. */
453
+ function registerIpc(ipcMain, aegis, dir, persistApiKey, openExternal, onReplyFinished) {
454
+ const dispatch = createIpcDispatch(aegis, dir, persistApiKey, openExternal);
375
455
  for (const [name, handler] of Object.entries(dispatch)) {
376
456
  if (name === 'chatCompletion') {
377
457
  // Streaming render (D2.1): when the renderer asks for stream, SSE deltas
@@ -381,7 +461,7 @@ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
381
461
  // dispatch (what the headless shell test drives directly).
382
462
  ipcMain.handle(`${IPC_PREFIX}${name}`, (event, payload) => {
383
463
  const opts = { ...(payload || {}) };
384
- if (!opts.stream) return handler(opts);
464
+ if (!opts.stream) return withReplyNotify(handler(opts), event, onReplyFinished);
385
465
  const sender = event && event.sender;
386
466
  const forward = (chunk) => {
387
467
  if (
@@ -398,7 +478,11 @@ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
398
478
  sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
399
479
  }
400
480
  };
401
- return handler({ ...opts, stream: true, onStream: forward });
481
+ return withReplyNotify(
482
+ handler({ ...opts, stream: true, onStream: forward }),
483
+ event,
484
+ onReplyFinished
485
+ );
402
486
  });
403
487
  continue;
404
488
  }
@@ -407,6 +491,161 @@ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
407
491
  return dispatch;
408
492
  }
409
493
 
494
+ /**
495
+ * Tool-call approval toggle ("confirm mode"): `aegis:getConfirmMode` /
496
+ * `aegis:setConfirmMode` (see registerConfirmModeIpc below). Pure injection of
497
+ * the settings store, same shape as createQuickLauncherDispatch — so it is
498
+ * unit-testable in plain Node with a stub store, and bootstrap() wires the
499
+ * real one (desktop/lib/settings.js, reserved `__confirmMode` namespace).
500
+ *
501
+ * `{ enabled: true }` (the default) is the historical behaviour: the engine's
502
+ * gate (desktop/lib/local/engine.js gatedExecuteTool) previews and asks before
503
+ * every exec/writeFile/editFile. `false` lets the renderer's Settings switch
504
+ * turn that off for good — the engine reads the flag per tool call, so the
505
+ * change applies to the very next call, no restart.
506
+ */
507
+ function createConfirmModeDispatch(settings) {
508
+ if (!settings) throw new Error('createConfirmModeDispatch requires a settings store');
509
+ return {
510
+ getConfirmMode: () => ({ enabled: settings.getConfirmMode() }),
511
+ setConfirmMode: (payload) => {
512
+ // Absent/undefined means "off"? No — an explicit boolean is required to
513
+ // change anything: a malformed payload must not silently disable the
514
+ // approval gate, so it only ever sets what was clearly asked for.
515
+ const enabled = payload && payload.enabled;
516
+ if (typeof enabled !== 'boolean') return { enabled: settings.getConfirmMode() };
517
+ return { enabled: settings.setConfirmMode(enabled) };
518
+ },
519
+ };
520
+ }
521
+
522
+ /** Register getConfirmMode/setConfirmMode as `aegis:<name>` on ipcMain —
523
+ * the same ${IPC_PREFIX}<method> convention registerIpc uses for the rest of
524
+ * the renderer's aegis.* bridge (their handlers need nothing but the
525
+ * payload, so the event is dropped exactly like every non-chatCompletion
526
+ * method there). */
527
+ function registerConfirmModeIpc(ipcMain, dispatch) {
528
+ for (const [name, handler] of Object.entries(dispatch)) {
529
+ ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
530
+ }
531
+ return dispatch;
532
+ }
533
+
534
+ /** Turn a session title/id into a filesystem-safe base filename. */
535
+ function safeExportBasename(session) {
536
+ const base = (session && (session.title || session.id)) || 'session';
537
+ return base.replace(/[^\w.-]+/g, '_').slice(0, 80) || 'session';
538
+ }
539
+
540
+ /**
541
+ * Pure mapping: `aegis:exportSession` -> read the session from the sessions
542
+ * store (lib/sync/sessions.js is the single source of truth for its shape),
543
+ * serialize it, then hand the (defaultPath, filters) to an injected
544
+ * `showSaveDialog` and the resulting path + content to an injected
545
+ * `writeFile`. Both are injected — exactly like `openExternal` on
546
+ * createIpcDispatch — so this is unit-testable with stub functions and needs
547
+ * no Electron import of its own; bootstrap() wires the real
548
+ * dialog.showSaveDialog / fs.promises.writeFile.
549
+ */
550
+ function createExportDispatch(sessions, dir, showSaveDialog, writeFile) {
551
+ return {
552
+ exportSession: async (payload) => {
553
+ const sessionId = payload && payload.sessionId;
554
+ const format = payload && payload.format === 'json' ? 'json' : 'markdown';
555
+ const session = sessionId ? sessions.getSession(dir, sessionId) : null;
556
+ if (!session) return { ok: false, reason: 'session not found' };
557
+
558
+ const content = format === 'json' ? sessions.toJson(session) : sessions.toMarkdown(session);
559
+ const ext = format === 'json' ? 'json' : 'md';
560
+ const filters = format === 'json'
561
+ ? [{ name: 'JSON', extensions: ['json'] }]
562
+ : [{ name: 'Markdown', extensions: ['md'] }];
563
+
564
+ const dialogResult = await showSaveDialog({
565
+ defaultPath: `${safeExportBasename(session)}.${ext}`,
566
+ filters,
567
+ });
568
+ if (!dialogResult || dialogResult.canceled || !dialogResult.filePath) {
569
+ return { ok: false, canceled: true };
570
+ }
571
+
572
+ try {
573
+ await writeFile(dialogResult.filePath, content);
574
+ } catch (err) {
575
+ return { ok: false, reason: errorText(err) };
576
+ }
577
+ return { ok: true, filePath: dialogResult.filePath };
578
+ },
579
+ };
580
+ }
581
+
582
+ /** Register export dispatch methods as `aegis:<name>`, same convention as
583
+ * registerIpc()/registerUpdateIpc() above. */
584
+ function registerExportIpc(ipcMain, sessions, dir, showSaveDialog, writeFile) {
585
+ const dispatch = createExportDispatch(sessions, dir, showSaveDialog, writeFile);
586
+ for (const [name, handler] of Object.entries(dispatch)) {
587
+ ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
588
+ }
589
+ return dispatch;
590
+ }
591
+
592
+ /**
593
+ * Pure mapping: `quick:<name>` -> quick-launcher state. `opts.isPackaged`,
594
+ * `opts.applyConfig` and `opts.pushToMain` are injected (default no-ops) so
595
+ * this is unit-testable in plain Node, same pattern as createExportDispatch's
596
+ * injected showSaveDialog/writeFile — bootstrap() wires the real
597
+ * globalShortcut-backed applyConfig and window-relaying pushToMain.
598
+ *
599
+ * `applyConfig(cfg)` is expected to (re)register or unregister the global
600
+ * shortcut for the given `{ enabled, shortcut }` and resolve/return
601
+ * `{ active, reason }` — `reason` carries a human-readable cause the one time
602
+ * registration fails (e.g. the accelerator is already claimed by another
603
+ * app), so the renderer's settings card can show it instead of a silent
604
+ * no-op. This module never throws on a failed registration; it degrades to
605
+ * `active:false` and surfaces `reason`.
606
+ */
607
+ function createQuickLauncherDispatch(settings, opts = {}) {
608
+ const isPackaged = Boolean(opts.isPackaged);
609
+ const applyConfig = opts.applyConfig || (() => ({ active: false, reason: null }));
610
+ const pushToMain = opts.pushToMain || (() => ({ ok: false, reason: 'no main window' }));
611
+ let state = { active: false, reason: null };
612
+
613
+ function status() {
614
+ return { ...settings.quickLauncherConfig(), packaged: isPackaged, ...state };
615
+ }
616
+
617
+ function setConfig(payload) {
618
+ const saved = settings.setQuickLauncherConfig({
619
+ enabled: payload && payload.enabled,
620
+ shortcut: payload && payload.shortcut,
621
+ });
622
+ state = applyConfig(saved) || { active: false, reason: null };
623
+ return { ...saved, packaged: isPackaged, ...state };
624
+ }
625
+
626
+ return {
627
+ status,
628
+ setConfig,
629
+ pushToMain: (payload, event) => pushToMain(payload, event),
630
+ };
631
+ }
632
+
633
+ /** Register quick:<name> on ipcMain. `pushToMain` needs the raw IPC `event`
634
+ * (to hide the launcher window that sent it — see bootstrap()'s
635
+ * pushQuickLauncherResult), so it is special-cased exactly like
636
+ * aegis:chatCompletion is in registerIpc(); every other method drops the
637
+ * event, matching the rest of this file's convention. */
638
+ function registerQuickLauncherIpc(ipcMain, dispatch) {
639
+ for (const [name, handler] of Object.entries(dispatch)) {
640
+ if (name === 'pushToMain') {
641
+ ipcMain.handle(`${QUICK_PREFIX}${name}`, (event, payload) => handler(payload, event));
642
+ continue;
643
+ }
644
+ ipcMain.handle(`${QUICK_PREFIX}${name}`, (_event, payload) => handler(payload));
645
+ }
646
+ return dispatch;
647
+ }
648
+
410
649
  /**
411
650
  * Resolve the per-user data directory for settings + sessions. Electron gives
412
651
  * us app.getPath('userData'); outside Electron (headless smoke tests) fall back
@@ -458,6 +697,14 @@ function createModelDispatch(engine) {
458
697
  'settings.remove': (payload) =>
459
698
  engine.settings.remove(payload && payload.provider),
460
699
  cancel: (payload) => engine.cancel(payload && payload.sessionId),
700
+ // Tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool):
701
+ // the renderer's approval card answers a pending exec/writeFile/editFile
702
+ // request here; newChat() clears a conversation's "allow for this
703
+ // session" grants so a fresh thread never inherits a prior one's.
704
+ respondApproval: (payload) =>
705
+ engine.respondApproval(payload && payload.approvalId, payload && payload.decision),
706
+ clearApprovals: (payload) =>
707
+ engine.clearSessionApprovals(payload && payload.sessionId),
461
708
  };
462
709
  }
463
710
 
@@ -608,8 +855,12 @@ function createSyncDispatch(sessions, dir, aegis) {
608
855
  * any pending sessions (plan §7 "retry on a heartbeat"): fire-and-forget,
609
856
  * never awaited, never throws — it just gives queued sessions another
610
857
  * chance to sync without a dedicated poller.
858
+ *
859
+ * `onReplyFinished`, like the identical param on registerIpc, is
860
+ * bootstrap()-only — it fires the native "reply ready" notification when the
861
+ * window is unfocused/hidden and is never set in the headless test path.
611
862
  */
612
- function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
863
+ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished) {
613
864
  const modelDispatch = createModelDispatch(engine);
614
865
  const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis);
615
866
 
@@ -634,7 +885,11 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
634
885
  sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
635
886
  }
636
887
  };
637
- return handler({ ...opts, onStream: forward });
888
+ return withReplyNotify(
889
+ handler({ ...opts, onStream: forward }),
890
+ event,
891
+ onReplyFinished
892
+ );
638
893
  });
639
894
  continue;
640
895
  }
@@ -662,15 +917,322 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
662
917
  return { modelDispatch, syncDispatch };
663
918
  }
664
919
 
920
+ /**
921
+ * Main -> renderer push channel for auto-update status (mirrors
922
+ * CHAT_DELTA_CHANNEL): the renderer's update banner listens here instead of
923
+ * polling `aegis:updateStatus`.
924
+ */
925
+ const UPDATE_STATUS_CHANNEL = `${IPC_PREFIX}updateStatus`;
926
+
927
+ /** Re-check for a new release every few hours — frequent enough that a
928
+ * long-lived session still notices a release, rare enough to not hammer
929
+ * the GitHub Releases API. */
930
+ const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
931
+
932
+ /**
933
+ * Auto-update state machine over electron-updater (GitHub Releases provider,
934
+ * configured via desktop/electron-builder.yml `publish:` — see main.js's
935
+ * top-of-file note and that file's comment for the feed this points at).
936
+ *
937
+ * Deliberately inert outside a packaged app: `electron .` in dev has no
938
+ * app-update.yml (electron-builder only writes one at build time), so
939
+ * calling checkForUpdates() there would just throw on every launch. Rather
940
+ * than special-case that error, `isPackaged: false` skips wiring the real
941
+ * autoUpdater entirely and every method resolves a harmless 'disabled'
942
+ * state — dev never touches the network or the update machinery.
943
+ *
944
+ * `autoDownload` stays false: checking happens automatically (on ready and
945
+ * on the interval below), but the multi-hundred-MB download itself only
946
+ * starts when the renderer's banner "Download" button calls download() —
947
+ * see the IPC dispatch below. This keeps every step user-visible and
948
+ * matches the "never auto-restart without consent" rule: quitAndInstall()
949
+ * is likewise only ever invoked by an explicit "Restart to install" click.
950
+ */
951
+ function createUpdateManager({ autoUpdater, isPackaged, onStatus }) {
952
+ let state = { status: 'idle', version: null, error: null };
953
+
954
+ function setState(patch) {
955
+ state = { ...state, ...patch };
956
+ if (onStatus) onStatus(state);
957
+ }
958
+
959
+ if (!isPackaged || !autoUpdater) {
960
+ const disabledState = { status: 'disabled', version: null, error: null };
961
+ return {
962
+ status: () => disabledState,
963
+ check: () => Promise.resolve(disabledState),
964
+ download: () => Promise.resolve(disabledState),
965
+ quitAndInstall: () => {},
966
+ start: () => {},
967
+ };
968
+ }
969
+
970
+ autoUpdater.autoDownload = false;
971
+ autoUpdater.autoInstallOnAppQuit = false;
972
+
973
+ autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: null }));
974
+ autoUpdater.on('update-available', (info) =>
975
+ setState({ status: 'available', version: info && info.version, error: null })
976
+ );
977
+ autoUpdater.on('update-not-available', () => setState({ status: 'up-to-date', error: null }));
978
+ autoUpdater.on('download-progress', (progress) =>
979
+ setState({ status: 'downloading', progress: progress && progress.percent })
980
+ );
981
+ autoUpdater.on('update-downloaded', (info) =>
982
+ setState({ status: 'downloaded', version: info && info.version, error: null })
983
+ );
984
+ // Any failure — offline, a malformed feed, a 404 on latest.yml — lands
985
+ // here instead of an unhandled rejection, since electron-updater emits
986
+ // 'error' for background failures that occur outside the promise a
987
+ // check()/download() call is awaiting.
988
+ autoUpdater.on('error', (err) => setState({ status: 'error', error: errorText(err) }));
989
+
990
+ async function check() {
991
+ try {
992
+ await autoUpdater.checkForUpdates();
993
+ } catch (err) {
994
+ setState({ status: 'error', error: errorText(err) });
995
+ }
996
+ return state;
997
+ }
998
+
999
+ async function download() {
1000
+ try {
1001
+ await autoUpdater.downloadUpdate();
1002
+ } catch (err) {
1003
+ setState({ status: 'error', error: errorText(err) });
1004
+ }
1005
+ return state;
1006
+ }
1007
+
1008
+ function quitAndInstall() {
1009
+ autoUpdater.quitAndInstall();
1010
+ }
1011
+
1012
+ function start() {
1013
+ check();
1014
+ const timer = setInterval(check, UPDATE_CHECK_INTERVAL_MS);
1015
+ // Never keep the process alive solely to poll for updates.
1016
+ if (timer.unref) timer.unref();
1017
+ }
1018
+
1019
+ return { status: () => state, check, download, quitAndInstall, start };
1020
+ }
1021
+
1022
+ /** Pure mapping: IPC payload -> update-manager call, same shape as
1023
+ * createIpcDispatch — unit-testable with a stub updateManager. */
1024
+ function createUpdateDispatch(updateManager) {
1025
+ return {
1026
+ checkForUpdates: () => updateManager.check(),
1027
+ downloadUpdate: () => updateManager.download(),
1028
+ quitAndInstallUpdate: () => {
1029
+ updateManager.quitAndInstall();
1030
+ return { ok: true };
1031
+ },
1032
+ updateStatus: () => Promise.resolve(updateManager.status()),
1033
+ };
1034
+ }
1035
+
1036
+ /** Register update dispatch methods as `aegis:<name>`, same convention as
1037
+ * registerIpc() above. */
1038
+ function registerUpdateIpc(ipcMain, updateManager) {
1039
+ const dispatch = createUpdateDispatch(updateManager);
1040
+ for (const [name, handler] of Object.entries(dispatch)) {
1041
+ ipcMain.handle(`${IPC_PREFIX}${name}`, (_event, payload) => handler(payload));
1042
+ }
1043
+ return dispatch;
1044
+ }
1045
+
1046
+ /** Send a no-payload ping to whichever window currently has focus (falling
1047
+ * back to the first open window so a menu click still does something when
1048
+ * triggered via a global accelerator with no window focused, e.g. right
1049
+ * after 'activate' on mac). Used for the two accelerators that have no
1050
+ * built-in Electron role — "new chat" and "search" are renderer state, not
1051
+ * something main.js can act on directly. */
1052
+ function sendToFocusedWindow(BrowserWindow, channel) {
1053
+ const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
1054
+ if (win && !win.isDestroyed()) win.webContents.send(channel);
1055
+ }
1056
+
1057
+ /**
1058
+ * Deliver a resolved deep link to one window over DEEP_LINK_CHANNEL. A cold
1059
+ * launch (`aegis://…` handed to the OS while the app wasn't running) reaches
1060
+ * this before the renderer script has run `aegis.onDeepLink(...)` — sending
1061
+ * immediately would be lost, so a still-loading page gets the payload queued
1062
+ * for its first 'did-finish-load' instead of sent right away.
1063
+ */
1064
+ function sendDeepLinkToWindow(win, parsed) {
1065
+ if (!win || win.isDestroyed() || !parsed) return;
1066
+ const deliver = () => {
1067
+ if (!win.isDestroyed()) win.webContents.send(DEEP_LINK_CHANNEL, parsed);
1068
+ };
1069
+ if (win.webContents.isLoadingMainFrame()) {
1070
+ win.webContents.once('did-finish-load', deliver);
1071
+ } else {
1072
+ deliver();
1073
+ }
1074
+ }
1075
+
1076
+ /** Application menu: the platform defaults (Edit roles, mac's app/quit
1077
+ * items) plus the native-shell accelerators this plan adds — New Chat
1078
+ * (Cmd/Ctrl+N), Search (Cmd/Ctrl+K, opens the memory/session search
1079
+ * overlay), Reload (Cmd/Ctrl+R) and Toggle DevTools (Cmd/Ctrl+Shift+I) are
1080
+ * plain Electron roles that already carry the right accelerator on every
1081
+ * platform — plus a manual "Check for Updates…" entry, since the automatic
1082
+ * checks in createUpdateManager.start() only run on ready and every few
1083
+ * hours, and an About item (mac gets one for free in the app submenu; other
1084
+ * platforms get a Help menu — role 'about' shows app.setAboutPanelOptions()
1085
+ * cross-platform since Electron 15). */
1086
+ function buildAppMenu({ app, Menu, BrowserWindow, updateManager }) {
1087
+ const isMac = process.platform === 'darwin';
1088
+
1089
+ app.setAboutPanelOptions({
1090
+ applicationName: app.getName(),
1091
+ applicationVersion: app.getVersion(),
1092
+ version: app.getVersion(),
1093
+ });
1094
+
1095
+ const checkForUpdatesItem = {
1096
+ label: 'Check for Updates…',
1097
+ click: () => updateManager.check(),
1098
+ };
1099
+ const newChatItem = {
1100
+ label: 'New Chat',
1101
+ accelerator: 'CmdOrCtrl+N',
1102
+ click: () => sendToFocusedWindow(BrowserWindow, MENU_NEW_CHAT_CHANNEL),
1103
+ };
1104
+ const searchItem = {
1105
+ label: 'Search…',
1106
+ accelerator: 'CmdOrCtrl+K',
1107
+ click: () => sendToFocusedWindow(BrowserWindow, MENU_SEARCH_CHANNEL),
1108
+ };
1109
+ // Neither item knows the open session id — that's renderer state — so, like
1110
+ // newChatItem/searchItem above, the click just pings the renderer, which
1111
+ // calls window.aegis.exportSession() with its own currentSessionId. "Save
1112
+ // as…" writes the human-readable Markdown form; "Export Session…" writes
1113
+ // the raw JSON record (round-trippable, e.g. for re-import).
1114
+ const saveAsItem = {
1115
+ label: 'Save as…',
1116
+ accelerator: 'CmdOrCtrl+S',
1117
+ click: () => sendToFocusedWindow(BrowserWindow, MENU_EXPORT_MARKDOWN_CHANNEL),
1118
+ };
1119
+ const exportSessionItem = {
1120
+ label: 'Export Session…',
1121
+ click: () => sendToFocusedWindow(BrowserWindow, MENU_EXPORT_JSON_CHANNEL),
1122
+ };
1123
+
1124
+ const template = [
1125
+ ...(isMac
1126
+ ? [
1127
+ {
1128
+ label: app.getName(),
1129
+ submenu: [
1130
+ { role: 'about' },
1131
+ checkForUpdatesItem,
1132
+ { type: 'separator' },
1133
+ { role: 'services' },
1134
+ { type: 'separator' },
1135
+ { role: 'hide' },
1136
+ { role: 'hideOthers' },
1137
+ { role: 'unhide' },
1138
+ { type: 'separator' },
1139
+ { role: 'quit' },
1140
+ ],
1141
+ },
1142
+ ]
1143
+ : []),
1144
+ {
1145
+ label: 'File',
1146
+ submenu: [
1147
+ newChatItem,
1148
+ { type: 'separator' },
1149
+ saveAsItem,
1150
+ exportSessionItem,
1151
+ { type: 'separator' },
1152
+ ...(isMac ? [] : [checkForUpdatesItem, { type: 'separator' }]),
1153
+ isMac ? { role: 'close' } : { role: 'quit' },
1154
+ ],
1155
+ },
1156
+ {
1157
+ label: 'Edit',
1158
+ submenu: [
1159
+ { role: 'undo' },
1160
+ { role: 'redo' },
1161
+ { type: 'separator' },
1162
+ { role: 'cut' },
1163
+ { role: 'copy' },
1164
+ { role: 'paste' },
1165
+ { role: 'selectAll' },
1166
+ ],
1167
+ },
1168
+ {
1169
+ label: 'View',
1170
+ submenu: [
1171
+ searchItem,
1172
+ { type: 'separator' },
1173
+ { role: 'reload', accelerator: 'CmdOrCtrl+R' },
1174
+ { role: 'forceReload' },
1175
+ { type: 'separator' },
1176
+ { role: 'toggleDevTools', accelerator: 'CmdOrCtrl+Shift+I' },
1177
+ ],
1178
+ },
1179
+ ...(isMac
1180
+ ? []
1181
+ : [
1182
+ {
1183
+ label: 'Help',
1184
+ submenu: [{ role: 'about' }],
1185
+ },
1186
+ ]),
1187
+ ];
1188
+ return Menu.buildFromTemplate(template);
1189
+ }
1190
+
665
1191
  // ---------------------------------------------------------------------------
666
1192
  // Electron-only bootstrap
667
1193
  // ---------------------------------------------------------------------------
668
1194
 
669
1195
  function bootstrap() {
670
- const { app, BrowserWindow, ipcMain, safeStorage } = electron;
1196
+ const { app, BrowserWindow, ipcMain, safeStorage, shell, Menu, Notification, screen, dialog, globalShortcut } = electron;
1197
+
1198
+ // A second launch (double-clicking the icon again, `aegis` from a second
1199
+ // terminal, …) must focus the existing window, not spawn a second process
1200
+ // fighting the first over the same on-disk settings/session/queue files —
1201
+ // see the 'second-instance' handler below, registered once the window
1202
+ // machinery it calls (createWindow/openWindows) exists later in this
1203
+ // function (safe: the event only fires well after bootstrap() returns).
1204
+ //
1205
+ // A second launch is also how an aegis:// link reaches an already-running
1206
+ // app on Windows/Linux: the OS starts a second process with the URL on its
1207
+ // argv, requestSingleInstanceLock() hands that argv to 'second-instance' on
1208
+ // *this* process below, and the second process exits here.
1209
+ if (!app.requestSingleInstanceLock()) {
1210
+ app.quit();
1211
+ return;
1212
+ }
671
1213
 
672
1214
  app.setName('AEGIS Desktop');
673
1215
 
1216
+ // Register the aegis:// scheme so the OS routes those links to this app.
1217
+ // Safe to call unconditionally (idempotent) and before 'ready'.
1218
+ app.setAsDefaultProtocolClient(deepLink.PROTOCOL);
1219
+
1220
+ // macOS delivers a registered scheme via 'open-url', not argv — must be
1221
+ // listened for before 'ready' or an activation during launch is dropped.
1222
+ // A cold launch (app not yet running) races this handler against
1223
+ // createWindow() below, so the parsed link is stashed and flushed once the
1224
+ // first window exists; a warm launch (already running) delivers straight
1225
+ // to the focused window, mirroring 'second-instance' below.
1226
+ let pendingDeepLink = deepLink.parseDeepLinkArgv(process.argv);
1227
+ app.on('open-url', (event, url) => {
1228
+ event.preventDefault();
1229
+ const parsed = deepLink.parseDeepLinkUrl(url);
1230
+ if (!parsed) return;
1231
+ const win = BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0];
1232
+ if (win) sendDeepLinkToWindow(win, parsed);
1233
+ else pendingDeepLink = parsed;
1234
+ });
1235
+
674
1236
  const aegis = createClient();
675
1237
  const dataDir = resolveUserDataDir(app);
676
1238
 
@@ -688,13 +1250,252 @@ function bootstrap() {
688
1250
  // "Remove" delete the AEGIS key; defect #1).
689
1251
  const persistApiKey = (key) => settings.setAegisKey(key);
690
1252
 
691
- registerIpc(ipcMain, aegis, dataDir, persistApiKey);
692
- registerModelIpc(ipcMain, engine, sessionsDir, aegis);
1253
+ // Native "reply ready" notification: main.js is exactly where every
1254
+ // chatCompletion/model:chat call already resolves (withReplyNotify above),
1255
+ // so this is the one place that can tell whether the window that asked for
1256
+ // it is still the one in front. Fires only when that window is
1257
+ // minimized/hidden/unfocused; a foregrounded chat needs no OS-level nudge.
1258
+ // Notification.isSupported() gates platforms/sandboxes with no native
1259
+ // notification centre so this never throws.
1260
+ function notifyReplyIfUnfocused(event) {
1261
+ const sender = event && event.sender;
1262
+ const win = sender && BrowserWindow.fromWebContents(sender);
1263
+ if (!win || win.isDestroyed()) return;
1264
+ if (win.isFocused() && win.isVisible()) return;
1265
+ if (!Notification || !Notification.isSupported()) return;
1266
+ const note = new Notification({ title: 'AEGIS Desktop', body: 'Reply ready' });
1267
+ note.on('click', () => {
1268
+ if (win.isDestroyed()) return;
1269
+ if (win.isMinimized()) win.restore();
1270
+ if (!win.isVisible()) win.show();
1271
+ win.focus();
1272
+ });
1273
+ note.show();
1274
+ }
1275
+
1276
+ registerIpc(
1277
+ ipcMain,
1278
+ aegis,
1279
+ dataDir,
1280
+ persistApiKey,
1281
+ (url) => shell.openExternal(url),
1282
+ notifyReplyIfUnfocused
1283
+ );
1284
+ registerModelIpc(ipcMain, engine, sessionsDir, aegis, notifyReplyIfUnfocused);
1285
+
1286
+ // Tool-call approval toggle (Settings → "Confirm before running tools"):
1287
+ // aegis:getConfirmMode / aegis:setConfirmMode. Registered here because this
1288
+ // is where the settings store lands — the exact tool gate it controls lives
1289
+ // in the engine created alongside it (lib/local/engine.js gatedExecuteTool
1290
+ // reads settings.getConfirmMode() on every mutating tool call).
1291
+ registerConfirmModeIpc(ipcMain, createConfirmModeDispatch(settings));
1292
+
1293
+ // ---------------------------------------------------------------------
1294
+ // Global quick-launcher (D? quick launcher): a frameless, always-on-top,
1295
+ // taskbar-hidden popup toggled by a systemwide shortcut, for a one-shot
1296
+ // question without switching to (or even seeing) the main window.
1297
+ // ---------------------------------------------------------------------
1298
+
1299
+ // Set by createWindow() below once the main window exists; read by
1300
+ // pushQuickLauncherResult() to know where a "add to chat" push should land.
1301
+ // `let` (not `const`) because createWindow() can run more than once
1302
+ // (macOS 'activate' with no windows open, a 'second-instance' relaunch).
1303
+ let mainWindowRef = null;
1304
+ let quickWin = null;
1305
+
1306
+ function getOrCreateQuickLauncherWindow() {
1307
+ if (quickWin && !quickWin.isDestroyed()) return quickWin;
1308
+ quickWin = new BrowserWindow({
1309
+ width: 560,
1310
+ height: 320,
1311
+ show: false,
1312
+ frame: false,
1313
+ alwaysOnTop: true,
1314
+ skipTaskbar: true,
1315
+ resizable: false,
1316
+ movable: true,
1317
+ fullscreenable: false,
1318
+ minimizable: false,
1319
+ maximizable: false,
1320
+ backgroundColor: '#0d1117',
1321
+ webPreferences: {
1322
+ preload: path.join(__dirname, 'preload.js'),
1323
+ contextIsolation: true,
1324
+ nodeIntegration: false,
1325
+ sandbox: true,
1326
+ },
1327
+ });
1328
+ quickWin.setMenuBarVisibility(false);
1329
+ quickWin.loadFile(path.join(__dirname, 'renderer', 'quick.html'));
1330
+
1331
+ // Escape hides it. Caught at the Electron input-event level (rather than
1332
+ // a renderer keydown -> IPC round trip) so there is exactly one place —
1333
+ // here — that decides what "hide" means, matching the blur handler right
1334
+ // below: plain win.hide(), nothing more. Neither path ever calls
1335
+ // mainWindowRef.focus() or otherwise touches the main window, so hiding
1336
+ // the launcher — by Escape, by blur, or by pressing the toggle shortcut
1337
+ // again — can never steal focus from whatever window had it before the
1338
+ // launcher opened.
1339
+ quickWin.webContents.on('before-input-event', (_event, input) => {
1340
+ if (input.type === 'keyDown' && input.key === 'Escape' && !quickWin.isDestroyed()) {
1341
+ quickWin.hide();
1342
+ }
1343
+ });
1344
+ // Click-away / Alt-Tab away hides it too — a launcher that stays pinned
1345
+ // on screen after the user's attention has moved on is just clutter.
1346
+ quickWin.on('blur', () => {
1347
+ if (!quickWin.isDestroyed() && quickWin.isVisible()) quickWin.hide();
1348
+ });
1349
+ quickWin.on('closed', () => {
1350
+ quickWin = null;
1351
+ });
1352
+ return quickWin;
1353
+ }
1354
+
1355
+ function toggleQuickLauncher() {
1356
+ const win = getOrCreateQuickLauncherWindow();
1357
+ if (win.isVisible()) {
1358
+ win.hide();
1359
+ return;
1360
+ }
1361
+ const cursor = screen.getCursorScreenPoint();
1362
+ const displays = screen.getAllDisplays();
1363
+ const current = win.getBounds();
1364
+ const bounds = quickLauncherLib.computeQuickLauncherBounds({
1365
+ cursor,
1366
+ displays,
1367
+ size: { width: current.width, height: current.height },
1368
+ });
1369
+ win.setBounds(bounds);
1370
+ win.show();
1371
+ win.focus();
1372
+ }
1373
+
1374
+ /**
1375
+ * (Re)register the global shortcut for `{ enabled, shortcut }`, or
1376
+ * unregister it when this build/config doesn't want one active (dev run,
1377
+ * flag off). `globalShortcut.unregisterAll()` is safe here — this app
1378
+ * registers no other global accelerators (the menu's Cmd/Ctrl+N etc. are
1379
+ * plain Electron Menu roles, local to the focused window, not
1380
+ * globalShortcut). Never throws: a taken accelerator or an invalid
1381
+ * accelerator string both degrade to `{ active: false, reason }` with a
1382
+ * console.warn, exactly per the "gracefully degrading" requirement — a
1383
+ * bad shortcut must never crash the app or block it from starting.
1384
+ */
1385
+ function applyQuickLauncherConfig(cfg) {
1386
+ globalShortcut.unregisterAll();
1387
+ if (!quickLauncherLib.shouldEnableGlobalShortcut({ isPackaged: app.isPackaged, enabled: cfg.enabled })) {
1388
+ return { active: false, reason: null };
1389
+ }
1390
+ try {
1391
+ const ok = globalShortcut.register(cfg.shortcut, toggleQuickLauncher);
1392
+ if (!ok) {
1393
+ const reason = `"${cfg.shortcut}" could not be registered — it may already be in use by another application.`;
1394
+ console.warn(`[quick-launcher] ${reason}`);
1395
+ return { active: false, reason };
1396
+ }
1397
+ return { active: true, reason: null };
1398
+ } catch (err) {
1399
+ const reason = errorText(err);
1400
+ console.warn(`[quick-launcher] failed to register shortcut "${cfg.shortcut}": ${reason}`);
1401
+ return { active: false, reason };
1402
+ }
1403
+ }
1404
+
1405
+ /**
1406
+ * `quick:pushToMain` — the launcher's "add to chat" keystroke. Hides the
1407
+ * launcher window that sent the request (never the reverse: the main
1408
+ * window is never hidden), delivers the payload to the main window over
1409
+ * QUICK_LAUNCHER_PUSH_CHANNEL (whose handler in renderer/app.js owns
1410
+ * actually building the chat turn — same "renderer owns the state" split
1411
+ * as every other menu-ping channel in this file), then brings the main
1412
+ * window forward so the user lands where the new turn appeared.
1413
+ */
1414
+ function pushQuickLauncherResult(payload, event) {
1415
+ const senderWin = event && event.sender && BrowserWindow.fromWebContents(event.sender);
1416
+ if (senderWin && !senderWin.isDestroyed()) senderWin.hide();
1417
+ const target =
1418
+ mainWindowRef && !mainWindowRef.isDestroyed()
1419
+ ? mainWindowRef
1420
+ : BrowserWindow.getAllWindows().find((w) => w !== senderWin) || null;
1421
+ if (!target) return { ok: false, reason: 'no main window is open' };
1422
+ target.webContents.send(QUICK_LAUNCHER_PUSH_CHANNEL, payload || {});
1423
+ if (target.isMinimized()) target.restore();
1424
+ if (!target.isVisible()) target.show();
1425
+ target.focus();
1426
+ return { ok: true };
1427
+ }
1428
+
1429
+ const quickLauncherDispatch = createQuickLauncherDispatch(settings, {
1430
+ isPackaged: app.isPackaged,
1431
+ applyConfig: applyQuickLauncherConfig,
1432
+ pushToMain: pushQuickLauncherResult,
1433
+ });
1434
+ registerQuickLauncherIpc(ipcMain, quickLauncherDispatch);
1435
+ // Apply whatever was last saved (or the default) right away: ship the
1436
+ // shortcut only when app.isPackaged || the settings flag is on — see
1437
+ // shouldEnableGlobalShortcut — so a plain `electron .` dev run never grabs
1438
+ // a systemwide hotkey unless the developer opted in from Settings.
1439
+ quickLauncherDispatch.setConfig(settings.quickLauncherConfig());
1440
+
1441
+ // A held global shortcut outlives this app if not released — every quit
1442
+ // path (explicit quit, window-all-closed on non-mac, OS shutdown) must
1443
+ // free it.
1444
+ app.on('will-quit', () => {
1445
+ globalShortcut.unregisterAll();
1446
+ });
1447
+
1448
+ // electron-updater touches the network and expects a build-time
1449
+ // app-update.yml that only exists in a packaged app; requiring it is safe
1450
+ // either way, but it's wrapped defensively so a version mismatch or a
1451
+ // missing optional native dep degrades to "updates disabled" instead of
1452
+ // taking the whole app down.
1453
+ let realAutoUpdater = null;
1454
+ try {
1455
+ realAutoUpdater = require('electron-updater').autoUpdater;
1456
+ } catch {
1457
+ realAutoUpdater = null;
1458
+ }
1459
+
1460
+ const openWindows = new Set();
1461
+ function broadcastUpdateStatus(state) {
1462
+ for (const win of openWindows) {
1463
+ if (!win.isDestroyed()) win.webContents.send(UPDATE_STATUS_CHANNEL, state);
1464
+ }
1465
+ }
1466
+
1467
+ const updateManager = createUpdateManager({
1468
+ autoUpdater: realAutoUpdater,
1469
+ isPackaged: app.isPackaged,
1470
+ onStatus: broadcastUpdateStatus,
1471
+ });
1472
+ registerUpdateIpc(ipcMain, updateManager);
1473
+ registerExportIpc(
1474
+ ipcMain,
1475
+ sessionStore,
1476
+ sessionsDir,
1477
+ (opts) => dialog.showSaveDialog(BrowserWindow.getFocusedWindow(), opts),
1478
+ (filePath, content) => fs.promises.writeFile(filePath, content, 'utf8')
1479
+ );
1480
+ Menu.setApplicationMenu(buildAppMenu({ app, Menu, BrowserWindow, updateManager }));
1481
+
1482
+ // The local file this window is allowed to be at — used both to restore a
1483
+ // saved position/reload and to recognise (and block) any navigation away
1484
+ // from it. pathToFileURL normalises the platform path separators so the
1485
+ // will-navigate comparison below works identically on Windows.
1486
+ const appUrl = pathToFileURL(path.join(__dirname, 'renderer', 'index.html')).href;
693
1487
 
694
1488
  function createWindow() {
1489
+ const defaultBounds = { width: 1080, height: 720 };
1490
+ const saved = windowState.load(dataDir);
1491
+ // A saved position from a monitor that's since been unplugged (or a
1492
+ // resolution that shrank) must never place the window off every
1493
+ // connected display — that's a window that "opens" but nobody can see
1494
+ // or reach.
1495
+ const bounds = windowState.clampToDisplay(saved, screen.getAllDisplays(), defaultBounds);
1496
+
695
1497
  const win = new BrowserWindow({
696
- width: 1080,
697
- height: 720,
1498
+ ...bounds,
698
1499
  minWidth: 720,
699
1500
  minHeight: 480,
700
1501
  backgroundColor: '#0d1117',
@@ -708,8 +1509,60 @@ function bootstrap() {
708
1509
  },
709
1510
  });
710
1511
 
711
- win.setMenuBarVisibility(false);
1512
+ if (saved && saved.isMaximized) win.maximize();
1513
+
1514
+ // Popups (window.open, target=_blank, a model-rendered link that isn't
1515
+ // routed through the aegis:openExternal IPC method) never get a second
1516
+ // BrowserWindow inside this app: an http/https URL goes to the OS
1517
+ // browser, everything else (file://, javascript:, …) is dropped — same
1518
+ // allowlist as isSafeExternalUrl above.
1519
+ win.webContents.setWindowOpenHandler(({ url }) => {
1520
+ if (isSafeExternalUrl(url)) shell.openExternal(url);
1521
+ return { action: 'deny' };
1522
+ });
1523
+
1524
+ // In-page navigation is likewise locked to this one local file. Without
1525
+ // this, a compromised renderer script (or a link that reaches the
1526
+ // BrowserWindow's own navigation instead of window.open) could carry the
1527
+ // whole app to an arbitrary origin; http/https targets are hard-blocked
1528
+ // here too, exactly like the popup case, and handed to the OS browser.
1529
+ win.webContents.on('will-navigate', (navEvent, navigationUrl) => {
1530
+ if (navigationUrl === appUrl) return;
1531
+ navEvent.preventDefault();
1532
+ if (isSafeExternalUrl(navigationUrl)) shell.openExternal(navigationUrl);
1533
+ });
1534
+
1535
+ // Bounds persistence: debounced on resize/move (dragging fires dozens of
1536
+ // events per second — writing a file on every one would be wasteful and
1537
+ // would fight the OS for disk I/O mid-drag), and flushed unconditionally
1538
+ // on close so the final size/position always lands. getNormalBounds() is
1539
+ // used while maximized so un-maximizing next launch restores the pre-
1540
+ // maximize rectangle instead of the full-screen one.
1541
+ let saveBoundsTimer = null;
1542
+ function persistBounds() {
1543
+ if (win.isDestroyed()) return;
1544
+ const isMaximized = win.isMaximized();
1545
+ const rect = isMaximized ? win.getNormalBounds() : win.getBounds();
1546
+ windowState.save(dataDir, { ...rect, isMaximized });
1547
+ }
1548
+ function scheduleBoundsSave() {
1549
+ if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
1550
+ saveBoundsTimer = setTimeout(persistBounds, 500);
1551
+ }
1552
+ win.on('resize', scheduleBoundsSave);
1553
+ win.on('move', scheduleBoundsSave);
1554
+ win.on('close', () => {
1555
+ if (saveBoundsTimer) clearTimeout(saveBoundsTimer);
1556
+ persistBounds();
1557
+ });
1558
+
712
1559
  win.loadFile(path.join(__dirname, 'renderer', 'index.html'));
1560
+ openWindows.add(win);
1561
+ mainWindowRef = win;
1562
+ win.on('closed', () => {
1563
+ openWindows.delete(win);
1564
+ if (mainWindowRef === win) mainWindowRef = null;
1565
+ });
713
1566
  return win;
714
1567
  }
715
1568
 
@@ -719,7 +1572,12 @@ function bootstrap() {
719
1572
  // key was saved in-app (safeStorage is usable only after app ready).
720
1573
  const persistedKey = settings.aegisRawKey();
721
1574
  if (persistedKey) aegis.setApiKey(persistedKey);
722
- createWindow();
1575
+ const win = createWindow();
1576
+ updateManager.start();
1577
+ // Cold-launch deep link (Linux/Windows argv, or a pre-ready macOS
1578
+ // 'open-url'): the window didn't exist when it was parsed above, so
1579
+ // deliver it now that one does.
1580
+ if (pendingDeepLink) sendDeepLinkToWindow(win, pendingDeepLink);
723
1581
  });
724
1582
 
725
1583
  app.on('window-all-closed', () => {
@@ -729,6 +1587,27 @@ function bootstrap() {
729
1587
  app.on('activate', () => {
730
1588
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
731
1589
  });
1590
+
1591
+ // Second launch while already running: focus (and restore/show) the
1592
+ // existing window instead of leaving the new process to just exit having
1593
+ // done nothing visible. `argv` is the second process's argv — on
1594
+ // Windows/Linux this is how an aegis:// link reaches an already-running
1595
+ // app (see the 'open-url'/macOS comment above); the URL arrives as a bare
1596
+ // positional entry (the Linux argv quirk deepLink.extractDeepLinkUrl scans
1597
+ // for), not a named flag.
1598
+ app.on('second-instance', (_event, argv) => {
1599
+ const win = BrowserWindow.getAllWindows()[0];
1600
+ const parsed = deepLink.parseDeepLinkArgv(argv);
1601
+ if (!win) {
1602
+ const created = createWindow();
1603
+ if (parsed) sendDeepLinkToWindow(created, parsed);
1604
+ return;
1605
+ }
1606
+ if (win.isMinimized()) win.restore();
1607
+ if (!win.isVisible()) win.show();
1608
+ win.focus();
1609
+ if (parsed) sendDeepLinkToWindow(win, parsed);
1610
+ });
732
1611
  }
733
1612
 
734
1613
  if (electron && electron.app) {
@@ -742,6 +1621,13 @@ module.exports = {
742
1621
  MODEL_PREFIX,
743
1622
  SYNC_PREFIX,
744
1623
  CHAT_DELTA_CHANNEL,
1624
+ MENU_NEW_CHAT_CHANNEL,
1625
+ MENU_SEARCH_CHANNEL,
1626
+ MENU_EXPORT_MARKDOWN_CHANNEL,
1627
+ MENU_EXPORT_JSON_CHANNEL,
1628
+ DEEP_LINK_CHANNEL,
1629
+ UPDATE_STATUS_CHANNEL,
1630
+ UPDATE_CHECK_INTERVAL_MS,
745
1631
  taggedChunk,
746
1632
  maskKey,
747
1633
  createModelDispatch,
@@ -750,4 +1636,19 @@ module.exports = {
750
1636
  createEngine,
751
1637
  resolveUserDataDir,
752
1638
  importForeignMemory,
1639
+ isSafeExternalUrl,
1640
+ createUpdateManager,
1641
+ createUpdateDispatch,
1642
+ registerUpdateIpc,
1643
+ buildAppMenu,
1644
+ createExportDispatch,
1645
+ registerExportIpc,
1646
+ safeExportBasename,
1647
+ sendDeepLinkToWindow,
1648
+ QUICK_PREFIX,
1649
+ QUICK_LAUNCHER_PUSH_CHANNEL,
1650
+ createQuickLauncherDispatch,
1651
+ registerQuickLauncherIpc,
1652
+ createConfirmModeDispatch,
1653
+ registerConfirmModeIpc,
753
1654
  };