diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 16e174d..ec754d2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,9 @@ ## Summary -- Describe the user-facing outcome. +- Describe the user-facing outcome and any compatibility or migration impact. +- If the change is internal-only, say so. ## Validation -- List the commands or checks run. - -## Changelog - -- [ ] Added `changes/..md` -- [ ] This is dependency maintenance, or it is internal-only and the PR - explains why a maintainer should apply `skip-changelog`: +- List the exact commands or checks run and the real boundaries exercised. +- Do not replace results with “tests passed.” diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 8b48c9b..0afcf30 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -12,6 +12,11 @@ on: required: false default: 14 type: number + sign: + description: Sign and notarize the macOS build (requires Apple secrets). + required: false + default: false + type: boolean workflow_dispatch: inputs: checkout_ref: @@ -24,6 +29,11 @@ on: required: false default: 14 type: number + sign: + description: Sign and notarize the macOS build (requires Apple secrets). + required: false + default: false + type: boolean permissions: contents: read @@ -116,9 +126,54 @@ jobs: - name: Test the locked desktop crate run: cargo test --release --locked --manifest-path desktop/src-tauri/Cargo.toml - - name: Build the unsigned desktop installer + - name: Prepare the Apple notarization key + id: apple_key + if: runner.os == 'macOS' && inputs.sign + shell: bash + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + if [[ -z "$APPLE_API_KEY_P8" ]]; then + echo "::error::inputs.sign is true but Apple signing secrets are missing." >&2 + exit 1 + fi + key_path="$RUNNER_TEMP/apple_api_key.p8" + printf '%s' "$APPLE_API_KEY_P8" | openssl base64 -d -A > "$key_path" + echo "path=$key_path" >> "$GITHUB_OUTPUT" + + - name: Build the desktop installer + working-directory: desktop + env: + VIDXP_DESKTOP_SIGN: ${{ (runner.os == 'macOS' && inputs.sign) && '1' || '' }} + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_PATH: ${{ steps.apple_key.outputs.path }} run: npm run desktop:build + + - name: Verify signing and notarization + if: runner.os == 'macOS' && inputs.sign working-directory: desktop + shell: bash + run: | + dmg="$(find src-tauri/target/release/bundle/dmg -maxdepth 1 -name '*.dmg' | head -n1)" + [[ -n "$dmg" ]] + # Tauri removes the intermediate .app after packaging the DMG. + codesign --verify --strict --verbose=2 "$dmg" + mountpoint="$(mktemp -d "$RUNNER_TEMP/vidxp-dmg.XXXXXX")" + trap 'hdiutil detach "$mountpoint" -quiet >/dev/null 2>&1 || true; rmdir "$mountpoint" >/dev/null 2>&1 || true' EXIT + hdiutil attach "$dmg" -readonly -nobrowse -mountpoint "$mountpoint" + app="$(find "$mountpoint" -maxdepth 1 -name '*.app' | head -n1)" + [[ -n "$app" ]] + # The .app is signed (Developer ID), hardened-runtime, notarized + stapled. + codesign --verify --strict --verbose=2 "$app" + codesign -dvv "$app" 2>&1 | grep -i 'Authority=Developer ID Application' + codesign -dvv "$app" 2>&1 | grep -iE 'flags=.*runtime' + xcrun stapler validate "$app" + spctl -a -t exec -vv "$app" - name: Verify the Windows app uses the GUI subsystem if: runner.os == 'Windows' diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index 835ea17..d8bccb9 100644 --- a/.github/workflows/release-candidate.yml +++ b/.github/workflows/release-candidate.yml @@ -111,6 +111,8 @@ jobs: with: artifact_retention_days: 30 checkout_ref: ${{ inputs.head_sha }} + sign: true + secrets: inherit containers: needs: contract diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 7d9c8c3..5c330ee 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -11,6 +11,7 @@ on: - reopened permissions: + actions: write contents: none statuses: write @@ -20,9 +21,13 @@ jobs: steps: - name: Classify the pull request without checking out its code env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PULL_REQUEST: ${{ github.event.pull_request.number }} TARGET_URL: ${{ github.event.pull_request.html_url }} shell: bash run: | @@ -38,3 +43,15 @@ jobs: -f context=release/candidate \ -f description="$description" \ -f target_url="$TARGET_URL" + + if [[ "$state" == "pending" && + "$HEAD_REPOSITORY" == "$GITHUB_REPOSITORY" && + "$GITHUB_ACTOR" != "github-actions[bot]" ]]; then + gh workflow run release-candidate.yml \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$BASE_REF" \ + -f pull_request="$PULL_REQUEST" \ + -f target_branch="$BASE_REF" \ + -f base_sha="$BASE_SHA" \ + -f head_sha="$HEAD_SHA" + fi diff --git a/.github/workflows/release-to-test-pypi.yml b/.github/workflows/release-to-test-pypi.yml index 2cc019e..e2b97b6 100644 --- a/.github/workflows/release-to-test-pypi.yml +++ b/.github/workflows/release-to-test-pypi.yml @@ -103,6 +103,7 @@ jobs: - name: Download and install the exact TestPyPI artifact shell: bash run: | + download_succeeded=false for _ in {1..30}; do state="$( python utils/verify_published_distribution.py \ @@ -111,17 +112,18 @@ jobs: --version "${{ needs.build.outputs.version }}" \ --dist dist )" - [[ "$state" == "identical" ]] && break + if [[ "$state" == "identical" ]] && python -m pip download \ + --index-url https://test.pypi.org/simple \ + --no-deps \ + --no-cache-dir \ + --dest downloaded \ + "vidxp==${{ needs.build.outputs.version }}"; then + download_succeeded=true + break + fi sleep 10 done - [[ "$state" == "identical" ]] - - python -m pip download \ - --index-url https://test.pypi.org/simple \ - --no-deps \ - --no-cache-dir \ - --dest downloaded \ - "vidxp==${{ needs.build.outputs.version }}" + [[ "$download_succeeded" == "true" ]] [[ "$(sha256sum dist/*.whl | cut -d' ' -f1)" == \ "$(sha256sum downloaded/*.whl | cut -d' ' -f1)" ]] python -m venv .testpypi-smoke diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7c056a0..8a47d03 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.4.0-b.2" + ".": "0.4.0-b.3" } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5fb1c80 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Contributor agent guide + +VidXP is a Python application with CLI, HTTP, MCP, and Desktop surfaces that +share the same application contracts. Read +[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) before making changes. + +## Working in the repository + +- Keep pull requests focused on one outcome and preserve unrelated work. +- Put shared behavior in the application or control plane; keep CLI, HTTP, + MCP, and Desktop code as thin adapters. +- Keep capability-specific models, schemas, dependencies, indexing, and search + logic under `src/vidxp/capabilities/`. +- Do not commit generated environments, model weights, media, indexes, build + outputs, or local data. +- Follow the Conventional Commit and release-note rules in the contributing + guide. State explicitly when a change is internal-only. + +## Validation + +Run the smallest relevant checks while developing, then the applicable checks +from the contributing guide before submitting a pull request. Common checks: + +```bash +uv run --no-sync ruff check . +uv run --no-sync pytest -q +npm --prefix desktop run check +``` + +Do not describe mocked tests as end-to-end validation. Report the exact +commands run and any required validation that could not be completed. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a944e2..03ab060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## [0.4.0-b.3](https://github.com/grayhatdevelopers/vidxp/compare/v0.4.0-b.2...v0.4.0-b.3) (2026-08-05) + + +### Features + +* **desktop:** add guided setup and local service management ([#97](https://github.com/grayhatdevelopers/vidxp/issues/97)) ([8e779a3](https://github.com/grayhatdevelopers/vidxp/commit/8e779a3364670aab833668956a289415eac8652b)) + + +### Bug Fixes + +* **desktop:** install managed runtimes from macOS Application Support paths ([f5187b7](https://github.com/grayhatdevelopers/vidxp/commit/f5187b7905c3dde27863c0ef9659909dc04000c7)) +* **desktop:** provide a signed and notarized macOS installer ([4074332](https://github.com/grayhatdevelopers/vidxp/commit/40743321907c5393dc66a7e7d21a95e8dc3e602e)) + ## [0.4.0-b.2](https://github.com/grayhatdevelopers/vidxp/compare/v0.4.0-b.1...v0.4.0-b.2) (2026-08-03) diff --git a/CODEX.md b/CODEX.md new file mode 100644 index 0000000..b5c2fc5 --- /dev/null +++ b/CODEX.md @@ -0,0 +1,14 @@ +# Codex contributor notes + +Start with [`AGENTS.md`](AGENTS.md) and +[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md). + +1. Inspect the affected implementation, shared contracts, and existing tests. +2. Make the smallest coherent change at the correct architecture boundary. +3. Reuse repository tooling and generated-file workflows instead of manually + recreating derived artifacts. +4. Run validation appropriate to the changed surface. +5. Summarize the outcome, exact validation performed, and remaining risks. + +Ask before introducing a new dependency, migration, public contract change, or +architecture direction that is not already established by the repository. diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 9ffa4e9..3b0243a 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,49 @@ 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 compatible VidXP and Python versions with the complete selected extra set, + then rechecks it. If the saved installation predates the required management + contract, Desktop offers to update that same uv-tool environment to the runtime + version bundled with the Desktop release before applying the chosen features. + It does not interpret fields missing from an older probe as disabled features. + 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/package-lock.json b/desktop/package-lock.json index 83613ab..d4f6e82 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "vidxp-desktop", - "version": "0.4.0-b.2", + "version": "0.4.0-b.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vidxp-desktop", - "version": "0.4.0-b.2", + "version": "0.4.0-b.3", "dependencies": { "@mantine/core": "9.5.0", "@tabler/icons-react": "3.46.0", diff --git a/desktop/package.json b/desktop/package.json index 01cc86c..caa6492 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "vidxp-desktop", "private": true, - "version": "0.4.0-b.2", + "version": "0.4.0-b.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/runtime-manifest.json b/desktop/runtime-manifest.json index fa12363..f43b85a 100644 --- a/desktop/runtime-manifest.json +++ b/desktop/runtime-manifest.json @@ -1,17 +1,35 @@ { "schema_version": 1, - "desktop_version": "0.4.0-b.2", + "desktop_version": "0.4.0-b.3", "package_name": "vidxp", - "package_version": "0.4.0-b.2", + "package_version": "0.4.0-b.3", "dependency_index": "https://pypi.org/simple", "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/scripts/build-desktop.mjs b/desktop/scripts/build-desktop.mjs index 9de4071..9ae495b 100644 --- a/desktop/scripts/build-desktop.mjs +++ b/desktop/scripts/build-desktop.mjs @@ -39,11 +39,21 @@ const executable = resolve( ".bin", process.platform === "win32" ? "tauri.cmd" : "tauri", ); -const result = spawnSync( - executable, - ["build", "--bundles", bundleSpec.bundle, "--ci", "--no-sign", "--", "--locked"], - { shell: process.platform === "win32", stdio: "inherit" }, -); + +// Sign + notarize only when explicitly requested on macOS (release builds). +const signMacos = + process.platform === "darwin" && process.env.VIDXP_DESKTOP_SIGN === "1"; + +const buildArgs = ["build", "--bundles", bundleSpec.bundle, "--ci"]; +if (!signMacos) { + buildArgs.push("--no-sign"); +} +buildArgs.push("--", "--locked"); + +const result = spawnSync(executable, buildArgs, { + shell: process.platform === "win32", + stdio: "inherit", +}); if (result.error) { throw result.error; diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index deef21a..491d135 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -4425,7 +4425,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vidxp-desktop" -version = "0.4.0-b.2" +version = "0.4.0-b.3" dependencies = [ "atomic-write-file", "hex", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index d7eba93..6c85b24 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vidxp-desktop" -version = "0.4.0-b.2" # x-release-please-version +version = "0.4.0-b.3" # x-release-please-version description = "The VidXP desktop launcher and local runtime supervisor" edition = "2024" rust-version = "1.97" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index f5c6f33..8af7082 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", @@ -90,5 +92,22 @@ fn main() { println!("cargo:rerun-if-changed=../../uv.lock"); println!("cargo:rerun-if-changed=../runtime-manifest.json"); - tauri_build::build() + let attributes = tauri_build::Attributes::new(); + #[cfg(windows)] + let attributes = { + add_windows_manifest(); + attributes.windows_attributes(tauri_build::WindowsAttributes::new_without_app_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/entitlements.plist b/desktop/src-tauri/entitlements.plist new file mode 100644 index 0000000..f2eb2ec --- /dev/null +++ b/desktop/src-tauri/entitlements.plist @@ -0,0 +1,12 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + 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..9ec8f40 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}, }; @@ -18,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}; @@ -45,6 +46,7 @@ const RUNTIME_CONSTRAINTS_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/runtime-constraints.txt")); const MODEL_CACHE_CATALOG_BYTES: &[u8] = include_bytes!("../../model-cache-catalog.json"); const PRODUCT_DATA_DIRECTORY_NAME: &str = "VidXP"; +const RUNTIME_CONSTRAINTS_FILE_NAME: &str = "runtime-constraints.txt"; const MAX_SETUP_OUTPUT_BYTES: usize = 4 * 1024 * 1024; static READINESS_SEQUENCE: AtomicU64 = AtomicU64::new(0); @@ -355,12 +357,84 @@ 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, +} + +#[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>, worker_stop: Arc, operation_cancellation: Arc>>, transition: Arc>, @@ -368,12 +442,15 @@ struct DesktopState { shutdown: background_process::CancellationToken, shutdown_started: AtomicBool, active_operations: Arc, + worker_status: Mutex)>>, + tray_menu: Mutex>, } 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())), @@ -381,6 +458,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), } } } @@ -407,6 +486,7 @@ enum TransitionKind { Delete, InstallMedia, InstallRuntime, + ConfigureExternalInstallation, PrepareModels, RecoverActivation, OpenBrowser, @@ -989,6 +1069,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 +1087,64 @@ 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 external_installation_version<'a>( + manifest: &'a RuntimeManifest, + runtime_update_required: bool, + reported_protocol_version: u32, + observed_package_version: &'a str, +) -> Result<&'a str, String> { + if runtime_update_required { + return Ok(&manifest.package_version); + } + match reported_protocol_version.cmp(&target_profiles::SUPPORTED_PROBE_PROTOCOL_VERSION) { + std::cmp::Ordering::Less => Ok(&manifest.package_version), + std::cmp::Ordering::Equal => Ok(observed_package_version), + std::cmp::Ordering::Greater => Err( + "This VidXP installation is newer than this Desktop version. Update VidXP Desktop before changing its features." + .into(), + ), + } } fn base_package_specification(manifest: &RuntimeManifest) -> String { @@ -1028,14 +1167,28 @@ fn package_acquisition_arguments(manifest: &RuntimeManifest, python: &Path) -> V ] } -fn dependency_installation_arguments( +struct UvInvocation { + arguments: Vec, + working_directory: PathBuf, +} + +fn dependency_installation_invocation( manifest: &RuntimeManifest, capabilities: &[String], surfaces: &[String], python: &Path, constraints: &Path, cpu_torch: bool, -) -> Vec { +) -> Result { + // uv 0.12 splits each --constraints value on spaces even when the operating system supplied + // it as one argument. Keep macOS "Application Support" paths in the working directory and + // pass only the staged file name. + let working_directory = constraints.parent().ok_or_else(|| { + "The staged runtime constraints path has no parent directory.".to_string() + })?; + let constraints_file_name = constraints + .file_name() + .ok_or_else(|| "The staged runtime constraints path has no file name.".to_string())?; let mut arguments = vec![ "pip".into(), "install".into(), @@ -1047,13 +1200,16 @@ fn dependency_installation_arguments( "--index-strategy".into(), "first-index".into(), "--constraints".into(), - constraints.to_string_lossy().into_owned(), + constraints_file_name.to_string_lossy().into_owned(), ]; if cpu_torch { arguments.extend(["--torch-backend".into(), "cpu".into()]); } arguments.push(package_specification(manifest, capabilities, surfaces)); - arguments + Ok(UvInvocation { + arguments, + working_directory: working_directory.to_path_buf(), + }) } fn capability_command_arguments( @@ -1794,6 +1950,7 @@ async fn uv_output( app: &AppHandle, paths: &DesktopPaths, arguments: Vec, + working_directory: Option<&Path>, cancellation: background_process::CancellationToken, operation: &str, ) -> Result<(), String> { @@ -1803,6 +1960,9 @@ async fn uv_output( .map_err(|error| format!("The bundled uv sidecar is unavailable: {error}"))? .args(arguments) .env_clear(); + if let Some(working_directory) = working_directory { + command = command.current_dir(working_directory); + } for (key, value) in clean_environment(paths) { command = command.env(key, value); } @@ -1816,6 +1976,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, @@ -1874,13 +2059,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, @@ -1908,10 +2094,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), ); @@ -1921,13 +2107,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] @@ -2001,7 +2189,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)); @@ -2011,8 +2200,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::()); + 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 @@ -2021,7 +2211,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] @@ -2038,9 +2230,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) @@ -2051,7 +2244,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, @@ -2076,11 +2269,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_ui_process(&worker_app.state::()); + stop_api_process(&worker_app.state::()); Ok(setup) }) .await @@ -2089,7 +2283,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] @@ -2100,12 +2296,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_ui_process(&worker_app.state::()); + stop_api_process(&worker_app.state::()); } Ok(result) }) @@ -2115,7 +2313,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] @@ -2485,7 +2685,7 @@ async fn install_runtime( .as_nanos(); let staging_name = format!(".staging-{profile_hash}-{timestamp}-{}", std::process::id()); let staging = paths.runtimes.join(&staging_name); - let constraints = staging.join("runtime-constraints.txt"); + let constraints = staging.join(RUNTIME_CONSTRAINTS_FILE_NAME); let install_result = async { uv_output( @@ -2499,6 +2699,7 @@ async fn install_runtime( "--managed-python".into(), "--no-config".into(), ], + None, cancellation.token(), "Managed Python setup", ) @@ -2516,22 +2717,25 @@ async fn install_runtime( &app, &paths, package_acquisition_arguments(&manifest, &executable(&staging, "python")), + None, cancellation.token(), "VidXP package acquisition", ) .await?; + let dependency_installation = dependency_installation_invocation( + &manifest, + &capabilities, + &surfaces, + &executable(&staging, "python"), + &constraints, + !cfg!(target_os = "macos"), + )?; uv_output( &app, &paths, - dependency_installation_arguments( - &manifest, - &capabilities, - &surfaces, - &executable(&staging, "python"), - &constraints, - !cfg!(target_os = "macos"), - ), + dependency_installation.arguments, + Some(&dependency_installation.working_directory), cancellation.token(), "VidXP package installation", ) @@ -2733,7 +2937,9 @@ 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(); + refresh_tray_for_selected_target(&app); Ok(InstallTransitionResult { install: InstallResult { @@ -2750,7 +2956,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 +3039,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 +3050,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 +3069,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 +3088,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,121 +3115,783 @@ fn stop_ui_process(state: &DesktopState) { } } -fn browser_readiness_nonce() -> String { - let sequence = READINESS_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - hex::encode(Sha256::digest(format!( - "{}:{timestamp}:{sequence}", - std::process::id() - ))) +fn inspect_browser_service(state: &DesktopState) -> Result { + 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)) } -fn configure_ui_service_command( - command: &mut Command, - repository_root: &Path, - port: u16, - readiness_file: &Path, - nonce: &str, -) { - command - // The desktop owns the one intentional browser open after readiness. Without - // headless mode Streamlit also opens the URL, producing duplicate tabs and - // potentially visible launcher consoles on Windows. - .env("STREAMLIT_SERVER_HEADLESS", "true") - .env("VIDXP_DESKTOP_READINESS_FILE", readiness_file) - .env("VIDXP_DESKTOP_READINESS_NONCE", nonce) - .env("VIDXP_DESKTOP_UI_PORT", port.to_string()) - .arg("--index-dir") - .arg(repository_root) - .args(["ui", "--host", "127.0.0.1", "--port", &port.to_string()]); +#[tauri::command] +fn browser_service_status( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> Result { + let _active = state.active_operations.register()?; + let status = inspect_browser_service(&state)?; + refresh_tray_menu(&app); + Ok(status) } -fn hide_main_window(app: &AppHandle) -> Result<(), String> { - let window = app - .get_webview_window("main") - .ok_or("The VidXP desktop window is unavailable.")?; - window - .hide() - .map_err(|error| format!("Could not hide VidXP to the system tray: {error}")) +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}"))?; + refresh_tray_menu(&app); + result } -fn show_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window("main") { - let _ = window.show(); - let _ = window.unminimize(); - let _ = window.set_focus(); - } +#[tauri::command] +async fn start_shared_browser( + app: AppHandle, + _state: tauri::State<'_, DesktopState>, +) -> Result { + start_browser_mode(app, true).await } -fn configured_runtime(app: &AppHandle) -> bool { - target_profiles::current_state(app) - .ok() - .is_some_and(|state| state.selected_profile().is_some()) +#[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.")) } -fn browser_surface_configured(app: &AppHandle) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or(u64::MAX); - target_profiles::current_state(app) - .ok() - .and_then(|state| state.selected_profile().cloned()) - .is_some_and(|profile| profile.is_ready(now) && profile.frontend.launchable) +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)) } -struct BrowserOpenGuard(AppHandle); - -fn claim_browser_open(active: &AtomicBool) -> bool { - active - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() +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), + } } -impl Drop for BrowserOpenGuard { - fn drop(&mut self) { - self.0 - .state::() - .browser_open_active - .store(false, Ordering::Release); +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)) } -async fn open_ui_in_browser(app: AppHandle) -> Result<(), String> { - let state = app.state::(); - if !claim_browser_open(&state.browser_open_active) { - return Ok(()); - } - let _browser_guard = BrowserOpenGuard(app.clone()); +fn execute_target_json( + command: Command, + operation: &str, + timeout: Duration, +) -> Result { + let output = background_process::run( + command, + background_process::BackgroundPolicy { + timeout, + 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()?; - 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 _transition = transition; - let state = worker_app.state::(); - start_ui(&worker_app, &state) + tauri::async_runtime::spawn_blocking(move || { + let (profile, paths) = selected_target_context(&app)?; + let arguments = capability_command_arguments(&manifest()?, "doctor", &profile.capabilities); + 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(arguments); + execute_target_json(command, "VidXP doctor", Duration::from_secs(180)) }) .await - .map_err(|error| format!("VidXP interface startup stopped unexpectedly: {error}"))??; - app.opener() - .open_url(&url, None::<&str>) - .map_err(|error| format!("Could not open VidXP in the default browser: {error}"))?; - hide_main_window(&app) + .map_err(|error| format!("VidXP doctor stopped unexpectedly: {error}"))? } -fn open_browser_or_show_manager(app: &AppHandle) { - if !browser_surface_configured(app) { - show_main_window(app); - return; - } - let app = app.clone(); - tauri::async_runtime::spawn(async move { - if let Err(error) = open_ui_in_browser(app.clone()).await { - show_main_window(&app); +#[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()); + } + let runtime_update_required = profile + .validation_error + .as_ref() + .is_some_and(|error| error.code == target_profiles::TargetErrorCode::RuntimeUpdateRequired); + if !runtime_update_required + && profile.probe_protocol_version == target_profiles::SUPPORTED_PROBE_PROTOCOL_VERSION + && selected_surfaces == profile.surfaces + && selected_capabilities == profile.capabilities + { + 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() + })?; + 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 target_version = external_installation_version( + &manifest, + runtime_update_required, + profile.probe_protocol_version, + &profile.observed_vidxp_version, + )?; + let arguments = external_installation_arguments( + &manifest, + &selected_capabilities, + &selected_surfaces, + &runtime.python_version, + target_version, + )?; + uv_output( + &app, + &paths, + arguments, + None, + 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())?; + let result = target_profiles::current_state(&app).map_err(|error| error.to_string())?; + refresh_tray_for_selected_target(&app); + Ok(result) +} + +#[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", Duration::from_secs(120))?; + serde_json::from_value(payload) + .map_err(|error| format!("VidXP returned an invalid processing status: {error}")) +} + +fn remember_worker_status( + state: &DesktopState, + profile_id: String, + status: &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>, +) -> Result { + run_worker_action(app, "worker-status").await +} + +#[tauri::command] +async fn start_local_worker( + app: AppHandle, + _state: tauri::State<'_, DesktopState>, +) -> Result { + run_worker_action(app, "start-worker").await +} + +#[tauri::command] +async fn stop_local_worker( + app: AppHandle, + _state: tauri::State<'_, DesktopState>, +) -> Result { + run_worker_action(app, "stop-worker").await +} + +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(); + } +} + +fn inspect_local_server(state: &DesktopState) -> Result { + 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)) +} + +#[tauri::command] +fn local_server_status( + app: AppHandle, + state: tauri::State<'_, DesktopState>, +) -> 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, + 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, 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_server_mode(&worker_app, &state, shared) + }) + .await + .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>, +) -> Result { + start_server(app, false).await +} + +#[tauri::command] +async fn start_shared_server( + app: AppHandle, + _state: tauri::State<'_, DesktopState>, +) -> Result { + start_server(app, true).await +} + +#[tauri::command] +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.", + )) +} + +fn browser_readiness_nonce() -> String { + let sequence = READINESS_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + hex::encode(Sha256::digest(format!( + "{}:{timestamp}:{sequence}", + std::process::id() + ))) +} + +fn configure_ui_service_command( + command: &mut Command, + repository_root: &Path, + port: u16, + readiness_file: &Path, + nonce: &str, + shared: bool, +) { + command + // The desktop owns the one intentional browser open after readiness. Without + // headless mode Streamlit also opens the URL, producing duplicate tabs and + // potentially visible launcher consoles on Windows. + .env("STREAMLIT_SERVER_HEADLESS", "true") + .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) + .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> { + let window = app + .get_webview_window("main") + .ok_or("The VidXP desktop window is unavailable.")?; + window + .hide() + .map_err(|error| format!("Could not hide VidXP to the system tray: {error}")) +} + +fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.unminimize(); + let _ = window.set_focus(); + } +} + +fn configured_runtime(app: &AppHandle) -> bool { + target_profiles::current_state(app) + .ok() + .is_some_and(|state| state.selected_profile().is_some()) +} + +fn browser_surface_configured(app: &AppHandle) -> bool { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(u64::MAX); + target_profiles::current_state(app) + .ok() + .and_then(|state| state.selected_profile().cloned()) + .is_some_and(|profile| profile.is_ready(now) && profile.frontend.launchable) +} + +struct BrowserOpenGuard(AppHandle); + +fn claim_browser_open(active: &AtomicBool) -> bool { + active + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() +} + +impl Drop for BrowserOpenGuard { + fn drop(&mut self) { + self.0 + .state::() + .browser_open_active + .store(false, Ordering::Release); + } +} + +async fn open_ui_in_browser(app: AppHandle) -> Result<(), String> { + let state = app.state::(); + if !claim_browser_open(&state.browser_open_active) { + return Ok(()); + } + let _browser_guard = BrowserOpenGuard(app.clone()); + let _active = state.active_operations.register()?; + let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::OpenBrowser) + .map_err(|error| error.to_string())?; + 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) +} + +fn open_browser_or_show_manager(app: &AppHandle) { + if !browser_surface_configured(app) { + show_main_window(app); + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Err(error) = open_ui_in_browser(app.clone()).await { + show_main_window(&app); app.dialog() .message(error) .title("VidXP could not open") @@ -3001,10 +3901,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), } } @@ -3021,6 +4119,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 { @@ -3049,10 +4148,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) @@ -3068,6 +4252,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(()) } @@ -3097,6 +4282,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 +4368,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!()) @@ -3219,16 +4418,17 @@ mod tests { use super::{ ActivationJournal, ActivationRecovery, ActivationStage, ActiveRuntime, DesktopAction, DesktopActivation, DesktopCloseAction, DesktopState, DraftPhase, DraftRecord, - ManagedSetupDraft, TargetTransitionCoordinator, TransitionKind, UiProcessAction, - WorkerStopSupervisor, action_for_activation, activation_recovery, - 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, + ManagedSetupDraft, RUNTIME_CONSTRAINTS_FILE_NAME, TargetTransitionCoordinator, + TransitionKind, UiProcessAction, WorkerStopSupervisor, action_for_activation, + activation_recovery, base_package_specification, capability_command_arguments, + claim_browser_open, clean_environment_from, close_action, configure_ui_service_command, + configured_runtime_status, dependency_installation_invocation, desktop_paths_from_roots, + display_command, external_installation_arguments, external_installation_version, + 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 +4971,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,21 +4987,83 @@ 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 external_install_updates_old_contracts_and_preserves_compatible_versions() { + let manifest = manifest().expect("manifest"); + let supported = crate::target_profiles::SUPPORTED_PROBE_PROTOCOL_VERSION; + + assert_eq!( + external_installation_version(&manifest, false, supported - 1, "older-release") + .expect("older contract"), + manifest.package_version + ); + assert_eq!( + external_installation_version(&manifest, true, supported, "older-release") + .expect("missing management contract"), + manifest.package_version + ); + assert_eq!( + external_installation_version(&manifest, false, supported, "compatible-release") + .expect("compatible contract"), + "compatible-release" + ); + assert!( + external_installation_version(&manifest, false, supported + 1, "newer-release") + .is_err() + ); + } + #[test] fn package_and_dependencies_use_channel_specific_indexes() { let manifest = manifest().expect("manifest"); let python = Path::new("managed-python"); - let constraints = Path::new("runtime-constraints.txt"); + let constraints = Path::new("staging").join(RUNTIME_CONSTRAINTS_FILE_NAME); let selected_package_index = manifest.dependency_index.as_str(); let acquisition = package_acquisition_arguments(&manifest, python); - let dependencies = dependency_installation_arguments( + let dependency_installation = dependency_installation_invocation( &manifest, &["scene".into()], &[], python, - constraints, + &constraints, true, - ); + ) + .expect("dependency installation"); + let dependencies = dependency_installation.arguments; assert_eq!(selected_package_index, "https://pypi.org/simple"); assert_eq!(manifest.dependency_index, "https://pypi.org/simple"); @@ -3815,6 +5085,42 @@ mod tests { .windows(2) .any(|items| items == ["--constraints", "runtime-constraints.txt"]) ); + assert_eq!( + dependency_installation.working_directory, + Path::new("staging") + ); + } + + #[test] + fn dependency_constraints_with_spaced_parent_use_a_local_file_name() { + let manifest = manifest().expect("manifest"); + let constraints = Path::new("Users") + .join("grayhat") + .join("Library") + .join("Application Support") + .join("dev.grayhat.vidxp") + .join("runtimes") + .join("staging") + .join(RUNTIME_CONSTRAINTS_FILE_NAME); + + let invocation = dependency_installation_invocation( + &manifest, + &["scene".into()], + &[], + Path::new("managed-python"), + &constraints, + false, + ) + .expect("dependency installation"); + + assert_eq!( + invocation + .arguments + .windows(2) + .find(|items| items[0] == "--constraints"), + Some(&["--constraints".into(), RUNTIME_CONSTRAINTS_FILE_NAME.into()][..]) + ); + assert_eq!(invocation.working_directory, constraints.parent().unwrap()); } #[test] @@ -3827,6 +5133,16 @@ mod tests { ); } + #[test] + fn capability_commands_pass_an_explicit_empty_option() { + let manifest = manifest().expect("manifest"); + + assert_eq!( + capability_command_arguments(&manifest, "doctor", &[]), + ["doctor", "--json", "--modalities", ""] + ); + } + #[test] fn ffmpeg_encoder_check_matches_complete_encoder_names() { let encoders = " V....D libx264 H.264\n A....D aac AAC"; @@ -3846,6 +5162,7 @@ mod tests { 43123, Path::new("readiness.json"), "nonce", + false, ); assert!(command.get_envs().any(|(key, value)| { @@ -3869,6 +5186,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] @@ -3884,7 +5228,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) @@ -3893,6 +5237,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) @@ -3903,6 +5261,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, } diff --git a/desktop/src-tauri/src/target_profiles.rs b/desktop/src-tauri/src/target_profiles.rs index 7eb11bf..183f985 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, @@ -23,7 +23,7 @@ const SELECTED_PROFILE_KEY: &str = "selected_profile_id"; const CURRENT_STORE_SCHEMA_VERSION: u32 = 1; pub const CURRENT_PROFILE_SCHEMA_VERSION: u32 = 1; const SUPPORTED_PROBE_SCHEMA_VERSION: u32 = 1; -const SUPPORTED_PROBE_PROTOCOL_VERSION: u32 = 1; +pub const SUPPORTED_PROBE_PROTOCOL_VERSION: u32 = 1; const SUPPORTED_LAUNCH_PROTOCOL_VERSION: u32 = 2; const PRODUCT_ID: &str = "dev.grayhat.vidxp"; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); @@ -67,6 +67,7 @@ pub enum TargetErrorCode { LauncherIdentityMismatch, UnsupportedProbeSchema, UnsupportedProbeProtocol, + RuntimeUpdateRequired, UnsupportedLaunchProtocol, UnsupportedLaunchContract, InvalidDataRoot, @@ -196,6 +197,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 +265,8 @@ struct ProbeDocument { model_root: PathBuf, #[serde(default)] capabilities: ProbeCapabilities, + search_capabilities: Option>, + surfaces: Option>, } #[derive(Clone, Debug, Default)] @@ -465,7 +470,7 @@ fn collect_probe_output( ) } -#[cfg(test)] +#[cfg(all(test, windows))] fn collect_version_output(executable: &Path) -> Result { collect_command_output(executable, &["--version"], "The VidXP version check", None) } @@ -497,13 +502,16 @@ fn validate_probe_document( ), )); } - if document.protocol_version != SUPPORTED_PROBE_PROTOCOL_VERSION { + if document.protocol_version < SUPPORTED_PROBE_PROTOCOL_VERSION { + return Err(TargetError::new( + TargetErrorCode::RuntimeUpdateRequired, + "This VidXP installation must be updated before this Desktop version can manage its features and services.", + )); + } + if document.protocol_version > SUPPORTED_PROBE_PROTOCOL_VERSION { return Err(TargetError::new( TargetErrorCode::UnsupportedProbeProtocol, - format!( - "This executable uses desktop probe protocol {}; VidXP desktop supports protocol {}.", - document.protocol_version, SUPPORTED_PROBE_PROTOCOL_VERSION - ), + "This VidXP installation is newer than this Desktop version. Update VidXP Desktop before connecting it.", )); } if document.launch_contract.protocol_version != SUPPORTED_LAUNCH_PROTOCOL_VERSION { @@ -537,6 +545,23 @@ fn validate_probe_document( )); } } + let search_capabilities = document.search_capabilities.ok_or_else(|| { + TargetError::new( + TargetErrorCode::RuntimeUpdateRequired, + "This VidXP installation must be updated before this Desktop version can manage its features and services.", + ) + })?; + let surface_capabilities = document.surfaces.ok_or_else(|| { + TargetError::new( + TargetErrorCode::RuntimeUpdateRequired, + "This VidXP installation must be updated before this Desktop version can manage its features and services.", + ) + })?; + let surfaces = surface_capabilities + .iter() + .filter(|(_, capability)| capability.available) + .map(|(name, _)| name.clone()) + .collect(); Ok(ValidatedTarget { executable: canonical.to_path_buf(), product_version: document.product_version, @@ -554,6 +579,8 @@ fn validate_probe_document( repository_root: document.repository_root, model_root: document.model_root, frontend: document.capabilities.frontend, + capabilities: search_capabilities, + surfaces, validated_at: now, }) } @@ -680,7 +707,7 @@ pub fn validate_executable( validate_executable_with(path, desktop_version, collect_probe_output) } -#[cfg(test)] +#[cfg(all(test, windows))] fn inspect_executable(path: &Path, desktop_version: &str) -> Result { inspect_executable_with( path, @@ -817,8 +844,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 +1202,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; } @@ -1557,7 +1588,7 @@ mod tests { product: PRODUCT_ID.into(), product_version: "0.4.0-b".into(), schema_version: 1, - protocol_version: 1, + protocol_version: SUPPORTED_PROBE_PROTOCOL_VERSION, launch_contract: ProbeLaunchContract { protocol_version: 2, surface: "browser".into(), @@ -1576,6 +1607,8 @@ mod tests { repository_root: root.join("data").join("repositories").join("default"), model_root: root.join("data").join("models"), capabilities: ProbeCapabilities::default(), + search_capabilities: Some(Vec::new()), + surfaces: Some(BTreeMap::new()), } } @@ -1591,6 +1624,54 @@ 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 = Some(vec!["scene".into()]); + probe.surfaces.as_mut().expect("surfaces").insert( + "mcp".into(), + FrontendCapability { + available: true, + launchable: false, + optional: true, + code: "mcp_available".into(), + message: "Available".into(), + remediation: String::new(), + }, + ); + probe + .surfaces + .as_mut() + .expect("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 probe_requires_feature_and_service_inventory() { + 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 = None; + probe.surfaces = None; + + assert_eq!( + validate_probe_document(&canonical, "nonce", probe, 100) + .expect_err("older management contract") + .code, + TargetErrorCode::RuntimeUpdateRequired + ); + } + #[test] fn validation_pipeline_reports_missing_malformed_timeout_and_failed_probes() { let missing = std::env::temp_dir().join("vidxp-missing-probe-executable"); @@ -1679,11 +1760,20 @@ mod tests { ); let mut wrong_protocol = document(&canonical, "nonce"); - wrong_protocol.protocol_version = 2; + wrong_protocol.protocol_version = SUPPORTED_PROBE_PROTOCOL_VERSION - 1; assert_eq!( validate_probe_document(&canonical, "nonce", wrong_protocol, 100) .expect_err("protocol") .code, + TargetErrorCode::RuntimeUpdateRequired + ); + + let mut newer_protocol = document(&canonical, "nonce"); + newer_protocol.protocol_version = SUPPORTED_PROBE_PROTOCOL_VERSION + 1; + assert_eq!( + validate_probe_document(&canonical, "nonce", newer_protocol, 100) + .expect_err("newer protocol") + .code, TargetErrorCode::UnsupportedProbeProtocol ); } @@ -1900,8 +1990,20 @@ mod tests { assert_eq!(inspected.state, InspectionState::ReadyToUse); assert!(inspected.adoptable); - assert_eq!(validated.product_version, "0.4.0b0"); - assert_eq!(validated.probe_protocol_version, 1); + if let Some(expected_version) = + std::env::var_os("VIDXP_DESKTOP_INTEGRATION_EXPECTED_VERSION") + { + assert_eq!( + validated.product_version, + expected_version.to_string_lossy() + ); + } else { + assert!(!validated.product_version.trim().is_empty()); + } + assert_eq!( + validated.probe_protocol_version, + SUPPORTED_PROBE_PROTOCOL_VERSION + ); assert_eq!(validated.launch_protocol_version, 2); assert_eq!(validated.runtime.python_version, "3.14.0"); assert!(validated.frontend.launchable); @@ -1990,7 +2092,7 @@ mod tests { executable: PathBuf::from("/runtime/bin/vidxp"), product_version: "0.5.0".into(), probe_schema_version: 1, - probe_protocol_version: 1, + probe_protocol_version: SUPPORTED_PROBE_PROTOCOL_VERSION, launch_protocol_version: 2, runtime: RuntimeIdentity { python_executable: PathBuf::from("/runtime/bin/python"), @@ -2010,6 +2112,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-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 35f42c5..f328a1e 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "VidXP", - "version": "0.4.0-b.2", + "version": "0.4.0-b.3", "identifier": "dev.grayhat.vidxp", "build": { "beforeDevCommand": "npm run dev", @@ -55,7 +55,8 @@ } }, "macOS": { - "minimumSystemVersion": "13.0" + "minimumSystemVersion": "13.0", + "entitlements": "entitlements.plist" } } } 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 @@ + + + + + + + diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index fd07763..b29b02e 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,128 @@ 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('offers an in-place manifest update when the selected runtime contract is too old', async () => { + const updateRequired = { + ...localProfile, + observed_vidxp_version: 'installed-release', + probe_protocol_version: 1, + capabilities: [], + surfaces: [], + validation_error: { + code: 'runtime_update_required', + message: 'This VidXP installation must be updated before Desktop can manage it.', + }, + }; + const state = { profiles: [updateRequired], selected_profile_id: updateRequired.id, issues: [] }; + mocks.targetSetupState.mockResolvedValue(state); + mocks.recheckTargetState.mockResolvedValue(state); + mocks.runtimeManifest.mockResolvedValue({ + package_version: 'required-release', + 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.configureExternalInstallation.mockResolvedValue(localState); + const user = userEvent.setup(); + renderApp(); + + await user.click(await screen.findByRole('button', { name: 'Update this installation' })); + expect(await screen.findByText(/from installed-release to required-release/i)).toBeVisible(); + expect(screen.getByRole('checkbox', { name: /Visual scene search/i })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Process videos on this computer/i })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /Browser interface/i })).toBeChecked(); + await user.click(screen.getByRole('button', { name: 'Update and apply' })); + + await waitFor(() => expect(mocks.configureExternalInstallation).toHaveBeenCalledWith(['scene'], ['worker', 'browser'])); + }); + + it('shows readiness progress and names the scope returned by doctor', async () => { + const report = deferred<{ ok: boolean; modalities: string[]; checks: { capability: string; kind: string; name: string; ok: boolean }[] }>(); + mocks.targetSetupState.mockResolvedValue(localState); + mocks.recheckTargetState.mockResolvedValue(localState); + mocks.targetDoctor.mockReturnValue(report.promise); + const user = userEvent.setup(); + renderApp(); + + await user.click(await screen.findByRole('button', { name: 'Check readiness' })); + expect(await screen.findByRole('heading', { name: 'Checking VidXP readiness' })).toBeVisible(); + expect(screen.getByText(/does not download or change anything/i)).toBeVisible(); + report.resolve({ ok: true, modalities: [], checks: [{ capability: 'media', kind: 'distribution', name: 'FFmpeg', ok: true }] }); + + expect(await screen.findByText('No search features were checked')).toBeVisible(); + expect(screen.getByText('FFmpeg')).toBeVisible(); + expect(screen.getByText('Video tools are ready')).toBeVisible(); + }); + it('uses the parent exclusive operation while browser startup is pending', async () => { const browserManaged = { ...managedProfile, @@ -143,9 +285,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 +295,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 +321,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 +337,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 +351,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 +359,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 +368,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 +392,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 +414,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 +451,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 +466,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 +487,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 +505,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 +516,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 +530,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={