aegis-desktop 1.0.13 → 1.0.14

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
@@ -292,6 +292,12 @@ const MENU_EXPORT_JSON_CHANNEL = `${IPC_PREFIX}menuExportJson`;
292
292
  * as the channels above: main has no panel state, it just asks the renderer to
293
293
  * open the sheet it already owns. */
294
294
  const MENU_FINGERPRINT_CHANNEL = `${IPC_PREFIX}menuFingerprint`;
295
+ /** Account > Google Drive Backup… — opens the same φ(α) sheet as the item
296
+ * above, scrolled straight to the Drive row. Only on the menu once an AEGIS
297
+ * account key exists (buildAppMenu's `enabled`, rebuilt on accountKey's
298
+ * onChange): signing in with Google is itself how an anonymous install gets
299
+ * one, so the item stays but disabled until that first key lands. */
300
+ const MENU_GOOGLE_DRIVE_CHANNEL = `${IPC_PREFIX}menuGoogleDrive`;
295
301
 
296
302
  /**
297
303
  * Main -> renderer push for a resolved aegis:// deep link (D? deep linking).
@@ -413,6 +419,54 @@ function errorText(err) {
413
419
  return err && err.message ? err.message : String(err);
414
420
  }
415
421
 
422
+ /** Is this rejection the user pressing stop?
423
+ *
424
+ * `engine.cancel()` aborts the session's AbortController, and the in-flight
425
+ * transport turn rejects with `DOMException [AbortError]: This operation was
426
+ * aborted`. That is the app working exactly as asked — but `ipcMain.handle`
427
+ * prints EVERY rejection with a full stack ("Error occurred in handler for
428
+ * 'model:chat':"), so a deliberate Ctrl-X was the loudest thing in the
429
+ * terminal and read like a crash. The renderer cannot tell it either: an
430
+ * abort that crosses IPC loses `name`/`code` and arrives as a bare message,
431
+ * which is why the classification is made HERE, where the object is intact,
432
+ * and handed on as data (see the `aborted` resolution in registerModelIpc).
433
+ *
434
+ * Evidence only — `aegisCancelled`, `name`, `code` — and never the message.
435
+ *
436
+ * The first version also matched any message containing "abort", as the
437
+ * renderer's `isCancellation` backstop does. That is wrong on this side of the
438
+ * boundary: the renderer has a per-turn `userStopped` flag to corroborate a
439
+ * fuzzy match against, and main has no such notion, so the message WAS the
440
+ * whole signal. A genuine failure saying "connection aborted by peer" or
441
+ * "upstream aborted the connection" would have been resolved as a clean stop
442
+ * and quietly discarded, since this branch rethrows everything else. The bit
443
+ * that cannot lie is stamped on the error by the engine at the point it knows
444
+ * for certain — its own controller's signal read aborted — which no upstream
445
+ * cause can fake. `name`/`code` are kept because a DOMException raised by
446
+ * `AbortController.abort()` itself carries them reliably.
447
+ */
448
+ function isAbortShapedError(err) {
449
+ if (!err || typeof err !== 'object') return false;
450
+ if (err.aegisCancelled === true) return true;
451
+ return err.name === 'AbortError' || err.code === 'ABORT_ERR';
452
+ }
453
+
454
+ /** The resolved stand-in for a stopped turn.
455
+ *
456
+ * Same shape as the `bestEffortFailed` payload below, so every existing
457
+ * `data.choices[0].message.content` reader keeps working, plus the
458
+ * `aborted` discriminator the renderer keys its salvage path off.
459
+ */
460
+ function abortedTurnPayload(err) {
461
+ return {
462
+ aborted: true,
463
+ status: 0,
464
+ code: 'ABORT_ERR',
465
+ message: errorText(err),
466
+ choices: [{ message: { content: '' } }],
467
+ };
468
+ }
469
+
416
470
  /**
417
471
  * Precise classification of a cloud-sync refusal — the desktop port of
418
472
  * `describeError()` in cli/src/cloudsync.js, so both hosts name a 402/401 the
@@ -2074,6 +2128,21 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
2074
2128
  if (name === 'chat') {
2075
2129
  ipcMain.handle(`${MODEL_PREFIX}${name}`, (event, payload) => {
2076
2130
  const opts = { ...(payload || {}) };
2131
+ // A background dispatch the user never typed — the welcome panel's
2132
+ // greeting, a discovery lane — is allowed to fail quietly. A REJECTED
2133
+ // ipcMain.handle is printed by Electron itself ("Error occurred in
2134
+ // handler for 'model:chat':" plus the whole stack), and the renderer
2135
+ // that catches it is another process, so a greeting that timed out on
2136
+ // boot put a provider stack trace in the terminal as the first thing
2137
+ // the app said. Nothing was lost: those lanes already treat "no answer"
2138
+ // as "keep what is on screen". So the flag turns the rejection into a
2139
+ // resolved classified payload (the renderer gets MORE than it used to —
2140
+ // status and code, which `ipcRenderer.invoke` would have flattened to a
2141
+ // message string anyway) and the terminal gets one line, not a stack.
2142
+ const bestEffort = opts.bestEffort === true;
2143
+ // …and it never reaches the wire: this is a host-side instruction, not
2144
+ // a provider field, and the engine forwards what it recognises.
2145
+ delete opts.bestEffort;
2077
2146
  // The rung every renderer sends is low|medium|high; this is the one
2078
2147
  // place a stale window can still hand us the retired `auto` (a page
2079
2148
  // loaded before the select lost that row, or a renderer that forgot to
@@ -2099,10 +2168,59 @@ function registerModelIpc(ipcMain, engine, sessionsDir, aegis, onReplyFinished,
2099
2168
  sender.send(CHAT_DELTA_CHANNEL, taggedChunk(chunk, opts.sessionId));
2100
2169
  }
2101
2170
  };
2102
- return withReplyNotify(
2103
- handler({ ...opts, onStream: forward }),
2104
- event,
2105
- onReplyFinished
2171
+ const lane = opts.class || 'model';
2172
+ const turn = handler({ ...opts, onStream: forward });
2173
+ if (!bestEffort) {
2174
+ // A user-pressed stop must not be printed as an app error. The
2175
+ // rejection itself is correct — it is how the turn unwinds — so the
2176
+ // catch is added AFTER withReplyNotify, which means the success and
2177
+ // "reply ready" notification paths are untouched and only the
2178
+ // rejection is reclassified. Anything that is not an abort rethrows
2179
+ // verbatim: a genuine provider failure still reaches the renderer as
2180
+ // a rejection, and still prints, because that is information.
2181
+ //
2182
+ // Resolved rather than rethrown because `ipcMain.handle` prints what
2183
+ // it rejects and no flag suppresses that. The renderer turns this
2184
+ // payload back into an AbortError (see `models.chat` in app.js), so
2185
+ // `isCancellation` → `salvageTurn` still runs and a half-written
2186
+ // answer is kept and labelled "stopped by you" — the same outcome as
2187
+ // before, minus the stack trace.
2188
+ return withReplyNotify(turn, event, onReplyFinished).catch((err) => {
2189
+ if (!isAbortShapedError(err)) throw err;
2190
+ console.warn(
2191
+ `[model] ${lane} turn stopped before it finished: ${errorText(err)} [ABORT_ERR]`
2192
+ );
2193
+ return abortedTurnPayload(err);
2194
+ });
2195
+ }
2196
+ return turn.then(
2197
+ (result) => {
2198
+ if (onReplyFinished) onReplyFinished(event, result);
2199
+ return result;
2200
+ },
2201
+ (err) => {
2202
+ // Classified, not verbatim: the status and code are what tell an
2203
+ // operator which failure this was (0/timeout, 402 balance, 401
2204
+ // key), and neither survives the IPC boundary unless it is stated
2205
+ // here. The stack does not survive anything useful.
2206
+ const status = err && typeof err.status === 'number' ? err.status : 0;
2207
+ const code = (err && err.code) || '';
2208
+ console.warn(
2209
+ `[model] background ${lane} turn produced no answer: ${errorText(err)}` +
2210
+ `${status ? ` (status ${status})` : ''}${code ? ` [${code}]` : ''}`
2211
+ );
2212
+ // Resolved, so Electron does not print the rejection and the lane
2213
+ // that asked can read "nothing came back" without a try/catch.
2214
+ // `bestEffortFailed` is the discriminator; the empty content keeps
2215
+ // every existing `data.choices[0].message.content` reader working.
2216
+ return {
2217
+ bestEffortFailed: true,
2218
+ status,
2219
+ code: code || undefined,
2220
+ message: errorText(err),
2221
+ choices: [{ message: { content: '' } }],
2222
+ };
2223
+ }
2106
2224
  );
2107
2225
  });
2108
2226
  continue;
@@ -2723,35 +2841,39 @@ const ACCOUNT_STATUS_CHANNEL = `${IPC_PREFIX}accountStatusChanged`;
2723
2841
  const UPDATE_CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000;
2724
2842
 
2725
2843
  /**
2726
- * Auto-update state machine over electron-updater (GitHub Releases provider,
2727
- * configured via desktop/electron-builder.yml `publish:` — see main.js's
2728
- * top-of-file note and that file's comment for the feed this points at).
2844
+ * The npm registry is the ONLY update channel, packaged build included.
2845
+ *
2846
+ * There used to be a second one: electron-updater reading GitHub Releases via
2847
+ * electron-builder.yml's `publish:` feed. That feed never existed. The repo
2848
+ * named there (aegiscloud-devs/aegiscode-plugin) is private and has no
2849
+ * published release, so every launch of a packaged app failed its check with
2729
2850
  *
2730
- * Deliberately inert outside a packaged app: `electron .` in dev has no
2731
- * app-update.yml (electron-builder only writes one at build time), so
2732
- * calling checkForUpdates() there would just throw on every launch. Rather
2733
- * than special-case that error, `isPackaged: false` skips wiring the real
2734
- * autoUpdater entirely and every method resolves a harmless 'disabled'
2735
- * state — dev never touches the network or the update machinery.
2851
+ * 404 "method: GET url: .../releases.atom Please double check that your
2852
+ * authentication token is correct..."
2736
2853
  *
2737
- * `autoDownload` stays false: checking happens automatically (on ready and
2738
- * on the interval below), but the multi-hundred-MB download itself only
2739
- * starts when the renderer's banner "Download" button calls download() —
2740
- * see the IPC dispatch below. This keeps every step user-visible and
2741
- * matches the "never auto-restart without consent" rule: quitAndInstall()
2742
- * is likewise only ever invoked by an explicit "Restart to install" click.
2854
+ * — GitHub answers 404 rather than 403 to an unauthenticated request on a
2855
+ * private repo, and electron-updater reports every non-200 as an auth problem,
2856
+ * so the banner blamed the user's token for a destination that was never
2857
+ * public and never had a release. Meanwhile aegis-desktop ships to npm on
2858
+ * every release. So the packaged path now uses the same npm manager as every
2859
+ * other install, and `publish:`/electron-updater are gone rather than left
2860
+ * pointing at a feed nobody can read.
2861
+ *
2862
+ * Checking happens automatically on ready and on the interval below; nothing
2863
+ * here installs or restarts on its own. `download()` runs only on the banner's
2864
+ * explicit click, and `quitAndInstall()` only on "Restart to install".
2743
2865
  */
