=== apps/desktop/src/connect-server-sync.ts @@ -140,7 +140,19 @@ export interface CreateConnectServerSyncArgs { clearIntervalFn?: (handle: unknown) => void; } +/** + * Why the last sync produced no server list. `null` once a sync has succeeded. + * Surfaced in the Server menu so an empty list is distinguishable from an + * account that genuinely has no servers. + */ +export type ConnectServerSyncSkipReason = + | "no-credential" + | "unauthorized" + | "unavailable"; + export interface ConnectServerSync { + /** Why the last sync yielded no servers, or null after a successful sync. */ + getSkipReason(): ConnectServerSyncSkipReason | null; /** Start the 10-minute background timer (unref'd so it does not keep the app alive). */ start(): void; stop(): void; @@ -180,6 +192,7 @@ export function createConnectServerSync( let lastSyncAttemptAt = 0; let inFlight: Promise | null = null; let loggedFailure = false; + let skipReason: ConnectServerSyncSkipReason | null = null; /** * Prefer the local server: it holds the pairing secret and always reflects @@ -189,19 +202,23 @@ export function createConnectServerSync( async function fetchServers(): Promise { const serverUrl = args.getLocalServerUrl(); if (serverUrl !== null) { + skipReason = "unavailable"; return fetchConnectAccountServers({ serverUrl, fetchImpl: args.fetchImpl, }); } const credential = args.getCredential(); if (credential === null) { + skipReason = "no-credential"; return null; } try { + skipReason = "unavailable"; return await listAccountServers(credential, args.gateFetchImpl); } catch (error) { if (error instanceof ConnectListError && error.code === "unauthorized") { + skipReason = "unauthorized"; args.onUnauthorized(); } return null; @@ -222,6 +239,7 @@ export function createConnectServerSync( } loggedFailure = false; + skipReason = null; args.onServers(selectTargetableConnectServers(result)); } @@ -274,6 +292,7 @@ export function createConnectServerSync( } return { + getSkipReason: () => skipReason, start, stop, onRuntimeReady, === apps/desktop/src/main.ts @@ -671,8 +671,30 @@ function buildMenuServerItems(): Array<{ return items; } +/** + * The Server menu lists Connect servers only after a successful sync. When the + * sync could not run, an empty list otherwise looks identical to an account + * with no servers, so name the reason instead. + */ +function buildMenuServersNote(): string | null { + if (listMenuConnectServers().length > 0) { + return null; + } + switch (connectServerSync?.getSkipReason() ?? null) { + case "no-credential": + return "No Connect servers — sign in to bb Connect"; + case "unauthorized": + return "No Connect servers — bb Connect sign-in expired"; + case "unavailable": + return "No Connect servers — could not reach bb Connect"; + default: + return null; + } +} + function installCurrentApplicationMenu(): void { installApplicationMenu({ + serversNote: buildMenuServersNote(), accelerators: currentApplicationMenuAccelerators, isMac: process.platform === "darwin", createNewWindow() { === apps/desktop/src/menu.ts @@ -45,6 +45,12 @@ export interface InstallApplicationMenuArgs { onServerMenuWillShow?: () => void; serverDaemonLogsMenuEnabled: boolean; servers: ApplicationMenuServerItem[]; + /** + * Why no Connect servers are listed, when that is because the sync could not + * run rather than because the account has none. Rendered as a disabled item + * so the two cases are distinguishable. + */ + serversNote?: string | null; } function createServerDaemonLogsMenuItems( @@ -75,8 +81,13 @@ function createServerMenuItems( type: "radio" as const, }), ); + const note: MenuItemConstructorOptions[] = + args.serversNote === undefined || args.serversNote === null + ? [] + : [{ enabled: false, label: args.serversNote }]; return [ ...serverItems, + ...note, { type: "separator" }, { label: SET_SERVER_URL_MENU_LABEL,