diff --git a/CHANGELOG.md b/CHANGELOG.md index 0790b0b..ad686dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Studio: WiFi token memory** โ€” Studio now remembers the agent token per discovered device (keyed by Bonjour instance name) in localStorage. After a successful WiFi connect, subsequent mDNS-discovered sessions for that device prefill the token automatically and show a "๐Ÿ”‘ saved" tag. Each remembered device gets a small "โœ•" button to forget the token. + ## [0.7.0] - 2026-05-02 ### Added diff --git a/studio/frontend/src/main.ts b/studio/frontend/src/main.ts index 3f8c96b..50fe8da 100644 --- a/studio/frontend/src/main.ts +++ b/studio/frontend/src/main.ts @@ -309,6 +309,47 @@ $("help-overlay").addEventListener("click", (e) => { type WiFiDevice = { name: string; host: string; port: number; version: string }; +const WIFI_TOKENS_KEY = "fp.studio.wifi-tokens"; + +// Token store: deviceName โ†’ token. Persisted in localStorage so a user who +// has connected once doesn't need to paste the token again on subsequent +// launches. Keyed by the Bonjour instance name (typically the device's +// hostname) since the IP can change across DHCP leases but the name is +// stable. Tokens for an LAN-only test agent inside a desktop app are an +// acceptable trust boundary; this is not a credential vault. +function loadTokenStore(): Record { + try { + const raw = localStorage.getItem(WIFI_TOKENS_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function saveTokenStore(store: Record) { + try { + localStorage.setItem(WIFI_TOKENS_KEY, JSON.stringify(store)); + } catch { + // localStorage full or denied โ€” non-fatal, user just re-enters next time + } +} + +function rememberToken(deviceName: string, token: string) { + const store = loadTokenStore(); + store[deviceName] = token; + saveTokenStore(store); +} + +function forgetToken(deviceName: string) { + const store = loadTokenStore(); + delete store[deviceName]; + saveTokenStore(store); +} + +function recalledToken(deviceName: string): string | undefined { + return loadTokenStore()[deviceName]; +} + let wifiSelected: WiFiDevice | null = null; const wifiSeen = new Map(); @@ -337,9 +378,26 @@ function selectWiFiDevice(dev: WiFiDevice) { for (const li of Array.from($("wifi-devices").children) as HTMLElement[]) { li.classList.toggle("selected", li.dataset.key === `${dev.host}:${dev.port}`); } + const remembered = recalledToken(dev.name); $("wifi-selected-label").textContent = `Connecting to ${dev.name} (${dev.host}:${dev.port})`; ($("wifi-token-row") as HTMLElement).hidden = false; - ($("wifi-token-input") as HTMLInputElement).focus(); + const input = $("wifi-token-input") as HTMLInputElement; + input.value = remembered ?? ""; + input.focus(); +} + +function renderForgetButton(deviceName: string, container: HTMLElement) { + const btn = document.createElement("button"); + btn.className = "btn-mini wifi-forget"; + btn.title = `Forget remembered token for ${deviceName}`; + btn.textContent = "โœ•"; + btn.addEventListener("click", (e) => { + e.stopPropagation(); + forgetToken(deviceName); + btn.remove(); + toast(`Forgot token for ${deviceName}`, "info", 1500); + }); + container.appendChild(btn); } EventsOn("wifi:device-found", (dev: WiFiDevice) => { @@ -360,6 +418,14 @@ EventsOn("wifi:device-found", (dev: WiFiDevice) => { hostEl.className = "wifi-host"; hostEl.textContent = `${dev.host}:${dev.port}${dev.version ? ` ยท v${dev.version}` : ""}`; li.append(nameEl, hostEl); + if (recalledToken(dev.name)) { + const savedTag = document.createElement("span"); + savedTag.className = "wifi-saved-tag"; + savedTag.textContent = "๐Ÿ”‘ saved"; + savedTag.title = "Token remembered from a previous connection"; + li.appendChild(savedTag); + renderForgetButton(dev.name, li); + } li.addEventListener("click", () => selectWiFiDevice(dev)); list.appendChild(li); }); @@ -380,6 +446,7 @@ $("btn-wifi-connect").addEventListener("click", async () => { setStatus("connecting", `Connecting to ${wifiSelected.host}โ€ฆ`); try { await ConnectWiFi(wifiSelected.host, wifiSelected.port, token); + rememberToken(wifiSelected.name, token); setStatus("connected", `Connected ยท ${wifiSelected.name}`); closeWiFiOverlay(); startStream(); diff --git a/studio/frontend/src/style.css b/studio/frontend/src/style.css index b24093d..8aab4c4 100644 --- a/studio/frontend/src/style.css +++ b/studio/frontend/src/style.css @@ -386,6 +386,9 @@ body { display: flex; flex-direction: column; overflow: hidden; } margin-top: 6px; } .wifi-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } +.wifi-saved-tag { color: var(--fg-dim); font-size: 10px; margin-left: 8px; padding: 1px 6px; background: var(--bg-1); border-radius: var(--radius-sm); } +.wifi-device-list .btn-mini.wifi-forget { float: right; opacity: 0.5; padding: 0 6px; font-size: 11px; } +.wifi-device-list .btn-mini.wifi-forget:hover { opacity: 1; } kbd { display: inline-block; padding: 1px 6px;