From 49606073550a67b295dcab9f6d94518a8ac5ff79 Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 28 Jun 2026 12:36:58 +0200 Subject: [PATCH 01/18] feat(runner): configurable global APP_ID passed to maestro as -e Add an App ID setting that maestro-deck forwards to every run as `-e APP_ID=`, so flow files referencing ${APP_ID} (the CI placeholder) run locally without per-file edits. Empty value passes nothing, preserving prior behavior. Threaded through all four runners (Android, Web, iOS sim, iOS physical). --- src-tauri/src/ipc/commands.rs | 12 +++-- src-tauri/src/runner/mod.rs | 58 ++++++++++++++++++++- src/components/MainView.tsx | 6 +-- src/components/settings/GeneralSettings.tsx | 20 +++++++ src/lib/ipc.ts | 3 +- src/stores/settingsStore.test.ts | 9 ++++ src/stores/settingsStore.ts | 11 ++++ 7 files changed, 110 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/ipc/commands.rs b/src-tauri/src/ipc/commands.rs index 881a2d9..435b54f 100644 --- a/src-tauri/src/ipc/commands.rs +++ b/src-tauri/src/ipc/commands.rs @@ -953,6 +953,7 @@ pub async fn install_ios_device_bridge() -> AppResult { #[tauri::command] pub async fn run_flow( file_path: String, + app_id: Option, app: AppHandle, state: State<'_, AppState>, ) -> AppResult { @@ -962,12 +963,16 @@ pub async fn run_flow( .clone() .ok_or(AppError::NoDevice)?; + // Configured global APP_ID, forwarded to maestro as `-e APP_ID=…` so flows + // referencing `${APP_ID}` (the CI placeholder) run locally unchanged. + let app_id = app_id.as_deref(); + if device.platform == crate::device::Platform::Web { // Web flows run with no `--udid`; maestro targets the browser via the // flow's `url:` header. Pause the studio keeper so its browser doesn't // contend with the one `maestro test` launches. teardown_web(state.inner()).await; - return runner::spawn_web_runner(app, &file_path).await; + return runner::spawn_web_runner(app, &file_path, app_id).await; } if device.platform == crate::device::Platform::Ios { @@ -981,6 +986,7 @@ pub async fn run_flow( &device.serial, &file_path, crate::ios_session::PHYSICAL_BRIDGE_PORT, + app_id, ) .await; } @@ -1000,7 +1006,7 @@ pub async fn run_flow( state .ios_sim_run_active .store(true, std::sync::atomic::Ordering::SeqCst); - let spawned = runner::spawn_ios_runner(app, &device.serial, &file_path).await; + let spawned = runner::spawn_ios_runner(app, &device.serial, &file_path, app_id).await; if spawned.is_err() { state .ios_sim_run_active @@ -1027,7 +1033,7 @@ pub async fn run_flow( // With maestro 2.5.x, `maestro test` uses an adb-socket // (AdbSocketFactory) instead of a host TCP forward, so it cohabits // peacefully with our running studio. No cleanup needed. - runner::spawn_runner(app, &serial, &file_path, None).await + runner::spawn_runner(app, &serial, &file_path, app_id, None).await } #[tauri::command] diff --git a/src-tauri/src/runner/mod.rs b/src-tauri/src/runner/mod.rs index a1dcefc..f8c30c2 100644 --- a/src-tauri/src/runner/mod.rs +++ b/src-tauri/src/runner/mod.rs @@ -51,6 +51,18 @@ fn maestro_bin() -> String { crate::tool_paths::maestro_bin() } +/// Build the `-e APP_ID=` args for a maestro `test` invocation, or an +/// empty vec when no app id is configured. This lets a single global APP_ID +/// (set in Settings) feed the `${APP_ID}` placeholder users keep in their CI +/// flow files, so those flows run locally without per-file edits. `-e` is a +/// `test`-subcommand option, so callers must insert these args *after* `test`. +fn app_id_env_args(app_id: Option<&str>) -> Vec { + match app_id.map(str::trim).filter(|s| !s.is_empty()) { + Some(value) => vec!["-e".to_string(), format!("APP_ID={value}")], + None => Vec::new(), + } +} + /// Spawn `maestro --udid test ` and stream stdout/stderr to /// the frontend via Tauri events. The PID is returned so the frontend can /// request a stop. @@ -63,10 +75,12 @@ pub async fn spawn_runner( app: AppHandle, serial: &str, flow_path: &str, + app_id: Option<&str>, on_exit: Option>, ) -> AppResult { let bin = maestro_bin(); info!(bin = %bin, serial, flow = %flow_path, "spawning maestro"); + let env_args = app_id_env_args(app_id); // Maestro 2.5.x's session manager calls `dadb.Dadb.list()` which // walks every adb-server transport before honoring `--udid` — any @@ -112,6 +126,7 @@ pub async fn spawn_runner( let mut child = Command::new(&bin) .no_window() .args(["--udid", serial, "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -185,14 +200,20 @@ pub async fn spawn_runner( /// Maestro targets the browser via the flow's `url:` header, and no adb /// emulator-ghost preamble (irrelevant to web). Streams stdout/stderr and /// emits `runner:exit` exactly like [`spawn_runner`]. -pub async fn spawn_web_runner(app: AppHandle, flow_path: &str) -> AppResult { +pub async fn spawn_web_runner( + app: AppHandle, + flow_path: &str, + app_id: Option<&str>, +) -> AppResult { let bin = maestro_bin(); info!(bin = %bin, flow = %flow_path, "spawning maestro (web)"); + let env_args = app_id_env_args(app_id); let mut child = Command::new(&bin) .no_window() // `-p web` is a global flag and must precede the `test` subcommand. .args(["-p", "web", "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -257,13 +278,20 @@ pub async fn spawn_web_runner(app: AppHandle, flow_path: &str) -> AppResult /// iOS). The caller stops the studio keeper first so `maestro test` can bring up /// its own XCTest driver on :22087 without contention. Streams stdout/stderr and /// emits `runner:exit` exactly like the other runners. -pub async fn spawn_ios_runner(app: AppHandle, udid: &str, flow_path: &str) -> AppResult { +pub async fn spawn_ios_runner( + app: AppHandle, + udid: &str, + flow_path: &str, + app_id: Option<&str>, +) -> AppResult { let bin = maestro_bin(); info!(bin = %bin, udid, flow = %flow_path, "spawning maestro (ios)"); + let env_args = app_id_env_args(app_id); let mut child = Command::new(&bin) .no_window() .args(["--udid", udid, "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -381,6 +409,7 @@ pub async fn spawn_ios_device_runner( udid: &str, flow_path: &str, port: u16, + app_id: Option<&str>, ) -> AppResult { let bin = maestro_bin(); if !maestro_supports_driver_host_port(&bin).await { @@ -392,10 +421,12 @@ pub async fn spawn_ios_device_runner( } let port_str = port.to_string(); info!(bin = %bin, udid, port, flow = %flow_path, "spawning maestro (ios physical)"); + let env_args = app_id_env_args(app_id); let mut child = Command::new(&bin) .no_window() .args(["--driver-host-port", &port_str, "--device", udid, "test"]) + .args(&env_args) .arg(flow_path) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -478,4 +509,27 @@ mod tests { let res = kill_runner(987_654_321).await; assert!(matches!(res, Err(AppError::RunnerFailed(_)))); } + + #[test] + fn app_id_env_args_emits_e_flag_when_set() { + assert_eq!( + app_id_env_args(Some("com.example.app")), + vec!["-e".to_string(), "APP_ID=com.example.app".to_string()] + ); + } + + #[test] + fn app_id_env_args_trims_whitespace() { + assert_eq!( + app_id_env_args(Some(" com.example.app ")), + vec!["-e".to_string(), "APP_ID=com.example.app".to_string()] + ); + } + + #[test] + fn app_id_env_args_empty_when_none_or_blank() { + assert!(app_id_env_args(None).is_empty()); + assert!(app_id_env_args(Some("")).is_empty()); + assert!(app_id_env_args(Some(" ")).is_empty()); + } } diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index a8e5f67..8b8cf6d 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -94,7 +94,7 @@ export function MainView() { } resetSteps(); initSteps(parseFlow(content).steps); - const pid = await ipc.runFlow(path); + const pid = await ipc.runFlow(path, useSettingsStore.getState().appId); setRunning(pid); appendLog("system", `[runner started pid ${pid} · ${path}]`); } catch (err) { @@ -119,7 +119,7 @@ export function MainView() { const { content: c2 } = useFlowStore.getState(); resetSteps(); initSteps(parseFlow(c2).steps); - const pid = await ipc.runFlow(folder); + const pid = await ipc.runFlow(folder, useSettingsStore.getState().appId); setRunning(pid); appendLog("system", `[runner started pid ${pid} · all flows in ${folder}]`); } catch (err) { @@ -147,7 +147,7 @@ export function MainView() { })); resetSteps(); initSteps(remappedSteps); - const pid = await ipc.runFlow(tempPath); + const pid = await ipc.runFlow(tempPath, useSettingsStore.getState().appId); setRunning(pid); appendLog( "system", diff --git a/src/components/settings/GeneralSettings.tsx b/src/components/settings/GeneralSettings.tsx index c315dc9..82c2f2d 100644 --- a/src/components/settings/GeneralSettings.tsx +++ b/src/components/settings/GeneralSettings.tsx @@ -28,6 +28,8 @@ export function GeneralSettings() { const setAutoCheckUpdatesEnabled = useSettingsStore((s) => s.setAutoCheckUpdatesEnabled); const confirmBeforeQuit = useSettingsStore((s) => s.confirmBeforeQuit); const setConfirmBeforeQuit = useSettingsStore((s) => s.setConfirmBeforeQuit); + const appId = useSettingsStore((s) => s.appId); + const setAppId = useSettingsStore((s) => s.setAppId); return ( + + + + call("get_dark_mode"), // iOS-only: press the Home button to return to the home screen. iosPressHome: () => call("ios_press_home"), - runFlow: (filePath: string) => call("run_flow", { filePath }), + runFlow: (filePath: string, appId?: string) => + call("run_flow", { filePath, appId: appId?.trim() || null }), stopFlow: (pid: number) => call("stop_flow", { pid }), listWorkspace: (path: string) => call("list_workspace", { path }), startStream: () => call("start_stream"), diff --git a/src/stores/settingsStore.test.ts b/src/stores/settingsStore.test.ts index 804a90b..afa3b74 100644 --- a/src/stores/settingsStore.test.ts +++ b/src/stores/settingsStore.test.ts @@ -33,6 +33,7 @@ beforeEach(() => { fastHierarchyEnabled: false, autoSaveEnabled: true, consoleMode: "simple", + appId: "", }); }); @@ -48,6 +49,7 @@ describe("settingsStore defaults", () => { expect(s.consoleMode).toBe("simple"); // Web support is beta — the target stays hidden until opted in. expect(s.webBrowserEnabled).toBe(false); + expect(s.appId).toBe(""); }); }); @@ -98,4 +100,11 @@ describe("settingsStore setters", () => { useSettingsStore.getState().setConsoleMode("simple"); expect(useSettingsStore.getState().consoleMode).toBe("simple"); }); + + it("setAppId stores a trimmed value", () => { + useSettingsStore.getState().setAppId(" com.example.app "); + expect(useSettingsStore.getState().appId).toBe("com.example.app"); + useSettingsStore.getState().setAppId(""); + expect(useSettingsStore.getState().appId).toBe(""); + }); }); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 039c7c0..af76490 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -36,6 +36,13 @@ interface SettingsState { * from the dialog itself ("don't ask again") or re-enable it in Settings. */ confirmBeforeQuit: boolean; consoleMode: ConsoleMode; + /** + * Value fed to maestro as `-e APP_ID=` on every run, so flow files + * can reference `${APP_ID}` (the same placeholder used in CI) and still run + * locally without editing each file. Empty string = pass nothing, behaving + * exactly as before. + */ + appId: string; setInspectKey: (k: string) => void; setShowFps: (v: boolean) => void; setTheme: (t: ThemeMode) => void; @@ -47,6 +54,7 @@ interface SettingsState { setWebBrowserEnabled: (v: boolean) => void; setConfirmBeforeQuit: (v: boolean) => void; setConsoleMode: (m: ConsoleMode) => void; + setAppId: (id: string) => void; } export const useSettingsStore = create()( @@ -63,6 +71,7 @@ export const useSettingsStore = create()( webBrowserEnabled: false, confirmBeforeQuit: true, consoleMode: "simple", + appId: "", setInspectKey: (inspectKey) => set({ inspectKey }), setShowFps: (showFps) => set({ showFps }), setTheme: (theme) => set({ theme }), @@ -74,6 +83,7 @@ export const useSettingsStore = create()( setWebBrowserEnabled: (webBrowserEnabled) => set({ webBrowserEnabled }), setConfirmBeforeQuit: (confirmBeforeQuit) => set({ confirmBeforeQuit }), setConsoleMode: (consoleMode) => set({ consoleMode }), + setAppId: (appId) => set({ appId: appId.trim() }), }), { name: "maestro-deck.settings", @@ -89,6 +99,7 @@ export const useSettingsStore = create()( webBrowserEnabled: s.webBrowserEnabled, confirmBeforeQuit: s.confirmBeforeQuit, consoleMode: s.consoleMode, + appId: s.appId, }), }, ), From dce3d5eed384c114f0470e50c963d4ed0b4febde Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 28 Jun 2026 13:12:07 +0200 Subject: [PATCH 02/18] feat(settings): editable Billy AI system prompt with reset to default Add a 'Billy AI' settings section that lets users override Billy's system prompt and reset back to the version shipped with the app. The override is stored as customPrompt|null in a persisted store, so users who never customize keep receiving the latest embedded prompt across updates; getEffectiveBillyPrompt() resolves override ?? embedded and is read fresh on each message. --- src/components/settings/BillySettings.tsx | 72 ++++++++++++++++++++ src/components/settings/SettingsPage.test.ts | 1 + src/components/settings/sections.tsx | 2 + src/lib/chat/systemPrompt.ts | 11 +++ src/stores/billyPromptStore.test.ts | 49 +++++++++++++ src/stores/billyPromptStore.ts | 35 ++++++++++ src/stores/chatStore.ts | 4 +- 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/components/settings/BillySettings.tsx create mode 100644 src/stores/billyPromptStore.test.ts create mode 100644 src/stores/billyPromptStore.ts diff --git a/src/components/settings/BillySettings.tsx b/src/components/settings/BillySettings.tsx new file mode 100644 index 0000000..d59ccd5 --- /dev/null +++ b/src/components/settings/BillySettings.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +import { useState } from "react"; + +import { Button } from "@/components/ui/Button"; +import { SettingsSection } from "@/components/settings/SettingsPrimitives"; +import { BILLY_SYSTEM_PROMPT } from "@/lib/chat/systemPrompt"; +import { useBillyPromptStore } from "@/stores/billyPromptStore"; + +export function BillySettings() { + const customPrompt = useBillyPromptStore((s) => s.customPrompt); + const setCustomPrompt = useBillyPromptStore((s) => s.setCustomPrompt); + const reset = useBillyPromptStore((s) => s.reset); + + // Draft starts from the effective prompt: the override if one exists, else + // the embedded default. Editing only persists on Save. + const [draft, setDraft] = useState(customPrompt ?? BILLY_SYSTEM_PROMPT); + + const isCustomized = customPrompt !== null; + const effective = customPrompt ?? BILLY_SYSTEM_PROMPT; + const dirty = draft !== effective; + + const onSave = () => setCustomPrompt(draft); + const onReset = () => { + reset(); + setDraft(BILLY_SYSTEM_PROMPT); + }; + + return ( + +
+
+ + System prompt + + {isCustomized ? ( + + Customized + + ) : ( + Default + )} +
+ +