@bobfrankston/rmfmail 1.2.188 → 1.2.191

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.
@@ -58,6 +58,15 @@ function vlog(msg: string): void {
58
58
  } catch { /* ignore */ }
59
59
  }
60
60
 
61
+ /** C156: user-visible bootstrap narration. A new-device install looked
62
+ * frozen (Bob 2026-07-22) because the stage breadcrumbs only went to vlog.
63
+ * This logs (console capture shows it pre-UI) AND emits a bootstrapStatus
64
+ * event the client renders in the status bar + folder-tree loading slot. */
65
+ function narrate(msg: string): void {
66
+ console.log(`[android] ${msg}`);
67
+ emitEvent({ type: "bootstrapStatus", message: msg });
68
+ }
69
+
61
70
  // ── Sync Manager ──
62
71
 
63
72
  /** Race a promise against a wall-clock timeout. On timeout it REJECTS so the
@@ -1024,61 +1033,81 @@ async function refreshAccessToken(refreshToken: string): Promise<{ access_token:
1024
1033
  // ── Token provider (browser OAuth, same as desktop) ──
1025
1034
 
1026
1035
  function createNativeTokenProvider(email: string): () => Promise<string> {
1027
- return async () => {
1028
- // Check cached token first
1029
- const cached = await getCachedToken(email);
1030
- if (cached?.access_token) {
1031
- const expiresAt = cached.expires_at || 0;
1032
- const bufferMs = 5 * 60 * 1000; // 5 min buffer
1033
- if (Date.now() < expiresAt - bufferMs) {
1034
- return cached.access_token;
1035
- }
1036
- // Try refresh
1037
- if (cached.refresh_token) {
1038
- try {
1039
- console.log(`[oauth] Refreshing token for ${email}`);
1040
- const refreshed = await refreshAccessToken(cached.refresh_token);
1041
- const token = {
1042
- access_token: refreshed.access_token,
1043
- refresh_token: cached.refresh_token,
1044
- expires_at: Date.now() + refreshed.expires_in * 1000,
1045
- };
1046
- await setCachedToken(email, token);
1047
- return token.access_token;
1048
- } catch (e: any) {
1049
- console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
1050
- }
1036
+ return () => {
1037
+ // C158: single-flight per email. Startup fires several concurrent
1038
+ // token requests (GDrive reconcile, contacts sync, syncAll) — with
1039
+ // no cached token each one launched its OWN browser-consent intent
1040
+ // (three "Starting OAuth flow" in one boot, 2026-07-22 logit trail).
1041
+ // Concurrent callers now share one resolution. Keyed on a window
1042
+ // global, not a module local, because the double-init bug that
1043
+ // exposed this also loads the module twice — two module instances
1044
+ // must still share the guard.
1045
+ const w = window as any;
1046
+ const inflight: Map<string, Promise<string>> = w.__rmfOAuthInflight ||= new Map();
1047
+ const key = canonEmail(email);
1048
+ const existing = inflight.get(key);
1049
+ if (existing) return existing;
1050
+ const p = fetchTokenForEmail(email).finally(() => inflight.delete(key));
1051
+ inflight.set(key, p);
1052
+ return p;
1053
+ };
1054
+ }
1055
+
1056
+ async function fetchTokenForEmail(email: string): Promise<string> {
1057
+ // Check cached token first
1058
+ const cached = await getCachedToken(email);
1059
+ if (cached?.access_token) {
1060
+ const expiresAt = cached.expires_at || 0;
1061
+ const bufferMs = 5 * 60 * 1000; // 5 min buffer
1062
+ if (Date.now() < expiresAt - bufferMs) {
1063
+ return cached.access_token;
1064
+ }
1065
+ // Try refresh
1066
+ if (cached.refresh_token) {
1067
+ try {
1068
+ console.log(`[oauth] Refreshing token for ${email}`);
1069
+ const refreshed = await refreshAccessToken(cached.refresh_token);
1070
+ const token = {
1071
+ access_token: refreshed.access_token,
1072
+ refresh_token: cached.refresh_token,
1073
+ expires_at: Date.now() + refreshed.expires_in * 1000,
1074
+ };
1075
+ await setCachedToken(email, token);
1076
+ return token.access_token;
1077
+ } catch (e: any) {
1078
+ console.warn(`[oauth] Refresh failed: ${e.message}, starting new flow`);
1051
1079
  }
1052
1080
  }
1081
+ }
1053
1082
 
1054
- // No valid token — start browser OAuth flow
1055
- const bridge = (window as any)._nativeBridge;
1056
- if (!bridge?.app?.startOAuth) {
1057
- throw new Error("No native OAuth bridge");
1058
- }
1083
+ // No valid token — start browser OAuth flow
1084
+ const bridge = (window as any)._nativeBridge;
1085
+ if (!bridge?.app?.startOAuth) {
1086
+ throw new Error("No native OAuth bridge");
1087
+ }
1059
1088
 
1060
- const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
1061
- client_id: OAUTH_CLIENT.clientId,
1062
- redirect_uri: OAUTH_CLIENT.redirectUri,
1063
- response_type: "code",
1064
- scope: OAUTH_SCOPES,
1065
- access_type: "offline",
1066
- prompt: "consent",
1067
- login_hint: email,
1068
- }).toString();
1069
-
1070
- console.log(`[oauth] Starting browser consent for ${email}`);
1071
- const code = await bridge.app.startOAuth(authUrl);
1072
- const tokens = await exchangeCodeForTokens(code);
1073
- const token = {
1074
- access_token: tokens.access_token,
1075
- refresh_token: tokens.refresh_token,
1076
- expires_at: Date.now() + tokens.expires_in * 1000,
1077
- };
1078
- await setCachedToken(email, token);
1079
- console.log(`[oauth] Token obtained for ${email}`);
1080
- return token.access_token;
1089
+ const authUrl = `${OAUTH_CLIENT.authUri}?` + new URLSearchParams({
1090
+ client_id: OAUTH_CLIENT.clientId,
1091
+ redirect_uri: OAUTH_CLIENT.redirectUri,
1092
+ response_type: "code",
1093
+ scope: OAUTH_SCOPES,
1094
+ access_type: "offline",
1095
+ prompt: "consent",
1096
+ login_hint: email,
1097
+ }).toString();
1098
+
1099
+ console.log(`[oauth] Starting browser consent for ${email}`);
1100
+ narrate(`Signing into ${email}…`);
1101
+ const code = await bridge.app.startOAuth(authUrl);
1102
+ const tokens = await exchangeCodeForTokens(code);
1103
+ const token = {
1104
+ access_token: tokens.access_token,
1105
+ refresh_token: tokens.refresh_token,
1106
+ expires_at: Date.now() + tokens.expires_in * 1000,
1081
1107
  };
1108
+ await setCachedToken(email, token);
1109
+ console.log(`[oauth] Token obtained for ${email}`);
1110
+ return token.access_token;
1082
1111
  }
1083
1112
 
1084
1113
  // ── GDrive folder lookup ──
@@ -1339,7 +1368,23 @@ async function waitForNativeBridge(timeoutMs: number = 5000): Promise<void> {
1339
1368
  });
1340
1369
  }
1341
1370
 
1342
- export async function initAndroid(): Promise<void> {
1371
+ export function initAndroid(): Promise<void> {
1372
+ // C158: idempotency guard. The 2026-07-22 fold/unfold logit trail showed
1373
+ // ONE WebView reload executing the boot module TWICE — duplicate "bridge
1374
+ // installed", duplicate GDrive lookups, tripled OAuth launches. The
1375
+ // guard lives on window (not a module local) so it holds even when the
1376
+ // module itself is instantiated twice (bundle + package-path specifiers
1377
+ // resolve to distinct module instances).
1378
+ const w = window as any;
1379
+ if (w.__rmfInitAndroid) {
1380
+ console.warn("[android] initAndroid called again — duplicate suppressed (C158)");
1381
+ vlog("C158: duplicate initAndroid call suppressed");
1382
+ return w.__rmfInitAndroid;
1383
+ }
1384
+ return w.__rmfInitAndroid = initAndroidOnce();
1385
+ }
1386
+
1387
+ async function initAndroidOnce(): Promise<void> {
1343
1388
  console.log("[android] Initializing mailx (main-thread mode)...");
1344
1389
 
1345
1390
  // Main-thread path: async I/O (fetch, TCP bridge) doesn't block the UI,
@@ -1410,7 +1455,7 @@ export async function initAndroid(): Promise<void> {
1410
1455
  reconcileTry++;
1411
1456
  setGDriveTokenProvider(tp);
1412
1457
  try {
1413
- console.log(`[android] Looking up GDrive .rmfmail folder… (try ${reconcileTry}/${RECONCILE_MAX_TRIES})`);
1458
+ narrate(`Fetching settings from Google Drive… (try ${reconcileTry})`);
1414
1459
  vlog(`gdrive-reconcile try ${reconcileTry}: token fetch`);
1415
1460
  // Watchdog the token fetch — it crosses into native code and has
1416
1461
  // hung on cold boots; without a timeout the whole reconcile just
@@ -1449,7 +1494,7 @@ export async function initAndroid(): Promise<void> {
1449
1494
  console.warn(`[android] List debug: ${e.message}`);
1450
1495
  }
1451
1496
  // Read accounts directly from GDrive (bypass IndexedDB cache)
1452
- console.log("[android] Reading accounts.jsonc from GDrive...");
1497
+ narrate("Loading accounts from Google Drive…");
1453
1498
  const gdriveAccounts = await loadAccountsFromCloud();
1454
1499
  console.log(`[android] GDrive returned ${gdriveAccounts.length} accounts: ${gdriveAccounts.map(a => a.id).join(",")}`);
1455
1500
  if (gdriveAccounts.length > 0) {
@@ -1468,6 +1513,15 @@ export async function initAndroid(): Promise<void> {
1468
1513
  await syncManager.addAccount(account);
1469
1514
  }
1470
1515
  console.log(`[android] Loaded ${accounts.length} accounts from GDrive`);
1516
+ // C157: sync NOW. The boot-time syncAll ran before these
1517
+ // providers existed (fresh install: zero local accounts),
1518
+ // so without this kick the folder tree stayed empty until
1519
+ // the next 60 s poll tick — on top of OAuth consent time,
1520
+ // that read as "frozen for minutes". syncAll's inbox
1521
+ // phase lists folders first, so the tree paints in
1522
+ // seconds; message counts stream in behind.
1523
+ narrate(`Syncing ${accounts.length} account(s)…`);
1524
+ syncManager.syncAll().catch(e => console.error(`[android] post-reconcile sync: ${e.message}`));
1471
1525
  }
1472
1526
  // Register this Android device in clients.jsonc
1473
1527
  await registerDeviceInGDrive(tp, folderId, accounts.map(a => a.id));
@@ -1755,6 +1809,10 @@ function installBridge(): void {
1755
1809
  await syncManager.addAccount(account);
1756
1810
  db.upsertAccount(account.id, account.name, account.email, JSON.stringify(account));
1757
1811
  console.log(`[android] Account added: ${email}`);
1812
+ // C157: the "Syncing..." message below was aspirational — no
1813
+ // sync was actually kicked, so the new account showed nothing
1814
+ // until the next 60 s poll tick.
1815
+ syncManager.syncAll().catch(e => console.error(`[android] post-setup sync: ${e.message}`));
1758
1816
  return { ok: true, message: `Added ${email}. Syncing...` };
1759
1817
  } catch (e: any) {
1760
1818
  return { ok: false, error: e.message };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bobfrankston/mailx-store-web",
3
- "version": "0.1.59",
3
+ "version": "0.1.62",
4
4
  "type": "module",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",