From 50efb999b27d38586d3a259c9a87a43f0cd7bed5 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 5 Aug 2026 16:15:43 +0500 Subject: [PATCH 1/4] feat(desktop): manage VidXP runtime services --- INSTALLATION_GUIDE.md | 36 +- desktop/runtime-manifest.json | 20 +- desktop/src-tauri/build.rs | 2 + desktop/src-tauri/src/browser_readiness.rs | 19 +- desktop/src-tauri/src/lib.rs | 912 ++++++++++++++++++++- desktop/src-tauri/src/target_profiles.rs | 58 +- desktop/src/App.test.tsx | 196 +++-- desktop/src/App.tsx | 24 +- desktop/src/components/LocalSetup.tsx | 59 +- desktop/src/components/ManagedSetup.tsx | 89 +- desktop/src/components/TargetChoice.tsx | 8 +- desktop/src/components/TargetSummary.tsx | 375 ++++++++- desktop/src/styles.css | 5 + desktop/src/tauri.test.ts | 51 ++ desktop/src/tauri.ts | 61 ++ docs/desktop.md | 62 +- src/vidxp/api_cli.py | 31 +- src/vidxp/cli_commands/jobs.py | 49 +- src/vidxp/cli_commands/runtime.py | 4 + src/vidxp/frontend.py | 22 +- src/vidxp/local_probe.py | 134 ++- src/vidxp/mcp_cli.py | 9 +- tests/test_api_cli.py | 32 + tests/test_cli.py | 54 +- tests/test_local_probe.py | 24 + tests/test_packaging.py | 8 +- 26 files changed, 2078 insertions(+), 266 deletions(-) diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 9ffa4e9..43b22a3 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -26,7 +26,7 @@ Local model work requires a capability or worker extra. |---|---|---| | CLI or MCP | [uv 0.12+](https://docs.astral.sh/uv/getting-started/installation/) | Python and the isolated VidXP environment | | Desktop-managed target | A supported OS, internet access for first setup, FFmpeg, ffprobe, `libx264`, and `aac` | uv, Python, VidXP, and selected model files | -| Desktop with existing target | A compatible local `vidxp` executable and that installation's own media-runtime setup | Target discovery and launch coordination only | +| Desktop with existing target | A compatible local `vidxp` executable and that installation's own media-runtime setup | Target discovery, service controls, and feature reinstallation for isolated uv tools | | Docker | Docker Engine or Docker Desktop | Python, VidXP, and FFmpeg inside the image | Native CLI and desktop processing require FFmpeg, ffprobe, `libx264`, and @@ -354,27 +354,45 @@ does not install anything before that choice: - **Use an existing installation** discovers compatible `vidxp` executables or lets you browse to one. Desktop validates the versioned probe and launch - contracts, but the installation stays externally owned. Desktop never - installs, repairs, updates, removes, or broadly stops it. If its browser - surface is missing, enable the `frontend` extra with that installation's own - package-management workflow before Desktop can open it. + contracts, and the installation stays selected and externally owned. + For an isolated uv-tool installation, **Setup options** can change its search, + local-processing, browser, AI-assistant, or app-integration features. Desktop recreates that app environment at + its reported VidXP and Python versions with the complete selected extra set, + then rechecks it. Other environment types stay with their original package + manager. Desktop does not broadly stop an external installation. The + compatibility probe reports installed search, processing, and integration features. - **Set up VidXP for me** creates a private Python and VidXP runtime owned by Desktop. Python and uv do not need to be installed separately. Capability - code, the optional browser interface, model storage, and initial model - preparation are selected before applying the draft. + code, optional local video processing, browser interface, AI-assistant + integration, and app integration service, + model storage, and initial model preparation are selected before applying + the draft. A managed setup or update remains a draft until its candidate runtime passes the Desktop probe and launch contracts. Activation then replaces the previous managed target atomically; failed or cancelled work leaves the previous target -authoritative. For an unchanged ready runtime, **Prepare / verify models** +authoritative. For an unchanged ready runtime, **Check downloaded models** checks cached files and downloads only missing selected model material without requiring a configuration change. +The active-target panel can run the selected installation's read-only +`vidxp doctor --json` check, start/monitor/stop local video processing through +the existing worker supervisor, generate `mcpServers` JSON bound to that exact +installation and repository, and start/monitor/stop a Desktop-owned loopback +`vidxp-api` process when the app integration service is installed. These controls remain +available after installation; Desktop is not only a first-run installer or a +browser launcher. It broadly stops only a Desktop-owned target and only +reinstalls an existing isolated tool after the user confirms the feature change. +Browser and app-service processes start private to the current computer. +Desktop can also invoke each service's existing `--share` mode: it shows the +resolved LAN port and URLs, warns that the shared browser has no authentication, +and exposes the API/MCP bearer token behind the connection details. + Starting Desktop, or starting it a second time, shows and focuses the control panel without opening a browser. **Open VidXP** explicitly starts or reuses the loopback browser service and opens one tab. Closing a configured window hides it to the tray. Tray actions are **Manage VidXP**, **Open VidXP**, and **Quit -VidXP**. Quit stops the exact browser service Desktop launched; broad worker +VidXP**. Quit stops the exact browser and API services Desktop launched; broad worker shutdown is limited to a Desktop-owned runtime. The NSIS, DMG, and AppImage packages do not bundle FFmpeg. Managed setup can diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json index fa12363..b60f697 100644 --- a/desktop/runtime-manifest.json +++ b/desktop/runtime-manifest.json @@ -7,11 +7,29 @@ "python_version": "3.14.6", "uv_version": "0.12.0", "surfaces": { + "worker": { + "extra": "local-worker", + "label": "Process videos on this computer", + "description": "Run background indexing, search, and grounded questions locally. This includes all built-in search features and is the normal desktop setup.", + "default": true + }, "browser": { "extra": "frontend", "label": "Browser interface", - "description": "Installs the local browser interface. Leave this off for a processing-only runtime.", + "description": "Use VidXP's visual workspace in your default browser. It stays private to this computer unless you explicitly share it without authentication.", "default": true + }, + "mcp": { + "extra": "mcp", + "label": "AI assistant integration", + "description": "Use VidXP from an MCP-compatible AI assistant installed on this computer.", + "default": false + }, + "server": { + "extra": "server", + "label": "App integration service", + "description": "Run an API and network-style MCP connection for other software. It is private by default and can be shared on your local network with bearer-token authentication.", + "default": false } }, "capabilities": { diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index f5c6f33..f24596f 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -46,6 +46,8 @@ fn main() { "local-worker", "--extra", "frontend", + "--extra", + "server", "--no-dev", "--no-emit-project", "--no-hashes", diff --git a/desktop/src-tauri/src/browser_readiness.rs b/desktop/src-tauri/src/browser_readiness.rs index 17225df..f354a6f 100644 --- a/desktop/src-tauri/src/browser_readiness.rs +++ b/desktop/src-tauri/src/browser_readiness.rs @@ -22,6 +22,8 @@ struct ReadinessMarker { port: u16, #[serde(rename = "pid")] _pid: u32, + #[serde(default)] + network_url: Option, } fn marker_matches(contents: &[u8], nonce: &str, port: u16) -> bool { @@ -66,7 +68,7 @@ pub fn wait_for_browser_readiness( port: u16, deadline: Instant, cancellation: &CancellationToken, -) -> Result<(), String> { +) -> Result, String> { let address = SocketAddr::from(([127, 0, 0, 1], port)); while Instant::now() < deadline { if cancellation.is_cancelled() { @@ -83,11 +85,14 @@ pub fn wait_for_browser_readiness( "The VidXP interface exited during startup ({status})." )); } - if fs::read(marker_path).is_ok_and(|contents| { - marker_matches(&contents, nonce, port) && streamlit_health_is_ready(address) - }) { + if let Ok(contents) = fs::read(marker_path) + && marker_matches(&contents, nonce, port) + && streamlit_health_is_ready(address) + { + let marker = serde_json::from_slice::(&contents) + .map_err(|error| format!("The interface readiness marker is invalid: {error}"))?; let _ = fs::remove_file(marker_path); - return Ok(()); + return Ok(marker.network_url); } thread::sleep(Duration::from_millis(50)); } @@ -231,11 +236,12 @@ mod tests { "nonce": "launch", "port": port, "pid": process.id() + 1, + "network_url": format!("http://192.168.1.20:{port}"), }) .to_string(), ) .expect("marker"); - wait_for_browser_readiness( + let network_url = wait_for_browser_readiness( &mut process, &marker, "launch", @@ -244,6 +250,7 @@ mod tests { &CancellationToken::default(), ) .expect("ready"); + assert_eq!(network_url, Some(format!("http://192.168.1.20:{port}"))); assert!(!marker.exists()); server.join().expect("server"); } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index f098d92..b469276 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2,14 +2,15 @@ use std::{ borrow::Cow, collections::{BTreeMap, BTreeSet}, env, fs, - io::{self, Write}, - net::TcpListener, + io::{self, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, path::{Path, PathBuf}, process::Command, sync::{ Arc, Mutex, OnceLock, atomic::{AtomicBool, AtomicU64, Ordering}, }, + thread, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -355,12 +356,68 @@ struct RuntimeReconciliation { struct ManagedUi { process: background_process::OwnedChild, - url: String, + port: u16, + local_url: String, + network_url: Option, + shared: bool, + profile_id: String, +} + +#[derive(Clone, Debug, Serialize)] +struct BrowserServiceStatus { + state: &'static str, + running: bool, + shared: bool, + port: Option, + local_url: Option, + network_url: Option, + detail: String, +} + +struct ManagedApiService { + process: background_process::OwnedChild, + port: u16, + health_host: String, + origin: String, + health_url: String, + mcp_url: String, + bearer_token: Option, + shared: bool, profile_id: String, } +#[derive(Clone, Debug, Serialize)] +struct LocalServerStatus { + state: &'static str, + running: bool, + shared: bool, + port: Option, + origin: Option, + health_url: Option, + mcp_url: Option, + bearer_token: Option, + detail: String, +} + +#[derive(Debug, Deserialize)] +struct ApiShareDetails { + origin: String, + host: String, + port: u16, + health_url: String, + mcp_url: String, + bearer_token: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +struct LocalWorkerStatus { + running: bool, + detail: String, +} + struct DesktopState { ui_process: Mutex>, + api_process: Mutex>, worker_stop: Arc, operation_cancellation: Arc>>, transition: Arc>, @@ -374,6 +431,7 @@ impl Default for DesktopState { fn default() -> Self { Self { ui_process: Mutex::new(None), + api_process: Mutex::new(None), worker_stop: Arc::new(WorkerStopSupervisor::default()), operation_cancellation: Arc::new(Mutex::new(None)), transition: Arc::new(Mutex::new(TransitionState::default())), @@ -407,6 +465,7 @@ enum TransitionKind { Delete, InstallMedia, InstallRuntime, + ConfigureExternalInstallation, PrepareModels, RecoverActivation, OpenBrowser, @@ -989,6 +1048,16 @@ fn package_specification( capabilities: &[String], surfaces: &[String], ) -> String { + package_specification_for_version(manifest, capabilities, surfaces, &manifest.package_version) +} + +fn package_specification_for_version( + manifest: &RuntimeManifest, + capabilities: &[String], + surfaces: &[String], + version: &str, +) -> String { + let local_worker_selected = surfaces.iter().any(|name| name == "worker"); let extras: BTreeSet<_> = manifest .surfaces .iter() @@ -997,15 +1066,45 @@ fn package_specification( .chain( capabilities .iter() + .filter(|_| !local_worker_selected) .map(|name| manifest.capabilities[name].extra.clone()), ) .collect(); - format!( - "{}[{}]=={}", - manifest.package_name, - extras.into_iter().collect::>().join(","), - manifest.package_version - ) + let extras = extras.into_iter().collect::>().join(","); + if extras.is_empty() { + format!("{}=={}", manifest.package_name, version) + } else { + format!("{}[{}]=={}", manifest.package_name, extras, version) + } +} + +fn external_installation_arguments( + manifest: &RuntimeManifest, + capabilities: &[String], + surfaces: &[String], + python_version: &str, + version: &str, +) -> Result, String> { + if version.is_empty() + || !version + .chars() + .all(|character| character.is_ascii_alphanumeric() || ".!+_-".contains(character)) + { + return Err("The selected installation reported an invalid package version.".into()); + } + Ok(vec![ + "tool".into(), + "install".into(), + "--force".into(), + "--python".into(), + python_version.into(), + "--no-config".into(), + "--default-index".into(), + manifest.dependency_index.clone(), + "--index-strategy".into(), + "first-index".into(), + package_specification_for_version(manifest, capabilities, surfaces, version), + ]) } fn base_package_specification(manifest: &RuntimeManifest) -> String { @@ -1816,6 +1915,31 @@ async fn uv_output( Ok(()) } +async fn uv_captured_output( + app: &AppHandle, + paths: &DesktopPaths, + arguments: Vec, + cancellation: background_process::CancellationToken, + operation: &str, +) -> Result { + let mut command = app + .shell() + .sidecar("uv") + .map_err(|error| format!("The bundled uv sidecar is unavailable: {error}"))? + .args(arguments) + .env_clear(); + for (key, value) in clean_environment(paths) { + command = command.env(key, value); + } + command = command + .env("UV_CACHE_DIR", paths.cache.join("uv")) + .env("UV_PYTHON_INSTALL_DIR", &paths.python) + .env("UV_NO_CONFIG", "1") + .env("UV_MANAGED_PYTHON", "1"); + let command: Command = command.into(); + supervised_output(command, cancellation, operation).await +} + fn run_vidxp( runtime: &Path, paths: &DesktopPaths, @@ -2013,6 +2137,7 @@ async fn adopt_local_target( )?; let setup = target_profiles::adopt_validated(&app, validated, display_name)?; stop_ui_process(&app.state::()); + stop_api_process(&app.state::()); Ok(setup) }) .await @@ -2081,6 +2206,7 @@ async fn select_target_profile( target_profiles::select_profile(&app, &profile_id, &desktop_version)? }; stop_ui_process(&app.state::()); + stop_api_process(&app.state::()); Ok(setup) }) .await @@ -2106,6 +2232,7 @@ async fn delete_target_profile( let result = target_profiles::delete_profile(&app, &profile_id)?; if selected.as_deref() == Some(&profile_id) { stop_ui_process(&app.state::()); + stop_api_process(&app.state::()); } Ok(result) }) @@ -2733,6 +2860,7 @@ async fn install_runtime( .await .map_err(|error| format!("Managed activation stopped unexpectedly: {error}"))??; stop_ui_process(&state); + stop_api_process(&state); transition.commit_draft(); Ok(InstallTransitionResult { @@ -2750,7 +2878,39 @@ async fn install_runtime( }) } -fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { +fn stopped_browser_status(detail: impl Into) -> BrowserServiceStatus { + BrowserServiceStatus { + state: "stopped", + running: false, + shared: false, + port: None, + local_url: None, + network_url: None, + detail: detail.into(), + } +} + +fn running_browser_status(ui: &ManagedUi) -> BrowserServiceStatus { + BrowserServiceStatus { + state: "ready", + running: true, + shared: ui.shared, + port: Some(ui.port), + local_url: Some(ui.local_url.clone()), + network_url: ui.network_url.clone(), + detail: if ui.shared { + "The browser interface is available on this local network.".into() + } else { + "The browser interface is available only on this computer.".into() + }, + } +} + +fn start_ui( + app: &AppHandle, + state: &DesktopState, + shared: bool, +) -> Result { let manifest = manifest()?; let selected = target_profiles::selected_profile(app).map_err(|error| error.to_string())?; let mut paths = desktop_paths(app)?; @@ -2801,8 +2961,10 @@ fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { .map_err(|error| format!("Could not inspect the interface process: {error}"))? .is_none(); match ui_process_action(running, &ui.profile_id, &profile.id) { - UiProcessAction::Reuse => return Ok(ui.url.clone()), - UiProcessAction::Replace => { + UiProcessAction::Reuse if ui.shared == shared => { + return Ok(running_browser_status(ui)); + } + UiProcessAction::Reuse | UiProcessAction::Replace => { ui.process.terminate_and_reap(); } UiProcessAction::Start => {} @@ -2810,7 +2972,7 @@ fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { *active_process = None; } - let listener = TcpListener::bind(("127.0.0.1", 0)) + let listener = TcpListener::bind((if shared { "0.0.0.0" } else { "127.0.0.1" }, 0)) .map_err(|error| format!("Could not reserve a local interface port: {error}"))?; let port = listener .local_addr() @@ -2829,29 +2991,18 @@ fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { )); } - let mut command = match profile.kind { - target_profiles::TargetKind::Managed => { - let active = active_runtime(&paths)?; - if profile.managed_runtime_profile.as_deref() != Some(active.profile.as_str()) { - return Err( - "The selected managed target no longer matches the active desktop runtime." - .into(), - ); - } - configured_command(&profile.executable, &paths) - } - target_profiles::TargetKind::ExistingLocal => Command::new(&profile.executable), - }; + let mut command = target_command(&profile, &paths, &profile.executable); configure_ui_service_command( &mut command, &profile.repository_root, port, &readiness_file, &nonce, + shared, ); let mut process = background_process::spawn_service(command) .map_err(|error| format!("Could not start the VidXP interface: {}", error.detail))?; - browser_readiness::wait_for_browser_readiness( + let network_url = browser_readiness::wait_for_browser_readiness( &mut process, &readiness_file, &nonce, @@ -2859,13 +3010,22 @@ fn start_ui(app: &AppHandle, state: &DesktopState) -> Result { Instant::now() + Duration::from_secs(30), &state.shutdown, )?; - let url = format!("http://127.0.0.1:{port}"); - *active_process = Some(ManagedUi { + if shared && network_url.is_none() { + process.terminate_and_reap(); + return Err("The browser interface started, but VidXP could not determine its local-network address.".into()); + } + let local_url = format!("http://127.0.0.1:{port}"); + let ui = ManagedUi { process, - url: url.clone(), + port, + local_url, + network_url, + shared, profile_id: profile.id.clone(), - }); - Ok(url) + }; + let status = running_browser_status(&ui); + *active_process = Some(ui); + Ok(status) } fn stop_ui_process(state: &DesktopState) { @@ -2877,6 +3037,579 @@ fn stop_ui_process(state: &DesktopState) { } } +#[tauri::command] +fn browser_service_status( + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + let mut active = state + .ui_process + .lock() + .map_err(|_| "The browser process supervisor is unavailable.".to_string())?; + let Some(ui) = active.as_mut() else { + return Ok(stopped_browser_status("The browser interface is stopped.")); + }; + if ui + .process + .try_wait() + .map_err(|error| format!("Could not inspect the browser interface: {error}"))? + .is_some() + { + *active = None; + return Ok(stopped_browser_status("The browser interface exited.")); + } + Ok(running_browser_status(ui)) +} + +#[tauri::command] +async fn start_shared_browser( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + start_ui(&app, &state, true) + }) + .await + .map_err(|error| format!("Browser sharing startup stopped unexpectedly: {error}"))? +} + +#[tauri::command] +fn stop_browser_service( + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + stop_ui_process(&state); + Ok(stopped_browser_status("The browser interface was stopped.")) +} + +fn target_companion_executable(profile: &target_profiles::TargetProfile, name: &str) -> PathBuf { + let filename = if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_owned() + }; + profile + .executable + .parent() + .map_or_else(|| PathBuf::from(&filename), |parent| parent.join(&filename)) +} + +fn target_command( + profile: &target_profiles::TargetProfile, + paths: &DesktopPaths, + executable_path: &Path, +) -> Command { + match profile.kind { + target_profiles::TargetKind::Managed => configured_command(executable_path, paths), + target_profiles::TargetKind::ExistingLocal => Command::new(executable_path), + } +} + +fn selected_target_context( + app: &AppHandle, +) -> Result<(target_profiles::TargetProfile, DesktopPaths), String> { + let profile = target_profiles::selected_profile(app).map_err(|error| error.to_string())?; + let mut paths = desktop_paths(app)?; + paths.data = profile.data_root.clone(); + paths.repository = profile.repository_root.clone(); + if let Some(model_directory) = &profile.model_directory { + paths.models = model_directory.clone(); + } + Ok((profile, paths)) +} + +fn execute_target_json(command: Command, operation: &str) -> Result { + let output = background_process::run( + command, + background_process::BackgroundPolicy { + timeout: Duration::from_secs(120), + max_output_bytes: MAX_SETUP_OUTPUT_BYTES, + }, + None, + ) + .map_err(|error| format!("{operation} failed: {}", error.detail))?; + let payload = serde_json::from_slice(&output.stdout).map_err(|error| { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + format!( + "{operation} did not return valid JSON: {error}{}", + if stderr.is_empty() { + String::new() + } else { + format!(". {stderr}") + } + ) + })?; + Ok(payload) +} + +#[tauri::command] +async fn target_doctor( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || { + let (profile, paths) = selected_target_context(&app)?; + let mut command = target_command(&profile, &paths, &profile.executable); + command + .arg("--data-dir") + .arg(&profile.data_root) + .arg("--index-dir") + .arg(&profile.repository_root) + .args(["doctor", "--json"]); + execute_target_json(command, "VidXP doctor") + }) + .await + .map_err(|error| format!("VidXP doctor stopped unexpectedly: {error}"))? +} + +#[tauri::command] +async fn configure_external_installation( + app: AppHandle, + state: tauri::State<'_, DesktopState>, + capabilities: Vec, + surfaces: Vec, +) -> Result { + let cancellation = OperationCancellationGuard::register(&state)?; + let _transition = + TargetTransitionCoordinator::begin(&state, TransitionKind::ConfigureExternalInstallation) + .map_err(|error| error.to_string())?; + let manifest = manifest()?; + let selected_surfaces = selected_surfaces(&manifest, &surfaces)?; + let selected_capabilities: Vec<_> = capabilities + .into_iter() + .collect::>() + .into_iter() + .collect(); + if let Some(unknown) = selected_capabilities + .iter() + .find(|name| !manifest.capabilities.contains_key(*name)) + { + return Err(format!("Unknown VidXP search feature: {unknown}")); + } + let (profile, paths) = selected_target_context(&app)?; + if profile.kind != target_profiles::TargetKind::ExistingLocal + || profile.lifecycle_ownership != target_profiles::LifecycleOwnership::External + { + return Err("Use the managed setup screen to change this VidXP installation.".into()); + } + if selected_surfaces == profile.surfaces && selected_capabilities == profile.capabilities { + return Ok(target_profiles::current_state(&app).map_err(|error| error.to_string())?); + } + let runtime = profile.runtime.as_ref().ok_or_else(|| { + "The selected installation did not report its Python environment.".to_string() + })?; + if !runtime.python_executable.is_file() { + return Err( + "The selected installation's Python environment is no longer available.".into(), + ); + } + let tool_directory_output = uv_captured_output( + &app, + &paths, + vec!["tool".into(), "dir".into()], + cancellation.token(), + "VidXP installation lookup", + ) + .await?; + let tool_directory = PathBuf::from( + String::from_utf8_lossy(&tool_directory_output.stdout) + .trim() + .to_owned(), + ); + let expected_environment = tool_directory.join(&manifest.package_name); + if !same_path(&runtime.prefix, &expected_environment) { + return Err( + "This VidXP installation is not an isolated uv tool installation. Use its package manager to change installed features, then check it again." + .into(), + ); + } + stop_ui_process(&state); + stop_api_process(&state); + let arguments = external_installation_arguments( + &manifest, + &selected_capabilities, + &selected_surfaces, + &runtime.python_version, + &profile.observed_vidxp_version, + )?; + uv_output( + &app, + &paths, + arguments, + cancellation.token(), + "VidXP feature update", + ) + .await?; + target_profiles::validated_selected_profile_with_cancellation( + &app, + &manifest.desktop_version, + Some(&state.shutdown), + ) + .map_err(|error| error.to_string())?; + target_profiles::current_state(&app).map_err(|error| error.to_string()) +} + +#[tauri::command] +async fn mcp_client_config( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || { + let (profile, paths) = selected_target_context(&app)?; + if !profile + .surfaces + .iter() + .any(|surface| surface == "mcp" || surface == "server") + { + return Err( + "The selected VidXP installation does not expose an installed MCP surface.".into(), + ); + } + let executable_path = target_companion_executable(&profile, "vidxp-mcp"); + if !executable_path.is_file() { + return Err(format!( + "The selected installation did not provide {}.", + executable_path.display() + )); + } + let mut command = target_command(&profile, &paths, &executable_path); + command + .arg("--print-config") + .arg("--repository") + .arg("default") + .arg("--index-directory") + .arg(&profile.repository_root) + .arg("--data-dir") + .arg(&profile.data_root); + let output = checked_output(command, "VidXP MCP configuration")?; + let payload: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("VidXP returned invalid MCP configuration JSON: {error}"))?; + serde_json::to_string_pretty(&payload) + .map_err(|error| format!("Could not format the MCP configuration: {error}")) + }) + .await + .map_err(|error| format!("MCP configuration stopped unexpectedly: {error}"))? +} + +fn execute_worker_action(app: &AppHandle, action: &str) -> Result { + let (profile, paths) = selected_target_context(app)?; + if !profile.surfaces.iter().any(|surface| surface == "worker") { + return Err("Local video processing is not installed for this VidXP setup.".into()); + } + let mut command = target_command(&profile, &paths, &profile.executable); + command + .arg("--data-dir") + .arg(&profile.data_root) + .arg("--index-dir") + .arg(&profile.repository_root) + .args(["jobs", action]); + let payload = execute_target_json(command, "VidXP local processing")?; + serde_json::from_value(payload) + .map_err(|error| format!("VidXP returned an invalid processing status: {error}")) +} + +#[tauri::command] +async fn local_worker_status( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || execute_worker_action(&app, "worker-status")) + .await + .map_err(|error| format!("Local processing status stopped unexpectedly: {error}"))? +} + +#[tauri::command] +async fn start_local_worker( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || execute_worker_action(&app, "start-worker")) + .await + .map_err(|error| format!("Local processing startup stopped unexpectedly: {error}"))? +} + +#[tauri::command] +async fn stop_local_worker( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || execute_worker_action(&app, "stop-worker")) + .await + .map_err(|error| format!("Local processing shutdown stopped unexpectedly: {error}"))? +} + +fn http_health_is_ready(host: &str, port: u16) -> bool { + let Ok(address) = format!("{host}:{port}").parse::() else { + return false; + }; + let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(200)) else { + return false; + }; + let _ = stream.set_read_timeout(Some(Duration::from_millis(300))); + if stream + .write_all( + format!("GET /health HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n").as_bytes(), + ) + .is_err() + { + return false; + } + let mut response = [0_u8; 128]; + stream + .read(&mut response) + .is_ok_and(|read| String::from_utf8_lossy(&response[..read]).starts_with("HTTP/1.1 200")) +} + +fn stopped_server_status(detail: impl Into) -> LocalServerStatus { + LocalServerStatus { + state: "stopped", + running: false, + shared: false, + port: None, + origin: None, + health_url: None, + mcp_url: None, + bearer_token: None, + detail: detail.into(), + } +} + +fn running_server_status(service: &ManagedApiService, healthy: bool) -> LocalServerStatus { + LocalServerStatus { + state: if healthy { "ready" } else { "starting" }, + running: true, + shared: service.shared, + port: Some(service.port), + health_url: Some(service.health_url.clone()), + mcp_url: Some(service.mcp_url.clone()), + origin: Some(service.origin.clone()), + bearer_token: service.bearer_token.clone(), + detail: if healthy { + if service.shared { + "The API and MCP service is available on this local network.".into() + } else { + "The API and MCP service is available only on this computer.".into() + } + } else { + "The service process is running but its health endpoint is not ready.".into() + }, + } +} + +fn stop_api_process(state: &DesktopState) { + let Ok(mut active) = state.api_process.lock() else { + return; + }; + if let Some(mut service) = active.take() { + service.process.terminate_and_reap(); + } +} + +#[tauri::command] +fn local_server_status(state: tauri::State<'_, DesktopState>) -> Result { + let _active = state.active_operations.register()?; + let mut active = state + .api_process + .lock() + .map_err(|_| "The API process supervisor is unavailable.".to_string())?; + let Some(service) = active.as_mut() else { + return Ok(stopped_server_status( + "The local API and MCP service is stopped.", + )); + }; + if service + .process + .try_wait() + .map_err(|error| format!("Could not inspect the local service process: {error}"))? + .is_some() + { + *active = None; + return Ok(stopped_server_status( + "The local API and MCP service exited.", + )); + } + let healthy = http_health_is_ready(&service.health_host, service.port); + Ok(running_server_status(service, healthy)) +} + +fn api_service_command( + profile: &target_profiles::TargetProfile, + paths: &DesktopPaths, + executable_path: &Path, + port: u16, + shared: bool, +) -> Command { + let mut command = target_command(profile, paths, executable_path); + command + .env("VIDXP_REPOSITORY_ROOT", &profile.repository_root) + .env("VIDXP_MODEL_CACHE", &paths.models) + .arg("--data-dir") + .arg(&profile.data_root) + .arg("--port") + .arg(port.to_string()); + if shared { + command.arg("--share"); + } + command +} + +fn start_server_mode( + app: &AppHandle, + state: &DesktopState, + shared: bool, +) -> Result { + let (profile, paths) = selected_target_context(app)?; + if !profile.surfaces.iter().any(|surface| surface == "server") { + return Err( + "The selected VidXP installation does not include the app integration service.".into(), + ); + } + let mut active = state + .api_process + .lock() + .map_err(|_| "The API process supervisor is unavailable.".to_string())?; + if let Some(service) = active.as_mut() { + let running = service + .process + .try_wait() + .map_err(|error| format!("Could not inspect the local service process: {error}"))? + .is_none(); + if running && service.profile_id == profile.id && service.shared == shared { + let healthy = http_health_is_ready(&service.health_host, service.port); + return Ok(running_server_status(service, healthy)); + } + service.process.terminate_and_reap(); + *active = None; + } + let listener = TcpListener::bind((if shared { "0.0.0.0" } else { "127.0.0.1" }, 0)) + .map_err(|error| format!("Could not reserve a local API port: {error}"))?; + let port = listener + .local_addr() + .map_err(|error| format!("Could not identify the local API port: {error}"))? + .port(); + drop(listener); + let executable_path = target_companion_executable(&profile, "vidxp-api"); + if !executable_path.is_file() { + return Err(format!( + "The selected installation did not provide {}.", + executable_path.display() + )); + } + let (health_host, origin, health_url, mcp_url, bearer_token) = if shared { + let mut details_command = + api_service_command(&profile, &paths, &executable_path, port, true); + details_command.arg("--print-share-details"); + let output = checked_output(details_command, "VidXP network sharing setup")?; + let details: ApiShareDetails = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("VidXP returned invalid network sharing details: {error}"))?; + if details.port != port { + return Err("VidXP reported the wrong network sharing port.".into()); + } + ( + details.host, + details.origin, + details.health_url, + details.mcp_url, + Some(details.bearer_token), + ) + } else { + let origin = format!("http://127.0.0.1:{port}"); + ( + "127.0.0.1".into(), + origin.clone(), + format!("{origin}/health"), + format!("{origin}/mcp"), + None, + ) + }; + let command = api_service_command(&profile, &paths, &executable_path, port, shared); + let mut process = background_process::spawn_service(command).map_err(|error| { + format!( + "Could not start the local API and MCP service: {}", + error.detail + ) + })?; + let deadline = Instant::now() + Duration::from_secs(30); + loop { + if http_health_is_ready(&health_host, port) { + break; + } + if process + .try_wait() + .map_err(|error| format!("Could not inspect the local service: {error}"))? + .is_some() + { + return Err("The local API and MCP service exited before becoming healthy.".into()); + } + if Instant::now() >= deadline { + return Err( + "The local API and MCP service did not become healthy within 30 seconds.".into(), + ); + } + thread::sleep(Duration::from_millis(100)); + } + let service = ManagedApiService { + process, + port, + health_host, + origin, + health_url, + mcp_url, + bearer_token, + shared, + profile_id: profile.id, + }; + let status = running_server_status(&service, true); + *active = Some(service); + Ok(status) +} + +async fn start_server( + app: AppHandle, + state: tauri::State<'_, DesktopState>, + shared: bool, +) -> Result { + let _active = state.active_operations.register()?; + tauri::async_runtime::spawn_blocking(move || { + let state = app.state::(); + start_server_mode(&app, &state, shared) + }) + .await + .map_err(|error| format!("Local service startup stopped unexpectedly: {error}"))? +} + +#[tauri::command] +async fn start_local_server( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + start_server(app, state, false).await +} + +#[tauri::command] +async fn start_shared_server( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + start_server(app, state, true).await +} + +#[tauri::command] +fn stop_local_server(state: tauri::State<'_, DesktopState>) -> Result { + let _active = state.active_operations.register()?; + stop_api_process(&state); + Ok(stopped_server_status( + "The Desktop-owned API and MCP service was stopped.", + )) +} + fn browser_readiness_nonce() -> String { let sequence = READINESS_SEQUENCE.fetch_add(1, Ordering::Relaxed); let timestamp = SystemTime::now() @@ -2895,6 +3628,7 @@ fn configure_ui_service_command( port: u16, readiness_file: &Path, nonce: &str, + shared: bool, ) { command // The desktop owns the one intentional browser open after readiness. Without @@ -2904,9 +3638,16 @@ fn configure_ui_service_command( .env("VIDXP_DESKTOP_READINESS_FILE", readiness_file) .env("VIDXP_DESKTOP_READINESS_NONCE", nonce) .env("VIDXP_DESKTOP_UI_PORT", port.to_string()) + .env("VIDXP_DESKTOP_UI_SHARED", if shared { "1" } else { "0" }) .arg("--index-dir") .arg(repository_root) - .args(["ui", "--host", "127.0.0.1", "--port", &port.to_string()]); + .arg("ui"); + if shared { + command.arg("--share"); + } else { + command.args(["--host", "127.0.0.1"]); + } + command.args(["--port", &port.to_string()]); } fn hide_main_window(app: &AppHandle) -> Result<(), String> { @@ -2970,13 +3711,16 @@ async fn open_ui_in_browser(app: AppHandle) -> Result<(), String> { let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::OpenBrowser) .map_err(|error| error.to_string())?; let worker_app = app.clone(); - let url = tauri::async_runtime::spawn_blocking(move || { + let status = tauri::async_runtime::spawn_blocking(move || { let _transition = transition; let state = worker_app.state::(); - start_ui(&worker_app, &state) + start_ui(&worker_app, &state, false) }) .await .map_err(|error| format!("VidXP interface startup stopped unexpectedly: {error}"))??; + let url = status + .local_url + .ok_or_else(|| "VidXP did not report its local browser address.".to_string())?; app.opener() .open_url(&url, None::<&str>) .map_err(|error| format!("Could not open VidXP in the default browser: {error}"))?; @@ -3021,6 +3765,7 @@ fn begin_shutdown(app: &AppHandle) { cancel_active_operation(&state); log::info!("VidXP supervised shutdown requested"); stop_ui_process(&state); + stop_api_process(&state); let app = app.clone(); let operations = state.active_operations.clone(); tauri::async_runtime::spawn(async move { @@ -3097,6 +3842,7 @@ fn shutdown(app: &AppHandle, deadline: Instant) { let state = app.state::(); cancel_active_operation(&state); stop_ui_process(&state); + stop_api_process(&state); let Ok(mut paths) = desktop_paths(app) else { log::warn!("Could not resolve desktop paths during shutdown"); return; @@ -3182,7 +3928,20 @@ pub fn run() { model_directory_inventory, prepare_managed_models, install_runtime, - launch_ui + launch_ui, + target_doctor, + configure_external_installation, + mcp_client_config, + local_worker_status, + start_local_worker, + stop_local_worker, + browser_service_status, + start_shared_browser, + stop_browser_service, + local_server_status, + start_local_server, + start_shared_server, + stop_local_server ]); let app = builder .build(tauri::generate_context!()) @@ -3224,11 +3983,12 @@ mod tests { base_package_specification, capability_command_arguments, claim_browser_open, clean_environment_from, close_action, configure_ui_service_command, configured_runtime_status, dependency_installation_arguments, desktop_paths_from_roots, - display_command, inventory_model_directory, manifest, manifest_digest, - normalize_line_endings, normalized_runtime_constraints, package_acquisition_arguments, - package_specification, read_active_runtime_snapshot, reconcile_managed_runtime_storage, - required_encoder_missing, restore_active_runtime, selected_capabilities, selected_surfaces, - ui_process_action, write_activation_journal, write_active_runtime, + display_command, external_installation_arguments, inventory_model_directory, manifest, + manifest_digest, normalize_line_endings, normalized_runtime_constraints, + package_acquisition_arguments, package_specification, read_active_runtime_snapshot, + reconcile_managed_runtime_storage, required_encoder_missing, restore_active_runtime, + selected_capabilities, selected_surfaces, ui_process_action, write_activation_journal, + write_active_runtime, }; use std::{ ffi::OsStr, @@ -3771,6 +4531,14 @@ mod tests { package_specification(&manifest, &["scene".into()], &[]), format!("vidxp[scene]=={version}") ); + assert_eq!( + package_specification( + &manifest, + &["actor".into(), "dialogue".into(), "scene".into()], + &["worker".into()], + ), + format!("vidxp[local-worker]=={version}") + ); assert_eq!( selected_surfaces(&manifest, &["browser".into(), "browser".into()]) .expect("surface selection"), @@ -3779,6 +4547,40 @@ mod tests { assert!(selected_surfaces(&manifest, &["unknown".into()]).is_err()); } + #[test] + fn external_install_recreates_the_reported_version_with_the_selected_features() { + let manifest = manifest().expect("manifest"); + let arguments = external_installation_arguments( + &manifest, + &["scene".into()], + &["server".into(), "mcp".into()], + "3.14.6", + "0.4.0-b.1", + ) + .expect("external surface arguments"); + + assert_eq!(&arguments[..3], ["tool", "install", "--force"]); + assert!( + arguments + .windows(2) + .any(|items| items == ["--python", "3.14.6"]) + ); + assert_eq!( + arguments.last().expect("package"), + "vidxp[mcp,scene,server]==0.4.0-b.1" + ); + assert!( + external_installation_arguments( + &manifest, + &[], + &["mcp".into()], + "3.14.6", + "0.4.0 @ https://example.invalid/package.whl", + ) + .is_err() + ); + } + #[test] fn package_and_dependencies_use_channel_specific_indexes() { let manifest = manifest().expect("manifest"); @@ -3846,6 +4648,7 @@ mod tests { 43123, Path::new("readiness.json"), "nonce", + false, ); assert!(command.get_envs().any(|(key, value)| { @@ -3869,6 +4672,33 @@ mod tests { "43123", ] ); + + let mut shared = Command::new("vidxp"); + configure_ui_service_command( + &mut shared, + Path::new("repository"), + 43124, + Path::new("shared-readiness.json"), + "shared-nonce", + true, + ); + assert!(shared.get_envs().any(|(key, value)| { + key == OsStr::new("VIDXP_DESKTOP_UI_SHARED") && value == Some(OsStr::new("1")) + })); + assert_eq!( + shared + .get_args() + .map(|argument| argument.to_string_lossy().into_owned()) + .collect::>(), + [ + "--index-dir", + "repository", + "ui", + "--share", + "--port", + "43124" + ] + ); } #[test] diff --git a/desktop/src-tauri/src/target_profiles.rs b/desktop/src-tauri/src/target_profiles.rs index 7eb11bf..57bc618 100644 --- a/desktop/src-tauri/src/target_profiles.rs +++ b/desktop/src-tauri/src/target_profiles.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeSet, HashSet}, + collections::{BTreeMap, BTreeSet, HashSet}, fs, path::{Path, PathBuf}, process::Command, @@ -196,6 +196,8 @@ pub struct ValidatedTarget { pub repository_root: PathBuf, pub model_root: PathBuf, pub frontend: FrontendCapability, + pub capabilities: Vec, + pub surfaces: Vec, pub validated_at: u64, } @@ -262,6 +264,10 @@ struct ProbeDocument { model_root: PathBuf, #[serde(default)] capabilities: ProbeCapabilities, + #[serde(default)] + search_capabilities: Vec, + #[serde(default)] + surfaces: BTreeMap, } #[derive(Clone, Debug, Default)] @@ -537,6 +543,12 @@ fn validate_probe_document( )); } } + let surfaces = document + .surfaces + .iter() + .filter(|(_, capability)| capability.available) + .map(|(name, _)| name.clone()) + .collect(); Ok(ValidatedTarget { executable: canonical.to_path_buf(), product_version: document.product_version, @@ -554,6 +566,8 @@ fn validate_probe_document( repository_root: document.repository_root, model_root: document.model_root, frontend: document.capabilities.frontend, + capabilities: document.search_capabilities, + surfaces, validated_at: now, }) } @@ -817,8 +831,8 @@ fn local_profile(validated: ValidatedTarget, display_name: Option) -> Ta last_successful_validation_at: Some(validated.validated_at), validation_error: None, managed_runtime_profile: None, - capabilities: Vec::new(), - surfaces: Vec::new(), + capabilities: validated.capabilities, + surfaces: validated.surfaces, model_directory: None, } } @@ -1175,6 +1189,10 @@ fn apply_validation(profile: &mut TargetProfile, validated: ValidatedTarget) { profile.launch_protocol_version = validated.launch_protocol_version; profile.runtime = Some(validated.runtime); profile.frontend = validated.frontend; + if profile.kind == TargetKind::ExistingLocal { + profile.capabilities = validated.capabilities; + } + profile.surfaces = validated.surfaces; profile.last_successful_validation_at = Some(validated.validated_at); profile.validation_error = None; } @@ -1576,6 +1594,8 @@ mod tests { repository_root: root.join("data").join("repositories").join("default"), model_root: root.join("data").join("models"), capabilities: ProbeCapabilities::default(), + search_capabilities: Vec::new(), + surfaces: BTreeMap::new(), } } @@ -1591,6 +1611,36 @@ mod tests { assert!(!validated.frontend.launchable); } + #[test] + fn probe_projects_installed_product_surfaces() { + let executable = std::env::current_exe().expect("current executable"); + let canonical = fs::canonicalize(executable).expect("canonical executable"); + let mut probe = document(&canonical, "nonce"); + probe.search_capabilities = vec!["scene".into()]; + probe.surfaces.insert( + "mcp".into(), + FrontendCapability { + available: true, + launchable: false, + optional: true, + code: "mcp_available".into(), + message: "Available".into(), + remediation: String::new(), + }, + ); + probe + .surfaces + .insert("server".into(), FrontendCapability::default()); + + let validated = + validate_probe_document(&canonical, "nonce", probe, 100).expect("valid probe"); + + assert_eq!(validated.surfaces, ["mcp"]); + let profile = local_profile(validated, None); + assert_eq!(profile.capabilities, ["scene"]); + assert_eq!(profile.surfaces, ["mcp"]); + } + #[test] fn validation_pipeline_reports_missing_malformed_timeout_and_failed_probes() { let missing = std::env::temp_dir().join("vidxp-missing-probe-executable"); @@ -2010,6 +2060,8 @@ mod tests { message: "Available".into(), remediation: String::new(), }, + capabilities: vec!["scene".into()], + surfaces: vec!["browser".into(), "mcp".into(), "server".into()], validated_at: 200, } } diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index fd07763..78eaba9 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -12,6 +12,8 @@ const mocks = vi.hoisted(() => ({ prepareManagedModels: vi.fn(), runtimeManifest: vi.fn(), runtimeStatus: vi.fn(), launchUi: vi.fn(), chooseModelDirectory: vi.fn(), modelDirectoryInventory: vi.fn(), + targetDoctor: vi.fn(), mcpClientConfig: vi.fn(), localServerStatus: vi.fn(), localWorkerStatus: vi.fn(), browserServiceStatus: vi.fn(), + startLocalServer: vi.fn(), startSharedServer: vi.fn(), stopLocalServer: vi.fn(), startSharedBrowser: vi.fn(), stopBrowserService: vi.fn(), startLocalWorker: vi.fn(), stopLocalWorker: vi.fn(), configureExternalInstallation: vi.fn(), })); const windowMocks = vi.hoisted(() => ({ @@ -36,7 +38,7 @@ const localProfile = { repository_root: 'C:\\Data\\repositories\\default', display_repository_root: 'C:\\Data\\repositories\\default', observed_vidxp_version: '0.4.0', probe_schema_version: 1, probe_protocol_version: 1, launch_protocol_version: 1, runtime: null, frontend, last_successful_validation_at: 1, - last_validated_at: '2026-08-01T10:00:00Z', validation_error: null, capabilities: [], surfaces: ['browser'], + last_validated_at: '2026-08-01T10:00:00Z', validation_error: null, capabilities: [], surfaces: ['worker', 'browser'], }; const managedProfile = { ...localProfile, id: 'managed-a', display_name: 'Managed VidXP', kind: 'managed', @@ -58,17 +60,17 @@ function renderApp() { } async function enterLocal(user: ReturnType) { - await screen.findByRole('heading', { name: 'Where should VidXP run?' }); + await screen.findByRole('heading', { name: 'How would you like to set up VidXP?' }); await user.click(screen.getByRole('radio', { name: /Use an existing installation/i })); await user.click(screen.getByRole('button', { name: 'Continue' })); } async function enterManaged(user: ReturnType) { - await screen.findByRole('heading', { name: 'Where should VidXP run?' }); + await screen.findByRole('heading', { name: 'How would you like to set up VidXP?' }); await user.click(screen.getByRole('radio', { name: /Set up VidXP for me/i })); await user.click(screen.getByRole('button', { name: 'Continue' })); - await user.click(screen.getByRole('button', { name: 'Continue to setup' })); - await screen.findByRole('heading', { name: 'Set up local processing' }); + await user.click(screen.getByRole('button', { name: 'Choose features' })); + await screen.findByRole('heading', { name: 'Choose your VidXP features' }); } describe('desktop target lifecycle', () => { @@ -87,16 +89,34 @@ describe('desktop target lifecycle', () => { mocks.beginManagedSetup.mockResolvedValue({ id: 'draft-1', previous_profile_id: null }); mocks.cancelManagedSetup.mockResolvedValue(emptyState); mocks.confirmForgetTarget.mockResolvedValue(true); - mocks.runtimeManifest.mockResolvedValue({ package_version: '0.4.0', capabilities: { scene: { extra: 'scene', label: 'Visual scene search' } }, surfaces: { browser: { extra: 'frontend', label: 'Browser interface', description: 'Browser UI', default: true } } }); + mocks.runtimeManifest.mockResolvedValue({ package_version: '0.4.0', capabilities: { scene: { extra: 'scene', label: 'Visual scene search' } }, surfaces: { + worker: { extra: 'local-worker', label: 'Process videos on this computer', description: 'Run video work locally.', default: true }, + browser: { extra: 'frontend', label: 'Browser interface', description: 'Open VidXP in your browser.', default: true }, + mcp: { extra: 'mcp', label: 'AI assistant integration', description: 'Connect a compatible AI app.', default: false }, + server: { extra: 'server', label: 'App integration service', description: 'Let other local apps connect.', default: false }, + } }); mocks.runtimeStatus.mockResolvedValue({ state: 'never_configured', ready: false, runtime_profile: null, package_version: '0.4.0', capabilities: [], surfaces: [], model_directory: 'C:\\Models', detail: 'No managed runtime yet.' }); mocks.modelDirectoryInventory.mockResolvedValue({ directory: 'C:\\Models', exists: false, readable: true, total_bytes: 0, file_count: 0, recognized_models: [], empty: true, verification_required: false, truncated: false, detail: 'Empty.' }); mocks.installMediaRuntime.mockResolvedValue({ ready: true }); mocks.installRuntime.mockResolvedValue({ - install: { package_version: '0.4.0', capabilities: ['scene'], surfaces: ['browser'], model_directory: 'C:\\Models', prepared: true }, + install: { package_version: '0.4.0', capabilities: ['scene'], surfaces: ['worker', 'browser'], model_directory: 'C:\\Models', prepared: true }, setup: { profiles: [managedProfile], selected_profile_id: managedProfile.id, issues: [] }, }); mocks.prepareManagedModels.mockResolvedValue({ profiles: [managedProfile], selected_profile_id: managedProfile.id, issues: [] }); mocks.launchUi.mockResolvedValue(undefined); + mocks.targetDoctor.mockResolvedValue({ ok: true, modalities: ['scene'], checks: [{ capability: 'media', kind: 'distribution', name: 'ffmpeg', ok: true }] }); + mocks.mcpClientConfig.mockResolvedValue('{"mcpServers":{"vidxp":{"command":"vidxp-mcp"}}}'); + mocks.browserServiceStatus.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' }); + mocks.startSharedBrowser.mockResolvedValue({ state: 'ready', running: true, shared: true, port: 8501, local_url: 'http://127.0.0.1:8501', network_url: 'http://192.168.1.20:8501', detail: 'Shared.' }); + mocks.stopBrowserService.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, local_url: null, network_url: null, detail: 'Stopped.' }); + mocks.localServerStatus.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, origin: null, health_url: null, mcp_url: null, bearer_token: null, detail: 'Stopped.' }); + mocks.startLocalServer.mockResolvedValue({ state: 'ready', running: true, shared: false, port: 32191, origin: 'http://127.0.0.1:32191', health_url: 'http://127.0.0.1:32191/health', mcp_url: 'http://127.0.0.1:32191/mcp', bearer_token: null, detail: 'Healthy.' }); + mocks.startSharedServer.mockResolvedValue({ state: 'ready', running: true, shared: true, port: 32191, origin: 'http://192.168.1.20:32191', health_url: 'http://192.168.1.20:32191/health', mcp_url: 'http://192.168.1.20:32191/mcp', bearer_token: 'secret-token', detail: 'Shared.' }); + mocks.stopLocalServer.mockResolvedValue({ state: 'stopped', running: false, shared: false, port: null, origin: null, health_url: null, mcp_url: null, bearer_token: null, detail: 'Stopped.' }); + mocks.localWorkerStatus.mockResolvedValue({ running: false, detail: 'Stopped.' }); + mocks.startLocalWorker.mockResolvedValue({ running: true, detail: 'Ready.' }); + mocks.stopLocalWorker.mockResolvedValue({ running: false, detail: 'Stopped.' }); + mocks.configureExternalInstallation.mockResolvedValue(localState); }); it('shows the target-first choice without a remote placeholder', async () => { @@ -112,10 +132,10 @@ describe('desktop target lifecycle', () => { mocks.recheckTargetState.mockReturnValue(new Promise((done) => { resolve = done; })); renderApp(); expect(await screen.findByRole('heading', { name: 'Studio VidXP' })).toBeVisible(); - expect(screen.getByRole('button', { name: 'Recheck target' })).toHaveAttribute('data-loading'); + expect(screen.getByRole('button', { name: 'Check connection' })).toHaveAttribute('data-loading'); expect(mocks.recheckTargetState).toHaveBeenCalledTimes(1); resolve(localState); - await waitFor(() => expect(screen.getByRole('button', { name: 'Recheck target' })).not.toHaveAttribute('data-loading')); + await waitFor(() => expect(screen.getByRole('button', { name: 'Check connection' })).not.toHaveAttribute('data-loading')); }); it('opens the browser once and settles its loading state', async () => { @@ -128,6 +148,71 @@ describe('desktop target lifecycle', () => { await waitFor(() => expect(open).not.toHaveAttribute('data-loading')); }); + it('uses doctor, exact MCP config, and supervised server controls for installed surfaces', async () => { + const operational = { + ...managedProfile, + frontend, + surfaces: ['worker', 'browser', 'mcp', 'server'], + validation_error: null, + }; + const state = { profiles: [operational], selected_profile_id: operational.id, issues: [] }; + mocks.targetSetupState.mockResolvedValue(state); + mocks.recheckTargetState.mockResolvedValue(state); + const user = userEvent.setup(); + renderApp(); + + await screen.findByRole('heading', { name: 'Managed VidXP' }); + await waitFor(() => expect(mocks.localServerStatus).toHaveBeenCalled()); + await waitFor(() => expect(mocks.localWorkerStatus).toHaveBeenCalled()); + await waitFor(() => expect(mocks.browserServiceStatus).toHaveBeenCalled()); + + await user.click(screen.getByRole('button', { name: 'Start processing' })); + expect(mocks.startLocalWorker).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Stop processing' })); + expect(mocks.stopLocalWorker).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Check readiness' })); + expect(await screen.findByText('VidXP is ready')).toBeVisible(); + expect(mocks.targetDoctor).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Set up connection' })); + expect(await screen.findByRole('heading', { name: 'Connect an AI assistant' })).toBeVisible(); + expect(mocks.mcpClientConfig).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Share browser' })); + expect(await screen.findByText('http://192.168.1.20:8501')).toBeVisible(); + expect(mocks.startSharedBrowser).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Stop sharing' })); + expect(mocks.stopBrowserService).toHaveBeenCalledTimes(1); + + await user.click(screen.getByRole('button', { name: 'Start locally' })); + expect(await screen.findByText('http://127.0.0.1:32191/mcp')).toBeVisible(); + expect(mocks.startLocalServer).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Share service' })); + expect(await screen.findByText('http://192.168.1.20:32191/mcp')).toBeVisible(); + expect(mocks.startSharedServer).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole('button', { name: 'Stop service' })); + expect(mocks.stopLocalServer).toHaveBeenCalledTimes(1); + }); + + it('adds optional features to the selected existing installation', async () => { + const updated = { ...localProfile, surfaces: ['worker', 'browser', 'mcp'] }; + const updatedState = { profiles: [updated], selected_profile_id: updated.id, issues: [] }; + mocks.targetSetupState.mockResolvedValue(localState); + mocks.recheckTargetState.mockResolvedValue(localState); + mocks.configureExternalInstallation.mockResolvedValue(updatedState); + const user = userEvent.setup(); + renderApp(); + + await user.click(await screen.findByRole('button', { name: 'Setup options' })); + expect(await screen.findByRole('heading', { name: 'Change features for this installation' })).toBeVisible(); + await user.click(screen.getByRole('checkbox', { name: /AI assistant integration/i })); + await user.click(screen.getByRole('button', { name: 'Apply changes' })); + + await waitFor(() => expect(mocks.configureExternalInstallation).toHaveBeenCalledWith([], ['worker', 'browser', 'mcp'])); + expect(await screen.findByRole('button', { name: 'Set up connection' })).toBeVisible(); + }); + it('uses the parent exclusive operation while browser startup is pending', async () => { const browserManaged = { ...managedProfile, @@ -143,9 +228,9 @@ describe('desktop target lifecycle', () => { const open = await screen.findByRole('button', { name: 'Open VidXP' }); await user.click(open); expect(open).toHaveAttribute('data-loading'); - expect(screen.getByRole('button', { name: 'Manage targets' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Recheck target' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Manage setup' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Switch installation' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Check connection' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Setup options' })).toBeDisabled(); opening.resolve(); await waitFor(() => expect(open).not.toHaveAttribute('data-loading')); }); @@ -153,22 +238,24 @@ describe('desktop target lifecycle', () => { it('adopts the inspected candidate without an installation action', async () => { mocks.activateLocalTarget.mockResolvedValue(localState); const user = userEvent.setup(); renderApp(); await enterLocal(user); - await user.click(await screen.findByRole('radio', { name: /VidXP executable/i })); + await user.click(await screen.findByRole('radio', { name: /VidXP installation/i })); await user.click(await screen.findByRole('button', { name: 'Use this installation' })); expect(mocks.inspectLocalTarget).toHaveBeenCalledTimes(1); expect(mocks.activateLocalTarget).toHaveBeenCalledTimes(1); expect(mocks.installRuntime).not.toHaveBeenCalled(); + expect(mocks.configureExternalInstallation).not.toHaveBeenCalled(); }); it('keeps fresh discovery fields authoritative while retaining inspection UI', async () => { const user = userEvent.setup(); renderApp(); await enterLocal(user); - await user.click(await screen.findByRole('radio', { name: /VidXP executable/i })); - await screen.findByText('Compatible contracts.'); + await user.click(await screen.findByRole('radio', { name: /VidXP installation/i })); + await screen.findByText('This VidXP installation is ready to connect.'); mocks.discoverLocalTargets.mockResolvedValue([{ executable: 'C:\\Tools\\VidXP\\vidxp.exe', display_path: 'C:\\New\\display.exe', source: 'Fresh scan' }]); await user.click(screen.getByRole('button', { name: 'Scan again' })); + await user.click(await screen.findByText('Technical details')); expect(await screen.findByText('C:\\New\\display.exe')).toBeVisible(); - expect(screen.getByText('Compatible contracts.')).toBeVisible(); - expect(screen.getByText('Discovered via Fresh scan')).toBeVisible(); + expect(screen.getByText('This VidXP installation is ready to connect.')).toBeVisible(); + expect(screen.getAllByText('Found on this computer')).toHaveLength(2); }); it('cancels managed setup back to the still-selected target', async () => { @@ -177,10 +264,10 @@ describe('desktop target lifecycle', () => { mocks.beginManagedSetup.mockResolvedValue({ id: 'draft-1', previous_profile_id: localProfile.id }); mocks.cancelManagedSetup.mockResolvedValue(localState); const user = userEvent.setup(); renderApp(); - await user.click(await screen.findByRole('button', { name: 'Manage targets' })); + await user.click(await screen.findByRole('button', { name: 'Switch installation' })); await user.click(screen.getByRole('radio', { name: /Set up VidXP for me/i })); await user.click(screen.getByRole('button', { name: 'Continue' })); - await user.click(screen.getByRole('button', { name: 'Continue to setup' })); + await user.click(screen.getByRole('button', { name: 'Choose features' })); await user.click(await screen.findByRole('button', { name: 'Back' })); expect(mocks.cancelManagedSetup).toHaveBeenCalledWith('draft-1'); expect(await screen.findByRole('heading', { name: 'Studio VidXP' })).toBeVisible(); @@ -193,10 +280,10 @@ describe('desktop target lifecycle', () => { mocks.selectTargetProfile.mockResolvedValue({ ...state, selected_profile_id: saved.id }); mocks.deleteTargetProfile.mockResolvedValue({ profiles: [saved], selected_profile_id: saved.id, issues: [] }); const user = userEvent.setup(); renderApp(); - await user.click(await screen.findByRole('button', { name: 'Manage targets' })); + await user.click(await screen.findByRole('button', { name: 'Switch installation' })); await user.click(screen.getAllByRole('button', { name: 'Select' }).find((button) => !button.hasAttribute('disabled'))!); expect(mocks.selectTargetProfile).toHaveBeenCalledWith(saved.id); - await user.click(screen.getByRole('button', { name: 'Manage targets' })); + await user.click(screen.getByRole('button', { name: 'Switch installation' })); await user.click(screen.getAllByRole('button', { name: 'Forget' })[0]); expect(mocks.deleteTargetProfile).toHaveBeenCalled(); }); @@ -207,7 +294,7 @@ describe('desktop target lifecycle', () => { mocks.targetSetupState.mockResolvedValue(invalidState); mocks.recheckTargetState.mockResolvedValue(invalidState); const user = userEvent.setup(); renderApp(); expect(await screen.findByText('Timed out.')).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Recheck target' })); + await user.click(screen.getByRole('button', { name: 'Check connection' })); expect(mocks.recheckTargetState).toHaveBeenCalledTimes(2); }); @@ -215,8 +302,8 @@ describe('desktop target lifecycle', () => { const setup = { profiles: [managedProfile], selected_profile_id: managedProfile.id, issues: [] }; mocks.targetSetupState.mockResolvedValue(setup); mocks.recheckTargetState.mockResolvedValue(setup); renderApp(); - expect(await screen.findByText('Unavailable · return to managed setup to enable the browser surface')).toBeVisible(); - expect(screen.getByRole('button', { name: 'Manage setup' })).toBeEnabled(); + expect(await screen.findByText('The browser interface is not enabled')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Setup options' })).toBeEnabled(); }); it('keeps ready managed settings read-only until a draft is dirty, then offers Apply and Reset', async () => { @@ -224,9 +311,9 @@ describe('desktop target lifecycle', () => { const user = userEvent.setup(); renderApp(); await enterManaged(user); const apply = await screen.findByRole('button', { name: 'Apply update' }); expect(apply).toBeDisabled(); - await user.click(screen.getByRole('checkbox', { name: /Browser interface/i })); + await user.click(screen.getByRole('checkbox', { name: /VidXP app|Browser interface/i })); expect(apply).toBeEnabled(); - expect(screen.getByText(/installed runtime remains active while Desktop creates/i)).toBeVisible(); + expect(screen.getByText(/switches to the updated setup only after/i)).toBeVisible(); await user.click(screen.getByRole('button', { name: 'Reset changes' })); expect(apply).toBeDisabled(); await waitFor(() => expect(mocks.modelDirectoryInventory).toHaveBeenCalledTimes(2)); @@ -248,7 +335,8 @@ describe('desktop target lifecycle', () => { const user = userEvent.setup(); renderApp(); await enterManaged(user); expect(screen.getByRole('checkbox', { name: /Visual scene search/i })).toBeChecked(); expect(screen.getByRole('checkbox', { name: /Actor recognition/i })).not.toBeChecked(); - expect(screen.getByRole('checkbox', { name: /Browser interface/i })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /VidXP app|Browser interface/i })).not.toBeChecked(); + await user.click(screen.getByText('Storage location')); expect(screen.getByText('D:\\CustomModels')).toBeVisible(); expect(screen.getByRole('button', { name: 'Repair VidXP' })).toBeEnabled(); }); @@ -269,20 +357,34 @@ describe('desktop target lifecycle', () => { const user = userEvent.setup(); renderApp(); await enterManaged(user); expect(screen.getByRole('checkbox', { name: /Actor recognition/i })).toBeChecked(); expect(screen.getByRole('checkbox', { name: /Visual scene search/i })).toBeChecked(); - expect(screen.getByRole('checkbox', { name: /Browser interface/i })).toBeChecked(); - expect(screen.getByText(/cannot recover settings from the unreadable pointer/i)).toBeVisible(); - await user.click(screen.getByRole('button', { name: 'Configure replacement' })); + expect(screen.getByRole('checkbox', { name: /VidXP app|Browser interface/i })).toBeChecked(); + expect(screen.getByText(/could not read the saved setup/i)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Rebuild VidXP' })); await waitFor(() => expect(mocks.installRuntime).toHaveBeenCalledWith(expect.objectContaining({ capabilities: expect.arrayContaining(['actor', 'scene']), surfaces: ['browser'], draft_id: 'draft-1', }))); - expect(screen.queryByText('Select at least one capability.')).not.toBeInTheDocument(); + expect(screen.queryByText('Select at least one search feature.')).not.toBeInTheDocument(); + }); + + it('optionally includes MCP and local sharing in a managed installation', async () => { + const user = userEvent.setup(); + renderApp(); + await enterManaged(user); + + await user.click(screen.getByRole('checkbox', { name: /AI assistant integration/i })); + await user.click(screen.getByRole('checkbox', { name: /App integration service/i })); + await user.click(screen.getByRole('button', { name: 'Install VidXP' })); + + expect(mocks.installRuntime).toHaveBeenCalledWith(expect.objectContaining({ + surfaces: ['worker', 'browser', 'mcp', 'server'], + })); }); it('passes the scoped draft through first-time installation and does not auto-open the browser', async () => { const user = userEvent.setup(); renderApp(); await enterManaged(user); - await user.click(await screen.findByRole('button', { name: 'Configure VidXP' })); + await user.click(await screen.findByRole('button', { name: 'Install VidXP' })); await waitFor(() => expect(mocks.installRuntime).toHaveBeenCalledWith(expect.objectContaining({ draft_id: 'draft-1' }))); expect(mocks.installMediaRuntime).toHaveBeenCalledWith('draft-1'); expect(mocks.launchUi).not.toHaveBeenCalled(); @@ -292,14 +394,14 @@ describe('desktop target lifecycle', () => { const pending = deferred<{ id: string; previous_profile_id: null }>(); mocks.beginManagedSetup.mockReturnValue(pending.promise); const user = userEvent.setup(); renderApp(); - await screen.findByRole('heading', { name: 'Where should VidXP run?' }); + await screen.findByRole('heading', { name: 'How would you like to set up VidXP?' }); await user.click(screen.getByRole('radio', { name: /Set up VidXP for me/i })); await user.click(screen.getByRole('button', { name: 'Continue' })); - const continueButton = screen.getByRole('button', { name: 'Continue to setup' }); + const continueButton = screen.getByRole('button', { name: 'Choose features' }); await user.dblClick(continueButton); expect(mocks.beginManagedSetup).toHaveBeenCalledTimes(1); pending.resolve({ id: 'draft-1', previous_profile_id: null }); - expect(await screen.findByRole('heading', { name: 'Set up local processing' })).toBeVisible(); + expect(await screen.findByRole('heading', { name: 'Choose your VidXP features' })).toBeVisible(); }); it('freezes managed controls and coalesces Apply while a replacement is running', async () => { @@ -307,16 +409,16 @@ describe('desktop target lifecycle', () => { const media = deferred<{ ready: boolean }>(); mocks.installMediaRuntime.mockReturnValue(media.promise); const user = userEvent.setup(); renderApp(); await enterManaged(user); - const browser = screen.getByRole('checkbox', { name: /Browser interface/i }); + const browser = screen.getByRole('checkbox', { name: /VidXP app|Browser interface/i }); await user.click(browser); const apply = screen.getByRole('button', { name: 'Apply update' }); await user.dblClick(apply); expect(mocks.installMediaRuntime).toHaveBeenCalledTimes(1); expect(screen.getByRole('button', { name: 'Back' })).toBeDisabled(); expect(browser).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Choose folder…' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Change location…' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Reset changes' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Prepare / verify models' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Check downloaded models' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Open VidXP' })).toBeDisabled(); media.resolve({ ready: true }); expect(await screen.findByRole('heading', { name: 'Managed VidXP' })).toBeVisible(); @@ -328,11 +430,11 @@ describe('desktop target lifecycle', () => { mocks.recheckTargetState.mockResolvedValue(setup); mocks.runtimeStatus.mockResolvedValue({ state: 'ready', ready: true, runtime_profile: 'runtime-a', package_version: '0.4.0', capabilities: ['scene'], surfaces: [], model_directory: 'C:\\Models', detail: 'Ready.' }); const user = userEvent.setup(); renderApp(); - await user.click(await screen.findByRole('button', { name: 'Manage setup' })); - await user.click(screen.getByRole('button', { name: 'Continue to setup' })); - await screen.findByRole('heading', { name: 'Set up local processing' }); + await user.click(await screen.findByRole('button', { name: 'Setup options' })); + await user.click(screen.getByRole('button', { name: 'Choose features' })); + await screen.findByRole('heading', { name: 'Choose your VidXP features' }); expect(screen.getByRole('button', { name: 'Apply update' })).toBeDisabled(); - await user.click(screen.getByRole('button', { name: 'Prepare / verify models' })); + await user.click(screen.getByRole('button', { name: 'Check downloaded models' })); expect(mocks.prepareManagedModels).toHaveBeenCalledWith('draft-1'); }); @@ -346,10 +448,10 @@ describe('desktop target lifecycle', () => { mocks.recheckTargetState.mockResolvedValue(setup); mocks.runtimeStatus.mockResolvedValue({ state: 'ready', ready: true, runtime_profile: 'runtime-a', package_version: '0.4.0', capabilities: ['scene'], surfaces: ['browser'], model_directory: 'C:\\Models', detail: 'Ready.' }); const user = userEvent.setup(); renderApp(); - await user.click(await screen.findByRole('button', { name: 'Manage targets' })); + await user.click(await screen.findByRole('button', { name: 'Switch installation' })); await enterManaged(user); - expect(screen.getByText(/another target is currently selected/i)).toBeVisible(); - expect(screen.getByRole('button', { name: 'Prepare / verify models' })).toBeDisabled(); + expect(screen.getByText(/not your active installation/i)).toBeVisible(); + expect(screen.getByRole('button', { name: 'Check downloaded models' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Open VidXP' })).toBeDisabled(); expect(mocks.prepareManagedModels).not.toHaveBeenCalled(); expect(mocks.launchUi).not.toHaveBeenCalled(); @@ -357,7 +459,7 @@ describe('desktop target lifecycle', () => { it('uses the committed install state without a fallible status refresh', async () => { const user = userEvent.setup(); renderApp(); await enterManaged(user); - await user.click(screen.getByRole('button', { name: 'Configure VidXP' })); + await user.click(screen.getByRole('button', { name: 'Install VidXP' })); expect(await screen.findByRole('heading', { name: 'Managed VidXP' })).toBeVisible(); expect(mocks.runtimeStatus).toHaveBeenCalledTimes(1); expect(mocks.targetSetupState).toHaveBeenCalledTimes(1); @@ -371,7 +473,7 @@ describe('desktop target lifecycle', () => { mocks.recheckTargetState.mockResolvedValue(state); mocks.selectTargetProfile.mockReturnValue(selection.promise); const user = userEvent.setup(); renderApp(); - await user.click(await screen.findByRole('button', { name: 'Manage targets' })); + await user.click(await screen.findByRole('button', { name: 'Switch installation' })); const select = screen.getAllByRole('button', { name: 'Select' }).find((button) => !button.hasAttribute('disabled'))!; await user.dblClick(select); expect(mocks.selectTargetProfile).toHaveBeenCalledTimes(1); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index c06d714..1de0591 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { Alert, Badge, Button, Group, Loader, Stack, Text, ThemeIcon, Title } from '@mantine/core'; +import { Alert, Badge, Button, Code, Group, Loader, Stack, Text, ThemeIcon, Title } from '@mantine/core'; import { IconAlertCircle, IconArrowLeft, IconDownload, IconTrash } from '@tabler/icons-react'; import { useCallback, useEffect, useReducer, useRef } from 'react'; @@ -104,7 +104,7 @@ export function App() { } catch (error) { settleOperation(current, { type: 'operationFailed', - failure: errorMessage(error, 'The active target could not be checked.'), + failure: errorMessage(error, 'VidXP could not check the active installation.'), }); } }, [settleOperation, startOperation]); @@ -123,7 +123,7 @@ export function App() { if (!mounted) return; dispatch({ type: 'loadFailed', - failure: errorMessage(error, 'VidXP Desktop could not load its target profiles.'), + failure: errorMessage(error, 'VidXP Desktop could not load your installations.'), }); }); return () => { @@ -172,7 +172,7 @@ export function App() { } catch (error) { settleOperation(current, { type: 'operationFailed', - failure: errorMessage(error, 'The saved target could not be selected.'), + failure: errorMessage(error, 'The saved installation could not be selected.'), }); } } @@ -187,7 +187,7 @@ export function App() { } catch (error) { settleOperation(current, { type: 'operationFailed', - failure: errorMessage(error, 'The saved target could not be forgotten.'), + failure: errorMessage(error, 'The saved installation could not be removed.'), }); } } @@ -214,23 +214,23 @@ export function App() {
{state.failure && } color="red" title="Desktop issue" role="alert" mb="lg">{state.failure}} {state.setup?.issues.map((issue) => {issue.message})} - {state.stage === 'loading' &&
Restoring your VidXP target…
} + {state.stage === 'loading' &&
Loading your VidXP setup…
} {state.stage === 'choice' && <> - {profile && } + {profile && } {state.setup && state.setup.profiles.length > 0 &&
- Saved targets{state.setup.profiles.length} + Saved installations{state.setup.profiles.length} {state.setup.profiles.map((saved) => -
{saved.display_name}{saved.id === state.setup?.selected_profile_id ? ' · Active' : ''}{saved.kind === 'managed' ? 'Desktop managed' : 'Externally managed'} · {saved.display_executable}
+
{saved.display_name}{saved.id === state.setup?.selected_profile_id ? ' · Active' : ''}{saved.kind === 'managed' ? 'Managed by VidXP' : 'Managed by you'}
Location{saved.display_executable}
{saved.kind !== 'managed' && }
)}
} dispatch({ type: 'choice', choice })} onContinue={() => dispatch({ type: 'navigate', stage: state.choice === 'existing_local' ? 'local' : 'managed-confirm' })} /> } {state.stage === 'local' && dispatch({ type: 'navigate', stage: 'choice' })} onActivated={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} />} - {state.stage === 'managed-confirm' &&
CONFIRM MANAGED SETUPLet VidXP Desktop manage a private runtime?Your active target stays available until the replacement is installed, validated, and activated.
} + {state.stage === 'managed-confirm' &&
SET UP VIDXPInstall and manage VidXP on this computer?You choose the features. VidXP checks the new setup before switching to it, so your current installation stays available.
} {state.stage === 'managed' && state.draft && dispatch({ type: 'operationSettled', setup, draft: null, stage: 'summary' })} />} - {state.stage === 'summary' && profile && recheck()} onManageManaged={() => dispatch({ type: 'navigate', stage: 'managed-confirm' })} onChooseAnother={() => dispatch({ type: 'navigate', stage: 'choice', choice: null })} onOpen={openBrowser} />} -
Target metadata stays private to VidXP Desktop.Credentials are never stored in this setup profile.
+ {state.stage === 'summary' && profile && recheck()} onManageManaged={() => dispatch({ type: 'navigate', stage: 'managed-confirm' })} onSetupChanged={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} onChooseAnother={() => dispatch({ type: 'navigate', stage: 'choice', choice: null })} onOpen={openBrowser} />} +
Your VidXP settings stay on this computer.Desktop only stops services that it starts.
); diff --git a/desktop/src/components/LocalSetup.tsx b/desktop/src/components/LocalSetup.tsx index 0584d35..6be770f 100644 --- a/desktop/src/components/LocalSetup.tsx +++ b/desktop/src/components/LocalSetup.tsx @@ -108,7 +108,7 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { ))); } catch (error) { if (candidateGeneration.current.get(path) !== generation) return; - const message = errorMessage(error, 'This executable could not be inspected.'); + const message = errorMessage(error, 'This VidXP installation could not be checked.'); setCandidates((current) => current.map((candidate) => ( candidatePath(candidate) === path ? { ...candidate, checking: false, inspection: undefined, inspectionError: message } @@ -134,7 +134,7 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { setSelectedPath(path); void checkCandidate(path); } catch (error) { - setFailure(errorMessage(error, 'The selected executable could not be opened.')); + setFailure(errorMessage(error, 'The selected VidXP installation could not be opened.')); } finally { setBusy(null); } @@ -151,7 +151,7 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { ); onActivated(setup); } catch (error) { - setFailure(errorMessage(error, 'The inspected target could not be activated.')); + setFailure(errorMessage(error, 'This VidXP installation could not be connected.')); } finally { setBusy(null); } @@ -163,29 +163,29 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) {
EXISTING INSTALLATION Connect this desktop to VidXP - Choose an installation to check whether it supports this Desktop. Checking and connecting it will not modify it. + Choose the VidXP installation you already use. Connecting it here will not change or update it.
Found on this computer - Select one candidate to check it, or browse to another executable. + Select an installation, or browse if yours is not listed.
{busy === 'discover' && candidates.length === 0 ? ( -
Looking for VidXP executables…
+
Looking for VidXP installations…
) : candidates.length > 0 ? ( - + {candidates.map((candidate) => { const path = candidatePath(candidate); const selected = selectedPath === path; const inspection = candidate.inspection; const validation = inspection?.validation; - const title = inspection?.reported_version ? `VidXP ${inspection.reported_version}` : 'VidXP executable'; + const title = inspection?.reported_version ? `VidXP ${inspection.reported_version}` : 'VidXP installation'; const color = inspection?.state === 'ready_to_use' ? 'teal' : inspection?.state === 'update_required' ? 'yellow' : inspection?.state === 'cannot_start' || candidate.inspectionError ? 'red' : 'gray'; return (
@@ -199,43 +199,44 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { {candidate.checking ? 'Checking…' : inspection ? stateLabel[inspection.state] : candidate.inspectionError ? 'Cannot start' : 'Found'} - {candidateDisplayPath(candidate)} - {candidate.checking ? 'Checking compatibility…' : inspection || candidate.inspectionError ? candidate.source && `Discovered via ${candidate.source}` : 'Not checked'} + {candidate.checking ? 'Checking installed features…' : inspection || candidate.inspectionError ? candidate.source && `Found on this computer` : 'Select to check'}
{selected && candidate.checking && ( -
Checking identity, probe, and launch compatibility…
+
Checking this VidXP installation…
)} {selected && candidate.inspectionError && ( )} {selected && inspection && (
- {inspection.message} -
- Desktop probe{inspection.probe_compatible ? `Compatible · protocol ${validation?.protocol_version ?? 'supported'}` : 'Unavailable or incompatible'} - Launch contract{inspection.launch_compatible ? `Compatible · protocol ${validation?.launch_protocol_version ?? 'supported'}` : 'Not accepted'} - {validation?.python_version && <>Python{validation.python_version}} - {validation?.display_data_root && <>Data root{validation.display_data_root}} - {validation?.frontend && <>Browser interface{validation.frontend.launchable ? 'Available' : 'Unavailable'}} -
+ {inspection.adoptable ? 'This VidXP installation is ready to connect.' : inspection.message} + {validation?.surfaces && {validation.surfaces.map((surface) => {surface === 'worker' ? 'Local video processing' : surface === 'browser' ? 'Browser interface' : surface === 'mcp' ? 'AI assistant integration' : surface === 'server' ? 'App integration service' : surface})}} +
+ Technical details + {candidateDisplayPath(candidate)} +
+ Compatibility check{inspection.probe_compatible ? `Supported · version ${validation?.protocol_version ?? 'current'}` : 'Not supported'} + App connection{inspection.launch_compatible ? `Supported · version ${validation?.launch_protocol_version ?? 'current'}` : 'Not supported'} + {validation?.python_version && <>Python{validation.python_version}} + {validation?.display_data_root && <>Data location{validation.display_data_root}} +
+ {inspection.technical_details && {inspection.technical_details}} +
{inspection.remediation && What to do: {inspection.remediation}} {validation?.can_launch_frontend === false && ( - - Usable: This installation remains available through its own command-line workflows. - Missing: {validation.frontend?.message} - Enable it: {validation.frontend?.remediation} + + You can still connect this installation and use its other features. After connecting, open Setup options to add the browser interface. )} - {inspection.technical_details &&
Technical details{inspection.technical_details}
} {inspection.adoptable && validation && ( - setDisplayName(event.currentTarget.value)} /> + setDisplayName(event.currentTarget.value)} /> @@ -248,13 +249,13 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { ) : ( -
+
)} - +
- {busy === 'activate' &&
Saving and activating this target…
} + {busy === 'activate' &&
Connecting this VidXP installation…
} {failure && } ); diff --git a/desktop/src/components/ManagedSetup.tsx b/desktop/src/components/ManagedSetup.tsx index d041715..e00b747 100644 --- a/desktop/src/components/ManagedSetup.tsx +++ b/desktop/src/components/ManagedSetup.tsx @@ -49,7 +49,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o const [modelDirectory, setModelDirectory] = useState(''); const [inventory, setInventory] = useState(null); const [operation, setOperation] = useState('load'); - const [message, setMessage] = useState('Loading managed runtime options…'); + const [message, setMessage] = useState('Loading VidXP options…'); const [failure, setFailure] = useState(null); const operations = useExclusiveOperation(); const initialLoad = useRef item !== value)); } + function toggleSurface(id: string, checked: boolean) { + toggleValue(id, checked, setSurfaces, surfaces); + if (id === 'worker' && checked && manifest) { + setCapabilities(Object.keys(manifest.capabilities)); + } + } + + function toggleCapability(id: string, checked: boolean) { + toggleValue(id, checked, setCapabilities, capabilities); + if (!checked && surfaces.includes('worker')) { + setSurfaces(surfaces.filter((surface) => surface !== 'worker')); + } + } + async function chooseFolder() { const operationId = beginOperation('folder'); if (operationId === null) return; @@ -146,7 +160,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o async function install() { if (capabilities.length === 0) { - setFailure('Select at least one capability.'); + setFailure('Select at least one search feature.'); return; } const operationId = beginOperation('install'); @@ -166,20 +180,20 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o const repaired = await runtimeStatus(); if (repaired.ready) { setStatus(repaired); - setMessage('The managed runtime is ready.'); + setMessage('VidXP is ready.'); return; } } setMessage( captured.prepare_models - ? 'Creating the managed runtime, verifying cached models, and downloading anything missing…' - : 'Creating the managed runtime…', + ? 'Installing VidXP and preparing the selected search features…' + : 'Installing VidXP…', ); const result = await installRuntime(captured); - setMessage(result.install.prepared ? 'Runtime and selected models are ready.' : 'Runtime ready. Model downloads were deferred.'); + setMessage(result.install.prepared ? 'VidXP and the selected search features are ready.' : 'VidXP is installed. Search files can be downloaded later.'); onCommitted(result.setup); } catch (error) { - setFailure(errorMessage(error, 'Managed setup failed. The previous target remains authoritative; any completed replacement runtime is retained for recovery.')); + setFailure(errorMessage(error, 'Setup did not finish. Your previous VidXP installation is unchanged.')); } finally { settleOperation(operationId); } @@ -211,7 +225,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o setFailure('Models were prepared, but the cache inventory could not be refreshed.'); } } catch (error) { - setFailure(errorMessage(error, 'Model preparation failed. The installed runtime remains active.')); + setFailure(errorMessage(error, 'The search files could not be prepared. Your installed VidXP remains available.')); } finally { settleOperation(operationId); } @@ -248,7 +262,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o } const isBusy = operation !== null; - const attentionTitle = /ffmpeg|ffprobe/i.test(message) ? 'Media tools required' : 'Managed runtime needs attention'; + const attentionTitle = /ffmpeg|ffprobe/i.test(message) ? 'Video tools need attention' : 'VidXP needs attention'; function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; @@ -266,9 +280,9 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o
- DESKTOP-MANAGED RUNTIME - Set up local processing - VidXP Desktop owns this private runtime. Installation starts only when you confirm below. + SETUP OPTIONS + Choose your VidXP features + Choose what VidXP can search, where video work runs, and how you want to open or connect to it. You can change these later.
{!manifest ? ( @@ -276,7 +290,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o ) : (
- Capabilities + Search features
{Object.entries(manifest.capabilities).map(([id, capability]) => ( { if (!isBusy) toggleValue(id, !capabilities.includes(id), setCapabilities, capabilities); }} + onClick={() => { if (!isBusy) toggleCapability(id, !capabilities.includes(id)); }} > - + ))}
- Interface + Video processing + + {Object.entries(manifest.surfaces).filter(([id]) => id === 'worker').map(([id, surface]) => ( + toggleSurface(id, event.currentTarget.checked)} label={surface.label} description={surface.description} /> + ))} + +
+ +
+ Interfaces and integrations - {Object.entries(manifest.surfaces).map(([id, surface]) => ( - toggleValue(id, event.currentTarget.checked, setSurfaces, surfaces)} label={surface.label} description={surface.description} /> + {Object.entries(manifest.surfaces).filter(([id]) => id !== 'worker').map(([id, surface]) => ( + toggleSurface(id, event.currentTarget.checked)} label={surface.label} description={surface.description} /> ))}
-
Model storage{modelDirectory ? displayPath(modelDirectory) : 'Using the default location'}
- +
Downloaded model storageVidXP keeps the files needed by your selected search features here.{modelDirectory &&
Storage location{displayPath(modelDirectory)}
}
+
{operation === 'load' || operation === 'folder' || operation === 'reset' ? ( @@ -326,7 +349,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o {inventory.recognized_models.length > 0 && ( {inventory.recognized_models.map((model) => {model.label})} )} - Cached files detected; verification required. VidXP will reuse valid cached files and download only missing material. + VidXP will reuse files that are ready and download only what is missing. ) : null}
@@ -344,26 +367,26 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o className="managedAttention" icon={
- {desktopSurfaceUnavailable && ( + {needsRuntimeUpdate && ( + + This Desktop version cannot safely read or manage the features in the selected Python installation. + + + )} + {!needsRuntimeUpdate && desktopSurfaceUnavailable && ( Other installed features are still available. Open Setup options to add the browser interface. )} - {validationError && ( + {validationError && !needsRuntimeUpdate && ( {validationError.message}
Technical details{validationError.code}
@@ -276,12 +311,13 @@ export function TargetSummary({ profile, validationError, checking, operationPen
Status{validationError ? 'Needs attention' : 'Connected'} - Available{[ + Available{runtimeCompatible ? [ workerAvailable && 'local video processing', !desktopSurfaceUnavailable && 'browser interface', mcpAvailable && 'AI assistant integration', serverAvailable && 'app integration service', - ].filter(Boolean).join(', ') || 'Command-line tools'} + ].filter(Boolean).join(', ') || 'Command-line tools' : 'Available after the installation is updated'} + Search features{runtimeCompatible ? profile.capabilities.map(capabilityLabel).join(', ') || 'None installed' : 'Unknown until the installation is updated'} {profile.last_validated_at && <>Last checked{new Date(profile.last_validated_at).toLocaleString()}}
@@ -310,13 +346,13 @@ export function TargetSummary({ profile, validationError, checking, operationPen -
Readiness checkMake sure video tools, search features, and downloaded models are ready to use.
- +
Readiness checkChecks FFmpeg plus the packages and downloaded models required by each installed search feature.
+
{doctor && ( - + 0 ? 'teal' : 'yellow'} title={doctor.ok ? doctor.modalities.length > 0 ? 'VidXP is ready' : 'Video tools are ready' : `${failedChecks.length} item${failedChecks.length === 1 ? '' : 's'} need attention`}> {doctor.ok - ? 'Your installed search features and media tools are ready.' + ? doctor.modalities.length > 0 ? `Ready search features: ${doctor.modalities.map(capabilityLabel).join(', ')}.` : 'No search features were reported, so this result covers only the shared video tools.' : <>Open setup to repair a managed installation, or use your installer to repair an external one.
See check details{failedChecks.map((check) => {check.error || `${check.name} is unavailable.`})}
}
)} @@ -372,8 +408,26 @@ export function TargetSummary({ profile, validationError, checking, operationPen + { if (busy !== 'doctor') setReadinessOpened(false); }} title={busy === 'doctor' ? 'Checking VidXP readiness' : 'VidXP readiness'} size="lg" closeOnClickOutside={busy !== 'doctor'} closeOnEscape={busy !== 'doctor'}> + {busy === 'doctor' ? + Checking the selected installation… {readinessElapsed}s + VidXP is inspecting video tools, installed search packages, and model files. It does not download or change anything, and stops after three minutes if it cannot finish. +
Search features expected from this installation{profile.capabilities.map(capabilityLabel).join(', ') || 'The installed runtime will report these as part of the check.'}
+
: doctor ? + {doctor.modalities.length > 0 + ? Search features checked: {doctor.modalities.map(capabilityLabel).join(', ')}. + : Only the shared video tools were reported by this installation.} + {doctor.checks.map((check) => +
{check.name}{capabilityLabel(check.capability)} · {check.kind === 'model' ? 'Downloaded model' : 'Installed package or video tool'}{check.error && {check.error}}
+ {check.ok ? 'Ready' : 'Needs attention'} +
)} + +
: null} +
+ { if (busy === null) setExternalSetupOpened(false); }} title="Change features for this installation" size="lg" closeOnClickOutside={busy === null} closeOnEscape={busy === null}> Choose what VidXP can search, where video work runs, and how other apps can connect. + {needsRuntimeUpdate && externalManifest && VidXP will update this same installation from {profile.observed_vidxp_version} to {externalManifest.package_version}, then apply the selected features. It will not create a Desktop-managed copy.} {externalFailure && {externalFailure}{externalTechnical &&
Technical details{externalTechnical}
}
} {!externalManifest ? : Search features @@ -389,10 +443,10 @@ export function TargetSummary({ profile, validationError, checking, operationPen return { const checked = event.currentTarget.checked; setExternalSurfaces((current) => checked ? [...current, id] : current.filter((value) => value !== id)); }} label={surface.label} description={surface.description} />; })} } - VidXP reinstalls this isolated app environment at its current version with the selected features. It does not replace it with a Desktop-managed copy. + {!needsRuntimeUpdate && VidXP reinstalls this isolated app environment at its current compatible version with the selected features. It does not replace it with a Desktop-managed copy.} - +
diff --git a/docs/desktop.md b/docs/desktop.md index 7ab73a7..c0f1d35 100644 --- a/docs/desktop.md +++ b/docs/desktop.md @@ -115,7 +115,11 @@ The target-first screen offers two paths: browser, AI-assistant, or app-integration features. For an isolated uv-tool installation, the bundled `uv` uses `uv tool install --force` to recreate the tool environment from the complete - selected extra set while pinning its reported VidXP and Python versions. The + selected extra set while retaining its compatible VidXP and Python versions. + If its probe contract predates the Desktop management contract, Desktop stops + presenting the missing fields as disabled features and instead offers an + explicit in-place update to the package version in the bundled runtime + manifest. A newer, unsupported probe requires a Desktop update. The target is stopped only if Desktop launched its UI/API child, then re-probed after the package operation. Other external environment types remain under their original package manager. @@ -140,6 +144,11 @@ disabled, then resolves that package's selected extras. Beta and stable desktop releases use production PyPI for both steps, so a pinned prerelease and its normal dependencies come from one authoritative index. TestPyPI is used only for package-only nightly validation and is never a desktop runtime source. +The release contract classifies prerelease versions as beta and ordinary +versions as stable, and the bundled manifest pins the matching Python runtime. +This release does not include an automatic Desktop updater, so there is not yet +a user-facing update-channel enrollment preference; adding one belongs with the +signed updater rather than the Python runtime selector. Windows and Linux resolve CPU-only PyTorch wheels using uv's `--torch-backend cpu`; macOS uses native PyPI wheels. The custom PyTorch index is therefore a resolver input and is not embedded as a package URL, avoiding From 37b7409f6bf588789f2d47734d362143fdd0cf0a Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 5 Aug 2026 17:55:42 +0500 Subject: [PATCH 3/4] feat(desktop): expose runtime controls in tray --- desktop/src-tauri/src/lib.rs | 588 +++++++++++++++++++++++++---- desktop/src-tauri/src/lifecycle.rs | 14 + 2 files changed, 521 insertions(+), 81 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 16de508..2f4a7e2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use tauri::{ AppHandle, Manager, RunEvent, WindowEvent, - menu::{Menu, MenuItem}, + menu::{Menu, MenuItem, PredefinedMenuItem, Submenu}, tray::TrayIconBuilder, }; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; @@ -415,6 +415,22 @@ struct LocalWorkerStatus { detail: String, } +#[derive(Clone)] +struct TrayMenuItems { + installation: MenuItem, + browser: Submenu, + open_browser: MenuItem, + share_browser: MenuItem, + stop_browser: MenuItem, + worker: Submenu, + start_worker: MenuItem, + stop_worker: MenuItem, + server: Submenu, + start_server: MenuItem, + share_server: MenuItem, + stop_server: MenuItem, +} + struct DesktopState { ui_process: Mutex>, api_process: Mutex>, @@ -425,6 +441,8 @@ struct DesktopState { shutdown: background_process::CancellationToken, shutdown_started: AtomicBool, active_operations: Arc, + worker_status: Mutex)>>, + tray_menu: Mutex>, } impl Default for DesktopState { @@ -439,6 +457,8 @@ impl Default for DesktopState { shutdown: background_process::CancellationToken::default(), shutdown_started: AtomicBool::new(false), active_operations: Arc::new(ActiveOperations::default()), + worker_status: Mutex::new(None), + tray_menu: Mutex::new(None), } } } @@ -2017,13 +2037,14 @@ async fn refresh_target_state( let desktop_version = manifest.desktop_version; let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::Revalidate)?; let cancellation = state.shutdown.clone(); - tauri::async_runtime::spawn_blocking(move || { + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { let _transition = transition; - match target_profiles::selected_profile(&app) { + match target_profiles::selected_profile(&worker_app) { Ok(profile) => { if profile.kind == target_profiles::TargetKind::Managed { let validation = (|| { - let paths = desktop_paths(&app).map_err(|message| { + let paths = desktop_paths(&worker_app).map_err(|message| { transition_error( target_profiles::TargetErrorCode::ManagedRuntimeUnavailable, message, @@ -2051,10 +2072,10 @@ async fn refresh_target_state( Some(&cancellation), ) })(); - let _ = target_profiles::persist_selected_validation(&app, validation); + let _ = target_profiles::persist_selected_validation(&worker_app, validation); } else { let _ = target_profiles::validated_selected_profile_with_cancellation( - &app, + &worker_app, &desktop_version, Some(&cancellation), ); @@ -2064,13 +2085,15 @@ async fn refresh_target_state( if error.code == target_profiles::TargetErrorCode::SelectedProfileMissing => {} Err(error) => return Err(error), } - target_profiles::current_state(&app) + target_profiles::current_state(&worker_app) }) .await .map_err(|error| target_profiles::TargetError { code: target_profiles::TargetErrorCode::ValidationRequired, message: format!("Target revalidation stopped unexpectedly: {error}"), - })? + })??; + refresh_tray_for_selected_target(&app); + Ok(result) } #[tauri::command] @@ -2144,7 +2167,8 @@ async fn adopt_local_target( let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::Adopt)?; let desktop_version = manifest.desktop_version; let cancellation = state.shutdown.clone(); - tauri::async_runtime::spawn_blocking(move || { + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { let _transition = transition; let canonical = fs::canonicalize(Path::new(&executable)).unwrap_or_else(|_| PathBuf::from(&executable)); @@ -2154,9 +2178,9 @@ async fn adopt_local_target( Some(&cancellation), |path| Command::new(path), )?; - let setup = target_profiles::adopt_validated(&app, validated, display_name)?; - stop_ui_process(&app.state::()); - stop_api_process(&app.state::()); + let setup = target_profiles::adopt_validated(&worker_app, validated, display_name)?; + stop_ui_process(&worker_app.state::()); + stop_api_process(&worker_app.state::()); Ok(setup) }) .await @@ -2165,7 +2189,9 @@ async fn adopt_local_target( target_profiles::TargetErrorCode::ValidationRequired, format!("Target adoption stopped unexpectedly: {error}"), ) - })? + })??; + refresh_tray_for_selected_target(&app); + Ok(result) } #[tauri::command] @@ -2182,9 +2208,10 @@ async fn select_target_profile( let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::Select)?; let desktop_version = manifest.desktop_version; let cancellation = state.shutdown.clone(); - tauri::async_runtime::spawn_blocking(move || { + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { let _transition = transition; - let candidate = target_profiles::current_state(&app)? + let candidate = target_profiles::current_state(&worker_app)? .profiles .into_iter() .find(|profile| profile.id == profile_id) @@ -2195,7 +2222,7 @@ async fn select_target_profile( ) })?; let setup = if candidate.kind == target_profiles::TargetKind::Managed { - let paths = desktop_paths(&app).map_err(|message| { + let paths = desktop_paths(&worker_app).map_err(|message| { transition_error( target_profiles::TargetErrorCode::ManagedRuntimeUnavailable, message, @@ -2220,12 +2247,12 @@ async fn select_target_profile( &desktop_version, Some(&cancellation), )?; - target_profiles::select_validated_profile(&app, &profile_id, validated)? + target_profiles::select_validated_profile(&worker_app, &profile_id, validated)? } else { - target_profiles::select_profile(&app, &profile_id, &desktop_version)? + target_profiles::select_profile(&worker_app, &profile_id, &desktop_version)? }; - stop_ui_process(&app.state::()); - stop_api_process(&app.state::()); + stop_ui_process(&worker_app.state::()); + stop_api_process(&worker_app.state::()); Ok(setup) }) .await @@ -2234,7 +2261,9 @@ async fn select_target_profile( target_profiles::TargetErrorCode::ValidationRequired, format!("Target selection stopped unexpectedly: {error}"), ) - })? + })??; + refresh_tray_for_selected_target(&app); + Ok(result) } #[tauri::command] @@ -2245,13 +2274,14 @@ async fn delete_target_profile( ) -> Result { let _active = track_target_operation(&state)?; let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::Delete)?; - tauri::async_runtime::spawn_blocking(move || { + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { let _transition = transition; - let selected = target_profiles::current_state(&app)?.selected_profile_id; - let result = target_profiles::delete_profile(&app, &profile_id)?; + let selected = target_profiles::current_state(&worker_app)?.selected_profile_id; + let result = target_profiles::delete_profile(&worker_app, &profile_id)?; if selected.as_deref() == Some(&profile_id) { - stop_ui_process(&app.state::()); - stop_api_process(&app.state::()); + stop_ui_process(&worker_app.state::()); + stop_api_process(&worker_app.state::()); } Ok(result) }) @@ -2261,7 +2291,9 @@ async fn delete_target_profile( target_profiles::TargetErrorCode::ValidationRequired, format!("Target deletion stopped unexpectedly: {error}"), ) - })? + })??; + refresh_tray_for_selected_target(&app); + Ok(result) } #[tauri::command] @@ -2881,6 +2913,7 @@ async fn install_runtime( stop_ui_process(&state); stop_api_process(&state); transition.commit_draft(); + refresh_tray_for_selected_target(&app); Ok(InstallTransitionResult { install: InstallResult { @@ -3056,11 +3089,7 @@ fn stop_ui_process(state: &DesktopState) { } } -#[tauri::command] -fn browser_service_status( - state: tauri::State<'_, DesktopState>, -) -> Result { - let _active = state.active_operations.register()?; +fn inspect_browser_service(state: &DesktopState) -> Result { let mut active = state .ui_process .lock() @@ -3081,25 +3110,46 @@ fn browser_service_status( } #[tauri::command] -async fn start_shared_browser( +fn browser_service_status( app: AppHandle, state: tauri::State<'_, DesktopState>, ) -> Result { let _active = state.active_operations.register()?; - tauri::async_runtime::spawn_blocking(move || { - let state = app.state::(); - start_ui(&app, &state, true) + let status = inspect_browser_service(&state)?; + refresh_tray_menu(&app); + Ok(status) +} + +async fn start_browser_mode(app: AppHandle, shared: bool) -> Result { + let state = app.state::(); + let _active = state.active_operations.register()?; + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let state = worker_app.state::(); + start_ui(&worker_app, &state, shared) }) .await - .map_err(|error| format!("Browser sharing startup stopped unexpectedly: {error}"))? + .map_err(|error| format!("Browser sharing startup stopped unexpectedly: {error}"))?; + refresh_tray_menu(&app); + result +} + +#[tauri::command] +async fn start_shared_browser( + app: AppHandle, + _state: tauri::State<'_, DesktopState>, +) -> Result { + start_browser_mode(app, true).await } #[tauri::command] fn stop_browser_service( + app: AppHandle, state: tauri::State<'_, DesktopState>, ) -> Result { let _active = state.active_operations.register()?; stop_ui_process(&state); + refresh_tray_menu(&app); Ok(stopped_browser_status("The browser interface was stopped.")) } @@ -3227,7 +3277,7 @@ async fn configure_external_installation( && selected_surfaces == profile.surfaces && selected_capabilities == profile.capabilities { - return Ok(target_profiles::current_state(&app).map_err(|error| error.to_string())?); + return target_profiles::current_state(&app).map_err(|error| error.to_string()); } let runtime = profile.runtime.as_ref().ok_or_else(|| { "The selected installation did not report its Python environment.".to_string() @@ -3286,7 +3336,9 @@ async fn configure_external_installation( Some(&state.shutdown), ) .map_err(|error| error.to_string())?; - target_profiles::current_state(&app).map_err(|error| error.to_string()) + let result = target_profiles::current_state(&app).map_err(|error| error.to_string())?; + refresh_tray_for_selected_target(&app); + Ok(result) } #[tauri::command] @@ -3349,37 +3401,57 @@ fn execute_worker_action(app: &AppHandle, action: &str) -> Result, +) { + if let Ok(mut current) = state.worker_status.lock() { + *current = Some((profile_id, status.clone())); + } +} + +async fn run_worker_action( + app: AppHandle, + action: &'static str, +) -> Result { + let state = app.state::(); + let _active = state.active_operations.register()?; + let profile_id = target_profiles::selected_profile(&app) + .map_err(|error| error.to_string())? + .id; + let worker_app = app.clone(); + let result = + tauri::async_runtime::spawn_blocking(move || execute_worker_action(&worker_app, action)) + .await + .map_err(|error| format!("Local processing action stopped unexpectedly: {error}"))?; + remember_worker_status(&state, profile_id, &result); + refresh_tray_menu(&app); + result +} + #[tauri::command] async fn local_worker_status( app: AppHandle, - state: tauri::State<'_, DesktopState>, + _state: tauri::State<'_, DesktopState>, ) -> Result { - let _active = state.active_operations.register()?; - tauri::async_runtime::spawn_blocking(move || execute_worker_action(&app, "worker-status")) - .await - .map_err(|error| format!("Local processing status stopped unexpectedly: {error}"))? + run_worker_action(app, "worker-status").await } #[tauri::command] async fn start_local_worker( app: AppHandle, - state: tauri::State<'_, DesktopState>, + _state: tauri::State<'_, DesktopState>, ) -> Result { - let _active = state.active_operations.register()?; - tauri::async_runtime::spawn_blocking(move || execute_worker_action(&app, "start-worker")) - .await - .map_err(|error| format!("Local processing startup stopped unexpectedly: {error}"))? + run_worker_action(app, "start-worker").await } #[tauri::command] async fn stop_local_worker( app: AppHandle, - state: tauri::State<'_, DesktopState>, + _state: tauri::State<'_, DesktopState>, ) -> Result { - let _active = state.active_operations.register()?; - tauri::async_runtime::spawn_blocking(move || execute_worker_action(&app, "stop-worker")) - .await - .map_err(|error| format!("Local processing shutdown stopped unexpectedly: {error}"))? + run_worker_action(app, "stop-worker").await } fn http_health_is_ready(host: &str, port: u16) -> bool { @@ -3449,9 +3521,7 @@ fn stop_api_process(state: &DesktopState) { } } -#[tauri::command] -fn local_server_status(state: tauri::State<'_, DesktopState>) -> Result { - let _active = state.active_operations.register()?; +fn inspect_local_server(state: &DesktopState) -> Result { let mut active = state .api_process .lock() @@ -3476,6 +3546,17 @@ fn local_server_status(state: tauri::State<'_, DesktopState>) -> Result, +) -> Result { + let _active = state.active_operations.register()?; + let status = inspect_local_server(&state)?; + refresh_tray_menu(&app); + Ok(status) +} + fn api_service_command( profile: &target_profiles::TargetProfile, paths: &DesktopPaths, @@ -3608,40 +3689,44 @@ fn start_server_mode( Ok(status) } -async fn start_server( - app: AppHandle, - state: tauri::State<'_, DesktopState>, - shared: bool, -) -> Result { +async fn start_server(app: AppHandle, shared: bool) -> Result { + let state = app.state::(); let _active = state.active_operations.register()?; - tauri::async_runtime::spawn_blocking(move || { - let state = app.state::(); - start_server_mode(&app, &state, shared) + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let state = worker_app.state::(); + start_server_mode(&worker_app, &state, shared) }) .await - .map_err(|error| format!("Local service startup stopped unexpectedly: {error}"))? + .map_err(|error| format!("Local service startup stopped unexpectedly: {error}"))?; + refresh_tray_menu(&app); + result } #[tauri::command] async fn start_local_server( app: AppHandle, - state: tauri::State<'_, DesktopState>, + _state: tauri::State<'_, DesktopState>, ) -> Result { - start_server(app, state, false).await + start_server(app, false).await } #[tauri::command] async fn start_shared_server( app: AppHandle, - state: tauri::State<'_, DesktopState>, + _state: tauri::State<'_, DesktopState>, ) -> Result { - start_server(app, state, true).await + start_server(app, true).await } #[tauri::command] -fn stop_local_server(state: tauri::State<'_, DesktopState>) -> Result { +fn stop_local_server( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { let _active = state.active_operations.register()?; stop_api_process(&state); + refresh_tray_menu(&app); Ok(stopped_server_status( "The Desktop-owned API and MCP service was stopped.", )) @@ -3747,20 +3832,26 @@ async fn open_ui_in_browser(app: AppHandle) -> Result<(), String> { let _active = state.active_operations.register()?; let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::OpenBrowser) .map_err(|error| error.to_string())?; - let worker_app = app.clone(); - let status = tauri::async_runtime::spawn_blocking(move || { - let _transition = transition; - let state = worker_app.state::(); - start_ui(&worker_app, &state, false) - }) - .await - .map_err(|error| format!("VidXP interface startup stopped unexpectedly: {error}"))??; + let current = inspect_browser_service(&state)?; + let status = if current.running { + current + } else { + let worker_app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let _transition = transition; + let state = worker_app.state::(); + start_ui(&worker_app, &state, false) + }) + .await + .map_err(|error| format!("VidXP interface startup stopped unexpectedly: {error}"))?? + }; let url = status .local_url .ok_or_else(|| "VidXP did not report its local browser address.".to_string())?; app.opener() .open_url(&url, None::<&str>) .map_err(|error| format!("Could not open VidXP in the default browser: {error}"))?; + refresh_tray_menu(&app); hide_main_window(&app) } @@ -3782,10 +3873,208 @@ fn open_browser_or_show_manager(app: &AppHandle) { }); } +fn current_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(u64::MAX) +} + +fn tray_installation_label(profile: Option<&target_profiles::TargetProfile>, now: u64) -> String { + let Some(profile) = profile else { + return "No VidXP installation selected".into(); + }; + let state = match profile.validation_error.as_ref().map(|error| &error.code) { + Some(target_profiles::TargetErrorCode::RuntimeUpdateRequired) => "Update required", + Some(_) => "Needs attention", + None if !profile.is_ready(now) => "Check required", + None => "Ready", + }; + format!("{} · {state}", profile.display_name) +} + +fn tray_browser_label(status: &BrowserServiceStatus) -> String { + if !status.running { + return "Browser interface · Stopped".into(); + } + if status.shared { + return format!( + "Browser interface · Shared · {}", + status + .network_url + .as_deref() + .unwrap_or("address unavailable") + ); + } + format!( + "Browser interface · Private · {}", + status.local_url.as_deref().unwrap_or("address unavailable") + ) +} + +fn tray_server_label(status: &LocalServerStatus) -> String { + if !status.running { + return "App integration service · Stopped".into(); + } + format!( + "App integration service · {} · {}", + if status.shared { "Shared" } else { "Private" }, + status.origin.as_deref().unwrap_or("address unavailable") + ) +} + +fn refresh_tray_menu(app: &AppHandle) { + let state = app.state::(); + let items = state.tray_menu.lock().ok().and_then(|items| items.clone()); + let Some(items) = items else { + return; + }; + let target_state = target_profiles::current_state(app).ok(); + let profile = target_state + .as_ref() + .and_then(target_profiles::TargetState::selected_profile); + let ready = profile.is_some_and(|profile| profile.is_ready(current_unix_seconds())); + let browser_available = ready && profile.is_some_and(|profile| profile.frontend.launchable); + let worker_available = ready + && profile + .is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "worker")); + let server_available = ready + && profile + .is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "server")); + let browser = inspect_browser_service(&state) + .unwrap_or_else(|error| stopped_browser_status(format!("Status unavailable: {error}"))); + let server = inspect_local_server(&state) + .unwrap_or_else(|error| stopped_server_status(format!("Status unavailable: {error}"))); + let worker = profile.and_then(|profile| { + state.worker_status.lock().ok().and_then(|cached| { + cached + .as_ref() + .filter(|(profile_id, _)| profile_id == &profile.id) + .map(|(_, status)| status.clone()) + }) + }); + + let _ = items + .installation + .set_text(tray_installation_label(profile, current_unix_seconds())); + let _ = items.browser.set_text(tray_browser_label(&browser)); + let _ = items.browser.set_enabled(browser_available); + let _ = items.open_browser.set_enabled(browser_available); + let _ = items + .share_browser + .set_enabled(browser_available && !browser.shared); + let _ = items.stop_browser.set_enabled(browser.running); + + let worker_label = match worker.as_ref() { + Some(Ok(status)) if status.running => "Local video processing · Running", + Some(Ok(_)) => "Local video processing · Stopped", + Some(Err(_)) => "Local video processing · Needs attention", + None => "Local video processing · Checking…", + }; + let _ = items.worker.set_text(worker_label); + let _ = items.worker.set_enabled(worker_available); + let _ = items.start_worker.set_enabled( + worker_available + && worker + .as_ref() + .is_some_and(|status| status.as_ref().is_ok_and(|status| !status.running)), + ); + let _ = items.stop_worker.set_enabled( + worker + .as_ref() + .is_some_and(|status| status.as_ref().is_ok_and(|status| status.running)), + ); + + let _ = items.server.set_text(tray_server_label(&server)); + let _ = items.server.set_enabled(server_available); + let _ = items.start_server.set_text(if server.shared { + "Make private" + } else { + "Start privately" + }); + let _ = items + .start_server + .set_enabled(server_available && (!server.running || server.shared)); + let _ = items + .share_server + .set_enabled(server_available && !server.shared); + let _ = items.stop_server.set_enabled(server.running); +} + +fn refresh_tray_for_selected_target(app: &AppHandle) { + let state = app.state::(); + if let Ok(mut worker) = state.worker_status.lock() { + *worker = None; + } + refresh_tray_menu(app); + let profile = target_profiles::selected_profile(app).ok(); + if profile.is_some_and(|profile| { + profile.is_ready(current_unix_seconds()) + && profile.surfaces.iter().any(|surface| surface == "worker") + }) { + let status_app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = run_worker_action(status_app, "worker-status").await; + }); + } +} + +fn show_tray_action_error(app: &AppHandle, error: String) { + show_main_window(app); + app.dialog() + .message(error) + .title("VidXP action could not complete") + .kind(MessageDialogKind::Error) + .blocking_show(); +} + +fn perform_service_action(app: &AppHandle, action: DesktopAction) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let result = match action { + DesktopAction::ShareBrowser => start_browser_mode(app.clone(), true).await.map(|_| ()), + DesktopAction::StopBrowser => { + let state = app.state::(); + state.active_operations.register().map(|_active| { + stop_ui_process(&state); + refresh_tray_menu(&app); + }) + } + DesktopAction::StartWorker => run_worker_action(app.clone(), "start-worker") + .await + .map(|_| ()), + DesktopAction::StopWorker => run_worker_action(app.clone(), "stop-worker") + .await + .map(|_| ()), + DesktopAction::StartServer => start_server(app.clone(), false).await.map(|_| ()), + DesktopAction::ShareServer => start_server(app.clone(), true).await.map(|_| ()), + DesktopAction::StopServer => { + let state = app.state::(); + state.active_operations.register().map(|_active| { + stop_api_process(&state); + refresh_tray_menu(&app); + }) + } + DesktopAction::Manage | DesktopAction::OpenBrowser | DesktopAction::Quit => Ok(()), + }; + if let Err(error) = result { + refresh_tray_menu(&app); + show_tray_action_error(&app, error); + } + }); +} + fn perform_desktop_action(app: &AppHandle, action: DesktopAction) { match action { DesktopAction::Manage => show_main_window(app), DesktopAction::OpenBrowser => open_browser_or_show_manager(app), + DesktopAction::ShareBrowser + | DesktopAction::StopBrowser + | DesktopAction::StartWorker + | DesktopAction::StopWorker + | DesktopAction::StartServer + | DesktopAction::ShareServer + | DesktopAction::StopServer => perform_service_action(app, action), DesktopAction::Quit => begin_shutdown(app), } } @@ -3831,10 +4120,95 @@ async fn launch_ui(app: AppHandle) -> Result<(), String> { } fn create_tray(app: &tauri::App) -> tauri::Result<()> { + let installation = MenuItem::with_id( + app, + "installation-status", + "VidXP installation", + false, + None::<&str>, + )?; let open = MenuItem::with_id(app, "open", "Open VidXP", true, None::<&str>)?; + let share_browser = MenuItem::with_id( + app, + "share-browser", + "Share on local network", + true, + None::<&str>, + )?; + let stop_browser = MenuItem::with_id( + app, + "stop-browser", + "Stop browser interface", + false, + None::<&str>, + )?; + let browser = Submenu::with_items( + app, + "Browser interface", + true, + &[&share_browser, &stop_browser], + )?; + let start_worker = + MenuItem::with_id(app, "start-worker", "Start processing", false, None::<&str>)?; + let stop_worker = + MenuItem::with_id(app, "stop-worker", "Stop processing", false, None::<&str>)?; + let worker = Submenu::with_items( + app, + "Local video processing", + true, + &[&start_worker, &stop_worker], + )?; + let start_server = + MenuItem::with_id(app, "start-server", "Start privately", true, None::<&str>)?; + let share_server = MenuItem::with_id( + app, + "share-server", + "Share on local network", + true, + None::<&str>, + )?; + let stop_server = MenuItem::with_id(app, "stop-server", "Stop service", false, None::<&str>)?; + let server = Submenu::with_items( + app, + "App integration service", + true, + &[&start_server, &share_server, &stop_server], + )?; let manage = MenuItem::with_id(app, "manage", "Manage VidXP", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Quit VidXP", true, None::<&str>)?; - let menu = Menu::with_items(app, &[&open, &manage, &quit])?; + let separator = PredefinedMenuItem::separator(app)?; + let separator_two = PredefinedMenuItem::separator(app)?; + let menu = Menu::with_items( + app, + &[ + &installation, + &separator, + &open, + &browser, + &worker, + &server, + &separator_two, + &manage, + &quit, + ], + )?; + let items = TrayMenuItems { + installation, + browser, + open_browser: open, + share_browser, + stop_browser, + worker, + start_worker, + stop_worker, + server, + start_server, + share_server, + stop_server, + }; + if let Ok(mut current) = app.state::().tray_menu.lock() { + *current = Some(items); + } let mut tray = TrayIconBuilder::with_id("vidxp") .tooltip("VidXP") .menu(&menu) @@ -3850,6 +4224,7 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> { tray = tray.icon(icon.clone()); } tray.build(app)?; + refresh_tray_for_selected_target(app.handle()); Ok(()) } @@ -4777,7 +5152,7 @@ mod tests { } #[test] - fn tray_manage_browser_and_quit_actions_are_unambiguous() { + fn tray_service_actions_are_unambiguous() { assert_eq!( action_for_activation(DesktopActivation::Tray("manage")), Some(DesktopAction::Manage) @@ -4786,6 +5161,20 @@ mod tests { action_for_activation(DesktopActivation::Tray("open")), Some(DesktopAction::OpenBrowser) ); + for (id, expected) in [ + ("share-browser", DesktopAction::ShareBrowser), + ("stop-browser", DesktopAction::StopBrowser), + ("start-worker", DesktopAction::StartWorker), + ("stop-worker", DesktopAction::StopWorker), + ("start-server", DesktopAction::StartServer), + ("share-server", DesktopAction::ShareServer), + ("stop-server", DesktopAction::StopServer), + ] { + assert_eq!( + action_for_activation(DesktopActivation::Tray(id)), + Some(expected) + ); + } assert_eq!( action_for_activation(DesktopActivation::Tray("quit")), Some(DesktopAction::Quit) @@ -4796,6 +5185,43 @@ mod tests { ); } + #[test] + fn tray_service_labels_surface_scope_and_addresses() { + let browser = super::BrowserServiceStatus { + state: "ready", + running: true, + shared: true, + port: Some(43124), + local_url: Some("http://127.0.0.1:43124".into()), + network_url: Some("http://192.168.1.20:43124".into()), + detail: String::new(), + }; + let server = super::LocalServerStatus { + state: "ready", + running: true, + shared: false, + port: Some(43125), + origin: Some("http://127.0.0.1:43125".into()), + health_url: Some("http://127.0.0.1:43125/health".into()), + mcp_url: Some("http://127.0.0.1:43125/mcp".into()), + bearer_token: None, + detail: String::new(), + }; + + assert_eq!( + super::tray_browser_label(&browser), + "Browser interface · Shared · http://192.168.1.20:43124" + ); + assert_eq!( + super::tray_server_label(&server), + "App integration service · Private · http://127.0.0.1:43125" + ); + assert_eq!( + super::tray_installation_label(None, 0), + "No VidXP installation selected" + ); + } + #[test] fn repeated_browser_actions_reuse_one_service_and_target_changes_replace_it() { assert_eq!( diff --git a/desktop/src-tauri/src/lifecycle.rs b/desktop/src-tauri/src/lifecycle.rs index 7d7ece0..a4acdbf 100644 --- a/desktop/src-tauri/src/lifecycle.rs +++ b/desktop/src-tauri/src/lifecycle.rs @@ -14,6 +14,13 @@ pub(crate) enum UiProcessAction { pub(crate) enum DesktopAction { Manage, OpenBrowser, + ShareBrowser, + StopBrowser, + StartWorker, + StopWorker, + StartServer, + ShareServer, + StopServer, Quit, } @@ -37,6 +44,13 @@ pub(crate) fn action_for_activation(activation: DesktopActivation<'_>) -> Option } DesktopActivation::Tray("manage") => Some(DesktopAction::Manage), DesktopActivation::Tray("open") => Some(DesktopAction::OpenBrowser), + DesktopActivation::Tray("share-browser") => Some(DesktopAction::ShareBrowser), + DesktopActivation::Tray("stop-browser") => Some(DesktopAction::StopBrowser), + DesktopActivation::Tray("start-worker") => Some(DesktopAction::StartWorker), + DesktopActivation::Tray("stop-worker") => Some(DesktopAction::StopWorker), + DesktopActivation::Tray("start-server") => Some(DesktopAction::StartServer), + DesktopActivation::Tray("share-server") => Some(DesktopAction::ShareServer), + DesktopActivation::Tray("stop-server") => Some(DesktopAction::StopServer), DesktopActivation::Tray("quit") => Some(DesktopAction::Quit), DesktopActivation::Tray(_) => None, } From c9503ceaebd7e8674dabe0b9b05fdae5dd0baa2b Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 5 Aug 2026 22:45:22 +0500 Subject: [PATCH 4/4] fix(desktop): embed Windows manifest in tests --- desktop/src-tauri/build.rs | 20 +++++++++++++++++++- desktop/src-tauri/windows-app-manifest.xml | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/windows-app-manifest.xml diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index f24596f..4f9553e 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -92,5 +92,23 @@ fn main() { println!("cargo:rerun-if-changed=../../uv.lock"); println!("cargo:rerun-if-changed=../runtime-manifest.json"); - tauri_build::build() + let mut attributes = tauri_build::Attributes::new(); + #[cfg(windows)] + { + attributes = attributes + .windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()); + add_windows_manifest(); + } + tauri_build::try_build(attributes).expect("Tauri build configuration must be valid") +} + +#[cfg(windows)] +fn add_windows_manifest() { + let manifest = std::path::PathBuf::from( + std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo must provide CARGO_MANIFEST_DIR"), + ) + .join("windows-app-manifest.xml"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); } diff --git a/desktop/src-tauri/windows-app-manifest.xml b/desktop/src-tauri/windows-app-manifest.xml new file mode 100644 index 0000000..2d510ed --- /dev/null +++ b/desktop/src-tauri/windows-app-manifest.xml @@ -0,0 +1,14 @@ + + + + + + +