2744
2866
 
2745
2867
  /**
2746
- * Update state backed by the npm registry, for installs that are not packaged
2747
- * electron builds (i.e. `npm i -g aegis-desktop`).
2868
+ * Update state backed by the npm registry — the channel for EVERY install,
2869
+ * packaged AppImage/deb/NSIS/dmg included.
2748
2870
  *
2749
- * Mirrors the electron-updater manager's shape exactly so the IPC dispatch and
2750
- * the renderer banner need no branch: status/check/download/quitAndInstall/
2751
- * start. `download` and `quitAndInstall` are no-ops that report the install
2752
- * command instead of acting — npm owns installation here, and silently
2753
- * swapping the package under a running process is not something to do on the
2754
- * user's behalf.
2871
+ * Keeps the state shape the IPC dispatch and the renderer banner consume
2872
+ * (status/check/download/quitAndInstall/start), so neither needed a branch
2873
+ * when the packaged path stopped using electron-updater. Which installer owns
2874
+ * this copy is `detectInstallMethod`'s answer, and the command shown is that
2875
+ * plan's `instruction` — an AppImage or a global npm install must not be told
2876
+ * to run a command that writes a different copy.
2755
2877
  */
2756
2878
  /**
2757
2879
  * How long to wait before re-checking after a FAILED background check: 1m, 5m,
@@ -2792,6 +2914,13 @@ function describeRegistryFailure(reason, status, pkg) {
2792
2914
 
2793
2915
  function createNpmUpdateManager({
2794
2916
  onStatus,
2917
+ // A packaged build (AppImage/deb/NSIS/dmg) is NOT the npm copy. `npm i -g`
2918
+ // from inside one writes a SECOND install beside the running binary and the
2919
+ // relaunch then starts the unchanged original — a button that appears to work
2920
+ // and changes nothing. The version news still comes from npm (there is no
2921
+ // other channel), but the packaged case names the install instead of
2922
+ // pretending to apply it.
2923
+ packaged = false,
2795
2924
  now = () => Date.now(),
2796
2925
  fetchImpl,
2797
2926
  setTimeoutImpl = setTimeout,
@@ -2915,8 +3044,8 @@ function createNpmUpdateManager({
2915
3044
  *
2916
3045
  * `intervalMs: 0` is deliberate: the *schedule* stays this manager's (it
2917
3046
  * polls every `UPDATE_CHECK_INTERVAL_MS` and drives its retry ladder off the
2918
- * result), but the *check* is not re-implemented here. The desktop keeps its
2919
- * applier — `electron-updater` below — and loses its own version fetch.
3047
+ * result), but the *check* is not re-implemented here. The desktop's applier
3048
+ * is the npm install above; it has no version fetch of its own.
2920
3049
  */
