aegis-desktop 0.3.0 → 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
  *
@@ -106,15 +140,20 @@ function maskKey(key) {
106
140
  }
107
141
 
108
142
  /**
109
- * Classify a memory-endpoint failure: is this the free-plan cap, or is it just
110
- * offline? aegis1 answers HTTP 402 `free_session_limit_reached` on every
111
- * metered memory endpoint (app.py:9229) and the shared client attaches
112
- * `err.status` / `err.data` (vendor/aegis.js parseResponse) — but
113
- * `ipcRenderer.invoke` only carries the *message string* across the process
114
- * boundary. A thrown cap therefore reaches the renderer as the bare text
115
- * "free_session_limit_reached" with `status`/`data` stripped, which is why the
116
- * upgrade UI in fetchMemory() never fired. So: detect it here, in main, where
117
- * the fields still exist, and hand the renderer a plain resolved payload.
143
+ * Classify a memory-endpoint failure: is this the plan's sync quota, or is it
144
+ * just offline? aegis1 answers HTTP 402 `free_session_limit_reached` when a
145
+ * WRITE would exceed the plan's synced-token ceiling (free = 1M, pro = 10M).
146
+ * Since the quota became token-denominated, reads (search/pull) are always
147
+ * served over quota, so only pushes can answer 402 — a pull that 402s is an
148
+ * older server.
149
+ *
150
+ * The shared client attaches `err.status` / `err.data` (vendor/aegis.js
151
+ * parseResponse) — but `ipcRenderer.invoke` only carries the *message string*
152
+ * across the process boundary. A thrown cap therefore reaches the renderer as
153
+ * the bare text "free_session_limit_reached" with `status`/`data` stripped,
154
+ * which is why the upgrade UI in fetchMemory() never fired. So: detect it here,
155
+ * in main, where the fields still exist, and hand the renderer a plain resolved
156
+ * payload.
118
157
  *
119
158
  * Returns null for anything that is not a cap (offline, no key, 500, …).
120
159
  */
@@ -123,10 +162,19 @@ function upgradeInfo(err) {
123
162
  const data = (err && err.data) || {};
124
163
  const code = typeof data.error === 'string' ? data.error : '';
125
164
  if (status !== 402 && code !== 'free_session_limit_reached') return null;
165
+ // Quota numbers are TOKENS now (`tokensUsed` / `tokenLimit`). The legacy
166
+ // session-named keys are read only as a fallback so this build still shows a
167
+ // number against a server that predates the rename.
168
+ const used = data.tokensUsed != null
169
+ ? data.tokensUsed
170
+ : (data.sessionsUsed != null ? data.sessionsUsed : null);
171
+ const limit = data.tokenLimit != null
172
+ ? data.tokenLimit
173
+ : (data.freeSessionLimit != null ? data.freeSessionLimit : null);
126
174
  return {
127
175
  url: data.upgradeUrl || 'https://aegiscloud.org/subscribe',
128
- used: data.sessionsUsed != null ? data.sessionsUsed : null,
129
- limit: data.freeSessionLimit != null ? data.freeSessionLimit : null,
176
+ used,
177
+ limit,
130
178
  code: code || 'free_session_limit_reached',
131
179
  };
132
180
  }
@@ -164,14 +212,22 @@ async function saveMemoryWithQueue(aegis, dir, entry) {
164
212
  }
165
213
 
166
214
  /**
167
- * Read side of the same normalisation. `memorySearch` / `memoryList` run
168
- * `_memory_sync_access` server-side, so they 402 on exactly the same cap;
169
- * resolving `{ entries: [], upgrade }` keeps one detection path for all four
170
- * memory calls instead of four renderer-side branches that can't see `err.status`.
215
+ * Read side of the same normalisation. Since the sync quota became
216
+ * token-denominated, aegis1 no longer refuses reads over quota — it serves
217
+ * `memorySearch` / `memoryList` and reports `tokensUsed` / `tokenLimit` in the
218
+ * payload. So an exhausted account arrives as a *success*, and the notice has
219
+ * to be derived from the fields rather than from a thrown 402. The 402 branch
220
+ * stays for an older server that still caps reads.
221
+ *
222
+ * Both paths resolve the same `upgrade` shape so the renderer keeps one branch.
171
223
  */
