diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 0e897430..59908e1c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -2766,7 +2766,9 @@ async fn refresh_target_state( let _transition = transition; match target_profiles::selected_profile(&worker_app) { Ok(profile) => { - if profile.kind == target_profiles::TargetKind::Managed { + if profile.kind == target_profiles::TargetKind::Remote { + let _ = target_profiles::validated_selected_remote_profile(&worker_app); + } else if profile.kind == target_profiles::TargetKind::Managed { let validation = (|| { let paths = desktop_paths(&worker_app).map_err(|message| { transition_error( @@ -2876,6 +2878,45 @@ async fn inspect_local_target( })? } +#[tauri::command] +async fn inspect_remote_target( + state: tauri::State<'_, DesktopState>, + url: String, + authorization: Option, +) -> Result { + let _active = track_target_operation(&state)?; + tauri::async_runtime::spawn_blocking(move || { + target_profiles::inspect_remote(target_profiles::RemoteTargetInput { + url, + display_name: None, + authorization, + }) + }) + .await + .map_err(|error| transition_error(target_profiles::TargetErrorCode::RemoteUnavailable, format!("Remote inspection stopped unexpectedly: {error}")))? +} + +#[tauri::command] +async fn adopt_remote_target( + app: AppHandle, + state: tauri::State<'_, DesktopState>, + url: String, + display_name: Option, + authorization: Option, +) -> Result { + let _active = track_target_operation(&state)?; + let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::Adopt)?; + let worker_app = app.clone(); + let result = tauri::async_runtime::spawn_blocking(move || { + let _transition = transition; + target_profiles::adopt_remote(&worker_app, target_profiles::RemoteTargetInput { url, display_name, authorization }) + }) + .await + .map_err(|error| transition_error(target_profiles::TargetErrorCode::RemoteUnavailable, format!("Remote target adoption stopped unexpectedly: {error}")))??; + refresh_tray_for_selected_target(&app); + Ok(result) +} + #[tauri::command] async fn adopt_local_target( app: AppHandle, @@ -4121,6 +4162,9 @@ fn target_command( let mut command = match profile.kind { target_profiles::TargetKind::Managed => configured_command(executable_path, paths), target_profiles::TargetKind::ExistingLocal => Command::new(executable_path), + target_profiles::TargetKind::Remote => { + unreachable!("remote targets do not launch local VidXP commands") + } }; if profile.kind == target_profiles::TargetKind::Managed { configure_local_answer_environment(&mut command, paths); @@ -4904,6 +4948,13 @@ async fn open_ui_in_browser(app: AppHandle) -> Result<(), String> { let transition = TargetTransitionCoordinator::begin(&state, TransitionKind::OpenBrowser) .map_err(|error| error.to_string())?; let current = inspect_browser_service(&state)?; + if let Ok(profile) = target_profiles::selected_profile(&app) { + if profile.kind == target_profiles::TargetKind::Remote { + let url = profile.remote_url.ok_or_else(|| "The selected remote target has no server URL.".to_string())?; + app.opener().open_url(&url, None::<&str>).map_err(|error| format!("Could not open the remote VidXP interface: {error}"))?; + return Ok(()); + } + } let status = if current.running { current } else { @@ -5471,7 +5522,9 @@ pub fn run() { discover_local_targets, choose_local_executable, inspect_local_target, + inspect_remote_target, adopt_local_target, + adopt_remote_target, select_target_profile, delete_target_profile, confirm_forget_target, diff --git a/desktop/src-tauri/src/target_profiles.rs b/desktop/src-tauri/src/target_profiles.rs index 5501d644..10e230b7 100644 --- a/desktop/src-tauri/src/target_profiles.rs +++ b/desktop/src-tauri/src/target_profiles.rs @@ -29,12 +29,14 @@ const PRODUCT_ID: &str = "dev.grayhat.vidxp"; const PROBE_TIMEOUT: Duration = Duration::from_secs(10); const VALIDATION_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); const MAX_PROBE_STREAM_BYTES: usize = 256 * 1024; +const REMOTE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] pub enum TargetKind { ExistingLocal, Managed, + Remote, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -86,6 +88,9 @@ pub enum TargetErrorCode { DraftMismatch, DraftApplying, ManagedProfileOwned, + RemoteUnavailable, + RemoteAuthenticationRequired, + RemoteIncompatible, } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -169,6 +174,18 @@ pub struct TargetProfile { pub surfaces: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_directory: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_auth_scheme: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_auth_header: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_repository: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_model_config: Option, + #[serde(default)] + pub remote_job_ready: bool, } impl TargetProfile { @@ -202,6 +219,28 @@ pub struct ValidatedTarget { pub validated_at: u64, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RemoteTargetInspection { + pub url: String, + pub reachable: bool, + pub compatible: bool, + pub requires_authentication: bool, + pub authentication_scheme: Option, + pub product_version: Option, + pub capabilities: Vec, + pub repository: Option, + pub model_config: Option, + pub job_ready: bool, + pub message: String, +} + +#[derive(Clone, Debug)] +pub struct RemoteTargetInput { + pub url: String, + pub display_name: Option, + pub authorization: Option, +} + #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct TargetState { pub profiles: Vec, @@ -777,6 +816,7 @@ pub fn authorize_lifecycle( (&profile.kind, &profile.lifecycle_ownership), (TargetKind::ExistingLocal, LifecycleOwnership::External) | (TargetKind::Managed, LifecycleOwnership::Desktop) + | (TargetKind::Remote, LifecycleOwnership::External) ); if !structurally_valid { return Err(TargetError::new( @@ -852,7 +892,170 @@ fn local_profile(validated: ValidatedTarget, display_name: Option) -> Ta capabilities: validated.capabilities, surfaces: validated.surfaces, model_directory: None, + remote_url: None, + remote_auth_scheme: None, + remote_auth_header: None, + remote_repository: None, + remote_model_config: None, + remote_job_ready: false, + } +} + +fn remote_url(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + if value.is_empty() || !(value.starts_with("http://") || value.starts_with("https://")) { + return Err(TargetError::new( + TargetErrorCode::RemoteUnavailable, + "Enter a VidXP server URL beginning with http:// or https://.", + )); } + Ok(value.to_owned()) +} + +fn authentication_scheme(value: &str) -> Option { + value.split_whitespace().next().filter(|scheme| !scheme.is_empty()).map(str::to_owned) +} + +fn remote_get( + client: &reqwest::blocking::Client, + url: &str, + authorization: Option<&str>, +) -> Result { + let mut request = client.get(url); + if let Some(authorization) = authorization.filter(|value| !value.trim().is_empty()) { + request = request.header(reqwest::header::AUTHORIZATION, authorization); + } + request.send().map_err(|error| { + TargetError::new( + TargetErrorCode::RemoteUnavailable, + format!("The VidXP server could not be reached: {error}"), + ) + }) +} + +fn remote_json( + client: &reqwest::blocking::Client, + url: &str, + authorization: Option<&str>, +) -> Result<(Value, Option), TargetError> { + let response = remote_get(client, url, authorization)?; + let challenge = response + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()) + .and_then(authentication_scheme); + if response.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(TargetError::new( + TargetErrorCode::RemoteAuthenticationRequired, + challenge + .as_deref() + .map(|scheme| format!("This VidXP server requires {scheme} authentication.")) + .unwrap_or_else(|| "This VidXP server requires authentication.".to_owned()), + )); + } + if !response.status().is_success() { + return Err(TargetError::new( + TargetErrorCode::RemoteUnavailable, + format!("The VidXP server returned HTTP {}.", response.status()), + )); + } + let value = response.json::().map_err(|error| { + TargetError::new( + TargetErrorCode::RemoteIncompatible, + format!("The VidXP server returned invalid JSON: {error}"), + ) + })?; + Ok((value, challenge)) +} + +fn remote_field(value: &Value, keys: &[&str]) -> Option { + keys.iter().find_map(|key| value.get(*key).and_then(Value::as_str).map(str::to_owned)) +} + +fn remote_capabilities(value: &Value) -> Vec { + value + .get("items") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|item| item.get("name").or_else(|| item.get("id")).and_then(Value::as_str)) + .map(str::to_owned) + .collect() +} + +fn validate_remote(input: &RemoteTargetInput) -> Result<(TargetProfile, RemoteTargetInspection), TargetError> { + let base = remote_url(&input.url)?; + let client = reqwest::blocking::Client::builder() + .timeout(REMOTE_TIMEOUT) + .build() + .map_err(|error| TargetError::new(TargetErrorCode::RemoteUnavailable, format!("The remote connection could not be created: {error}")))?; + let (_, challenge) = remote_json(&client, &format!("{base}/health"), input.authorization.as_deref())?; + let (capability_value, protected_challenge) = remote_json(&client, &format!("{base}/api/v1/capabilities"), input.authorization.as_deref())?; + let (readiness, _) = remote_json(&client, &format!("{base}/api/v1/runtime/readiness"), input.authorization.as_deref())?; + let (workspace, _) = remote_json(&client, &format!("{base}/api/v1/workspace?page_size=1"), input.authorization.as_deref())?; + let capabilities = remote_capabilities(&capability_value); + let job_ready = readiness.get("ready").and_then(Value::as_bool).unwrap_or(false); + let repository = workspace + .get("index") + .and_then(|index| index.get("snapshot_id").or_else(|| index.get("state"))) + .and_then(Value::as_str) + .map(str::to_owned); + let model_config = readiness + .get("runtime") + .and_then(|runtime| runtime.get("name").or_else(|| runtime.get("profile"))) + .and_then(Value::as_str) + .map(str::to_owned); + let auth_scheme = protected_challenge.or(challenge); + let profile_id = format!("remote-{}", &hex::encode(Sha256::digest(base.as_bytes()))[..24]); + let display_name = input.display_name.clone().filter(|name| !name.trim().is_empty()).unwrap_or_else(|| base.clone()); + let now = unix_timestamp()?; + let profile = TargetProfile { + id: profile_id, + display_name, + schema_version: CURRENT_PROFILE_SCHEMA_VERSION, + kind: TargetKind::Remote, + lifecycle_ownership: LifecycleOwnership::External, + executable: PathBuf::new(), + data_root: PathBuf::new(), + repository_root: PathBuf::from(repository.clone().unwrap_or_else(|| base.clone())), + observed_vidxp_version: remote_field(&workspace, &["version", "product_version"]).unwrap_or_else(|| "remote".into()), + probe_schema_version: 1, + probe_protocol_version: 1, + launch_protocol_version: 1, + runtime: None, + frontend: FrontendCapability { available: true, launchable: true, optional: true, code: "remote_server".into(), message: "The remote server provides its browser interface.".into(), remediation: String::new() }, + last_successful_validation_at: Some(now), + validation_error: None, + managed_runtime_profile: None, + capabilities, + surfaces: vec!["browser".into(), "server".into()], + model_directory: None, + remote_url: Some(base), + remote_auth_scheme: auth_scheme.clone(), + remote_auth_header: input.authorization.clone(), + remote_repository: repository.clone(), + remote_model_config: model_config.clone(), + remote_job_ready: job_ready, + }; + let product_version = profile.observed_vidxp_version.clone(); + let profile_capabilities = profile.capabilities.clone(); + Ok((profile, RemoteTargetInspection { url: input.url.clone(), reachable: true, compatible: true, requires_authentication: input.authorization.is_none() && auth_scheme.is_some(), authentication_scheme: auth_scheme, product_version: Some(product_version), capabilities: profile_capabilities, repository, model_config, job_ready, message: "The remote VidXP server is compatible and ready to connect.".into() })) +} + +pub fn inspect_remote(input: RemoteTargetInput) -> Result { + validate_remote(&input).map(|(_, inspection)| inspection).or_else(|error| { + Ok(RemoteTargetInspection { url: input.url, reachable: !matches!(error.code, TargetErrorCode::RemoteUnavailable), compatible: false, requires_authentication: error.code == TargetErrorCode::RemoteAuthenticationRequired, authentication_scheme: None, product_version: None, capabilities: Vec::new(), repository: None, model_config: None, job_ready: false, message: error.message }) + }) +} + +pub fn adopt_remote(app: &AppHandle, input: RemoteTargetInput) -> Result { + let (profile, _) = validate_remote(&input)?; + let (store, mut decoded) = load_state(app)?; + let id = profile.id.clone(); + upsert_profile(&mut decoded, profile); + decoded.selected_profile_id = Some(id); + persist_state(&store, &decoded)?; + Ok(state_snapshot(decoded)) } fn managed_profile(managed: ManagedRuntimeProjection) -> TargetProfile { @@ -887,6 +1090,12 @@ fn managed_profile(managed: ManagedRuntimeProjection) -> TargetProfile { capabilities: managed.capabilities, surfaces: managed.surfaces, model_directory: Some(managed.model_directory), + remote_url: None, + remote_auth_scheme: None, + remote_auth_header: None, + remote_repository: None, + remote_model_config: None, + remote_job_ready: false, } } @@ -1247,6 +1456,9 @@ pub fn validated_selected_profile_with_cancellation( cancellation: Option<&CancellationToken>, ) -> Result { let profile = selected_profile(app)?; + if profile.kind == TargetKind::Remote { + return validated_selected_remote_profile(app); + } let validated = validate_executable_with( &profile.executable, desktop_version, @@ -1269,6 +1481,31 @@ pub fn validated_selected_profile_with_cancellation( persist_selected_validation(app, validated) } +pub fn validated_selected_remote_profile(app: &AppHandle) -> Result { + let profile = selected_profile(app)?; + let input = RemoteTargetInput { + url: profile.remote_url.clone().ok_or_else(|| TargetError::new(TargetErrorCode::ProfileMalformed, "The remote target is missing its server URL."))?, + display_name: Some(profile.display_name.clone()), + authorization: profile.remote_auth_header.clone(), + }; + let updated = validate_remote(&input); + let (store, mut decoded) = load_state(app)?; + let current = decoded.profiles.iter_mut().find(|candidate| candidate.id == profile.id).ok_or_else(|| TargetError::new(TargetErrorCode::ProfileNotFound, "The selected remote target no longer exists."))?; + match updated { + Ok((updated, _)) => { + *current = updated; + let result = current.clone(); + persist_state(&store, &decoded)?; + Ok(result) + } + Err(error) => { + current.validation_error = Some(error.clone()); + persist_state(&store, &decoded)?; + Err(error) + } + } +} + pub(crate) fn persist_selected_validation( app: &AppHandle, validated: Result, @@ -1335,6 +1572,19 @@ pub fn select_profile( "The selected VidXP target no longer exists.", ) })?; + if profile.kind == TargetKind::Remote { + let (updated, _) = validate_remote(&RemoteTargetInput { + url: profile.remote_url.clone().ok_or_else(|| TargetError::new(TargetErrorCode::ProfileMalformed, "The remote target is missing its server URL."))?, + display_name: Some(profile.display_name.clone()), + authorization: profile.remote_auth_header.clone(), + })?; + let (store, mut decoded) = load_state(app)?; + let current = decoded.profiles.iter_mut().find(|candidate| candidate.id == profile_id).ok_or_else(|| TargetError::new(TargetErrorCode::ProfileNotFound, "The selected remote target no longer exists."))?; + *current = updated; + decoded.selected_profile_id = Some(profile_id.to_owned()); + persist_state(&store, &decoded)?; + return Ok(state_snapshot(decoded)); + } let validated = validate_executable(&profile.executable, desktop_version)?; select_validated_profile(app, profile_id, validated) } diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index d2ef472d..37601a5d 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -135,11 +135,16 @@ describe('desktop target lifecycle', () => { mocks.configureExternalInstallation.mockResolvedValue(localState); }); - it('shows the target-first choice without a remote placeholder', async () => { + it('shows the remote target choice and opens remote setup', async () => { + const user = userEvent.setup(); renderApp(); - expect(await screen.findByRole('radio', { name: /Use an existing installation/i })).toBeVisible(); + expect(await screen.findByRole('radio', { name: /Connect to a remote server/i })).toBeVisible(); + expect(screen.getByRole('radio', { name: /Use an existing installation/i })).toBeVisible(); expect(screen.getByRole('radio', { name: /Set up VidXP for me/i })).toBeVisible(); - expect(screen.queryByText(/remote server/i)).not.toBeInTheDocument(); + + await user.click(screen.getByRole('radio', { name: /Connect to a remote server/i })); + await user.click(screen.getByRole('button', { name: 'Continue' })); + expect(await screen.findByRole('heading', { name: 'Connect to a VidXP server' })).toBeVisible(); }); it('shows the restored control panel immediately while one startup recheck is pending', async () => { diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index f7e533c6..6be591cf 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useReducer, useRef } from 'react'; import { LocalSetup } from './components/LocalSetup'; import { ManagedSetup } from './components/ManagedSetup'; +import { RemoteSetup } from './components/RemoteSetup'; import { TargetChoice } from './components/TargetChoice'; import { TargetSummary } from './components/TargetSummary'; import { DesktopViewport } from './components/TitleBar'; @@ -24,7 +25,7 @@ import { } from './tauri'; import { useExclusiveOperation } from './useAsyncAction'; -type Stage = 'loading' | 'choice' | 'local' | 'managed-confirm' | 'managed' | 'summary'; +type Stage = 'loading' | 'choice' | 'local' | 'remote' | 'managed-confirm' | 'managed' | 'summary'; type AppOperation = 'startup-check' | 'recheck' | 'begin-managed' | 'cancel-managed' | 'select-profile' | 'forget-profile' | 'open-browser'; interface CompletionNotice { @@ -237,9 +238,10 @@ export function App() { {saved.kind !== 'managed' && } )} } - dispatch({ type: 'choice', choice })} onContinue={() => dispatch({ type: 'navigate', stage: state.choice === 'existing_local' ? 'local' : 'managed-confirm' })} /> + dispatch({ type: 'choice', choice })} onContinue={() => dispatch({ type: 'navigate', stage: state.choice === 'existing_local' ? 'local' : state.choice === 'remote' ? 'remote' : 'managed-confirm' })} /> } {state.stage === 'local' && dispatch({ type: 'navigate', stage: 'choice' })} onActivated={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} />} + {state.stage === 'remote' && dispatch({ type: 'navigate', stage: 'choice' })} onActivated={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} />} {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', completionNotice, premiereSetupRequested: false })} />} {state.stage === 'summary' && profile && recheck()} onManageManaged={() => void beginManaged()} onSetUpPremiere={() => void beginManaged(true)} onSetupChanged={(setup) => dispatch({ type: 'operationSettled', setup, stage: 'summary' })} onChooseAnother={() => dispatch({ type: 'navigate', stage: 'choice', choice: null })} onOpen={openBrowser} />} diff --git a/desktop/src/components/RemoteSetup.tsx b/desktop/src/components/RemoteSetup.tsx new file mode 100644 index 00000000..912334c5 --- /dev/null +++ b/desktop/src/components/RemoteSetup.tsx @@ -0,0 +1,77 @@ +import { Alert, Button, Code, Group, Loader, PasswordInput, Stack, Text, TextInput, Title } from '@mantine/core'; +import { IconArrowLeft, IconCheck, IconCloud } from '@tabler/icons-react'; +import { useState } from 'react'; + +import { activateRemoteTarget, errorMessage, inspectRemoteTarget, type RemoteTargetInspection, type TargetSetupState } from '../tauri'; + +interface RemoteSetupProps { + onBack: () => void; + onActivated: (setup: TargetSetupState) => void; +} + +export function RemoteSetup({ onBack, onActivated }: RemoteSetupProps) { + const [url, setUrl] = useState('http://'); + const [name, setName] = useState('Remote VidXP'); + const [authorization, setAuthorization] = useState(''); + const [inspection, setInspection] = useState(null); + const [busy, setBusy] = useState<'check' | 'connect' | null>(null); + const [failure, setFailure] = useState(null); + + async function check() { + setBusy('check'); + setFailure(null); + try { + setInspection(await inspectRemoteTarget(url.trim(), authorization.trim() || undefined)); + } catch (error) { + setFailure(errorMessage(error, 'The remote VidXP server could not be checked.')); + } finally { + setBusy(null); + } + } + + async function connect() { + if (!inspection?.compatible) return; + setBusy('connect'); + setFailure(null); + try { + onActivated(await activateRemoteTarget(url.trim(), name.trim() || 'Remote VidXP', authorization.trim() || undefined)); + } catch (error) { + setFailure(errorMessage(error, 'The remote VidXP server could not be connected.')); + } finally { + setBusy(null); + } + } + + return ( +
+ +
+ REMOTE SERVER + Connect to a VidXP server + Desktop checks the server before saving it. Remote installation and service management stay on that server. +
+ {failure && {failure}} +
+ + setUrl(event.currentTarget.value)} disabled={busy !== null} /> + setName(event.currentTarget.value)} disabled={busy !== null} /> + setAuthorization(event.currentTarget.value)} disabled={busy !== null} /> + + +
+ {inspection &&
+ Server check + {inspection.message} + {inspection.requires_authentication && The server advertises {inspection.authentication_scheme ?? 'an authentication'} through its HTTP challenge. Enter the complete authorization header value, then check again.} + {inspection.compatible &&
+ Capabilities{inspection.capabilities.join(', ') || 'None reported'} + Repository{inspection.repository ?? 'Not reported'} + Model/config{inspection.model_config ?? 'Not reported'} + Job readiness{inspection.job_ready ? 'Ready' : 'Needs attention'} +
} + +
} + {busy === 'connect' &&
Saving remote target…
} +
+ ); +} diff --git a/desktop/src/components/TargetChoice.tsx b/desktop/src/components/TargetChoice.tsx index b5ba1347..23e2a65c 100644 --- a/desktop/src/components/TargetChoice.tsx +++ b/desktop/src/components/TargetChoice.tsx @@ -1,5 +1,5 @@ import { Button, Group, Radio, Stack, Text, ThemeIcon, Title } from '@mantine/core'; -import { IconDeviceDesktop, IconDownload } from '@tabler/icons-react'; +import { IconCloud, IconDeviceDesktop, IconDownload } from '@tabler/icons-react'; import type { TargetKind } from '../tauri'; @@ -11,6 +11,13 @@ interface TargetChoiceProps { } const targets = [ + { + value: 'remote' as const, + title: 'Connect to a remote server', + description: 'Use a VidXP server running on another computer or network.', + detail: 'The server remains responsible for its own installation and services.', + icon: IconCloud, + }, { value: 'existing_local' as const, title: 'Use an existing installation', diff --git a/desktop/src/components/TargetSummary.tsx b/desktop/src/components/TargetSummary.tsx index 684052e4..f0e21c94 100644 --- a/desktop/src/components/TargetSummary.tsx +++ b/desktop/src/components/TargetSummary.tsx @@ -74,13 +74,14 @@ export function TargetSummary({ profile, validationError, checking, operationPen const [externalTechnical, setExternalTechnical] = useState(null); const [readinessOpened, setReadinessOpened] = useState(false); const [readinessElapsed, setReadinessElapsed] = useState(0); + const remoteTarget = profile.kind === 'remote'; const needsRuntimeUpdate = validationError?.code === 'runtime_update_required'; const runtimeCompatible = !validationError; - const desktopSurfaceUnavailable = !runtimeCompatible || !profile.frontend.launchable; - const browserAvailable = runtimeCompatible && profile.surfaces.includes('browser'); + const desktopSurfaceUnavailable = !remoteTarget && (!runtimeCompatible || !profile.frontend.launchable); + const browserAvailable = !remoteTarget && runtimeCompatible && profile.surfaces.includes('browser'); const workerAvailable = runtimeCompatible && profile.surfaces.includes('worker'); - const mcpAvailable = runtimeCompatible && (profile.surfaces.includes('mcp') || profile.surfaces.includes('server')); - const serverAvailable = runtimeCompatible && profile.surfaces.includes('server'); + const mcpAvailable = !remoteTarget && runtimeCompatible && (profile.surfaces.includes('mcp') || profile.surfaces.includes('server')); + const serverAvailable = !remoteTarget && runtimeCompatible && profile.surfaces.includes('server'); const failedChecks = doctor?.checks.filter((check) => !check.ok) ?? []; const capabilityLabel = (capability: string) => capability === 'media' @@ -341,7 +342,7 @@ export function TargetSummary({ profile, validationError, checking, operationPen Choose how you want to use VidXP. Your setup is remembered the next time you open the app. - {profile.kind === 'managed' ? 'Managed by VidXP' : 'Your installation'} + {profile.kind === 'managed' ? 'Managed by VidXP' : remoteTarget ? 'Remote server' : 'Your installation'} @@ -371,28 +372,31 @@ export function TargetSummary({ profile, validationError, checking, operationPen
Status{validationError ? 'Needs attention' : 'Connected'} - Available{runtimeCompatible ? [ + Available{remoteTarget ? 'Remote VidXP server' : runtimeCompatible ? [ workerAvailable && 'local video processing', !desktopSurfaceUnavailable && 'browser interface', mcpAvailable && 'AI assistant integration', serverAvailable && 'app integration service', ].filter(Boolean).join(', ') || 'Command-line tools' : 'Available after the installation is updated'} - Search features{runtimeCompatible ? profile.capabilities.map(capabilityLabel).join(', ') || 'None installed' : 'Unknown until the installation is updated'} + Search features{runtimeCompatible ? profile.capabilities.map(capabilityLabel).join(', ') || 'None reported' : 'Unknown until the installation is updated'} {profile.last_validated_at && <>Last checked{new Date(profile.last_validated_at).toLocaleString()}}
Technical details
{profile.observed_vidxp_version && <>Version{profile.observed_vidxp_version}} + {remoteTarget && profile.remote_url && <>Server URL{profile.remote_url}} {executable && <>Program{executable}} - Data location{profile.display_data_root} + {remoteTarget + ? <>Repository{profile.remote_repository ?? 'Not reported'}Job readiness{profile.remote_job_ready ? 'Ready' : 'Needs attention'} + : <>Data location{profile.display_data_root}}
- {profile.kind === 'managed' + {remoteTarget ? null : profile.kind === 'managed' ? : } @@ -400,14 +404,14 @@ export function TargetSummary({ profile, validationError, checking, operationPen
- void openExternalSetup()} - /> + />} -
+ {!remoteTarget &&
Health and background services Check whether VidXP is usable and control only the services you enabled. @@ -478,7 +482,7 @@ export function TargetSummary({ profile, validationError, checking, operationPen } -
+
} setMcpConfig(null)} title="Connect an AI assistant" size="lg"> Copy this MCP setup into a compatible AI assistant. It already points to this VidXP installation and video library. diff --git a/desktop/src/styles.css b/desktop/src/styles.css index 4b03acff..fa85452e 100644 --- a/desktop/src/styles.css +++ b/desktop/src/styles.css @@ -120,6 +120,7 @@ button, [role='radio'], [role='checkbox'], input { -webkit-tap-highlight-color: .runtimeControlRow { gap: 1rem; padding: .9rem 0; border-top: 1px solid rgba(255,255,255,.08); } .runtimeControlRow > div:first-child { min-width: 0; flex: 1; } .connectionDetails { display: grid; gap: .25rem; margin-top: .65rem; } +.technicalSummary { margin-top: .8rem; } .mcpConfigCode { max-height: 22rem; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } .confirmationPanel { max-width: 42rem; margin: .75rem auto 0; padding: clamp(1.25rem, 3dvh, 2.25rem); border-radius: 1.25rem; text-align: center; } .ownershipNote { display: grid; grid-template-columns: auto 1fr; gap: .35rem 1rem; margin-top: 1.5rem; padding: 1rem; border-radius: .75rem; background: rgba(121,80,242,.12); text-align: left; } diff --git a/desktop/src/tauri.ts b/desktop/src/tauri.ts index cfe6275c..9f363700 100644 --- a/desktop/src/tauri.ts +++ b/desktop/src/tauri.ts @@ -1,7 +1,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; -export type TargetKind = 'existing_local' | 'managed'; +export type TargetKind = 'existing_local' | 'managed' | 'remote'; export type LifecycleOwnership = 'external' | 'desktop'; const WINDOWS_EXTENDED_PATH_PREFIX = '\\\\?\\'; @@ -61,6 +61,11 @@ interface WireTargetProfile { capabilities: string[]; surfaces: string[]; model_directory?: string; + remote_url?: string; + remote_auth_scheme?: string; + remote_repository?: string; + remote_model_config?: string; + remote_job_ready: boolean; } interface WireTargetState { @@ -77,6 +82,20 @@ export interface TargetProfile extends WireTargetProfile { last_validated_at: string | null; } +export interface RemoteTargetInspection { + url: string; + reachable: boolean; + compatible: boolean; + requires_authentication: boolean; + authentication_scheme: string | null; + product_version: string | null; + capabilities: string[]; + repository: string | null; + model_config: string | null; + job_ready: boolean; + message: string; +} + export interface TargetSetupState { profiles: TargetProfile[]; selected_profile_id: string | null; @@ -378,6 +397,18 @@ export function activateLocalTarget(executable: string, displayName?: string): P }).then(normalizeState); } +export function inspectRemoteTarget(url: string, authorization?: string): Promise { + return invoke('inspect_remote_target', { url, authorization: authorization || null }); +} + +export function activateRemoteTarget(url: string, displayName: string, authorization?: string): Promise { + return invoke('adopt_remote_target', { + url, + displayName: displayName || null, + authorization: authorization || null, + }).then(normalizeState); +} + export function selectTargetProfile(profileId: string): Promise { return invoke('select_target_profile', { profileId }).then(normalizeState); }