diff --git a/package.json b/package.json index f4fdec9..1f2a8f5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "io.foxbiz.shellular", "displayName": "Use your dev machine from your phone.", - "version": "0.0.36", - "versionCode": 36, + "version": "0.0.37", + "versionCode": 37, "description": "", "type": "module", "licenses": [ @@ -99,6 +99,7 @@ "libsodium-wrappers": "^0.8.4", "marked": "^18.0.2", "nanoid": "^5.1.11", + "qrcode-generator": "^2.0.4", "react": "^19.2.5", "react-chartjs-2": "^5.3.1", "react-dom": "^19.2.5", diff --git a/platforms/android/app/src/main/AndroidManifest.xml b/platforms/android/app/src/main/AndroidManifest.xml index 83c1205..d9e2782 100644 --- a/platforms/android/app/src/main/AndroidManifest.xml +++ b/platforms/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ CFBundleURLSchemes foxbiz - shellular + $(AUTH_CALLBACK_SCHEME) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e23c452..ab26691 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: nanoid: specifier: ^5.1.11 version: 5.1.11 + qrcode-generator: + specifier: ^2.0.4 + version: 2.0.4 react: specifier: ^19.2.5 version: 19.2.5 @@ -3280,6 +3283,9 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -7401,6 +7407,8 @@ snapshots: pvutils@1.1.5: {} + qrcode-generator@2.0.4: {} + qs@6.14.2: dependencies: side-channel: 1.1.0 diff --git a/src/components/ConnectionStatus.scss b/src/components/ConnectionStatus.scss deleted file mode 100644 index a07db3f..0000000 --- a/src/components/ConnectionStatus.scss +++ /dev/null @@ -1,59 +0,0 @@ -.connection-status { - position: fixed; - inset: 0; - z-index: 200; - display: flex; - align-items: center; - justify-content: center; - padding: calc(24px + var(--sat, 0px)) 24px calc(24px + var(--sab, 0px)); - // Soft, light scrim — the app stays faintly visible underneath so a - // reconnect feels like a momentary pause rather than a hard wall. - background: color-mix(in srgb, var(--primary) 32%, transparent); - backdrop-filter: blur(10px) saturate(1.05); - -webkit-backdrop-filter: blur(10px) saturate(1.05); -} - -.connection-status-card { - display: flex; - align-items: center; - gap: 14px; - max-width: min(420px, 100%); - padding: 16px 20px; - border: 1px solid var(--popup-border-color, var(--card-border)); - border-radius: 18px; - background: var(--popup-background, var(--surface-soft)); - box-shadow: - 0 12px 32px var(--shadow-color), - 0 2px 8px var(--shadow-color); - - .mascot { - flex: 0 0 auto; - } -} - -.connection-status-text { - display: flex; - flex-direction: column; - gap: 3px; - min-width: 0; -} - -.connection-status-message { - margin: 0; - font-size: 16px; - font-weight: 700; - line-height: 1.25; - color: var(--primary-text); -} - -.is-connecting .connection-status-message { - color: var(--accent); -} - -.connection-status-hint { - margin: 0; - font-size: 13px; - font-weight: 500; - line-height: 1.4; - color: var(--secondary-text); -} diff --git a/src/components/ConnectionStatus.tsx b/src/components/ConnectionStatus.tsx index 89aaf83..87439f0 100644 --- a/src/components/ConnectionStatus.tsx +++ b/src/components/ConnectionStatus.tsx @@ -1,4 +1,3 @@ -import "./ConnectionStatus.scss"; import Mascot from "components/Mascot"; import { AnimatePresence, motion } from "framer-motion"; import { useEffect, useState } from "react"; @@ -19,11 +18,34 @@ import { useShellular } from "state"; // recovery. const SHOW_DELAY_MS = 700; +// Once a reconnect has failed this many times the host is probably genuinely +// gone (laptop shut, CLI killed) rather than the socket having blipped, and +// riding out the remaining backoff behind a blocking scrim is just an annoyance +// — so we offer an explicit way out. Cancelling drops to `disconnected`, the +// home view's terminal state, where the host picker takes over. +// Note `reconnectAttempt` becomes 1 the moment a reconnect starts, before the +// first attempt has run — so 2 is the first value meaning "an attempt actually +// failed". Gating on 1 would flash the button during blips that resolve in well +// under a second. +const SHOW_CANCEL_AFTER_ATTEMPTS = 2; + +// During a CLI self-update the drop is expected, so we don't offer to give up +// straight away. But an update can genuinely fail and never come back, and the +// update reconnect budget runs several minutes — so still surface the escape +// hatch once the restart has clearly overrun the "minute or two" we promised. +const SHOW_CANCEL_AFTER_ATTEMPTS_WHILE_UPDATING = 6; + export default function ConnectionStatus() { - const { connectionStatus } = useShellular(); + const { connectionStatus, reconnectAttempt, hostUpdating, disconnect } = + useShellular(); const [visible, setVisible] = useState(false); const shouldShow = connectionStatus === "reconnecting"; + const canCancel = + reconnectAttempt >= + (hostUpdating + ? SHOW_CANCEL_AFTER_ATTEMPTS_WHILE_UPDATING + : SHOW_CANCEL_AFTER_ATTEMPTS); useEffect(() => { if (!shouldShow) { @@ -39,7 +61,9 @@ export default function ConnectionStatus() { {visible && ( - -
-

Reconnecting…

-

- Hang tight, picking up where you left off -

+
+ +
+

+ {hostUpdating ? "Updating CLI…" : "Reconnecting…"} +

+

+ {hostUpdating + ? canCancel + ? "This is taking longer than usual. The update may have failed." + : "Your dev machine is restarting after the update. This usually takes a minute or two." + : canCancel + ? "Still trying. Your dev machine may be offline." + : "Hang tight, picking up where you left off"} +

+
+ + {canCancel && ( + + Stop reconnecting + + )} + )} diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx index 6ff88ff..70b9a2c 100644 --- a/src/lib/auth.tsx +++ b/src/lib/auth.tsx @@ -36,6 +36,8 @@ type ProviderStatus = { }; type AuthStatus = "loading" | "authenticated" | "unauthenticated"; +/** Error from `authRequest`, carrying the HTTP status when there was one. */ +type AuthRequestError = Error & { httpStatus?: number }; type AccountAction = { type: "link" | "unlink"; provider: AuthProviderId; @@ -59,6 +61,8 @@ type AuthContextValue = { const REFRESH_TOKEN_KEY = "auth-refresh-token"; const REFRESH_SKEW_MS = 60 * 1000; +/** Backoff after a refresh that failed for non-auth reasons (offline, 5xx). */ +const REFRESH_RETRY_MS = 30 * 1000; const AuthContext = createContext(null); let accessToken: string | null = null; @@ -125,6 +129,9 @@ export function AuthProvider({ children }: { children: ReactNode }) { return true; } catch (err) { logAuthError("refresh browser session", err); + // See the token path below: don't sign the user out over a + // transient network/server failure. + if (!isAuthRejection(err)) return false; await clearAuth(); return false; } finally { @@ -148,6 +155,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { return true; } catch (err) { logAuthError("refresh session", err); + // Only a explicit rejection means the token is dead. On network or + // server errors keep it and stay signed in — the next refresh (or + // the next request that needs a token) will retry. + if (!isAuthRejection(err)) return false; await clearAuth(); setError("Your session expired. Please sign in again."); return false; @@ -206,15 +217,51 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; }, [refresh]); + // A refresh that failed for network reasons leaves the token on disk (only an + // explicit 401/403 clears it), so the session is recoverable — retry when the + // device comes back online or the app returns to the foreground instead of + // stranding the user on the login screen until they sign in again. + useEffect(() => { + if (status !== "unauthenticated") return; + + const retry = async () => { + if ( + !isBrowserCookieAuth() && + !(await secureStore.get(REFRESH_TOKEN_KEY)) + ) { + return; + } + refresh().catch(console.error); + }; + + window.addEventListener("online", retry); + document.addEventListener("resume", retry); + return () => { + window.removeEventListener("online", retry); + document.removeEventListener("resume", retry); + }; + }, [refresh, status]); + useEffect(() => { if (status !== "authenticated") return; - const delay = Math.max( - 1000, - accessTokenExpiresAt - Date.now() - REFRESH_SKEW_MS, + let timer = 0; + + const schedule = (delay: number) => { + timer = window.setTimeout(async () => { + const ok = await refresh().catch((err) => { + console.error(err); + return false; + }); + // A transient failure leaves us authenticated with a stale access + // token; retry on a short backoff rather than waiting for the next + // expiry that will never be scheduled. + if (!ok) schedule(REFRESH_RETRY_MS); + }, delay); + }; + + schedule( + Math.max(1000, accessTokenExpiresAt - Date.now() - REFRESH_SKEW_MS), ); - const timer = window.setTimeout(() => { - refresh().catch(console.error); - }, delay); return () => window.clearTimeout(timer); }, [refresh, status]); @@ -504,11 +551,16 @@ async function refreshWithoutReact(): Promise { refreshTokenValue = data.refreshToken; await secureStore.set(REFRESH_TOKEN_KEY, data.refreshToken); return true; - } catch { + } catch (err) { + // Drop the in-memory access token either way (it's unusable), but only + // destroy the refresh token when the server actually rejected it — + // otherwise a blip while connecting would silently sign the user out. accessToken = null; accessTokenExpiresAt = 0; - refreshTokenValue = null; - await secureStore.remove(REFRESH_TOKEN_KEY); + if (isAuthRejection(err)) { + refreshTokenValue = null; + await secureStore.remove(REFRESH_TOKEN_KEY); + } return false; } finally { refreshInFlight = null; @@ -550,11 +602,29 @@ async function authRequest( message?: string; }; if (!res.ok || json.success === false) { - throw new Error(json.error || json.message || "Authentication failed."); + const error = new Error( + json.error || json.message || "Authentication failed.", + ) as AuthRequestError; + error.httpStatus = res.status; + throw error; } return json.data as T; } +/** + * True only when the server explicitly rejected our credentials (401/403). + * + * Everything else — `fetch` rejecting outright (offline, DNS, TLS, backgrounded + * radio), 5xx during a deploy, 429, a gateway's HTML error page — says nothing + * about whether the refresh token is still valid. Treating those as "signed + * out" throws away a perfectly good token and forces a fresh login, which is + * how a week-long session turns into a daily one. + */ +export function isAuthRejection(err: unknown): boolean { + const status = (err as AuthRequestError)?.httpStatus; + return status === 401 || status === 403; +} + function callbackParams(url: string): Record { try { return Object.fromEntries(new URL(url).searchParams.entries()); @@ -579,7 +649,10 @@ function logAuthError(action: string, error: unknown): void { } async function getAuthCallbackScheme(): Promise { - if (process.env.PLATFORM !== "android") { + // Android and iOS both ship dev builds under a `.dev` bundle id so they can + // sit alongside the store build; those register `shellular-dev` instead of + // `shellular` so the OS routes the auth callback to the right app. + if (process.env.PLATFORM !== "android" && process.env.PLATFORM !== "ios") { return "shellular"; } @@ -589,7 +662,7 @@ async function getAuthCallbackScheme(): Promise { appInfo.packageName.endsWith(".dev") ? "shellular-dev" : "shellular", ) .catch((error) => { - console.warn("[auth] Failed to detect Android package name", error); + console.warn("[auth] Failed to detect app bundle id", error); return "shellular"; }); return authCallbackSchemeInFlight; diff --git a/src/lib/authRejection.test.ts b/src/lib/authRejection.test.ts new file mode 100644 index 0000000..79e5557 --- /dev/null +++ b/src/lib/authRejection.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { isAuthRejection } from "./auth"; + +/** + * Guards the rule that decides whether a failed `/auth/refresh` destroys the + * stored refresh token. Getting this wrong doesn't break a build or fail + * loudly — it just silently signs people out, so it's worth pinning down. + */ +describe("isAuthRejection", () => { + const withStatus = (httpStatus: number) => + Object.assign(new Error("nope"), { httpStatus }); + + it("treats 401/403 as a real rejection", () => { + expect(isAuthRejection(withStatus(401))).toBe(true); + expect(isAuthRejection(withStatus(403))).toBe(true); + }); + + it("does not sign out on server-side failures", () => { + // A deploy, a crash, a rate limit — the token is still fine. + for (const status of [500, 502, 503, 504, 429]) { + expect(isAuthRejection(withStatus(status))).toBe(false); + } + }); + + it("does not sign out on a bare network error", () => { + // `fetch` rejects with a plain TypeError when the device is offline, the + // radio is asleep, or DNS/TLS fails — no status is ever attached. + expect(isAuthRejection(new TypeError("Failed to fetch"))).toBe(false); + expect(isAuthRejection(new Error("Authentication failed."))).toBe(false); + }); + + it("does not sign out on malformed or missing errors", () => { + expect(isAuthRejection(undefined)).toBe(false); + expect(isAuthRejection(null)).toBe(false); + expect(isAuthRejection({})).toBe(false); + expect(isAuthRejection("401")).toBe(false); + }); + + it("does not treat other 4xx as a credential rejection", () => { + // 400/404 mean the request or route was wrong, not that we're signed out. + expect(isAuthRejection(withStatus(400))).toBe(false); + expect(isAuthRejection(withStatus(404))).toBe(false); + }); +}); diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 2864172..3430c99 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -1281,12 +1281,7 @@ export default function ChatConversationPage({ /> { - setShowConfigSheet(false); - requestAnimationFrame(() => { - promptInputRef.current?.focus(); - }); - }} + onClose={() => setShowConfigSheet(false)} title="Session Configuration" >
@@ -1331,12 +1326,7 @@ export default function ChatConversationPage({ { - setShowContextSheet(false); - requestAnimationFrame(() => { - promptInputRef.current?.focus(); - }); - }} + onClose={() => setShowContextSheet(false)} title="Context Window" > {contextWindowUsage && ( @@ -1645,9 +1635,6 @@ export default function ChatConversationPage({ setError((err as Error).message); } finally { setConfigSavingId(null); - requestAnimationFrame(() => { - promptInputRef.current?.focus(); - }); } } diff --git a/src/state/connection.ts b/src/state/connection.ts index a222843..3b12098 100644 --- a/src/state/connection.ts +++ b/src/state/connection.ts @@ -64,6 +64,21 @@ interface ConnectionSnapshot { hostInfo: ConnectedHostInfo | null; connectionStatus: ConnectionStatus; batteryInfo: BatteryInfo | null; + /** + * How many reconnect attempts have been made in the current run (reset to 0 + * whenever we settle into `connected` or `disconnected`). The UI reads this + * to decide when a reconnect has dragged on long enough to be worth offering + * a way out of — see ConnectionStatus. + */ + reconnectAttempt: number; + /** + * True from the moment the CLI reports it's self-updating until the next + * successful connect (or until we give up reconnecting). The CLI restarts + * mid-update, so the socket drop that follows is expected rather than a + * fault — the reconnect overlay reads this to explain the wait instead of + * implying something broke. + */ + hostUpdating: boolean; } type HandshakeError = Error & { @@ -134,7 +149,11 @@ const PING_INTERVAL_MS = 25_000; // slack before we tear down and reconnect. const LIVENESS_TIMEOUT_MS = 55_000; const MAX_RECONNECT_ATTEMPTS = 10; -const BASE_RECONNECT_DELAY_MS = 1000; +// Backoff runs 4s → 8s → 16s → 20s (capped) per attempt. Retrying faster than +// this rarely helps: a host that's genuinely gone won't be back in a second, +// and the sub-second reconnects that do succeed are handled by reconnectNow() +// on app resume / network-online rather than by this backoff. +const BASE_RECONNECT_DELAY_MS = 4000; const MAX_RECONNECT_DELAY_MS = 20_000; const CLIENT_ID_STORAGE_KEY = "shellular:client-id"; @@ -533,6 +552,8 @@ class ConnectionManager { hostInfo: null, connectionStatus: "disconnected", batteryInfo: null, + reconnectAttempt: 0, + hostUpdating: false, }; private connection: Connection | null = null; private pendingSocket: Connection | null = null; @@ -655,6 +676,9 @@ class ConnectionManager { }, connectionStatus: "connected", batteryInfo: null, + reconnectAttempt: 0, + // The CLI is back; whatever update was in flight is done. + hostUpdating: false, }); nextConnection.on(MsgType.BATTERY_UPDATE, (msg) => { if (msg instanceof Event) return; @@ -731,10 +755,21 @@ class ConnectionManager { hostInfo: null, connectionStatus: "disconnected", batteryInfo: null, + reconnectAttempt: 0, + hostUpdating: false, }); this.onDisconnected?.(); } + /** + * Mark that the CLI has begun a self-update. Held outside the socket because + * the update tears the socket down — a per-connection listener would be gone + * exactly when the UI needs to know why. + */ + setHostUpdating(updating: boolean) { + this.setSnapshot({ hostUpdating: updating }); + } + /** * Force an immediate reconnect for the active host. Safe to call on app * resume or when the network comes back: it no-ops when there's no active @@ -759,7 +794,11 @@ class ConnectionManager { this.stopPing(); this.closeConnection(); this.closePendingSocket(); - this.setSnapshot({ connectionStatus: "reconnecting", batteryInfo: null }); + this.setSnapshot({ + connectionStatus: "reconnecting", + batteryInfo: null, + reconnectAttempt: 0, + }); this.attemptReconnect(hostId, true); } @@ -802,6 +841,7 @@ class ConnectionManager { this.reconnectTimeout = null; } this.reconnectAttempt = 0; + this.setSnapshot({ reconnectAttempt: 0 }); } private async attemptReconnect(hostId: string, immediate = false) { @@ -818,10 +858,13 @@ class ConnectionManager { connectionStatus: "disconnected", hostInfo: null, batteryInfo: null, + reconnectAttempt: 0, + hostUpdating: false, }); this.onDisconnected?.(); return; } + this.setSnapshot({ reconnectAttempt: this.reconnectAttempt }); const backoff = Math.min( BASE_RECONNECT_DELAY_MS * 2 ** (this.reconnectAttempt - 1), @@ -1138,6 +1181,15 @@ export function disconnect() { connectionManager.disconnect(); } +/** + * Flag that the CLI is self-updating, so the reconnect overlay can explain the + * expected restart instead of presenting it as a failure. Cleared automatically + * on the next successful connect, on disconnect, and if reconnecting gives up. + */ +export function setHostUpdating(updating: boolean) { + connectionManager.setHostUpdating(updating); +} + /** * Force an immediate reconnect for the currently-active host if the socket is * not live. No-ops when disconnected (no active host) or already connected. diff --git a/src/state/index.tsx b/src/state/index.tsx index 713d329..1ce2089 100644 --- a/src/state/index.tsx +++ b/src/state/index.tsx @@ -107,6 +107,10 @@ interface ShellularContextValue { | "connected" | "reconnecting"; hostDir?: string; + /** Reconnect attempts made in the current reconnect run; 0 when settled. */ + reconnectAttempt: number; + /** True while the CLI is self-updating and restarting. */ + hostUpdating: boolean; batteryInfo: BatteryInfo | null; agents: Record; loadAgents: () => Promise; @@ -245,6 +249,10 @@ export function ShellularProvider({ children }: { children: ReactNode }) { const disconnect = useCallback(() => { detachAllTerminals(); pendingSavedHostRef.current = null; + // Clear the recovery target too. `recover()` (app resume / network back) + // reconnects to the last host whenever one is remembered, which would + // silently undo an explicit disconnect — including cancelling a reconnect. + setLastConnectedHost(null); disconnectWs(); }, []); diff --git a/src/tabs/home/ConnectionInfo.tsx b/src/tabs/home/ConnectionInfo.tsx index 15a8972..40c898d 100644 --- a/src/tabs/home/ConnectionInfo.tsx +++ b/src/tabs/home/ConnectionInfo.tsx @@ -17,6 +17,7 @@ import { type ConnectedHostInfo, onMessage, sendMessage, + setHostUpdating, } from "state/connection"; const COLLAPSED_AGENT_COUNT = 5; @@ -66,14 +67,17 @@ export default function ConnectionInfo({ case "starting": case "updating": setUpdating(true); + setHostUpdating(true); toast("Updating CLI…"); break; case "restarting": setUpdating(true); + setHostUpdating(true); toast("CLI is updating and restarting…", 4000); break; case "error": setUpdating(false); + setHostUpdating(false); toast( `Update failed: ${msg.data.message ?? "unknown error"}`, 4000, @@ -96,6 +100,10 @@ export default function ConnectionInfo({ if (!ok) return; setUpdating(true); + // Set this up front rather than waiting for the CLI's first status + // message — the socket can drop before it arrives, and the reconnect + // overlay needs to know the restart is expected. + setHostUpdating(true); toast("Requesting CLI update…"); sendMessage({ type: MsgType.HOST_UPDATE, data: {} }); }, [hostInfo.hostname, hostInfo.latestCliVersion]); diff --git a/src/tabs/home/HostQrSheet.tsx b/src/tabs/home/HostQrSheet.tsx new file mode 100644 index 0000000..c37aec8 --- /dev/null +++ b/src/tabs/home/HostQrSheet.tsx @@ -0,0 +1,130 @@ +import BottomSheet from "components/BottomSheet"; +import { formatConnectionString } from "lib/e2ee"; +import qrcode from "qrcode-generator"; +import { useEffect, useMemo, useState } from "react"; +import type { SavedHost } from "state"; + +/** + * How long the QR stays visible before it re-hides itself. This is a bearer + * credential with no expiry — re-hiding keeps it from sitting exposed on a + * screen the user walked away from, while a tap brings it straight back. + */ +const AUTO_HIDE_MS = 30_000; + +interface HostQrSheetProps { + host: SavedHost; + open: boolean; + onClose: () => void; +} + +export default function HostQrSheet({ host, open, onClose }: HostQrSheetProps) { + const [hidden, setHidden] = useState(false); + const [secondsLeft, setSecondsLeft] = useState(AUTO_HIDE_MS / 1000); + + const label = host.alias || `${host.username}@${host.hostname}`; + + // The exact string the scanner expects: `{hostId}:{base64Key}`. Rendered as a + // single SVG path (one rect per dark module would be ~1500 elements). + const qr = useMemo(() => { + if (!open) return null; + const code = qrcode(0, "M"); + code.addData(formatConnectionString(host.hostId, host.encryptionKey)); + code.make(); + + const count = code.getModuleCount(); + let path = ""; + for (let row = 0; row < count; row++) { + for (let col = 0; col < count; col++) { + if (code.isDark(row, col)) path += `M${col} ${row}h1v1h-1z`; + } + } + return { count, path }; + }, [open, host.hostId, host.encryptionKey]); + + // Reset visibility each time the sheet opens. + useEffect(() => { + if (!open) return; + setHidden(false); + setSecondsLeft(AUTO_HIDE_MS / 1000); + }, [open]); + + // Count down while revealed, then hide. + useEffect(() => { + if (!open || hidden) return; + + const tick = setInterval(() => { + setSecondsLeft((prev) => { + if (prev <= 1) { + setHidden(true); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(tick); + }, [open, hidden]); + + const reveal = () => { + setHidden(false); + setSecondsLeft(AUTO_HIDE_MS / 1000); + }; + + return ( + +
+

+ Scan this from another device to connect it to{" "} + {label}. +

+ +
+
+ {qr && ( + + {`Connection QR code for ${label}`} + + + )} +
+ + {hidden && ( + + )} +
+ +

+ {hidden + ? "Hidden to keep it off your screen." + : `Keep this QR code private — do not share it with anyone. Hiding in ${secondsLeft}s.`} +

+ + +
+
+ ); +} diff --git a/src/tabs/home/SavedHostItem.tsx b/src/tabs/home/SavedHostItem.tsx index dc24b83..159af96 100644 --- a/src/tabs/home/SavedHostItem.tsx +++ b/src/tabs/home/SavedHostItem.tsx @@ -7,6 +7,7 @@ import { formatRelativeTime, getPlatformIcon } from "lib/utils"; import { useCallback, useState } from "react"; import { type SavedHost, useShellular } from "state"; import { getConnectionSnapshot, getHostInfo } from "state/connection"; +import HostQrSheet from "./HostQrSheet"; interface SavedHostProps { host: SavedHost; @@ -15,6 +16,7 @@ interface SavedHostProps { export default function SavedHostItem({ host }: SavedHostProps) { const { removeSavedHost, connect, switchDevice, disconnect } = useShellular(); const [hostname, setHostname] = useState(host.alias || host.hostname); + const [showQr, setShowQr] = useState(false); const hostInfo = getHostInfo(); const { connectionStatus, sessionToken } = getConnectionSnapshot(); @@ -89,6 +91,20 @@ export default function SavedHostItem({ host }: SavedHostProps) { } }, }, + { + icon: "icon-qr_code_scanner", + label: "Show Connection QR", + onClick: async () => { + // This QR is a bearer credential that doesn't expire, so + // confirm intent before putting it on screen. + const ok = await dialog.confirm( + `Keep this QR code private — do not share it with anyone.`, + "Show Connection QR", + ); + if (!ok) return; + setShowQr(true); + }, + }, { icon: "icon-trash", label: "Remove", @@ -108,6 +124,7 @@ export default function SavedHostItem({ host }: SavedHostProps) { >
); } diff --git a/src/tabs/home/style.scss b/src/tabs/home/style.scss index a44917c..db034e3 100644 --- a/src/tabs/home/style.scss +++ b/src/tabs/home/style.scss @@ -14,10 +14,6 @@ margin-inline: auto; } - @include android { - padding-right: var(--sab, 0px); - } - .scan-label { font-size: 12px; color: var(--secondary-text);