172
224
  async function normalizeMemoryRead(promise) {
173
225
  try {
174
- return await promise;
226
+ const data = await promise;
227
+ const quota = quotaFromPayload(data);
228
+ // Entries are deliberately preserved: an over-quota account still owns its
229
+ // memory and must see it. Only the notice is added.
230
+ return quota ? Object.assign({}, data, { upgrade: quota }) : data;
175
231
  } catch (err) {
176
232
  const upgrade = upgradeInfo(err);
177
233
  if (!upgrade) throw err;
@@ -179,6 +235,22 @@ async function normalizeMemoryRead(promise) {
179
235
  }
180
236
  }
181
237
 
238
+ /** Over-quota notice derived from a successful read payload, or null.
239
+ * `tokensUsed`/`tokenLimit` come from aegis1 `_token_quota_fields`. */
240
+ function quotaFromPayload(data) {
241
+ if (!data || typeof data !== 'object') return null;
242
+ const used = data.tokensUsed != null ? data.tokensUsed : null;
243
+ const limit = data.tokenLimit != null ? data.tokenLimit : null;
244
+ if (used == null || limit == null || limit <= 0) return null;
245
+ if (used < limit) return null;
246
+ return {
247
+ url: 'https://aegiscloud.org/subscribe',
248
+ used,
249
+ limit,
250
+ code: 'sync_quota_reached',
251
+ };
252
+ }
253
+
182
254
  /**
183
255
  * `aegis:memoryImport` — scan this machine for other AI tools' memory and,
184
256
  * when confirmed, push it into AEGIS cloud memory.
@@ -255,11 +327,27 @@ async function importForeignMemory(aegis, dir, payload) {
255
327
  };
256
328
  }
257
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
+
258
342
  /**
259
343
  * Pure mapping: IPC payload -> shared-client call. No Electron types here, so
260
- * 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.
261
348
  */
262
- function createIpcDispatch(aegis, dir, persistApiKey) {
349
+ function createIpcDispatch(aegis, dir, persistApiKey, openExternal) {
350
+ const openExternalFn = openExternal || (() => Promise.reject(new Error('no opener configured')));
263
351
  const dispatch = {
264
352
  status: () => ({
265
353
  appVersion: APP_VERSION,
@@ -325,15 +413,45 @@ function createIpcDispatch(aegis, dir, persistApiKey) {
325
413
  importForeignMemory(aegis, dir, payload),
326
414
  importConversation: (payload) =>
327
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
+ },
328
430
  };
329
431
  return dispatch;
330
432
  }
331
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
+
332
446
  /** Register every dispatch method as `aegis:<name>` on ipcMain. `dir` (the
333
447
  * user-data dir) is optional and threaded through only for the memorySave
334
- * offline-queue fallback — see saveMemoryWithQueue(). */
335
- function registerIpc(ipcMain, aegis, dir, persistApiKey) {
336
- 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);
337
455
  for (const [name, handler] of Object.entries(dispatch)) {
338
456
  if (name === 'chatCompletion') {
339
457
  // Streaming render (D2.1): when the renderer asks for stream, SSE deltas
@@ -343,7 +461,7 @@ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
343
461
  // dispatch (what the headless shell test drives directly).
344
462
  ipcMain.handle(`${IPC_PREFIX}${name}`, (event, payload) => {
345
463
  const opts = { ...(payload || {}) };
346
- if (!opts.stream) return handler(opts);
464
+ if (!opts.stream) return withReplyNotify(handler(opts), event, onReplyFinished);
347
465
  const sender = event && event.sender;
348
466
  const forward = (chunk) => {
349
467
  if (
@@ -360,7 +478,11 @@ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
360
478
  sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
361
479
  }
362
480
  };
363
- return handler({ ...opts, stream: true, onStream: forward });
481
+ return withReplyNotify(
482
+ handler({ ...opts, stream: true, onStream: forward }),
483
+ event,
484
+ onReplyFinished
485
+ );
364
486
  });
365
487
  continue;
366
488
  }
@@ -369,6 +491,161 @@ function registerIpc(ipcMain, aegis, dir, persistApiKey) {
369
491
  return dispatch;
370
492
  }
371
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
+
372
649
  /**
373
650
  * Resolve the per-user data directory for settings + sessions. Electron gives
374
651
  * us app.getPath('userData'); outside Electron (headless smoke tests) fall back
@@ -420,6 +697,14 @@ function createModelDispatch(engine) {
420
697
  'settings.remove': (payload) =>
421
698
  engine.settings.remove(payload && payload.provider),
422
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),
423
708
  };
424
709
  }
425
710
 
@@ -570,8 +855,12 @@ function createSyncDispatch(sessions, dir, aegis) {
570
855
  * any pending sessions (plan §7 "retry on a heartbeat"): fire-and-forget,
571
856
  * never awaited, never throws — it just gives queued sessions another
572
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.
573
862
  */
574
- function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
863
+ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished) {
575
864
  const modelDispatch = createModelDispatch(engine);
576
865
  const syncDispatch = createSyncDispatch(sessionStore, sessionsDir, aegis);
577
866
 
@@ -596,7 +885,11 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
596
885
  sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
597
886
  }
598
887
  };
599
- return handler({ ...opts, onStream: forward });
888
+ return withReplyNotify(
889
+ handler({ ...opts, onStream: forward }),
890
+ event,
891
+ onReplyFinished
892
+ );
600
893
  });
601
894
  continue;
602
895
  }
