1#![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
13pub 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(¶ms.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(¤t);
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
636pub 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}