aegis-desktop 1.0.12 → 1.0.13

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
@@ -26,6 +26,7 @@
26
26
 
27
27
  const path = require('node:path');
28
28
  const fs = require('node:fs');
29
+ const { execFile } = require('node:child_process');
29
30
  const { pathToFileURL } = require('node:url');
30
31
 
31
32
  // Dev / CI / smoke-test layout: desktop/ lives inside the repo, so the shared
@@ -88,6 +89,30 @@ try {
88
89
  sharedTopup = require('./vendor/topup.js');
89
90
  }
90
91
 
92
+ // Google identity + Drive (client/google-signin.js, client/drive-connect.js,
93
+ // client/drive-upload.js). google-signin.js already existed for the CLI's
94
+ // `connect --google`; this is the first time desktop wires any of it. See
95
+ // lib/google-connect.js for how the three are sequenced.
96
+ let googleSignIn;
97
+ try {
98
+ googleSignIn = require('../client/google-signin.js');
99
+ } catch {
100
+ googleSignIn = require('./vendor/google-signin.js');
101
+ }
102
+ let driveConnectLib;
103
+ try {
104
+ driveConnectLib = require('../client/drive-connect.js');
105
+ } catch {
106
+ driveConnectLib = require('./vendor/drive-connect.js');
107
+ }
108
+ let driveUploadLib;
109
+ try {
110
+ driveUploadLib = require('../client/drive-upload.js');
111
+ } catch {
112
+ driveUploadLib = require('./vendor/drive-upload.js');
113
+ }
114
+ const { createGoogleConnect } = require('./lib/google-connect.js');
115
+
91
116
  // The device fingerprint (client/device-fingerprint.js) — the SAME identity the
92
117
  // auto-provision path registers a device-key against, now also carried in every
93
118
  // presence beat so one laptop is one machine in the admin panel rather than one
