From 3cff0cf72db30377194f0b48e174b590d09a9dc9 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 7 Aug 2026 15:04:36 +0500 Subject: [PATCH 1/3] fix(desktop): accept macOS managed Python runtimes --- desktop/src-tauri/src/lib.rs | 70 +++++++++++++--- desktop/src/App.test.tsx | 13 +++ desktop/src/components/LocalSetup.tsx | 10 ++- desktop/src/components/ManagedSetup.tsx | 105 +++++++++++++++--------- 4 files changed, 147 insertions(+), 51 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1dcc5c8..509937a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1722,6 +1722,30 @@ fn managed_probe_error(message: impl Into) -> target_profiles::TargetErr } } +fn validate_managed_runtime_identity( + runtime: &Path, + launcher: &Path, + identity: &target_profiles::RuntimeIdentity, +) -> Result<(), target_profiles::TargetError> { + if !path_is_confined(launcher, runtime) { + return Err(managed_probe_error(format!( + "The managed launcher resolved outside the Desktop-owned runtime at {}.", + runtime.display() + ))); + } + if !same_path(&identity.prefix, runtime) { + return Err(managed_probe_error(format!( + "The managed probe reported Python environment {}, but VidXP Desktop owns {}.", + identity.prefix.display(), + runtime.display() + ))); + } + // POSIX virtual environments commonly symlink their Python executable to a shared base + // interpreter. The environment prefix, not that resolved interpreter target, establishes + // which environment the managed launcher is running from. + Ok(()) +} + fn validate_managed_projection( paths: &DesktopPaths, projection: &target_profiles::ManagedRuntimeProjection, @@ -1752,14 +1776,7 @@ fn validate_managed_projection( ))); } } - if !path_is_confined(&validated.executable, &runtime) - || !path_is_confined(&validated.runtime.python_executable, &runtime) - || !same_path(&validated.runtime.prefix, &runtime) - { - return Err(managed_probe_error( - "The managed probe reported a launcher or Python runtime outside the active Desktop-owned environment.", - )); - } + validate_managed_runtime_identity(&runtime, &validated.executable, &validated.runtime)?; Ok(validated) } @@ -4712,8 +4729,8 @@ mod tests { 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, + selected_capabilities, selected_surfaces, ui_process_action, + validate_managed_runtime_identity, write_activation_journal, write_active_runtime, }; use std::{ ffi::OsStr, @@ -4755,6 +4772,39 @@ mod tests { ); } + #[test] + fn managed_runtime_accepts_a_shared_posix_base_interpreter() { + let root = std::env::temp_dir().join(format!( + "vidxp-managed-runtime-identity-{}", + std::process::id() + )); + let runtime = root.join("runtimes").join("profile"); + let launcher = runtime.join("bin").join("vidxp"); + fs::create_dir_all(launcher.parent().expect("launcher parent")).expect("runtime"); + fs::write(&launcher, b"launcher").expect("launcher"); + let identity = crate::target_profiles::RuntimeIdentity { + python_executable: root + .join("python") + .join("cpython") + .join("bin") + .join("python3"), + python_version: "3.13.5".into(), + implementation: "CPython".into(), + prefix: runtime.clone(), + base_prefix: root.join("python").join("cpython"), + }; + + assert!(validate_managed_runtime_identity(&runtime, &launcher, &identity).is_ok()); + assert!( + validate_managed_runtime_identity(&runtime, &root.join("other-vidxp"), &identity) + .is_err() + ); + let mut wrong_prefix = identity; + wrong_prefix.prefix = root.join("other-environment"); + assert!(validate_managed_runtime_identity(&runtime, &launcher, &wrong_prefix).is_err()); + let _ = fs::remove_dir_all(root); + } + #[test] fn managed_commands_discard_hostile_inherited_environment_and_restore_owned_roots() { let paths = diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 7020171..12eb42f 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -459,6 +459,19 @@ describe('desktop target lifecycle', () => { expect(mocks.launchUi).not.toHaveBeenCalled(); }); + it('keeps a managed installation failure visible in the setup dialog until it is acknowledged', async () => { + mocks.installRuntime.mockRejectedValueOnce('The installed runtime failed its compatibility check.'); + const user = userEvent.setup(); renderApp(); await enterManaged(user); + + await user.click(screen.getByRole('button', { name: 'Install VidXP' })); + + expect(await screen.findByRole('dialog', { name: 'Setup could not finish' })).toBeVisible(); + expect(screen.getByRole('alert', { name: 'VidXP was not installed' })).toHaveTextContent('The installed runtime failed its compatibility check.'); + expect(screen.getByText(/model files already downloaded remain cached/i)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Review setup' })); + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Setup could not finish' })).not.toBeInTheDocument()); + }); + it('blocks setup interaction and reports managed installation stages', async () => { const media = deferred<{ ready: boolean }>(); mocks.installMediaRuntime.mockReturnValue(media.promise); diff --git a/desktop/src/components/LocalSetup.tsx b/desktop/src/components/LocalSetup.tsx index 6be770f..592ddb3 100644 --- a/desktop/src/components/LocalSetup.tsx +++ b/desktop/src/components/LocalSetup.tsx @@ -64,6 +64,7 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { const [busy, setBusy] = useState<'discover' | 'browse' | 'activate' | null>('discover'); const [failure, setFailure] = useState(null); const candidateGeneration = useRef(new Map()); + const failureAlert = useRef(null); async function discover() { setBusy('discover'); @@ -90,6 +91,12 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { void discover(); }, []); + useEffect(() => { + if (!failure) return; + failureAlert.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); + failureAlert.current?.focus({ preventScroll: true }); + }, [failure]); + async function checkCandidate(path: string) { const generation = (candidateGeneration.current.get(path) ?? 0) + 1; candidateGeneration.current.set(path, generation); @@ -166,6 +173,8 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) { Choose the VidXP installation you already use. Connecting it here will not change or update it. + {failure &&
} +
@@ -256,7 +265,6 @@ export function LocalSetup({ onBack, onActivated }: LocalSetupProps) {
{busy === 'activate' &&
Connecting this VidXP installation…
} - {failure && } ); } diff --git a/desktop/src/components/ManagedSetup.tsx b/desktop/src/components/ManagedSetup.tsx index c88b5f5..5e4da2d 100644 --- a/desktop/src/components/ManagedSetup.tsx +++ b/desktop/src/components/ManagedSetup.tsx @@ -55,9 +55,11 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o const [operation, setOperation] = useState('load'); const [message, setMessage] = useState('Loading VidXP options…'); const [failure, setFailure] = useState(null); + const [installFailure, setInstallFailure] = useState(null); const [setupProgress, setSetupProgress] = useState(null); const [setupElapsed, setSetupElapsed] = useState(0); const operations = useExclusiveOperation(); + const failureAlert = useRef(null); const initialLoad = useRef window.clearInterval(timer); }, [operation]); + useEffect(() => { + if (!failure || installFailure) return; + failureAlert.current?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest' }); + failureAlert.current?.focus({ preventScroll: true }); + }, [failure, installFailure]); + function toggleValue(value: string, checked: boolean, setter: (next: string[]) => void, current: string[]) { setter(checked ? [...current, value] : current.filter((item) => item !== value)); } @@ -204,6 +212,7 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o draft_id: draftId, }; setFailure(null); + setInstallFailure(null); setSetupProgress({ draft_id: draftId, current: 1, @@ -231,7 +240,9 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o 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, 'Setup did not finish. Your previous VidXP installation is unchanged.')); + const detail = errorMessage(error, 'Setup did not finish. Your previous VidXP installation is unchanged.'); + setFailure(detail); + setInstallFailure(detail); } finally { settleOperation(operationId); setSetupProgress(null); @@ -305,6 +316,11 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o const progressCurrent = setupProgress?.current ?? 1; const progressTotal = setupProgress?.total ?? (prepareDuringInstall ? 8 : 7); + function dismissInstallFailure() { + setInstallFailure(null); + setFailure(null); + } + function formatBytes(bytes: number) { if (bytes < 1024) return `${bytes} B`; const units = ['KiB', 'MiB', 'GiB', 'TiB']; @@ -326,6 +342,8 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o Choose what VidXP can search, where video work runs, and how you want to open or connect to it. You can change these later.
+ {failure && !installFailure &&
} + {!manifest ? (
Loading setup options…
) : ( @@ -446,48 +464,55 @@ export function ManagedSetup({ draftId, selectedManagedRuntimeProfile, onBack, o )} undefined} - title="Setting up VidXP" + opened={operation === 'install' || installFailure !== null} + onClose={() => { if (operation !== 'install') dismissInstallFailure(); }} + title={installFailure ? 'Setup could not finish' : 'Setting up VidXP'} size="md" - closeOnClickOutside={false} - closeOnEscape={false} - withCloseButton={false} + closeOnClickOutside={operation !== 'install'} + closeOnEscape={operation !== 'install'} + withCloseButton={operation !== 'install'} > - - - Step {progressCurrent} of {progressTotal} - {setupElapsed}s elapsed - - - {setupProgress?.stage === 'models' - && setupProgress.model_message && ( - - - {setupProgress.model_message} - {setupProgress.model_current != null && setupProgress.model_total != null - ? - {formatBytes(setupProgress.model_current)} of {formatBytes(setupProgress.model_total)} - - : } - - {setupProgress.model_current != null && setupProgress.model_total != null && ( - - )} - - )} -
- {setupProgress?.message ?? 'Starting managed setup'} - The existing installation remains active until every step has completed and the replacement passes validation. -
-
+ {installFailure ? ( + + + Any model files already downloaded remain cached and will be reused when you retry. + + + ) : ( + + + Step {progressCurrent} of {progressTotal} + {setupElapsed}s elapsed + + + {setupProgress?.stage === 'models' + && setupProgress.model_message && ( + + + {setupProgress.model_message} + {setupProgress.model_current != null && setupProgress.model_total != null + ? + {formatBytes(setupProgress.model_current)} of {formatBytes(setupProgress.model_total)} + + : } + + {setupProgress.model_current != null && setupProgress.model_total != null && ( + + )} + + )} +
+ {setupProgress?.message ?? 'Starting managed setup'} + The existing installation remains active until every step has completed and the replacement passes validation. +
+
+ )}
- {failure && } ); } From 00ba5d5ed0606832606e57daf91dee953a0ec0f5 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 7 Aug 2026 15:44:54 +0500 Subject: [PATCH 2/3] fix(desktop): recover from worker status timeouts --- desktop/src/App.test.tsx | 24 +++++++++ desktop/src/components/TargetSummary.tsx | 69 ++++++++++++++++++------ 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 12eb42f..a7183cc 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -206,6 +206,30 @@ describe('desktop target lifecycle', () => { expect(mocks.stopLocalServer).toHaveBeenCalledTimes(1); }); + it('keeps worker timeouts with the worker control and clears them after recovery', async () => { + const operational = { + ...managedProfile, + frontend, + surfaces: ['worker', 'browser'], + validation_error: null, + }; + const state = { profiles: [operational], selected_profile_id: operational.id, issues: [] }; + mocks.targetSetupState.mockResolvedValue(state); + mocks.recheckTargetState.mockResolvedValue(state); + mocks.localWorkerStatus.mockRejectedValue('VidXP local processing failed: the operation exceeded 120 seconds'); + const user = userEvent.setup(); + renderApp(); + + const workerFailure = await screen.findByRole('alert', { name: 'Local processing status could not be checked' }); + expect(workerFailure).toHaveTextContent('the operation exceeded 120 seconds'); + expect(screen.queryByRole('alert', { name: 'That did not work' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Start processing' })); + + expect(await screen.findByRole('button', { name: 'Stop processing' })).toBeVisible(); + expect(screen.queryByRole('alert', { name: 'Local processing status could not be checked' })).not.toBeInTheDocument(); + }); + 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: [] }; diff --git a/desktop/src/components/TargetSummary.tsx b/desktop/src/components/TargetSummary.tsx index cc0381c..c647ab6 100644 --- a/desktop/src/components/TargetSummary.tsx +++ b/desktop/src/components/TargetSummary.tsx @@ -1,6 +1,6 @@ import { Alert, Badge, Button, Checkbox, Code, Group, Loader, Modal, Stack, Text, Title } from '@mantine/core'; import { IconActivityHeartbeat, IconCopy, IconExternalLink, IconPlugConnected, IconPlayerPlay, IconPlayerStop, IconRefresh, IconSettings, IconShare, IconTerminal2 } from '@tabler/icons-react'; -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { errorMessage, @@ -50,6 +50,11 @@ const CAPABILITY_LABELS: Record = { scene: 'Visual scene search', }; +interface WorkerFailure { + title: string; + detail: string; +} + export function TargetSummary({ profile, validationError, checking, operationPending, opening, onRecheck, onManageManaged, onSetupChanged, onChooseAnother, onOpen }: TargetSummaryProps) { const executable = profile.display_executable; const [doctor, setDoctor] = useState(null); @@ -60,6 +65,9 @@ export function TargetSummary({ profile, validationError, checking, operationPen const [codexSetup, setCodexSetup] = useState(null); const [busy, setBusy] = useState<'doctor' | 'config' | 'codex' | 'features' | 'worker-start' | 'worker-stop' | 'browser-share' | 'browser-stop' | 'server-start' | 'server-share' | 'server-stop' | null>(null); const [runtimeFailure, setRuntimeFailure] = useState(null); + const [workerFailure, setWorkerFailure] = useState(null); + const workerStatusRequest = useRef(0); + const workerActionActive = useRef(false); const [copied, setCopied] = useState(false); const [shareCopied, setShareCopied] = useState(false); const [externalSetupOpened, setExternalSetupOpened] = useState(false); @@ -131,20 +139,40 @@ export function TargetSummary({ profile, validationError, checking, operationPen useEffect(() => { if (!workerAvailable) { setWorker(null); + setWorkerFailure(null); return; } let active = true; + let timer: ReturnType | null = null; const poll = async () => { + if (workerActionActive.current) { + if (active) timer = setTimeout(() => void poll(), 5000); + return; + } + const request = ++workerStatusRequest.current; try { const status = await localWorkerStatus(); - if (active) setWorker(status); + if (active && request === workerStatusRequest.current) { + setWorker(status); + setWorkerFailure(null); + } } catch (error) { - if (active) setRuntimeFailure(errorMessage(error, 'Local video processing status could not be checked.')); + if (active && request === workerStatusRequest.current) { + setWorkerFailure({ + title: 'Local processing status could not be checked', + detail: errorMessage(error, 'VidXP could not check local video processing.'), + }); + } + } finally { + if (active) timer = setTimeout(() => void poll(), 5000); } }; void poll(); - const timer = setInterval(() => void poll(), 5000); - return () => { active = false; clearInterval(timer); }; + return () => { + active = false; + workerStatusRequest.current += 1; + if (timer) clearTimeout(timer); + }; }, [profile.id, workerAvailable]); useEffect(() => { @@ -271,12 +299,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen async function setWorkerRunning(running: boolean) { setBusy(running ? 'worker-start' : 'worker-stop'); - setRuntimeFailure(null); + setWorkerFailure(null); + workerActionActive.current = true; + workerStatusRequest.current += 1; try { setWorker(await (running ? startLocalWorker() : stopLocalWorker())); } catch (error) { - setRuntimeFailure(errorMessage(error, 'VidXP could not change local video processing.')); + setWorkerFailure({ + title: running ? 'Local processing could not be started' : 'Local processing could not be stopped', + detail: errorMessage(error, 'VidXP could not change local video processing.'), + }); } finally { + workerActionActive.current = false; setBusy(null); } } @@ -374,15 +408,18 @@ export function TargetSummary({ profile, validationError, checking, operationPen )} - {workerAvailable && -
- Local video processing - {worker?.running ? 'Ready to process indexing, search, and model jobs on this computer.' : 'Starts automatically when VidXP needs to process a video. You can also start it now.'} -
- {worker?.running - ? - : } -
} + {workerAvailable &&
+ +
+ Local video processing + {worker?.running ? 'Ready to process indexing, search, and model jobs on this computer.' : 'Starts automatically when VidXP needs to process a video. You can also start it now.'} +
+ {worker?.running + ? + : } +
+ {workerFailure && {workerFailure.detail}} +
} {browserAvailable &&
From 5e95405a51f94202b571940f8baa4f46b818f728 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 7 Aug 2026 15:57:01 +0500 Subject: [PATCH 3/3] fix(desktop): clarify tray service states --- desktop/src-tauri/src/lib.rs | 234 ++++++++++++++++++++++++++--------- 1 file changed, 173 insertions(+), 61 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 509937a..4d64de0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -4210,44 +4210,87 @@ fn current_unix_seconds() -> u64 { fn tray_installation_label(profile: Option<&target_profiles::TargetProfile>, now: u64) -> String { let Some(profile) = profile else { - return "No VidXP installation selected".into(); + return "No 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) + match profile.validation_error.as_ref().map(|error| &error.code) { + Some(target_profiles::TargetErrorCode::RuntimeUpdateRequired) => { + format!("{} · Update required", profile.display_name) + } + Some(_) => format!("{} · Needs attention", profile.display_name), + None if !profile.is_ready(now) => format!("{} · Check setup", profile.display_name), + None => profile.display_name.clone(), + } +} + +fn tray_capability_state(selected: bool, installed: bool, ready: bool) -> Option<&'static str> { + if !selected || !ready { + Some("Unavailable") + } else if !installed { + Some("Not installed") + } else { + None + } } -fn tray_browser_label(status: &BrowserServiceStatus) -> String { +fn tray_browser_label( + status: &BrowserServiceStatus, + selected: bool, + installed: bool, + ready: bool, + status_known: bool, +) -> String { + if let Some(state) = tray_capability_state(selected, installed, ready) { + return format!("Browser · {state}"); + } + if !status_known { + return "Browser · Status unknown".into(); + } if !status.running { - return "Browser interface · Stopped".into(); + return "Browser · Off".into(); } if status.shared { - return format!( - "Browser interface · Shared · {}", - status - .network_url - .as_deref() - .unwrap_or("address unavailable") - ); + return "Browser · Shared".into(); } - format!( - "Browser interface · Private · {}", - status.local_url.as_deref().unwrap_or("address unavailable") - ) + "Browser · Private".into() +} + +fn tray_worker_label( + status: Option<&Result>, + selected: bool, + installed: bool, + ready: bool, +) -> String { + if let Some(state) = tray_capability_state(selected, installed, ready) { + return format!("Processing · {state}"); + } + match status { + Some(Ok(status)) if status.running => "Processing · On", + Some(Ok(_)) => "Processing · Off", + Some(Err(_)) => "Processing · Status unknown", + None => "Processing · Checking…", + } + .into() } -fn tray_server_label(status: &LocalServerStatus) -> String { +fn tray_server_label( + status: &LocalServerStatus, + selected: bool, + installed: bool, + ready: bool, + status_known: bool, +) -> String { + if let Some(state) = tray_capability_state(selected, installed, ready) { + return format!("App integration · {state}"); + } + if !status_known { + return "App integration · Status unknown".into(); + } if !status.running { - return "App integration service · Stopped".into(); + return "App integration · Off".into(); } format!( - "App integration service · {} · {}", - if status.shared { "Shared" } else { "Private" }, - status.origin.as_deref().unwrap_or("address unavailable") + "App integration · {}", + if status.shared { "Shared" } else { "Private" } ) } @@ -4261,17 +4304,23 @@ fn refresh_tray_menu(app: &AppHandle) { let profile = target_state .as_ref() .and_then(target_profiles::TargetState::selected_profile); + let selected = profile.is_some(); 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) + let browser_installed = profile.is_some_and(|profile| profile.frontend.launchable); + let worker_installed = + profile.is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "worker")); + let server_installed = + profile.is_some_and(|profile| profile.surfaces.iter().any(|surface| surface == "server")); + let browser_available = ready && browser_installed; + let worker_available = ready && worker_installed; + let server_available = ready && server_installed; + let browser_result = inspect_browser_service(&state); + let browser_status_known = browser_result.is_ok(); + let browser = browser_result .unwrap_or_else(|error| stopped_browser_status(format!("Status unavailable: {error}"))); - let server = inspect_local_server(&state) + let server_result = inspect_local_server(&state); + let server_status_known = server_result.is_ok(); + let server = server_result .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| { @@ -4285,21 +4334,36 @@ fn refresh_tray_menu(app: &AppHandle) { 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_text(tray_browser_label( + &browser, + selected, + browser_installed, + ready, + browser_status_known, + )); let _ = items.browser.set_enabled(browser_available); + let _ = items.open_browser.set_text(if browser.running { + "Open VidXP" + } else { + "Start and open VidXP" + }); let _ = items.open_browser.set_enabled(browser_available); + let _ = items.share_browser.set_text(if browser.running { + "Share on local network" + } else { + "Start and share" + }); 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_text(tray_worker_label( + worker.as_ref(), + selected, + worker_installed, + ready, + )); let _ = items.worker.set_enabled(worker_available); let _ = items.start_worker.set_enabled( worker_available @@ -4313,7 +4377,13 @@ fn refresh_tray_menu(app: &AppHandle) { .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_text(tray_server_label( + &server, + selected, + server_installed, + ready, + server_status_known, + )); let _ = items.server.set_enabled(server_available); let _ = items.start_server.set_text(if server.shared { "Make private" @@ -4326,6 +4396,11 @@ fn refresh_tray_menu(app: &AppHandle) { let _ = items .share_server .set_enabled(server_available && !server.shared); + let _ = items.share_server.set_text(if server.running { + "Share on local network" + } else { + "Start and share" + }); let _ = items.stop_server.set_enabled(server.running); } @@ -4463,16 +4538,10 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> { true, None::<&str>, )?; - let stop_browser = MenuItem::with_id( - app, - "stop-browser", - "Stop browser interface", - false, - None::<&str>, - )?; + let stop_browser = MenuItem::with_id(app, "stop-browser", "Stop browser", false, None::<&str>)?; let browser = Submenu::with_items( app, - "Browser interface", + "Browser · Checking…", true, &[&share_browser, &stop_browser], )?; @@ -4482,7 +4551,7 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> { MenuItem::with_id(app, "stop-worker", "Stop processing", false, None::<&str>)?; let worker = Submenu::with_items( app, - "Local video processing", + "Processing · Checking…", true, &[&start_worker, &stop_worker], )?; @@ -4495,10 +4564,11 @@ fn create_tray(app: &tauri::App) -> tauri::Result<()> { true, None::<&str>, )?; - let stop_server = MenuItem::with_id(app, "stop-server", "Stop service", false, None::<&str>)?; + let stop_server = + MenuItem::with_id(app, "stop-server", "Stop integration", false, None::<&str>)?; let server = Submenu::with_items( app, - "App integration service", + "App integration · Checking…", true, &[&start_server, &share_server, &stop_server], )?; @@ -5620,7 +5690,7 @@ mod tests { } #[test] - fn tray_service_labels_surface_scope_and_addresses() { + fn tray_service_labels_are_compact_and_distinguish_availability() { let browser = super::BrowserServiceStatus { state: "ready", running: true, @@ -5643,16 +5713,58 @@ mod tests { }; assert_eq!( - super::tray_browser_label(&browser), - "Browser interface · Shared · http://192.168.1.20:43124" + super::tray_browser_label(&browser, true, true, true, true), + "Browser · Shared" + ); + assert_eq!( + super::tray_server_label(&server, true, true, true, true), + "App integration · Private" + ); + assert_eq!( + super::tray_browser_label(&browser, true, false, true, true), + "Browser · Not installed" + ); + assert_eq!( + super::tray_server_label(&server, true, true, false, true), + "App integration · Unavailable" ); assert_eq!( - super::tray_server_label(&server), - "App integration service · Private · http://127.0.0.1:43125" + super::tray_browser_label(&browser, true, true, true, false), + "Browser · Status unknown" ); assert_eq!( super::tray_installation_label(None, 0), - "No VidXP installation selected" + "No installation selected" + ); + } + + #[test] + fn tray_worker_labels_report_actual_state() { + let running = Ok(super::LocalWorkerStatus { + running: true, + detail: String::new(), + }); + let stopped = Ok(super::LocalWorkerStatus { + running: false, + detail: String::new(), + }); + let unavailable = Err("timed out".into()); + + assert_eq!( + super::tray_worker_label(Some(&running), true, true, true), + "Processing · On" + ); + assert_eq!( + super::tray_worker_label(Some(&stopped), true, true, true), + "Processing · Off" + ); + assert_eq!( + super::tray_worker_label(Some(&unavailable), true, true, true), + "Processing · Status unknown" + ); + assert_eq!( + super::tray_worker_label(None, true, false, true), + "Processing · Not installed" ); }