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 @@ -8,6 +8,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).

## [0.7.0] - 2026-05-02

Expand Down
38 changes: 38 additions & 0 deletions studio/frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
<button id="btn-refresh" class="btn btn-icon-only" title="Refresh devices">↻</button>
<button id="btn-wifi" class="btn btn-icon-only" title="Discover WiFi devices">📡</button>
<button id="btn-connect" class="btn" disabled title="Connect to selected device">Connect</button>
<span class="sep"></span>
<button id="btn-settings" class="btn btn-icon-only" title="Workspace settings (probe.yaml)">⚙</button>
</div>
<div class="status">
<span id="status-dot" class="status-dot status-disconnected"></span>
Expand Down Expand Up @@ -86,6 +88,42 @@

<div id="toast-container" aria-live="polite"></div>

<div id="settings-overlay" class="overlay" hidden>
<div class="overlay-card">
<div class="overlay-header">
<h2>Workspace settings</h2>
<button id="btn-settings-close" class="btn-mini" title="Close">✕</button>
</div>
<p class="dim">
Edit <code>probe.yaml</code> in the current workspace. Other fields you've set in <code>probe.yaml</code> are preserved on save.
</p>
<div id="settings-no-workspace" class="dim" hidden>
<em>Pick a workspace first (📂 in the file pane).</em>
</div>
<form id="settings-form" class="settings-form">
<label class="settings-row">
<span>Agent port</span>
<input id="settings-port" type="number" min="0" max="65535" placeholder="48686" />
</label>
<label class="settings-row">
<span>Default step timeout</span>
<input id="settings-timeout" type="text" placeholder="30s" />
</label>
<label class="settings-row">
<span>Preferred iOS UDID</span>
<input id="settings-ios" type="text" placeholder="(none)" autocomplete="off" spellcheck="false" />
</label>
<label class="settings-row">
<span>Preferred Android serial</span>
<input id="settings-android" type="text" placeholder="(none)" autocomplete="off" spellcheck="false" />
</label>
<div class="settings-actions">
<button type="button" id="btn-settings-save" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>

<div id="wifi-overlay" class="overlay" hidden>
<div class="overlay-card">
<div class="overlay-header">
Expand Down
65 changes: 65 additions & 0 deletions studio/frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ import {
Lint,
ListDevices,
ListDir,
LoadWorkspaceSettings,
PickWorkspace,
ReadFile,
RunFile,
SaveWorkspaceSettings,
StartWiFiDiscovery,
Status,
StopWiFiDiscovery,
WriteFile,
} from "../wailsjs/go/main/App";
import { main } from "../wailsjs/go/models";
import { EventsOn } from "../wailsjs/runtime/runtime";

const WORKSPACE_KEY = "fp.studio.workspace";
Expand Down Expand Up @@ -259,6 +262,8 @@ window.addEventListener("keydown", (e) => {

if (e.key === "Escape") {
closeHelpOverlay();
closeSettingsOverlay();
closeWiFiOverlay();
return;
}
if (e.key === "?" && !mod) {
Expand Down Expand Up @@ -305,6 +310,66 @@ $("help-overlay").addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "help-overlay") closeHelpOverlay();
});

// ---- Workspace settings overlay ----------------------------------------

async function openSettingsOverlay() {
const overlay = $("settings-overlay");
const noWorkspace = $("settings-no-workspace") as HTMLElement;
const form = $("settings-form") as HTMLFormElement;
overlay.hidden = false;

if (!currentDir || currentDir === "tests" || currentDir === ".") {
noWorkspace.hidden = false;
form.hidden = true;
return;
}
noWorkspace.hidden = true;
form.hidden = false;

try {
const s = await LoadWorkspaceSettings(currentDir);
($("settings-port") as HTMLInputElement).value = s.agentPort ? String(s.agentPort) : "";
($("settings-timeout") as HTMLInputElement).value = s.defaultsTimeout ?? "";
($("settings-ios") as HTMLInputElement).value = s.iosDeviceId ?? "";
($("settings-android") as HTMLInputElement).value = s.androidDeviceId ?? "";
} catch (err) {
toast(`Cannot load settings: ${err}`, "error");
}
}

function closeSettingsOverlay() {
$("settings-overlay").hidden = true;
}

$("btn-settings").addEventListener("click", openSettingsOverlay);
$("btn-settings-close").addEventListener("click", closeSettingsOverlay);
$("settings-overlay").addEventListener("click", (e) => {
if ((e.target as HTMLElement).id === "settings-overlay") closeSettingsOverlay();
});

