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/README.md +61 -0
- package/lib/deep-link.js +83 -0
- package/lib/local/engine.js +398 -29
- package/lib/local/tools.js +257 -13
- package/lib/quick-launcher.js +81 -0
- package/lib/settings.js +76 -0
- package/lib/sync/sessions.js +27 -0
- package/lib/window-state.js +63 -0
- package/main.js +971 -32
- package/package.json +8 -4
- package/preload.js +121 -0
- package/renderer/app.js +533 -16
- package/renderer/index.html +49 -0
- package/renderer/markdown.js +165 -0
- package/renderer/quick.css +126 -0
- package/renderer/quick.html +39 -0
- package/renderer/quick.js +188 -0
- package/renderer/style.css +341 -0
- package/renderer/vendor/NOTICE.md +20 -0
- package/renderer/vendor/aegis-highlight.js +135 -0
- package/renderer/vendor/marked.umd.js +80 -0
- package/renderer/vendor/purify.min.js +3 -0
- package/vendor/aegis.js +34 -4
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
|
|
110
|
-
* offline? aegis1 answers HTTP 402 `free_session_limit_reached`
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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
|
|
129
|
-
limit
|
|
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.
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
654
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
};
|