@@ -2771,6 +2796,10 @@ function createNpmUpdateManager({
2771
2796
  fetchImpl,
2772
2797
  setTimeoutImpl = setTimeout,
2773
2798
  clearTimeoutImpl = clearTimeout,
2799
+ // Both injectable so the install path is testable without running npm or
2800
+ // killing the test runner's own process.
2801
+ runUpgrade,
2802
+ relaunch,
2774
2803
  } = {}) {
2775
2804
  const PKG = 'aegis-desktop';
2776
2805
  let current = 'unknown';
@@ -2792,6 +2821,11 @@ function createNpmUpdateManager({
2792
2821
  attempts: 0,
2793
2822
  retryInMs: 0,
2794
2823
  command: null,
2824
+ // `plan` is the shared verdict the banner was rendered from. `download()`
2825
+ // installs from THIS object rather than re-deriving a command, so the
2826
+ // button can only ever run what the banner promised.
2827
+ plan: null,
2828
+ method: null,
2795
2829
  canDownload: false,
2796
2830
  };
2797
2831
  const set = (patch) => {
@@ -2818,6 +2852,34 @@ function createNpmUpdateManager({
2818
2852
  let inFlight = null;
2819
2853
  let interval = null;
2820
2854
 
2855
+ /**
2856
+ * How THIS desktop copy was installed: 'npm' | 'pip' | 'npx' | 'source'.
2857
+ *
2858
+ * Memoised — it cannot change while the process runs. `entry` is the app
2859
+ * directory: an unpackaged run is either a global npm install (a path with
2860
+ * `node_modules` in it), a checkout (`desktop/` beside `.git`), or a pip
2861
+ * launcher, which declares itself through AEGIS_INSTALL_METHOD because
2862
+ * nothing in the filesystem would otherwise say so.
2863
+ */
2864
+ let methodCache = null;
2865
+ function detectMethod() {
2866
+ if (methodCache) return methodCache;
2867
+ const detect = updater && updater.detectInstallMethod;
2868
+ if (typeof detect !== 'function') { methodCache = 'npm'; return methodCache; }
2869
+ try {
2870
+ methodCache = detect({
2871
+ env: process.env,
2872
+ argv: process.argv,
2873
+ pkg: PKG,
2874
+ entry: __dirname,
2875
+ fileExists: (rel) => fs.existsSync(path.resolve(__dirname, '..', rel)),
2876
+ });
2877
+ } catch {
2878
+ methodCache = 'npm';
2879
+ }
2880
+ return methodCache;
2881
+ }
2882
+
2821
2883
  /** 1m, 5m, 15m, 60m, 60m… — capped, never unbounded. */
2822
2884
  const delayFor = () =>
2823
2885
  UPDATE_RETRY_BACKOFF_MS[Math.min(Math.max(attempts - 1, 0), UPDATE_RETRY_BACKOFF_MS.length - 1)];
@@ -2917,8 +2979,14 @@ function createNpmUpdateManager({
2917
2979
  // The npm applier's instruction comes from the ONE shared verdict
2918
2980
  // (`updatePlan`), the same call `aegiscode`'s notice and `aegis doctor`
2919
2981
  // render — so the banner cannot offer a command the terminal would not.
2982
+ //
2983
+ // `method` is this copy's install channel, detected the same way the
2984
+ // CLI does. It matters because `npm i -g` against a pip-installed
2985
+ // aegis-desktop writes a second copy beside the one the user actually
2986
+ // runs: the button would appear to work and change nothing.
2987
+ const method = detectMethod();
2920
2988
  const plan = typeof updater.updatePlan === 'function'
2921
- ? updater.updatePlan({ current, latest: res.latest, behind: true, channel: 'npm', pkg: PKG })
2989
+ ? updater.updatePlan({ current, latest: res.latest, behind: true, channel: 'npm', pkg: PKG, method })
2922
2990
  : null;
2923
2991
  return set({
2924
2992
  status: 'available',
@@ -2928,11 +2996,13 @@ function createNpmUpdateManager({
2928
2996
  transient: false,
2929
2997
  attempts: 0,
2930
2998
  retryInMs: 0,
2999
+ plan,
3000
+ method,
2931
3001
  command: (plan && plan.instruction) || `npm i -g ${PKG}@latest`,
2932
- // npm owns installation here, so there is nothing for this process
2933
- // to download — the banner must offer the command, not a Download
2934
- // button that would silently do nothing.
2935
- canDownload: false,
3002
+ // 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),
2936
3006
  });
2937
3007
  }
2938
3008
 
@@ -2974,16 +3044,97 @@ function createNpmUpdateManager({
2974
3044
  }
2975
3045
  }
2976
3046
 
3047
+ /**
3048
+ * Run one installer argv and resolve to a status patch. Never rejects.
3049
+ *
3050
+ * `npm i -g` against a global prefix needs no elevation the way a system
3051
+ * package does, but it still writes outside the app dir and it can fail on a
3052
+ * permissions wall — so a failure is reported with npm's own last lines
3053
+ * rather than a blanket "update failed".
3054
+ */
3055
+ function runInstall(target) {
3056
+ const exec = runUpgrade || ((cmd, args) => new Promise((resolve) => {
3057
+ execFile(cmd, args, { timeout: 180_000, windowsHide: true }, (err, stdout, stderr) => {
3058
+ if (err) return resolve({ ok: false, output: String(stderr || stdout || err.message || err) });
3059
+ resolve({ ok: true, output: String(stdout || '') });
3060
+ });
3061
+ }));
3062
+ return Promise.resolve(exec(target.command, target.args)).then(
3063
+ (res) => ({ ok: Boolean(res && res.ok), output: String((res && res.output) || '') }),
3064
+ (err) => ({ ok: false, output: errorText(err) }),
3065
+ );
3066
+ }
3067
+
2977
3068
  return {
2978
3069
  status: () => state,
2979
3070
  check,
2980
- // npm installs are the user's to run; saying so beats pretending to act.
2981
- download: async () => set({
2982
- status: state.status === 'available' ? 'available' : state.status,
2983
- command: `npm i -g ${PKG}@latest`,
2984
- canDownload: false,
2985
- }),
2986
- quitAndInstall: () => {},
3071
+ /**
3072
+ * The banner's Download button. It INSTALLS.
3073
+ *
3074
+ * This used to be a no-op that re-set `command` and `canDownload:false`:
3075
+ * the button was hidden for every npm install, so the affordance and the
3076
+ * action were both missing, and the only way to update the window was to
3077
+ * read a shell command off a banner and type it into a different app.
3078
+ *
3079
+ * It still runs only on an explicit click (nothing here installs on a
3080
+ * timer), and it still does not hot-swap the running process: after a
3081
+ * successful install the honest state is 'downloaded' — "restart to install"
3082
+ * — 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.
3085
+ */
3086
+ download: async () => {
3087
+ const target = state.plan && state.plan.apply;
3088
+ if (!target) {
3089
+ // Nothing to install on this channel (npx fetches fresh every run; a
3090
+ // checkout belongs to its git remote). Say who owns it instead of
3091
+ // pretending to act.
3092
+ return set({
3093
+ status: state.status === 'available' ? 'available' : state.status,
3094
+ command: (state.plan && state.plan.instruction) || state.command,
3095
+ canDownload: false,
3096
+ });
3097
+ }
3098
+ set({ status: 'downloading', error: null, progress: 0 });
3099
+ const res = await runInstall(target);
3100
+ if (!res.ok) {
3101
+ // `transient: false` deliberately: an installer that refused is not a
3102
+ // network blip, and the retry ladder must not quietly re-run an npm
3103
+ // install every minute.
3104
+ return set({
3105
+ status: 'error',
3106
+ transient: false,
3107
+ error: res.output.trim().split('\n').slice(-3).join(' ').trim() || 'the installer failed',
3108
+ command: `${target.command} ${target.args.join(' ')}`,
3109
+ });
3110
+ }
3111
+ return set({
3112
+ status: 'downloaded',
3113
+ version: state.version,
3114
+ error: null,
3115
+ progress: 100,
3116
+ command: `npm i -g ${PKG}@latest`,
3117
+ canDownload: false,
3118
+ });
3119
+ },
3120
+ /**
3121
+ * "Restart to install". Relaunches the app so the freshly installed files
3122
+ * are what runs — `app.relaunch()` before `app.exit()`, the standard pair,
3123
+ * because `app.quit()` alone would not start a new process.
3124
+ */
3125
+ quitAndInstall: () => {
3126
+ if (state.status !== 'downloaded') return;
3127
+ const again = relaunch || (() => {
3128
+ try {
3129
+ const { app } = require('electron');
3130
+ app.relaunch();
3131
+ app.exit(0);
3132
+ } catch {
3133
+ /* not under Electron (a test or the CLI): nothing to relaunch */
3134
+ }
3135
+ });
3136
+ again();
3137
+ },
2987
3138
  start: () => {
2988
3139
  check().catch(() => {});
2989
3140
  interval = setInterval(() => check().catch(() => {}), UPDATE_CHECK_INTERVAL_MS);
@@ -3396,6 +3547,26 @@ function bootstrap() {
3396
3547
  describeFailure: autoProvision.provisionFailureText,
3397
3548
  onChange: (state) => broadcastAccountStatus(state),
3398
3549
  });
3550
+
3551
+ // Google sign-in + Drive connect (lib/google-connect.js). Same key store as
3552
+ // accountKey above (settings.setAegisKey) — one saved key, whichever door
3553
+ // last wrote it — and the same banner: a successful Google sign-in clears
3554
+ // whatever "no key" state accountKey was showing, same as a successful
3555
+ // register-device retry does.
3556
+ const googleConnect = createGoogleConnect({
3557
+ aegis,
3558
+ credentials,
3559
+ persistKey: (key) => settings.setAegisKey(key),
3560
+ openUrl: (url) => {
3561
+ if (!isSafeExternalUrl(url)) return false;
3562
+ shell.openExternal(url);
3563
+ return true;
3564
+ },
3565
+ googleSignIn,
3566
+ driveConnect: driveConnectLib,
3567
+ driveUpload: driveUploadLib,
3568
+ onAccountChange: () => broadcastAccountStatus(accountKey.status()),
3569
+ });
3399
3570
  const keyPresentAtStart = Boolean(aegis.apiKey);
3400
3571
  const earlyKeyProvision = autoProvision.autoProvisionApiKey(aegis, credentials, { app: 'desktop' }).then((result) => {
3401
3572
  accountKey.recordStartup(result);
@@ -3873,6 +4044,14 @@ function bootstrap() {
3873
4044
  registerUpdateIpc(ipcMain, updateManager);
3874
4045
  ipcMain.handle(`${IPC_PREFIX}accountStatus`, () => accountKey.status());
3875
4046
  ipcMain.handle(`${IPC_PREFIX}registerAccount`, () => accountKey.registerAccount());
4047
+ // Google sign-in + Drive connect/sync (lib/google-connect.js). Every call
4048
+ // resolves `{ ok, … }` / `{ ok: false, error }` rather than throwing, same
4049
+ // contract as the fingerprint* handlers below — the renderer shows the
4050
+ // actual reason instead of Electron's opaque "Error invoking remote method".
4051
+ ipcMain.handle(`${IPC_PREFIX}driveSignIn`, () => googleConnect.signInWithGoogle());
4052
+ ipcMain.handle(`${IPC_PREFIX}driveConnect`, () => googleConnect.connectDrive());
4053
+ ipcMain.handle(`${IPC_PREFIX}driveStatus`, () => googleConnect.status());
4054
+ ipcMain.handle(`${IPC_PREFIX}driveSyncMemory`, () => googleConnect.syncMemoryNow());
3876
4055
  registerExportIpc(
3877
4056
  ipcMain,
3878
4057
  sessionStore,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "1.0.12",
4
+ "version": "1.0.13",
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",
@@ -26,6 +26,7 @@
26
26
  "scripts": {
27
27
  "start": "electron .",
28
28
  "predist": "node scripts/predist.mjs",
29
+ "prepack": "node scripts/predist.mjs --quiet",
29
30
  "prepublishOnly": "npm run predist",
30
31
  "icon": "node scripts/generate-icon.mjs",
31
32
  "shots": "node scripts/capture-marketing-shots.mjs",
package/preload.js CHANGED
@@ -285,6 +285,20 @@ const api = {
285
285
  ipcRenderer.on(ACCOUNT_STATUS_CHANNEL, listener);
286
286
  return () => ipcRenderer.removeListener(ACCOUNT_STATUS_CHANNEL, listener);
287
287
  },
288
+ // Google sign-in + Drive connect/sync (main.js lib/google-connect.js).
289
+ // `driveSignIn` opens a browser for aegis1's `/auth/google` round-trip and
290
+ // replaces this machine's saved key with the Google-linked one — the
291
+ // desktop counterpart of the CLI's `aegiscode connect`. `driveConnect`
292
+ // opens `/connect/drive` and polls until this (now Google-linked) account
293
+ // shows a Drive token on file; it is only coherent once `driveSignIn` has
294
+ // actually run. `driveStatus` is a one-shot, no-browser read for painting
295
+ // the card on load. `driveSyncMemory` pulls this account's AEGIS cloud
296
+ // memory and backs it up to a dedicated "AEGIS Memory" Drive folder. Every
297
+ // one resolves `{ ok, … }` / `{ ok: false, error }`, never throws.
298
+ driveSignIn: () => invoke('driveSignIn'),
299
+ driveConnect: () => invoke('driveConnect'),
300
+ driveStatus: () => invoke('driveStatus'),
301
+ driveSyncMemory: () => invoke('driveSyncMemory'),
288
302
  // Native menu accelerators with no built-in Electron role (Cmd/Ctrl+N,
289
303
  // Cmd/Ctrl+K — see main.js buildAppMenu): main.js just pings the channel,
290
304
  // the renderer owns what "new chat" / "search" actually do.
package/renderer/app.js CHANGED
@@ -1183,12 +1183,15 @@ function renderUpdateBanner(state) {
1183
1183
  let showRetry = false;
1184
1184
  const version = state.version ? `v${state.version} ` : '';
1185
1185
  if (status === 'available') {
1186
- // The npm channel cannot install itself, so its banner names the command
1187
- // instead of offering a Download button that would do nothing.
1186
+ // A channel the main process can install for (npm, pip) gets a real
1187
+ // Download button — `canDownload` is true only when it has an argv to run.
1188
+ // The rest (npx, a source checkout) have nothing to install, so the banner
1189
+ // names the command instead of offering a button that would do nothing.
1188
1190
  text = state.command
1189
1191
  ? `Update ${version}available — run: ${state.command}`
1190
1192
  : `Update ${version}available.`;
1191
1193
  showDownload = !!state.canDownload;
1194
+ if (showDownload) text = `Update ${version}available.`;
1192
1195
  } else if (status === 'downloading') {
1193
1196
  const pct = typeof state.progress === 'number' ? ` (${Math.round(state.progress)}%)` : '';
1194
1197
  text = `Downloading update${pct}…`;
@@ -6467,9 +6470,16 @@ async function init() {
6467
6470
 
6468
6471
  els.updateDownloadBtn.addEventListener('click', () => {
6469
6472
  els.updateDownloadBtn.disabled = true;
6470
- aegis.downloadUpdate().finally(() => {
6471
- els.updateDownloadBtn.disabled = false;
6472
- });
6473
+ // The resolved value is the post-install state: render it as well as
6474
+ // waiting for the push, so a channel whose installer finishes inside the
6475
+ // invoke still repaints (and so a failure surfaces even if the push
6476
+ // channel is not wired yet).
6477
+ Promise.resolve(aegis.downloadUpdate())
6478
+ .then((state) => { if (state && state.status) renderUpdateBanner(state); })
6479
+ .catch(() => {})
6480
+ .finally(() => {
6481
+ els.updateDownloadBtn.disabled = false;
6482
+ });
6473
6483
  });
6474
6484
 
6475
6485
  // The only place quitAndInstallUpdate is ever called — an explicit user
@@ -78,6 +78,10 @@
78
78
  audit: 'fp-audit',
79
79
  vaultList: 'fp-vault-list',
80
80
  out: 'fp-out',
81
+ driveStatus: 'fp-drive-status',
82
+ driveSignIn: 'fp-drive-signin',
83
+ driveConnect: 'fp-drive-connect',
84
+ driveSyncNow: 'fp-drive-sync',
81
85
  };
82
86
 
83
87
  // ------------------------------------------------------------- formatting
@@ -139,6 +143,23 @@
139
143
  });
140
144
  }
141
145
 
146
+ /**
147
+ * The one-line summary of the Google Drive card — `desktop/lib/
148
+ * google-connect.js`'s `status()` shape, `{signedIn, connected, email}`.
149
+ *
150
+ * Three distinct states, because "not connected" means something different
151
+ * depending on whether this machine has signed in with Google at all:
152
+ * connecting Drive before signing in attaches the tokens to a different
153
+ * account than the one this machine polls (see google-connect.js's header
154
+ * comment), so the line has to say WHICH step is next, not just "no".
155
+ */
156
+ function driveLine(status) {
157
+ if (!status || status.ok === false) return (status && status.error) || 'unavailable';
158
+ if (!status.signedIn) return 'not signed in — sign in with Google first';
159
+ if (!status.connected) return 'signed in · Google Drive not connected yet';
160
+ return `Google Drive connected${status.email ? ` · ${status.email}` : ''}`;
161
+ }
162
+
142
163
  /** The anchor answer, kept as three separate claims on purpose: "the file is
143
164
  * gone", "the file was replaced" and "the key that sealed it has moved on"
144
165
  * are three different situations, and one boolean hides the interesting one
@@ -342,6 +363,50 @@
342
363
  say: (r) => (r.ok ? `vault backend set to ${dash(r.backend)}` : `could not save that: ${r.error}`),
343
364
  });
344
365
 
366
+ /**
367
+ * Google Drive — memory backup (desktop/lib/google-connect.js, exposed
368
+ * over `aegis.driveSignIn`/`driveConnect`/`driveStatus`/`driveSyncMemory`).
369
+ *
370
+ * Distinct from the vault backend buttons above: those choose where a
371
+ * SEALED φ(α) RECORD is published (rclone or the account vault). This is
372
+ * the account's AEGIS CLOUD MEMORY, backed up to the user's own Drive
373
+ * through real Google OAuth — unrelated data, unrelated credential.
374
+ */
375
+ function paintDrive(status) {
376
+ const line = el(IDS.driveStatus);
377
+ if (line) line.textContent = driveLine(status);
378
+ const connectBtn = el(IDS.driveConnect);
379
+ if (connectBtn) connectBtn.disabled = Boolean(status && !status.signedIn);
380
+ const syncBtn = el(IDS.driveSyncNow);
381
+ if (syncBtn) syncBtn.disabled = Boolean(!status || !status.connected);
382
+ }
383
+
384
+ async function loadDriveStatus() {
385
+ const r = await call('driveStatus');
386
+ state.drive = r;
387
+ paintDrive(r);
388
+ return r;
389
+ }
390
+
391
+ const doDriveSignIn = () =>
392
+ action('sign in with Google', () => call('driveSignIn'), {
393
+ after: (r) => (r && r.ok ? loadDriveStatus() : null),
394
+ say: (r) => (r.ok ? `signed in${r.username ? ` as ${r.username}` : ''}` : `sign-in failed: ${r.error}`),
395
+ });
396
+
397
+ const doDriveConnect = () =>
398
+ action('connect Google Drive', () => call('driveConnect'), {
399
+ after: (r) => (r && r.ok ? loadDriveStatus() : null),
400
+ say: (r) => (r.ok ? `Google Drive connected${r.email ? ` · ${r.email}` : ''}` : `could not connect: ${r.error}`),
401
+ });
402
+
403
+ const doDriveSyncNow = () =>
404
+ action('back up memory to Drive', () => call('driveSyncMemory'), {
405
+ say: (r) => (r.ok
406
+ ? `backed up ${r.count} memory entr${r.count === 1 ? 'y' : 'ies'}${r.webViewLink ? ` → ${r.webViewLink}` : ''}`
407
+ : `backup failed: ${r.error}`),
408
+ });
409
+
345
410
  function paintVault(list) {
346
411
  const ul = el(IDS.vaultList);
347
412
  if (!ul) return;
@@ -368,6 +433,7 @@
368
433
  paintStatus();
369
434
  await loadAutoMint();
370
435
  await loadVaultBackend();
436
+ await loadDriveStatus();
371
437
  return state.status;
372
438
  }
373
439
 
@@ -606,6 +672,9 @@
606
672
  bind(IDS.vaultBackendDrive, 'click', () => doSetVaultBackend('drive'));
607
673
  bind(IDS.vaultBackendCloud, 'click', () => doSetVaultBackend('aegiscloud'));
608
674
  bind(IDS.vaultBackendOff, 'click', () => doSetVaultBackend('off'));
675
+ bind(IDS.driveSignIn, 'click', doDriveSignIn);
676
+ bind(IDS.driveConnect, 'click', doDriveConnect);
677
+ bind(IDS.driveSyncNow, 'click', doDriveSyncNow);
609
678
  // The auto-seal switch is the ONE control on this panel that can file a
610
679
  // record with nobody watching, so it is the one that must repaint from
611
680
  // main's answer rather than from the checkbox the user just clicked.
@@ -626,8 +695,12 @@
626
695
  state,
627
696
  // Exposed for the test and for app.js's on-demand repaint. Every one is
628
697
  // idempotent, so calling them from a console is safe.
629
- actions: { doKeygen, doSeal, doSealNow, doVerify, doPublish, doAnchor, doBackup, doVault, doAudit, doSetVaultBackend },
698
+ actions: {
699
+ doKeygen, doSeal, doSealNow, doVerify, doPublish, doAnchor, doBackup, doVault, doAudit,
700
+ doSetVaultBackend, doDriveSignIn, doDriveConnect, doDriveSyncNow,
701
+ },
630
702
  loadVaultBackend,
703
+ loadDriveStatus,
631
704
  };
632
705
  }
633
706
 
@@ -643,6 +716,7 @@
643
716
  auditVerdict,
644
717
  sealLine,
645
718
  autoMintLine,
719
+ driveLine,
646
720
  dash,
647
721
  };
648
722
  });
@@ -927,6 +927,23 @@
927
927
 
928
928
  <ul class="fp-vault-list" id="fp-vault-list"></ul>
929
929
 
930
+ <!-- Google Drive — memory backup. Unrelated to the vault above: that
931
+ publishes a sealed φ(α) RECORD; this backs up the account's AEGIS
932
+ CLOUD MEMORY to the user's own Drive through real Google OAuth
933
+ (desktop/lib/google-connect.js). Two buttons because connecting
934
+ Drive is only coherent once this machine has signed in with the
935
+ SAME Google identity — see that file's header comment — so
936
+ "Connect Drive" stays disabled (fingerprint.js paintDrive()) until
937
+ sign-in has actually landed. -->
938
+ <div class="fp-controls">
939
+ <div class="fp-row"><span class="fp-k">Google Drive</span><span class="fp-v" id="fp-drive-status">–</span></div>
940
+ <div class="fp-actions">
941
+ <button type="button" id="fp-drive-signin" class="ghost-btn">Sign in with Google</button>
942
+ <button type="button" id="fp-drive-connect" class="ghost-btn" disabled>Connect Drive</button>
943
+ <button type="button" id="fp-drive-sync" class="ghost-btn" disabled>Back up memory now</button>
944
+ </div>
945
+ </div>
946
+
930
947
  <div class="fp-out" id="fp-out">Ready. The key is never displayed.</div>
931
948
  </div>
932
949
  </div>