Skip to main content

qualia_client_core/api/
qapp_launcher.rs

1//! Qapp launcher
2
3#![allow(non_snake_case)]
4
5use super::*;
6
7use crate::qapp_paths::{qapps_dir, resolve_package_manifest_path};
8use crate::qapp_registry;
9use serde::Serialize;
10use std::path::{Path, PathBuf};
11use std::time::Duration;
12
13/// Load `qapp.json` for an installed qapp.
14pub fn load_installed_qapp_package(
15    qapp_name: &str,
16) -> Result<qapp_registry::QappPackageManifest, String> {
17    let state = crate::state::APP_STATE
18        .get()
19        .ok_or("APP_STATE not initialized")?;
20    let data_dir = state.config.lock().unwrap().storage_path.clone();
21    let qapp_dir = qapps_dir(&data_dir).join(qapp_name);
22    load_qapp_package_from_dir(&qapp_dir)
23}
24
25pub(crate) fn load_qapp_package_from_dir(
26    qapp_dir: &Path,
27) -> Result<qapp_registry::QappPackageManifest, String> {
28    let manifest_path = resolve_package_manifest_path(qapp_dir)
29        .ok_or_else(|| format!("qapp.json not found in {}", qapp_dir.display()))?;
30    let content = std::fs::read_to_string(&manifest_path).map_err(|e| e.to_string())?;
31    serde_json::from_str::<qapp_registry::QappPackageManifest>(&content)
32        .map_err(|e| format!("Invalid qapp package manifest: {e}"))
33}
34
35fn resolve_entrypoint_path(
36    manifest: &qapp_registry::QappPackageManifest,
37    entrypoint: Option<&str>,
38) -> String {
39    let named_entrypoints = manifest.x_qualia.as_ref().map(|ext| &ext.entrypoints);
40
41    match entrypoint {
42        Some(requested) if !requested.trim().is_empty() => named_entrypoints
43            .and_then(|map| map.get(requested))
44            .cloned()
45            .unwrap_or_else(|| requested.to_string()),
46        _ => named_entrypoints
47            .and_then(|map| map.get("web"))
48            .cloned()
49            .unwrap_or_else(|| "index.html".to_string()),
50    }
51}
52
53fn split_asset_and_hash(relative_path: &str) -> (String, Option<String>) {
54    let mut parts = relative_path.splitn(2, '#');
55    let asset = parts.next().unwrap_or("").trim().trim_start_matches('/');
56    let hash = parts
57        .next()
58        .map(str::trim)
59        .filter(|value| !value.is_empty());
60    let asset_path = if asset.is_empty() {
61        "index.html".to_string()
62    } else {
63        asset.to_string()
64    };
65    (asset_path, hash.map(str::to_string))
66}
67
68fn encode_query_component(input: &str) -> String {
69    let mut encoded = String::with_capacity(input.len());
70    for byte in input.bytes() {
71        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
72            encoded.push(char::from(byte));
73        } else {
74            encoded.push('%');
75            encoded.push_str(&format!("{:02X}", byte));
76        }
77    }
78    encoded
79}
80
81fn append_launch_context(
82    mut base_url: String,
83    source: Option<String>,
84    surface: Option<String>,
85    payload_json: Option<String>,
86    qapp_name: Option<&str>,
87) -> String {
88    let mut params = Vec::new();
89
90    if let Some(source) = source.filter(|value| !value.trim().is_empty()) {
91        params.push(format!(
92            "qualia_source={}",
93            encode_query_component(source.trim())
94        ));
95    }
96
97    if let Some(surface) = surface.filter(|value| !value.trim().is_empty()) {
98        params.push(format!(
99            "qualia_surface={}",
100            encode_query_component(surface.trim())
101        ));
102    }
103
104    if let Some(payload_json) = payload_json.filter(|value| !value.trim().is_empty()) {
105        params.push(format!(
106            "qualia_payload={}",
107            encode_query_component(payload_json.trim())
108        ));
109    }
110
111    if let Some(qapp_name) = qapp_name.filter(|value| !value.trim().is_empty()) {
112        if let Ok(token) = issue_qapp_session_token(qapp_name.trim()) {
113            params.push(format!("qualia_token={}", encode_query_component(&token)));
114        }
115        let port = get_active_daemon_port();
116        if port > 0 {
117            params.push(format!("qualia_daemon_port={port}"));
118        }
119        params.push(format!(
120            "qualia_qapp={}",
121            encode_query_component(qapp_name.trim())
122        ));
123    }
124
125    if !params.is_empty() {
126        base_url.push(if base_url.contains('?') { '&' } else { '?' });
127        base_url.push_str(&params.join("&"));
128    }
129
130    base_url
131}
132
133fn append_hash_fragment(mut base_url: String, hash_fragment: Option<String>) -> String {
134    if let Some(hash_fragment) = hash_fragment {
135        base_url.push('#');
136        base_url.push_str(&hash_fragment);
137    }
138    base_url
139}
140
141#[derive(Serialize)]
142struct SparqlEndpointProbe {
143    target: String,
144    resolved_endpoint: String,
145    reachable: bool,
146    status_code: Option<u16>,
147    detail: String,
148    federation_supported: Option<bool>,
149}
150
151#[derive(Serialize)]
152struct AppRequirementCheck {
153    kind: String,
154    id: String,
155    required: bool,
156    status: String,
157    detail: String,
158}
159
160#[derive(Serialize)]
161struct QappReadinessReport {
162    qapp_name: String,
163    ready: bool,
164    summary: String,
165    blocking_issues: usize,
166    optional_warnings: usize,
167    checks: Vec<AppRequirementCheck>,
168}
169
170pub fn load_workspace_catalog() -> qualia_core_db::resource_catalog::ResourceCatalog {
171    qualia_core_db::resource_catalog::load_default()
172        .unwrap_or_else(|_| qualia_core_db::resource_catalog::ResourceCatalog::empty())
173}
174
175fn catalog_has_llm(
176    catalog: &qualia_core_db::resource_catalog::ResourceCatalog,
177    model: &str,
178) -> bool {
179    if catalog.find_llm(model).is_some() {
180        return true;
181    }
182    let target = normalize_resource_key(model);
183    catalog.llms.iter().any(|entry| {
184        normalize_resource_key(&entry.id) == target
185            || entry
186                .download
187                .local_filename()
188                .map(|file| normalize_resource_key(&file) == target)
189                .unwrap_or(false)
190    })
191}
192
193fn catalog_has_ontology(
194    catalog: &qualia_core_db::resource_catalog::ResourceCatalog,
195    ontology: &str,
196) -> bool {
197    if catalog.find_ontology(ontology).is_some() {
198        return true;
199    }
200    let target = normalize_resource_key(ontology);
201    catalog
202        .ontologies
203        .iter()
204        .any(|entry| normalize_resource_key(&entry.id) == target)
205}
206
207fn normalize_resource_key(value: &str) -> String {
208    value
209        .chars()
210        .filter(|c| c.is_ascii_alphanumeric())
211        .map(|c| c.to_ascii_lowercase())
212        .collect()
213}
214
215fn directory_contains_requirement(dir: &Path, requirement: &str) -> bool {
216    let target = normalize_resource_key(requirement);
217    std::fs::read_dir(dir)
218        .ok()
219        .into_iter()
220        .flat_map(|entries| entries.filter_map(Result::ok))
221        .any(|entry| {
222            let file_name = entry.file_name();
223            let candidate = normalize_resource_key(&file_name.to_string_lossy());
224            candidate.contains(&target) || target.contains(&candidate)
225        })
226}
227
228fn collect_matching_files(dir: &Path, requirement: &str) -> Vec<PathBuf> {
229    let target = normalize_resource_key(requirement);
230    std::fs::read_dir(dir)
231        .ok()
232        .into_iter()
233        .flat_map(|entries| entries.filter_map(Result::ok))
234        .map(|entry| entry.path())
235        .filter(|path| path.is_file())
236        .filter(|path| {
237            let file_name = path
238                .file_name()
239                .map(|name| name.to_string_lossy().to_string())
240                .unwrap_or_default();
241            let candidate = normalize_resource_key(&file_name);
242            candidate.contains(&target) || target.contains(&candidate)
243        })
244        .collect()
245}
246
247fn resolve_sparql_endpoint_from_catalog(
248    endpoint_or_id: &str,
249) -> Result<(String, Option<bool>), String> {
250    if endpoint_or_id.starts_with("http://") || endpoint_or_id.starts_with("https://") {
251        return Ok((endpoint_or_id.to_string(), None));
252    }
253
254    let catalog = load_workspace_catalog();
255    catalog
256        .find_sparql(endpoint_or_id)
257        .or_else(|| {
258            let target = normalize_resource_key(endpoint_or_id);
259            catalog
260                .sparql_endpoints
261                .iter()
262                .find(|entry| normalize_resource_key(&entry.id) == target)
263        })
264        .map(|entry| (entry.endpoint.clone(), entry.federation_supported))
265        .ok_or_else(|| format!("Unknown SPARQL endpoint id: {}", endpoint_or_id))
266}
267
268fn evaluate_capability_requirement(
269    requirement: &qapp_registry::QappLaunchRequirement,
270    daemon_running: bool,
271) -> AppRequirementCheck {
272    let (status, detail) = match requirement.capability.as_str() {
273        "qualia.localDaemon.health" | "qualia.localDaemon.query" => {
274            if daemon_running {
275                ("ready", "Local Qualia daemon is running.")
276            } else {
277                ("missing", "Local Qualia daemon is not currently running.")
278            }
279        }
280        "qualia.wasm.execute_ntriples_query"
281        | "qualia.wasm.compile_query_to_json"
282        | "qualia.wasm.validate_shacl_constraint" => (
283            "declared",
284            "WASM capability is manifest-declared but not actively verified by the desktop host.",
285        ),
286        "qualia.flutter.chatRepresentationLaunch" => (
287            "ready",
288            "Flutter desktop host can launch an app with chat representation context.",
289        ),
290        _ => (
291            "declared",
292            "Capability is declared in the manifest but not yet actively checked by the desktop host.",
293        ),
294    };
295
296    AppRequirementCheck {
297        kind: "capability".to_string(),
298        id: requirement.capability.clone(),
299        required: requirement.required,
300        status: status.to_string(),
301        detail: detail.to_string(),
302    }
303}
304
305pub fn inspect_installed_qapp_readiness(qapp_name: String) -> Result<String, String> {
306    let state = crate::state::APP_STATE.get().unwrap();
307    let data_dir = state.config.lock().unwrap().storage_path.clone();
308    let qapp_dir = qapps_dir(&data_dir).join(&qapp_name);
309    if !qapp_dir.exists() {
310        return Err(format!("Qapp directory not found: {qapp_name}"));
311    }
312
313    let manifest = load_qapp_package_from_dir(&qapp_dir)?;
314    let extension = manifest.x_qualia.clone().unwrap_or_default();
315    let daemon_running = *state.daemon_running.lock().unwrap();
316
317    let models_dir = PathBuf::from(&data_dir).join("Models");
318    let index_dir = PathBuf::from(&data_dir).join("Index");
319    let library_dir = PathBuf::from(&data_dir).join("SemanticLibrary");
320
321    let catalog = load_workspace_catalog();
322
323    let mut checks = Vec::new();
324
325    for requirement in &extension.requires {
326        checks.push(evaluate_capability_requirement(requirement, daemon_running));
327    }
328
329    if extension.local_daemon.is_some() {
330        checks.push(AppRequirementCheck {
331            kind: "daemon".to_string(),
332            id: "local-daemon".to_string(),
333            required: false,
334            status: if daemon_running { "ready" } else { "inactive" }.to_string(),
335            detail: if daemon_running {
336                "Local Qualia daemon is available for app integrations.".to_string()
337            } else {
338                "App declares local daemon integration, but the daemon is not running.".to_string()
339            },
340        });
341    }
342
343    for ontology in &extension.required_ontologies {
344        let in_catalog = catalog_has_ontology(&catalog, ontology);
345        let installed = directory_contains_requirement(&index_dir, ontology)
346            || directory_contains_requirement(&library_dir, ontology);
347        let status = if installed { "ready" } else { "missing" };
348        let detail = if installed {
349            format!(
350                "Ontology `{}` appears to be present in local Qualia storage.",
351                ontology
352            )
353        } else if in_catalog {
354            format!(
355                "Ontology `{}` is known in the bundled resource catalog but is not installed locally.",
356                ontology
357            )
358        } else {
359            format!(
360                "Ontology `{}` is required by the app but is not installed and was not found in the bundled catalog.",
361                ontology
362            )
363        };
364        checks.push(AppRequirementCheck {
365            kind: "ontology".to_string(),
366            id: ontology.clone(),
367            required: true,
368            status: status.to_string(),
369            detail,
370        });
371    }
372
373    for model in &extension.required_models {
374        let in_catalog = catalog_has_llm(&catalog, model);
375        let installed = directory_contains_requirement(&models_dir, model);
376        let status = if installed { "ready" } else { "missing" };
377        let detail = if installed {
378            format!(
379                "Model `{}` appears to be present in the local Models directory.",
380                model
381            )
382        } else if in_catalog {
383            format!(
384                "Model `{}` is known in the bundled model catalog but is not downloaded locally.",
385                model
386            )
387        } else {
388            format!(
389                "Model `{}` is required by the app but is not present and was not found in the bundled model catalog.",
390                model
391            )
392        };
393        checks.push(AppRequirementCheck {
394            kind: "model".to_string(),
395            id: model.clone(),
396            required: true,
397            status: status.to_string(),
398            detail,
399        });
400    }
401
402    for endpoint in &extension.optional_remote_endpoints {
403        let match_entry = catalog.find_sparql(endpoint).or_else(|| {
404            catalog.sparql_endpoints.iter().find(|entry| {
405                entry.endpoint == *endpoint
406                    || normalize_resource_key(&entry.id) == normalize_resource_key(endpoint)
407            })
408        });
409        let (status, detail) = if let Some(entry) = match_entry {
410            let federation_note = match entry.federation_supported {
411                Some(true) => " Federation is advertised as supported.",
412                Some(false) => " Federation is not advertised as supported.",
413                None => "",
414            };
415            (
416                "cataloged",
417                format!(
418                    "Endpoint `{}` is known to the bundled SPARQL catalog at {}.{}",
419                    endpoint, entry.endpoint, federation_note
420                ),
421            )
422        } else if endpoint.starts_with("http://") || endpoint.starts_with("https://") {
423            (
424                "declared",
425                format!(
426                    "Endpoint `{}` is declared directly in the manifest. The desktop host does not currently verify live reachability.",
427                    endpoint
428                ),
429            )
430        } else {
431            (
432                "missing",
433                format!(
434                    "Endpoint `{}` is not present in the bundled SPARQL catalog and is not an explicit URL.",
435                    endpoint
436                ),
437            )
438        };
439        checks.push(AppRequirementCheck {
440            kind: "sparql-endpoint".to_string(),
441            id: endpoint.clone(),
442            required: false,
443            status: status.to_string(),
444            detail,
445        });
446    }
447
448    let blocking_issues = checks
449        .iter()
450        .filter(|check| check.required && check.status != "ready")
451        .count();
452    let optional_warnings = checks
453        .iter()
454        .filter(|check| {
455            !check.required && !matches!(check.status.as_str(), "ready" | "cataloged" | "declared")
456        })
457        .count();
458    let ready = blocking_issues == 0;
459    let summary = if ready {
460        format!(
461            "`{}` is ready to launch with {} optional warnings.",
462            qapp_name, optional_warnings
463        )
464    } else {
465        format!(
466            "`{}` is missing {} required resources or capabilities.",
467            qapp_name, blocking_issues
468        )
469    };
470
471    let report = QappReadinessReport {
472        qapp_name,
473        ready,
474        summary,
475        blocking_issues,
476        optional_warnings,
477        checks,
478    };
479    serde_json::to_string(&report).map_err(|e| e.to_string())
480}
481
482pub fn list_installed_ontology_artifacts() -> Vec<String> {
483    let state = crate::state::APP_STATE.get().unwrap();
484    let data_dir = state.config.lock().unwrap().storage_path.clone();
485    let dirs = [
486        PathBuf::from(&data_dir).join("Index"),
487        PathBuf::from(&data_dir).join("SemanticLibrary"),
488    ];
489    let mut artifacts = Vec::new();
490
491    for dir in dirs {
492        if let Ok(entries) = std::fs::read_dir(dir) {
493            for entry in entries.filter_map(Result::ok) {
494                let path = entry.path();
495                if path.is_file() {
496                    artifacts.push(entry.file_name().to_string_lossy().to_string());
497                }
498            }
499        }
500    }
501
502    artifacts.sort();
503    artifacts.dedup();
504    artifacts
505}
506
507pub fn remove_installed_ontology(ontology_id: String) -> Result<String, String> {
508    let state = crate::state::APP_STATE.get().unwrap();
509    let data_dir = state.config.lock().unwrap().storage_path.clone();
510    let dirs = [
511        PathBuf::from(&data_dir).join("Index"),
512        PathBuf::from(&data_dir).join("SemanticLibrary"),
513    ];
514    let mut removed = 0usize;
515
516    for dir in dirs {
517        for path in collect_matching_files(&dir, &ontology_id) {
518            std::fs::remove_file(&path)
519                .map_err(|e| format!("Failed to remove {}: {}", path.display(), e))?;
520            removed += 1;
521        }
522    }
523
524    if removed == 0 {
525        return Err(format!(
526            "No installed ontology artifacts matched `{}`.",
527            ontology_id
528        ));
529    }
530
531    Ok(format!(
532        "Removed {} ontology artifact(s) for `{}`.",
533        removed, ontology_id
534    ))
535}
536
537pub fn remove_installed_model(model_id: String) -> Result<String, String> {
538    let state = crate::state::APP_STATE.get().unwrap();
539    let data_dir = state.config.lock().unwrap().storage_path.clone();
540    let models_dir = PathBuf::from(&data_dir).join("Models");
541    let matches = collect_matching_files(&models_dir, &model_id);
542    let mut removed_names = Vec::new();
543
544    for path in matches {
545        let name = path
546            .file_name()
547            .map(|n| n.to_string_lossy().to_string())
548            .unwrap_or_default();
549        let is_gguf = path
550            .extension()
551            .map(|ext| ext.to_string_lossy().eq_ignore_ascii_case("gguf"))
552            .unwrap_or(false);
553        let is_install = name.ends_with(".install.json");
554        if is_gguf || is_install {
555            std::fs::remove_file(&path)
556                .map_err(|e| format!("Failed to remove {}: {}", path.display(), e))?;
557            if is_gguf {
558                removed_names.push(name);
559            }
560        }
561    }
562
563    if removed_names.is_empty() {
564        return Err(format!("No installed model matched `{}`.", model_id));
565    }
566
567    {
568        let mut active_model = state.active_model.lock().unwrap();
569        if let Some(current) = active_model.clone() {
570            let normalized_current = normalize_resource_key(&current);
571            if removed_names
572                .iter()
573                .any(|name| normalized_current.contains(&normalize_resource_key(name)))
574            {
575                if let Some(record) = load_active_model_record_from_disk() {
576                    crate::model_lifecycle::unload_active_model(Some(record.profile_id));
577                } else {
578                    crate::model_lifecycle::unload_active_model(None);
579                }
580                *active_model = None;
581                clear_active_model_record();
582            }
583        }
584    }
585
586    Ok(format!(
587        "Removed {} model file(s): {}",
588        removed_names.len(),
589        removed_names.join(", ")
590    ))
591}
592
593pub fn test_sparql_endpoint(endpoint_or_id: String) -> Result<String, String> {
594    let (endpoint, federation_supported) = resolve_sparql_endpoint_from_catalog(&endpoint_or_id)?;
595    let client = reqwest::blocking::Client::builder()
596        .timeout(Duration::from_secs(8))
597        .build()
598        .map_err(|e| format!("SPARQL probe client error: {}", e))?;
599
600    let response = client
601        .get(&endpoint)
602        .header(
603            "Accept",
604            "application/sparql-results+json, application/json;q=0.9, */*;q=0.1",
605        )
606        .send();
607
608    let probe = match response {
609        Ok(response) => {
610            let status = response.status();
611            let reachable = status.is_success()
612                || status.is_redirection()
613                || matches!(status.as_u16(), 400 | 401 | 403 | 405 | 406);
614            SparqlEndpointProbe {
615                target: endpoint_or_id,
616                resolved_endpoint: endpoint,
617                reachable,
618                status_code: Some(status.as_u16()),
619                detail: format!("Endpoint responded with HTTP {}.", status.as_u16()),
620                federation_supported,
621            }
622        }
623        Err(err) => SparqlEndpointProbe {
624            target: endpoint_or_id,
625            resolved_endpoint: endpoint,
626            reachable: false,
627            status_code: None,
628            detail: format!("Endpoint probe failed: {}", err),
629            federation_supported,
630        },
631    };
632
633    serde_json::to_string(&probe).map_err(|e| e.to_string())
634}
635
636/// Returns the URL that should be opened in the system browser for a qapp.
637/// Looks up by directory name inside `{storage_path}/Qapps/`.
638pub fn launch_installed_qapp(qapp_name: String) -> Result<String, String> {
639    launch_installed_qapp_with_context(qapp_name.clone(), None, None, None, None)
640}
641
642pub fn launch_installed_qapp_with_context(
643    qapp_name: String,
644    entrypoint: Option<String>,
645    surface: Option<String>,
646    payload_json: Option<String>,
647    source: Option<String>,
648) -> Result<String, String> {
649    let state = crate::state::APP_STATE.get().unwrap();
650    let data_dir = state.config.lock().unwrap().storage_path.clone();
651    let storage_path = std::path::PathBuf::from(&data_dir);
652    if crate::qapp_install::is_package_revoked(&storage_path, &qapp_name) {
653        return Err(format!("Qapp package revoked: {qapp_name}"));
654    }
655    let qapp_dir = crate::qapp_paths::resolve_active_package_dir(&storage_path, &qapp_name);
656
657    if !qapp_dir
658        .join(crate::qapp_registry::QAPP_PACKAGE_MANIFEST)
659        .is_file()
660    {
661        return Err(format!("Qapp directory not found: {qapp_name}"));
662    }
663
664    let manifest = load_qapp_package_from_dir(&qapp_dir)?;
665    let resolved_entrypoint = resolve_entrypoint_path(&manifest, entrypoint.as_deref());
666    let (asset_path, hash_fragment) = split_asset_and_hash(&resolved_entrypoint);
667    let asset_file = qapp_dir.join(&asset_path);
668
669    let base_url = if let Some(port) = manifest.dev_port {
670        let trimmed = asset_path.trim_start_matches('/');
671        if trimmed.is_empty() || trimmed == "index.html" {
672            format!("http://localhost:{}", port)
673        } else {
674            format!("http://localhost:{}/{}", port, trimmed)
675        }
676    } else {
677        if !asset_file.exists() {
678            return Err(format!(
679                "{} not found in {}",
680                asset_path,
681                qapp_dir.display()
682            ));
683        }
684
685        if crate::qapps_protocol::qualia_protocol_port() != 0 {
686            crate::qapps_protocol::qualia_qapp_asset_url(&qapp_name, &asset_path)
687                .unwrap_or_else(|_| format!("file:///{}", asset_file.display()).replace('\\', "/"))
688        } else {
689            format!("file:///{}", asset_file.display()).replace('\\', "/")
690        }
691    };
692
693    let base_url = append_launch_context(base_url, source, surface, payload_json, Some(&qapp_name));
694    Ok(append_hash_fragment(base_url, hash_fragment))
695}