@@ -624,15 +917,322 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis) {
624
917
  return { modelDispatch, syncDispatch };
625
918
  }
626
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
+
627
1191
  // ---------------------------------------------------------------------------
628
1192
  // Electron-only bootstrap
629
1193
  // ---------------------------------------------------------------------------
630
1194
 
631
1195
  function bootstrap() {
632
- 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
+ }
633
1213
 
634
1214
  app.setName('AEGIS Desktop');
635
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
+
636
1236
  const aegis = createClient();
637
1237
  const dataDir = resolveUserDataDir(app);
638
1238
 
@@ -650,13 +1250,252 @@ function bootstrap() {
650
1250
  // "Remove" delete the AEGIS key; defect #1).
651
1251
  const persistApiKey = (key) => settings.setAegisKey(key);
652
1252
 
653
- registerIpc(ipcMain, aegis, dataDir, persistApiKey);
654
- 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;
655
1487
 
656
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
+
657
1497
  const win = new BrowserWindow({
658
- width: 1080,
659
- height: 720,
1498
+ ...bounds,
660
1499
  minWidth: 720,
661
1500
  minHeight: 480,
662
1501
  backgroundColor: '#0d1117',
@@ -670,8 +1509,60 @@ function bootstrap() {
670
1509
  },
671
1510
  });
672
1511
 
673
- 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
+
674
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
+ });
675
1566
  return win;
676
1567
  }
677
1568
 
@@ -681,7 +1572,12 @@ function bootstrap() {
681
1572
  // key was saved in-app (safeStorage is usable only after app ready).
682
1573
  const persistedKey = settings.aegisRawKey();
683
1574
  if (persistedKey) aegis.setApiKey(persistedKey);
684
- 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);
685
1581
  });
686
1582
 
687
1583
  app.on('window-all-closed', () => {
@@ -691,6 +1587,27 @@ function bootstrap() {
691
1587
  app.on('activate', () => {
692
1588
  if (BrowserWindow.getAllWindows().length === 0) createWindow();
693
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
+ });
694
1611
  }
695
1612
 
696
1613
  if (electron && electron.app) {
@@ -704,6 +1621,13 @@ module.exports = {
704
1621
  MODEL_PREFIX,
705
1622
  SYNC_PREFIX,
706
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,
707
1631
  taggedChunk,
708
1632
  maskKey,
709
1633
  createModelDispatch,
@@ -712,4 +1636,19 @@ module.exports = {
712
1636
  createEngine,
713
1637
  resolveUserDataDir,
714
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,
715
1654
  };