Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### 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.
- **Studio: workspace settings overlay** — new ⚙ button in the toolbar opens a small form for `agent.port`, `defaults.timeout`, `device.ios_device_id`, and `device.android_device_id`. Save writes back to `probe.yaml` in the active workspace. Other keys you've set in `probe.yaml` are preserved on save (loaded as a raw map; only the four managed keys are mutated).
- **Studio: diagnostics polish** — connection error toasts now include actionable hints (e.g. `Fix: brew install libimobiledevice` for missing iproxy/idevicesyslog, `Fix: rebuild your Flutter app with --dart-define=PROBE_AGENT=true` for missing token). Status indicator gets a tooltip showing device id + transport. Inspector pane gets a search box that scrolls to the first matching line and re-anchors on each live frame.

## [0.7.0] - 2026-05-02

Expand Down
8 changes: 8 additions & 0 deletions studio/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@
<section id="inspector-pane">
<div class="pane-title">
<span>Inspector</span>
<input
id="inspector-search"
type="search"
class="inspector-search"
placeholder="Search tree…"
autocomplete="off"
spellcheck="false"
/>
<span id="inspector-status" class="dim"></span>
</div>
<pre id="inspector-content" class="dim">Connect to load the widget tree.</pre>
Expand Down
101 changes: 94 additions & 7 deletions studio/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,24 +512,38 @@ $("btn-wifi-connect").addEventListener("click", async () => {
try {
await ConnectWiFi(wifiSelected.host, wifiSelected.port, token);
rememberToken(wifiSelected.name, token);
setStatus("connected", `Connected · ${wifiSelected.name}`);
setStatus(
"connected",
`Connected · ${wifiSelected.name}`,
`${wifiSelected.name} (${wifiSelected.host}:${wifiSelected.port}) · WiFi transport`
);
closeWiFiOverlay();
startStream();
} catch (err) {
setStatus("error", `WiFi connect failed: ${err}`);
toast(`Connect failed: ${err}`, "error");
const detail = enrichError(err);
setStatus("error", "WiFi connect failed", detail);
toast(`Connect failed: ${detail}`, "error", 8000);
}
});

// ---- Connection ---------------------------------------------------------

let connected = false;