2921
3050
  async function fetchDetailed() {
2922
3051
  if (fetchImpl) return fetchImpl({ pkg: PKG });
@@ -2998,11 +3127,22 @@ function createNpmUpdateManager({
2998
3127
  retryInMs: 0,
2999
3128
  plan,
3000
3129
  method,
3001
- command: (plan && plan.instruction) || `npm i -g ${PKG}@latest`,
3130
+ // A packaged build is not the npm copy, so it must not be told to run
3131
+ // `npm i -g` — that writes a second install and the relaunch starts
3132
+ // the unchanged original. It gets the same version news, addressed to
3133
+ // the install that actually owns it.
3134
+ command: packaged
3135
+ ? `${res.latest} is published — reinstall AEGIS Desktop from the installer to pick it up`
3136
+ : (plan && plan.instruction) || `npm i -g ${PKG}@latest`,
3137
+ // Whether `command` is something to TYPE or a sentence to READ. The
3138
+ // banner otherwise prefixes "run:" to it, which is right for
3139
+ // `npm i -g aegis-desktop@latest` and wrong for a channel with no
3140
+ // command at all (packaged, npx, a source checkout).
3141
+ commandIsSentence: packaged || Boolean(plan && !plan.apply),
3002
3142
  // A channel with an `apply` argv has something this process can run;
3003
- // `npx` and a source checkout do not, and offering a button that
3004
- // silently does nothing is worse than the command alone.
3005
- canDownload: Boolean(plan && plan.apply),
3143
+ // `npx`, a source checkout and a packaged build do not, and offering
3144
+ // a button that silently does nothing is worse than the command alone.
3145
+ canDownload: Boolean(plan && plan.apply) && !packaged,
3006
3146
  });
3007
3147
  }