$("btn-settings-save").addEventListener("click", async () => {
if (!currentDir) return;
const portRaw = ($("settings-port") as HTMLInputElement).value.trim();
const port = portRaw === "" ? 0 : Number(portRaw);
if (Number.isNaN(port)) {
toast("Agent port must be a number.", "error");
return;
}
const settings = main.WorkspaceSettings.createFrom({
agentPort: port,
defaultsTimeout: ($("settings-timeout") as HTMLInputElement).value.trim(),
iosDeviceId: ($("settings-ios") as HTMLInputElement).value.trim(),
androidDeviceId: ($("settings-android") as HTMLInputElement).value.trim(),
});
try {
await SaveWorkspaceSettings(currentDir, settings);
toast(`Saved probe.yaml in ${currentDir}`, "success", 2200);
closeSettingsOverlay();
} catch (err) {
toast(`Save failed: ${err}`, "error");
}
});

// ---- WiFi discovery overlay --------------------------------------------

type WiFiDevice = { name: string; host: string; port: number; version: string };
Expand Down
15 changes: 15 additions & 0 deletions studio/frontend/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,21 @@ body { display: flex; flex-direction: column; overflow: hidden; }
.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; }

/* Workspace settings form */
.settings-form { display: flex; flex-direction: column; gap: 10px; margin-top: 8px; }
.settings-row {
display: grid; grid-template-columns: 160px 1fr; align-items: center; gap: 10px;
font-size: 12px;
}
.settings-row > span { color: var(--fg-dim); }
.settings-row input {
padding: 6px 10px; box-sizing: border-box;
background: var(--bg-1); border: 1px solid var(--border-strong);
border-radius: var(--radius); color: var(--fg-strong);
font-family: ui-monospace, SF Mono, Menlo, monospace; font-size: 12px;
}
.settings-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
kbd {
display: inline-block;
padding: 1px 6px;
Expand Down
4 changes: 4 additions & 0 deletions studio/frontend/wailsjs/go/main/App.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ export function ListDevices():Promise<Array<main.DeviceInfo>>;

export function ListDir(arg1:string):Promise<Array<main.FileEntry>>;

export function LoadWorkspaceSettings(arg1:string):Promise<main.WorkspaceSettings>;

export function PickWorkspace():Promise<string>;

export function ReadFile(arg1:string):Promise<string>;

export function RunFile(arg1:string):Promise<Array<main.RunResult>>;

export function SaveWorkspaceSettings(arg1:string,arg2:main.WorkspaceSettings):Promise<void>;

export function StartWiFiDiscovery():Promise<void>;

export function Status():Promise<main.ConnectionStatus>;
Expand Down
8 changes: 8 additions & 0 deletions studio/frontend/wailsjs/go/main/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export function ListDir(arg1) {
return window['go']['main']['App']['ListDir'](arg1);
}

export function LoadWorkspaceSettings(arg1) {
return window['go']['main']['App']['LoadWorkspaceSettings'](arg1);
}

export function PickWorkspace() {
return window['go']['main']['App']['PickWorkspace']();
}
Expand All @@ -42,6 +46,10 @@ export function RunFile(arg1) {
return window['go']['main']['App']['RunFile'](arg1);
}

export function SaveWorkspaceSettings(arg1, arg2) {
return window['go']['main']['App']['SaveWorkspaceSettings'](arg1, arg2);
}

export function StartWiFiDiscovery() {
return window['go']['main']['App']['StartWiFiDiscovery']();
}
Expand Down
18 changes: 18 additions & 0 deletions studio/frontend/wailsjs/go/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,24 @@ export namespace main {
this.error = source["error"];
}
}
export class WorkspaceSettings {
agentPort: number;
defaultsTimeout: string;
iosDeviceId: string;
androidDeviceId: string;

static createFrom(source: any = {}) {
return new WorkspaceSettings(source);
}

constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.agentPort = source["agentPort"];
this.defaultsTimeout = source["defaultsTimeout"];
this.iosDeviceId = source["iosDeviceId"];
this.androidDeviceId = source["androidDeviceId"];
}
}

}

141 changes: 141 additions & 0 deletions studio/workspace_settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"
"time"

"gopkg.in/yaml.v3"

"github.com/alphawavesystems/flutter-probe/internal/config"
)

// WorkspaceSettings is the JSON shape the Studio settings form binds to.
// It exposes a deliberately small subset of probe.yaml — the four knobs
// most users actually touch. Other probe.yaml fields are preserved on
// save (loaded as raw map, only the managed keys are overwritten), so a
// power user with complex config doesn't lose anything by using the UI.
type WorkspaceSettings struct {
AgentPort int `json:"agentPort"` // probe.yaml: agent.port
DefaultsTimeout string `json:"defaultsTimeout"` // probe.yaml: defaults.timeout (Go duration string, e.g. "30s")
IOSDeviceID string `json:"iosDeviceId"` // probe.yaml: device.ios_device_id
AndroidDeviceID string `json:"androidDeviceId"` // probe.yaml: device.android_device_id
}

