From 9d66392a7be470046907e5bca54899b85c35d866 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:30:14 +0000 Subject: [PATCH 1/9] feat(settings): application setup script Adds an app-level setup script in Application settings. It runs once per distinct script (SHA-256 and run timestamp stored in the app DB settings table), reruns when the saved script changes, and optionally runs on every app startup. --- .../specs/app-setup-script-settings.spec.tsx | 46 +++ src-tauri/src/commands/settings.rs | 22 ++ src-tauri/src/core/app_setup_script.rs | 267 ++++++++++++++++++ src-tauri/src/core/mod.rs | 1 + src-tauri/src/lib.rs | 5 + src/components/AppSetupScriptSettings.tsx | 60 ++++ src/components/SettingsPage.tsx | 28 +- src/lib/api.ts | 18 ++ test/integration/app-setup-script.test.tsx | 57 ++++ 9 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 scripts/screenshot/specs/app-setup-script-settings.spec.tsx create mode 100644 src-tauri/src/core/app_setup_script.rs create mode 100644 src/components/AppSetupScriptSettings.tsx create mode 100644 test/integration/app-setup-script.test.tsx diff --git a/scripts/screenshot/specs/app-setup-script-settings.spec.tsx b/scripts/screenshot/specs/app-setup-script-settings.spec.tsx new file mode 100644 index 000000000..43da87811 --- /dev/null +++ b/scripts/screenshot/specs/app-setup-script-settings.spec.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import { it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createTestRepo, openRepo } from "../../../test/utils"; +import { render, screen } from "../../../test/test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { saveAppSetupScript } from "../../../src/lib/api"; +import { captureDocument } from "../capture"; + +it("captures the application setup script settings", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await saveAppSetupScript("", false); + + const user = userEvent.setup(); + render(); + await user.click(await screen.findByLabelText("Settings")); + await user.click(await screen.findByRole("tab", { name: /application/i })); + const textarea = await screen.findByLabelText("Application Setup Script"); + await screen.findByText("Never run"); + await captureDocument(document, { + name: "app-setup-script-settings-01-empty", + viewport: { width: 1440, height: 1200 }, + expectations: [ + "An 'Application Setup Script' textarea with a monospace placeholder sits in the Application tab.", + "A 'Run on every app startup' switch is shown in the off state with its label beside it.", + "The status line under the switch reads 'Never run'.", + ], + }); + + await user.type(textarea, "echo setup"); + await user.click( + screen.getByRole("switch", { name: "Run on every app startup" }), + ); + await user.click(screen.getByRole("button", { name: /save settings/i })); + await screen.findByText(/Last run .* passed/); + await captureDocument(document, { + name: "app-setup-script-settings-02-ran", + viewport: { width: 1440, height: 1200 }, + expectations: [ + "The textarea shows 'echo setup' in monospace.", + "The 'Run on every app startup' switch is on (filled primary color).", + "The status line reads 'Last run · passed'.", + ], + }); +}, 60000); diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 4951789c3..54968704e 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -89,3 +89,25 @@ pub fn set_window_repo_path( map.insert(window_map_label(window_label), repo_path); Ok(()) } + +#[tauri::command] +pub fn get_app_setup_script_status( + state: State, +) -> Result { + let db = state.db.lock_or_recover(); + core::app_setup_script::get_status(&db) +} + +#[tauri::command] +pub fn save_app_setup_script( + state: State, + script: String, + always_run: bool, +) -> Result<(), String> { + { + let db = state.db.lock_or_recover(); + core::app_setup_script::save(&db, &script, always_run)?; + } + core::app_setup_script::spawn_run_if_needed(core::app_setup_script::Trigger::SettingsChanged); + Ok(()) +} diff --git a/src-tauri/src/core/app_setup_script.rs b/src-tauri/src/core/app_setup_script.rs new file mode 100644 index 000000000..5a6feef02 --- /dev/null +++ b/src-tauri/src/core/app_setup_script.rs @@ -0,0 +1,267 @@ +//! Application setup script: a shell script the user sets in Application +//! settings. It runs once per distinct script, tracked by the SHA-256 of the +//! script stored in the app DB, so editing the script makes it run again. With +//! `always_run` on, it also runs on every app startup. + +use std::process::{Command, Stdio}; +use std::sync::Mutex; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::db::Database; +use crate::lock_ext::LockExt; + +pub const SCRIPT_KEY: &str = "app_setup_script"; +pub const ALWAYS_RUN_KEY: &str = "app_setup_script_always_run"; +pub const LAST_HASH_KEY: &str = "app_setup_script_last_hash"; +pub const LAST_RUN_AT_KEY: &str = "app_setup_script_last_run_at"; +pub const LAST_STATUS_KEY: &str = "app_setup_script_last_status"; + +/// What asked for the run. `always_run` only applies at startup; a settings +/// save runs the script only when its hash changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Trigger { + Startup, + SettingsChanged, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct AppSetupScriptStatus { + pub script: String, + pub always_run: bool, + pub last_hash: Option, + pub last_run_at: Option, + /// "passed" | "failed" + pub last_status: Option, + pub running: bool, +} + +/// Held for the whole run. Serializes runs, so a save made mid-run is +/// re-evaluated against the hash the run records. +static RUN_LOCK: Mutex<()> = Mutex::new(()); + +pub fn script_hash(script: &str) -> String { + Sha256::digest(script.as_bytes()) + .iter() + .map(|byte| format!("{:02x}", byte)) + .collect() +} + +pub fn get_status(db: &Database) -> Result { + let get = |key: &str| db.get_setting(key).map_err(|e| e.to_string()); + let script = get(SCRIPT_KEY)?.unwrap_or_default(); + let last_hash = get(LAST_HASH_KEY)?; + Ok(AppSetupScriptStatus { + // A saved script whose hash has not been recorded yet is about to run. + running: RUN_LOCK.try_lock().is_err() || is_changed(&script, last_hash.as_deref()), + always_run: get(ALWAYS_RUN_KEY)?.as_deref() == Some("true"), + last_run_at: get(LAST_RUN_AT_KEY)?, + last_status: get(LAST_STATUS_KEY)?, + script, + last_hash, + }) +} + +fn is_changed(script: &str, last_hash: Option<&str>) -> bool { + !script.trim().is_empty() && last_hash != Some(script_hash(script).as_str()) +} + +pub fn save(db: &Database, script: &str, always_run: bool) -> Result<(), String> { + db.set_setting(SCRIPT_KEY, script) + .and_then(|_| db.set_setting(ALWAYS_RUN_KEY, if always_run { "true" } else { "false" })) + .map_err(|e| e.to_string()) +} + +/// The script to run for `trigger`, or `None` when it should not run. +fn script_to_run(db: &Database, trigger: Trigger) -> Result, String> { + let status = get_status(db)?; + if status.script.trim().is_empty() { + return Ok(None); + } + let changed = is_changed(&status.script, status.last_hash.as_deref()); + let always = trigger == Trigger::Startup && status.always_run; + Ok((changed || always).then_some(status.script)) +} + +/// Runs the script with `sh -c` from the home directory. Returns whether it +/// exited successfully; output goes to the app log. +fn execute(script: &str) -> bool { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + let output = Command::new("sh") + .args(["-c", script]) + .current_dir(&home) + .env("PATH", crate::binary_paths::get_extended_path()) + .stdin(Stdio::null()) + .output(); + match output { + Ok(output) => { + log::info!( + "App setup script exited with {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output.status.success() + } + Err(e) => { + log::warn!("Failed to start app setup script: {}", e); + false + } + } +} + +/// Runs the script when `trigger` calls for it. Returns whether it ran. +pub fn run_if_needed(db: &Mutex, trigger: Trigger) -> Result { + let _run = RUN_LOCK.lock_or_recover(); + let Some(script) = script_to_run(&db.lock_or_recover(), trigger)? else { + return Ok(false); + }; + let status = if execute(&script) { "passed" } else { "failed" }; + let db = db.lock_or_recover(); + db.set_setting(LAST_HASH_KEY, &script_hash(&script)) + .and_then(|_| db.set_setting(LAST_RUN_AT_KEY, &Utc::now().to_rfc3339())) + .and_then(|_| db.set_setting(LAST_STATUS_KEY, status)) + .map_err(|e| e.to_string())?; + Ok(true) +} + +/// Runs `run_if_needed` on a background thread with its own connection to the +/// app DB, so a long script never blocks startup or the settings save. +pub fn spawn_run_if_needed(trigger: Trigger) { + let Ok(db_path) = std::env::var("TREQ_APP_DB_PATH") else { + log::warn!("TREQ_APP_DB_PATH unset; skipping app setup script"); + return; + }; + std::thread::spawn(move || { + let result = Database::new(db_path.into()) + .map_err(|e| e.to_string()) + .and_then(|db| run_if_needed(&Mutex::new(db), trigger)); + if let Err(e) = result { + log::warn!("App setup script failed: {}", e); + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_db(dir: &TempDir) -> Mutex { + let db = Database::new(dir.path().join("treq.db")).unwrap(); + db.init().unwrap(); + Mutex::new(db) + } + + /// A script that appends a line to `runs.log` in `dir`, so tests can count runs. + fn counting_script(dir: &TempDir, marker: &str) -> String { + format!( + "echo {} >> '{}'", + marker, + dir.path().join("runs.log").display() + ) + } + + fn run_count(dir: &TempDir) -> usize { + std::fs::read_to_string(dir.path().join("runs.log")) + .map(|s| s.lines().count()) + .unwrap_or(0) + } + + #[test] + fn runs_script_on_first_startup_and_records_hash_and_timestamp() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + let script = counting_script(&dir, "a"); + save(&db.lock().unwrap(), &script, false).unwrap(); + + assert!(run_if_needed(&db, Trigger::Startup).unwrap()); + + assert_eq!(run_count(&dir), 1); + let status = get_status(&db.lock().unwrap()).unwrap(); + assert_eq!(status.last_hash, Some(script_hash(&script))); + assert!(status.last_run_at.is_some()); + assert_eq!(status.last_status.as_deref(), Some("passed")); + } + + #[test] + fn skips_startup_run_when_script_unchanged() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), &counting_script(&dir, "a"), false).unwrap(); + run_if_needed(&db, Trigger::Startup).unwrap(); + + assert!(!run_if_needed(&db, Trigger::Startup).unwrap()); + assert_eq!(run_count(&dir), 1); + } + + #[test] + fn reruns_when_script_changes() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), &counting_script(&dir, "a"), false).unwrap(); + run_if_needed(&db, Trigger::SettingsChanged).unwrap(); + + save(&db.lock().unwrap(), &counting_script(&dir, "b"), false).unwrap(); + assert!(run_if_needed(&db, Trigger::SettingsChanged).unwrap()); + assert_eq!(run_count(&dir), 2); + } + + #[test] + fn always_run_reruns_unchanged_script_on_startup() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), &counting_script(&dir, "a"), true).unwrap(); + run_if_needed(&db, Trigger::Startup).unwrap(); + + assert!(run_if_needed(&db, Trigger::Startup).unwrap()); + assert_eq!(run_count(&dir), 2); + } + + #[test] + fn always_run_does_not_rerun_unchanged_script_on_settings_save() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), &counting_script(&dir, "a"), true).unwrap(); + run_if_needed(&db, Trigger::Startup).unwrap(); + + assert!(!run_if_needed(&db, Trigger::SettingsChanged).unwrap()); + assert_eq!(run_count(&dir), 1); + } + + #[test] + fn skips_blank_script() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), " \n", true).unwrap(); + + assert!(!run_if_needed(&db, Trigger::Startup).unwrap()); + assert_eq!(get_status(&db.lock().unwrap()).unwrap().last_run_at, None); + } + + #[test] + fn records_failed_status_and_does_not_retry_same_script() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), "exit 3", false).unwrap(); + + assert!(run_if_needed(&db, Trigger::Startup).unwrap()); + let status = get_status(&db.lock().unwrap()).unwrap(); + assert_eq!(status.last_status.as_deref(), Some("failed")); + assert!(!run_if_needed(&db, Trigger::Startup).unwrap()); + } + + #[test] + fn get_status_returns_saved_settings() { + let dir = TempDir::new().unwrap(); + let db = test_db(&dir); + save(&db.lock().unwrap(), "echo hi", true).unwrap(); + + let status = get_status(&db.lock().unwrap()).unwrap(); + assert_eq!(status.script, "echo hi"); + assert!(status.always_run); + assert_eq!(status.last_hash, None); + } +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index f95090ec8..bdb4d40f5 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -2,6 +2,7 @@ pub mod agent_chat; pub mod agent_cli; pub mod agent_supervisor; pub mod app; +pub mod app_setup_script; pub mod auto_update; pub mod browser_review; pub mod changes; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f25dc69c3..df8adde12 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -496,6 +496,9 @@ pub fn run() { app.manage(remote_exec_state); start_agent_ipc_listener(app.handle().clone(), dispatch_listener); start_instance_registry_heartbeat(app.handle().clone()); + crate::core::app_setup_script::spawn_run_if_needed( + crate::core::app_setup_script::Trigger::Startup, + ); // Listen for deep-link events and forward to frontend #[cfg(desktop)] @@ -833,6 +836,8 @@ pub fn run() { commands::get_setting, commands::get_settings_batch, commands::set_setting, + commands::get_app_setup_script_status, + commands::save_app_setup_script, commands::get_repo_setting, commands::set_repo_setting, commands::list_skill_catalog, diff --git a/src/components/AppSetupScriptSettings.tsx b/src/components/AppSetupScriptSettings.tsx new file mode 100644 index 000000000..2cc4756ed --- /dev/null +++ b/src/components/AppSetupScriptSettings.tsx @@ -0,0 +1,60 @@ +import type { AppSetupScriptStatus } from "../lib/api"; +import { Label } from "./ui/label"; +import { Switch } from "./ui/switch"; +import { Textarea } from "./ui/textarea"; + +interface AppSetupScriptSettingsProps { + script: string; + alwaysRun: boolean; + status?: AppSetupScriptStatus; + onScriptChange: (script: string) => void; + onAlwaysRunChange: (alwaysRun: boolean) => void; +} + +export function AppSetupScriptSettings({ + script, + alwaysRun, + status, + onScriptChange, + onAlwaysRunChange, +}: AppSetupScriptSettingsProps) { + return ( +
+ +