function setStatus(state: "disconnected" | "connecting" | "connected" | "error", text: string) {
function setStatus(
state: "disconnected" | "connecting" | "connected" | "error",
text: string,
tooltip?: string
) {
const dot = $("status-dot");
dot.classList.remove("status-disconnected", "status-connecting", "status-connected", "status-error");
dot.classList.add(`status-${state}`);
$("status-text").textContent = text;
// Mirror the human-readable text in title attributes so a hover gives
// device id / transport / last error without taking up toolbar space.
const tip = tooltip ?? text;
dot.title = tip;
$("status-text").title = tip;
connected = state === "connected";
updateRunButton();
const inspectorStatus = document.getElementById("inspector-status");
Expand All @@ -538,6 +552,33 @@ function setStatus(state: "disconnected" | "connecting" | "connected" | "error",
}
}

// enrichError maps known error fragments to a one-line actionable hint.
// Connection failures often come from missing host tools (iproxy, adb) or
// the app not being built with the agent flag — surfacing the fix inline
// beats sending the user to a wiki page.
function enrichError(raw: unknown): string {
const msg = String(raw);
if (/iproxy/i.test(msg)) {
return `${msg}\n\nFix: brew install libimobiledevice`;
}
if (/idevicesyslog/i.test(msg)) {
return `${msg}\n\nFix: brew install libimobiledevice (idevicesyslog ships with it)`;
}
if (/adb/i.test(msg) && /not found|not in PATH/i.test(msg)) {
return `${msg}\n\nFix: install Android SDK platform-tools and ensure adb is on your PATH`;
}
if (/token/i.test(msg) && /(not found|timeout)/i.test(msg)) {
return `${msg}\n\nFix: confirm your Flutter app is running with --dart-define=PROBE_AGENT=true`;
}
if (/PROBE_AGENT/.test(msg)) {
return `${msg}\n\nFix: rebuild your Flutter app with --dart-define=PROBE_AGENT=true`;
}
if (/refused|reset by peer|EOF/i.test(msg)) {
return `${msg}\n\nFix: the agent process likely isn't running. Start your Flutter app and try again.`;
}
return msg;
}

function updateRunButton() {
($("btn-run") as HTMLButtonElement).disabled = !connected || !currentPath;
}
Expand Down Expand Up @@ -640,12 +681,17 @@ $("btn-connect").addEventListener("click", async () => {
setStatus("connecting", "connecting…");
try {
const status = (await Connect(id)) as ConnectionStatus;
setStatus("connected", `${status.deviceName} · ${status.platform}`);
setStatus(
"connected",
`${status.deviceName} · ${status.platform}`,
`${status.deviceName} (${status.deviceId}) · ${status.platform} · USB transport`
);
startStream();
toast(`Connected to ${status.deviceName}`, "success");
} catch (err) {
setStatus("error", "connection failed");
toast(`Connect failed: ${err}`, "error", 6000);
const detail = enrichError(err);
setStatus("error", "connection failed", detail);
toast(`Connect failed: ${detail}`, "error", 8000);
setTimeout(() => setStatus("disconnected", "disconnected"), 2500);
}
});
Expand All @@ -664,6 +710,47 @@ EventsOn("connection:changed", (status: ConnectionStatus) => {
// The inspector pane is updated live by the device-stream module — every
// frame from the backend includes a widget tree dump. No manual refresh.

// Search input scrolls the <pre> to the first line containing the query.
// Re-runs on every input AND every animation frame so live updates from
// the device stream don't push the matched line off-screen. Cheap O(n)
// over the visible widget tree text — acceptable for trees up to a few
// thousand lines, which is the practical limit anyway.
let inspectorQuery = "";

function scrollInspectorToMatch() {
if (!inspectorQuery) return;
const pre = document.getElementById("inspector-content");
if (!pre) return;
const text = pre.textContent ?? "";
const idx = text.toLowerCase().indexOf(inspectorQuery.toLowerCase());
if (idx < 0) return;
// Approximate line number → scrollTop. The <pre> uses a monospace font
// with consistent line height; this is precise enough to put the match
// near the top of the visible area.
const lineNumber = text.slice(0, idx).split("\n").length - 1;
const lineHeight = parseFloat(getComputedStyle(pre).lineHeight) || 14;
pre.scrollTop = Math.max(0, lineNumber * lineHeight - 8);
}

const inspectorSearch = document.getElementById("inspector-search") as HTMLInputElement | null;
if (inspectorSearch) {
inspectorSearch.addEventListener("input", () => {
inspectorQuery = inspectorSearch.value;
scrollInspectorToMatch();
});
// Re-anchor on each frame the inspector content updates. The device-stream
// module replaces innerHTML wholesale per frame; without a re-scroll the
// matched line jumps back to the top.
const inspectorEl = document.getElementById("inspector-content");
if (inspectorEl) {
new MutationObserver(() => scrollInspectorToMatch()).observe(inspectorEl, {
childList: true,
characterData: true,
subtree: true,
});
}
}

// ---- Run ----------------------------------------------------------------

initResults();
Expand Down
11 changes: 11 additions & 0 deletions studio/frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,17 @@ body { display: flex; flex-direction: column; overflow: hidden; }
font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 12px;
}
.settings-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }

/* Inspector search */
.inspector-search {
flex: 1; max-width: 200px; margin-left: auto; margin-right: 8px;
padding: 2px 8px; box-sizing: border-box;
background: var(--bg-1); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); color: var(--fg-strong);
font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 11px;
}
.inspector-search:focus { outline: 1px solid var(--fg-dim); outline-offset: -1px; }

kbd {
display: inline-block;
padding: 1px 6px;
Expand Down
Loading