3008
3148
 
@@ -3080,11 +3220,12 @@ function createNpmUpdateManager({
3080
3220
  * timer), and it still does not hot-swap the running process: after a
3081
3221
  * successful install the honest state is 'downloaded' — "restart to install"
3082
3222
  * — because the new files are on disk and this process is still the old
3083
- * ones. That is the same promise the packaged electron-updater path makes,
3084
- * so the banner needs no branch.
3223
+ * ones. Packaged and npm installs share that promise, so the banner needs
3224
+ * no branch.
3085
3225
  */
3086
3226
  download: async () => {
3087
- const target = state.plan && state.plan.apply;
3227
+ // Nothing to run for a packaged build — see the `packaged` option above.
3228
+ const target = !packaged && state.plan && state.plan.apply;
3088
3229
  if (!target) {
3089
3230
  // Nothing to install on this channel (npx fetches fresh every run; a
3090
3231
  // checkout belongs to its git remote). Say who owns it instead of
@@ -3151,7 +3292,7 @@ function createNpmUpdateManager({
3151
3292
  };
3152
3293
  }
3153
3294
 
3154
- function createUpdateManager({ autoUpdater, isPackaged, onStatus }) {
3295
+ function createUpdateManager({ isPackaged, onStatus }) {
3155
3296
  let state = { status: 'idle', version: null, error: null, canDownload: false };
3156
3297
 
3157
3298
  function setState(patch) {
@@ -3159,76 +3300,16 @@ function createUpdateManager({ autoUpdater, isPackaged, onStatus }) {
3159
3300
  if (onStatus) onStatus(state);
3160
3301
  }
3161
3302
 
3162
- if (!isPackaged || !autoUpdater) {
3163
- // Not a packaged build — which is how this app is INSTALLED FROM NPM, not
3164
- // just how it runs in dev. That case used to resolve a flat 'disabled' and
3165
- // never check anything, so an npm-installed desktop could never learn a
3166
- // new version existed.
3167
- //
3168
- // electron-updater's configured feed is GitHub Releases
3169
- // (electron-builder.yml `publish:`), and there has never been a published
3170
- // release to read — the only one is a draft, invisible to the updater, and
3171
- // the release workflow cannot run while Actions is billing-locked. So the
3172
- // feed the packaged path depends on does not exist, while the registry we
3173
- // actually publish to does.
3174
- //
3175
- // npm is therefore the real update channel. Checking it costs one request
3176
- // a day, reports the version and the exact command, and deliberately does
3177
- // NOT self-install: replacing your own running process mid-session is a
3178
- // different promise from telling you an update exists.
3179
- return createNpmUpdateManager({ onStatus: setState });
3180
- }
3181
-
3182
- autoUpdater.autoDownload = false;
3183
- autoUpdater.autoInstallOnAppQuit = false;
3184
-
3185
- autoUpdater.on('checking-for-update', () => setState({ status: 'checking', error: null }));
3186
- autoUpdater.on('update-available', (info) =>
3187
- setState({ status: 'available', version: info && info.version, error: null, canDownload: true })
3188
- );
3189
- autoUpdater.on('update-not-available', () => setState({ status: 'up-to-date', error: null }));
3190
- autoUpdater.on('download-progress', (progress) =>
3191
- setState({ status: 'downloading', progress: progress && progress.percent })
3192
- );
3193
- autoUpdater.on('update-downloaded', (info) =>
3194
- setState({ status: 'downloaded', version: info && info.version, error: null })
3195
- );
3196
- // Any failure — offline, a malformed feed, a 404 on latest.yml — lands
3197
- // here instead of an unhandled rejection, since electron-updater emits
3198
- // 'error' for background failures that occur outside the promise a
3199
- // check()/download() call is awaiting.
3200
- autoUpdater.on('error', (err) => setState({ status: 'error', error: errorText(err) }));
3201
-
3202
- async function check() {
3203
- try {
3204
- await autoUpdater.checkForUpdates();
3205
- } catch (err) {
3206
- setState({ status: 'error', error: errorText(err) });
3207
- }
3208
- return state;
3209
- }
3210
-
3211
- async function download() {
3212
- try {
3213
- await autoUpdater.downloadUpdate();
3214
- } catch (err) {
3215
- setState({ status: 'error', error: errorText(err) });
3216
- }
3217
- return state;
3218
- }
3219
-
3220
- function quitAndInstall() {
3221
- autoUpdater.quitAndInstall();
3222
- }
3223
-
3224
- function start() {
3225
- check();
3226
- const timer = setInterval(check, UPDATE_CHECK_INTERVAL_MS);
3227
- // Never keep the process alive solely to poll for updates.
3228
- if (timer.unref) timer.unref();
3229
- }
3230
-
3231
- return { status: () => state, check, download, quitAndInstall, start };
3303
+ // ONE channel for every install shape: packaged or `npm i -g`, AppImage, deb,
3304
+ // NSIS, dmg. There is no second applier any more — see this section's header
3305
+ // for the 404 the GitHub Releases feed produced on every packaged launch.
3306
+ //
3307
+ // The old branch here existed only because electron-updater needed a
3308
+ // build-time app-update.yml and a non-packaged run had none. npm needs
3309
+ // neither, so there is nothing left to branch on: the npm manager reads the
3310
+ // registry, reports the version, and names the command that updates THIS
3311
+ // copy (its install method is detected, not assumed).
3312
+ return createNpmUpdateManager({ onStatus: setState, packaged: Boolean(isPackaged) });
3232
3313
  }
3233
3314
 
3234
3315
  /** Pure mapping: IPC payload -> update-manager call, same shape as
@@ -3298,7 +3379,7 @@ function sendDeepLinkToWindow(win, parsed) {
3298
3379
  * hours, and an About item (mac gets one for free in the app submenu; other
3299
3380
  * platforms get a Help menu — role 'about' shows app.setAboutPanelOptions()
3300
3381
  * cross-platform since Electron 15). */
3301
- function buildAppMenu({ app, Menu, BrowserWindow, updateManager }) {
3382
+ function buildAppMenu({ app, Menu, BrowserWindow, updateManager, loggedIn }) {
3302
3383
  const isMac = process.platform === 'darwin';
3303
3384
 
3304
3385
  app.setAboutPanelOptions({
@@ -3341,6 +3422,11 @@ function buildAppMenu({ app, Menu, BrowserWindow, updateManager }) {
3341
3422
  label: 'Authorship Panel…',
3342
3423
  click: () => sendToFocusedWindow(BrowserWindow, MENU_FINGERPRINT_CHANNEL),
3343
3424
  };
3425
+ const googleDriveItem = {
3426
+ label: 'Google Drive Backup…',
3427
+ enabled: Boolean(loggedIn),
3428
+ click: () => sendToFocusedWindow(BrowserWindow, MENU_GOOGLE_DRIVE_CHANNEL),
3429
+ };
3344
3430
 
3345
3431
  const template = [
3346
3432
  ...(isMac
@@ -3392,6 +3478,7 @@ function buildAppMenu({ app, Menu, BrowserWindow, updateManager }) {
3392
3478
  searchItem,
3393
3479
  { type: 'separator' },
3394
3480
  fingerprintItem,
3481
+ googleDriveItem,
3395
3482
  { type: 'separator' },
3396
3483
  { role: 'reload', accelerator: 'CmdOrCtrl+R' },
3397
3484
  { role: 'forceReload' },
@@ -3441,6 +3528,60 @@ function bootstrap() {
3441
3528
  app.commandLine.appendSwitch('disable-software-rasterizer');
3442
3529
  app.commandLine.appendSwitch('disable-gpu-compositing');
3443
3530
 
3531
+ // Chromium asks xdg-desktop-portal for the desktop accent colour on Linux so
3532
+ // form controls can match the system palette. On this Cinnamon session the
3533
+ // portal's appearance backend does not answer that request the way Chromium
3534
+ // expects, so Chromium cancels it and the portal framework prints to stderr:
3535
+ //
3536
+ // [<pid>:<date>:ERROR:components/dbus/xdg/request.cc(173)]
3537
+ // Request ended (non-user cancelled).
3538
+ //
3539
+ // Harmless — Chromium falls back to its default accent — but it lands in the
3540
+ // terminal of every `npm start` next to real errors, and reads like an app
3541
+ // failure. This app renders its own theme tokens and never consults the
3542
+ // system accent colour (no nativeTheme/accentColor reference in main.js,
3543
+ // preload.js, renderer/ or lib/), so that lookup is pure cost here.
3544
+ //
3545
+ // Measured on this Electron build (44.8.0) via dbus-monitor: the switch is
3546
+ // honoured and halves startup portal traffic — org.freedesktop.portal.
3547
+ // Settings Reads go 4 -> 2 and accent-color calls 2 -> 0 per launch. The
3548
+ // surviving Reads are color-scheme, which Electron 44 exposes no gate for;
3549
+ // left alone deliberately, since the window chrome follows light/dark.
3550
+ app.commandLine.appendSwitch('disable-features', 'UsePortalAccentColor');
3551
+
3552
+ // The X11 clipboard advertises its drag-and-drop target formats by looking
3553
+ // each MIME atom up in Chromium's atom cache, and Chromium logs an ERROR for
3554
+ // every atom it has to mint on the spot:
3555
+ //
3556
+ // [<pid>:<date>:ERROR:ui/gfx/x/atom_cache.cc(236)]
3557
+ // Add application/vnd.portal.filetransfer to kAtomsToCache
3558
+ // [<pid>:<date>:ERROR:ui/gfx/x/atom_cache.cc(236)]
3559
+ // Add application/vnd.portal.files to kAtomsToCache
3560
+ //
3561
+ // They fire the first time you CUT (Ctrl-X) or drag a selection, because the
3562
+ // initial atom table covers copy but not the portal file-transfer pair, and
3563
+ // they are printed at ERROR level even though the behaviour is correct: the
3564
+ // atoms get added and the clipboard works.
3565
+ //
3566
+ // Measured on this Electron build (44.8.0): no feature gate suppresses these
3567
+ // — `--disable-features=XdgFileTransferPortal` left them at 2, and adding
3568
+ // UsePortalAccentColor above also left them at 2. Only the log level does
3569
+ // (2 -> 0 at `--log-level=3`). So this is a logging decision, not a feature
3570
+ // one, and it is deliberately the LAST such switch: `3` = FATAL, which
3571
+ // silences ERROR-level chatter like the above while still printing anything
3572
+ // that actually kills the process.
3573
+ //
3574
+ // Never applied over a level the user chose: `--log-level` on the command
3575
+ // line, or ELECTRON_ENABLE_LOGGING (which asks for the verbose output this
3576
+ // would undo), wins. Neither is something this app sets for itself, so a
3577
+ // developer debugging Chromium still sees everything.
3578
+ const argvLogLevel = process.argv.some((a) =>
3579
+ /^--(log-level|enable-logging)/.test(a)
3580
+ );
3581
+ if (!argvLogLevel && !process.env.ELECTRON_ENABLE_LOGGING) {
3582
+ app.commandLine.appendSwitch('log-level', '3');
3583
+ }
3584
+
3444
3585
  // A second launch (double-clicking the icon again, `aegis` from a second
3445
3586
  // terminal, …) must focus the existing window, not spawn a second process
3446
3587
  // fighting the first over the same on-disk settings/session/queue files —
@@ -4012,18 +4153,6 @@ function bootstrap() {
4012
4153
  });
4013
4154
  });
4014
4155
 
4015
- // electron-updater touches the network and expects a build-time
4016
- // app-update.yml that only exists in a packaged app; requiring it is safe
4017
- // either way, but it's wrapped defensively so a version mismatch or a
4018
- // missing optional native dep degrades to "updates disabled" instead of
4019
- // taking the whole app down.
4020
- let realAutoUpdater = null;
4021
- try {
4022
- realAutoUpdater = require('electron-updater').autoUpdater;
4023
- } catch {
4024
- realAutoUpdater = null;
4025
- }
4026
-
4027
4156
  const openWindows = new Set();
4028
4157
  function broadcastUpdateStatus(state) {
4029
4158
  for (const win of openWindows) {
@@ -4034,10 +4163,19 @@ function bootstrap() {
4034
4163
  for (const win of openWindows) {
4035
4164
  if (!win.isDestroyed()) win.webContents.send(ACCOUNT_STATUS_CHANNEL, state);
4036
4165
  }
4166
+ // The Google Drive menu item is enabled/disabled on login state (see
4167
+ // buildAppMenu) — native menus don't repaint themselves, so every path
4168
+ // that can change aegis.apiKey (register-device, Google sign-in) has to
4169
+ // rebuild through here, the one place both already call.
4170
+ rebuildAppMenu();
4171
+ }
4172
+ function rebuildAppMenu() {
4173
+ Menu.setApplicationMenu(
4174
+ buildAppMenu({ app, Menu, BrowserWindow, updateManager, loggedIn: Boolean(aegis.apiKey) })
4175
+ );
4037
4176
  }
4038
4177
 
4039
4178
  const updateManager = createUpdateManager({
4040
- autoUpdater: realAutoUpdater,
4041
4179
  isPackaged: app.isPackaged,
4042
4180
  onStatus: broadcastUpdateStatus,
4043
4181
  });
@@ -4059,7 +4197,7 @@ function bootstrap() {
4059
4197
  (opts) => dialog.showSaveDialog(BrowserWindow.getFocusedWindow(), opts),
4060
4198
  (filePath, content) => fs.promises.writeFile(filePath, content, 'utf8')
4061
4199
  );
4062
- Menu.setApplicationMenu(buildAppMenu({ app, Menu, BrowserWindow, updateManager }));
4200
+ rebuildAppMenu();
4063
4201
 
4064
4202
  // The local file this window is allowed to be at — used both to restore a
4065
4203
  // saved position/reload and to recognise (and block) any navigation away
@@ -4337,6 +4475,7 @@ module.exports = {
4337
4475
  MENU_EXPORT_MARKDOWN_CHANNEL,
4338
4476
  MENU_EXPORT_JSON_CHANNEL,
4339
4477
  MENU_FINGERPRINT_CHANNEL,
4478
+ MENU_GOOGLE_DRIVE_CHANNEL,
4340
4479
  DEEP_LINK_CHANNEL,
4341
4480
  UPDATE_STATUS_CHANNEL,
4342
4481
  UPDATE_CHECK_INTERVAL_MS,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "1.0.13",
4
+ "version": "1.0.14",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
@@ -64,9 +64,6 @@
64
64
  "engines": {
65
65
  "node": ">=22.0.0"
66
66
  },
67
- "dependencies": {
68
- "electron-updater": "^6.8.9"
69
- },
70
67
  "devDependencies": {
71
68
  "electron-builder": "^26.15.3",
72
69
  "electron": "^44.0.0"
package/preload.js CHANGED
@@ -33,6 +33,7 @@ const MENU_SEARCH_CHANNEL = `${IPC_PREFIX}menuSearch`;
33
33
  const MENU_EXPORT_MARKDOWN_CHANNEL = `${IPC_PREFIX}menuExportMarkdown`;
34
34
  const MENU_EXPORT_JSON_CHANNEL = `${IPC_PREFIX}menuExportJson`;
35
35
  const MENU_FINGERPRINT_CHANNEL = `${IPC_PREFIX}menuFingerprint`;
36
+ const MENU_GOOGLE_DRIVE_CHANNEL = `${IPC_PREFIX}menuGoogleDrive`;
36
37
  const DEEP_LINK_CHANNEL = `${IPC_PREFIX}deepLink`;
37
38
  const QUICK_LAUNCHER_PUSH_CHANNEL = `${IPC_PREFIX}quickLauncherPush`;
38
39
 
@@ -260,11 +261,10 @@ const api = {
260
261
  getMemoryPersist: () => invoke('memoryPersist.get'),
261
262
  setMemoryPersist: (enabled) => invoke('memoryPersist.set', { enabled: Boolean(enabled) }),
262
263
 
263
- // Auto-update (electron-updater over GitHub Releases — see main.js
264
- // createUpdateManager). check/download resolve the same status shape the
265
- // push channel delivers; both are no-ops that resolve `{ status:
266
- // 'disabled' }` in an unpackaged dev build. quitAndInstallUpdate must only
267
- // ever be called from an explicit user action (the banner's "Restart to
264
+ // Auto-update (npm registry — see main.js createUpdateManager). check and
265
+ // download resolve the same status shape the push channel delivers: download
266
+ // installs on an explicit click and only then, and quitAndInstallUpdate must
267
+ // only ever be called from an explicit user action (the banner's "Restart to
268
268
  // install" button) — main.js never restarts on its own.
269
269
  checkForUpdates: () => invoke('checkForUpdates'),
270
270
  downloadUpdate: () => invoke('downloadUpdate'),
@@ -329,6 +329,14 @@ const api = {
329
329
  ipcRenderer.on(MENU_FINGERPRINT_CHANNEL, listener);
330
330
  return () => ipcRenderer.removeListener(MENU_FINGERPRINT_CHANNEL, listener);
331
331
  },
332
+ // Account > Google Drive Backup… — same sheet as Authorship Panel above,
333
+ // opened straight to the Drive row instead of leaving the Google sign-in
334
+ // buttons to be found by scrolling past the unrelated φ(α) key controls.
335
+ onMenuGoogleDrive: (onTrigger) => {
336
+ const listener = () => onTrigger();
337
+ ipcRenderer.on(MENU_GOOGLE_DRIVE_CHANNEL, listener);
338
+ return () => ipcRenderer.removeListener(MENU_GOOGLE_DRIVE_CHANNEL, listener);
339
+ },
332
340
  onMenuExportJson: (onTrigger) => {
333
341
  const listener = () => onTrigger();
334
342
  ipcRenderer.on(MENU_EXPORT_JSON_CHANNEL, listener);