// LoadWorkspaceSettings reads probe.yaml from the workspace and returns
// the four managed fields. Missing file → all zeros, so the UI form
// shows the user "this workspace has no probe.yaml yet" via empty state.
func (a *App) LoadWorkspaceSettings(workspace string) (WorkspaceSettings, error) {
if workspace == "" {
return WorkspaceSettings{}, fmt.Errorf("workspace path is required")
}
cfg, err := config.Load(workspace)
if err != nil {
return WorkspaceSettings{}, fmt.Errorf("loading probe.yaml: %w", err)
}
return WorkspaceSettings{
AgentPort: cfg.Agent.Port,
DefaultsTimeout: cfg.Defaults.Timeout.String(),
IOSDeviceID: cfg.Device.IOSDeviceID,
AndroidDeviceID: cfg.Device.AndroidDeviceID,
}, nil
}

// SaveWorkspaceSettings writes the four managed fields back to probe.yaml.
// Reads the existing file as a raw map so other keys (recipes_folder,
// cloud, plugins, etc.) survive untouched. Creates the file if missing.
func (a *App) SaveWorkspaceSettings(workspace string, s WorkspaceSettings) error {
if workspace == "" {
return fmt.Errorf("workspace path is required")
}

// Validate the duration string round-trips before touching disk.
if s.DefaultsTimeout != "" {
if _, err := time.ParseDuration(s.DefaultsTimeout); err != nil {
return fmt.Errorf("defaultsTimeout %q is not a valid Go duration: %w", s.DefaultsTimeout, err)
}
}
if s.AgentPort < 0 || s.AgentPort > 65535 {
return fmt.Errorf("agentPort %d is outside the valid TCP port range", s.AgentPort)
}

path := filepath.Join(workspace, "probe.yaml")
raw := map[string]any{}
existing, err := os.ReadFile(path)
if err == nil {
if err := yaml.Unmarshal(existing, &raw); err != nil {
return fmt.Errorf("parsing existing probe.yaml: %w", err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("reading probe.yaml: %w", err)
}

// Mutate only the keys we manage; leave the rest of the user's YAML alone.
setNestedString(raw, "agent", "port", "") // ensure parent map exists
setNestedString(raw, "device", "ios_device_id", "") // ensure parent map exists
setNestedString(raw, "defaults", "timeout", "") // ensure parent map exists

mergeKey(raw, "agent", "port", s.AgentPort)
mergeKey(raw, "defaults", "timeout", s.DefaultsTimeout)
mergeKey(raw, "device", "ios_device_id", s.IOSDeviceID)
mergeKey(raw, "device", "android_device_id", s.AndroidDeviceID)

out, err := yaml.Marshal(raw)
if err != nil {
return fmt.Errorf("encoding probe.yaml: %w", err)
}
if err := os.WriteFile(path, out, 0o644); err != nil {
return fmt.Errorf("writing probe.yaml: %w", err)
}
return nil
}

// mergeKey writes value at raw[parent][key]. If value is the zero value
// for its type, the key is removed instead — preserving the convention
// that probe.yaml only lists fields the user has explicitly set.
func mergeKey(raw map[string]any, parent, key string, value any) {
parentMap := ensureMap(raw, parent)
if isZero(value) {
delete(parentMap, key)
// Drop the empty parent map too so we don't litter probe.yaml with
// `agent: {}` when the user clears every field under it.
if len(parentMap) == 0 {
delete(raw, parent)
}
return
}
parentMap[key] = value
}

func ensureMap(raw map[string]any, key string) map[string]any {
if existing, ok := raw[key].(map[string]any); ok {
return existing
}
// yaml.v3 Unmarshal produces map[string]interface{} for object values,
// but Marshal accepts both shapes. Coerce to keep the writeback path
// consistent.
m := map[string]any{}
raw[key] = m
return m
}

// setNestedString is a no-op nudge that ensures the parent map exists
// without overriding any value the user already set there. Useful as
// a defensive prelude before mergeKey when we're about to delete keys.
func setNestedString(raw map[string]any, parent, _, _ string) {
ensureMap(raw, parent)
}

func isZero(v any) bool {
switch x := v.(type) {
case string:
return strings.TrimSpace(x) == ""
case int:
return x == 0
case bool:
return !x
}
return v == nil
}
Loading