use flate2::read::GzDecoder; use std::{ fs::{self, File}, io::Read, path::PathBuf, sync::Arc, }; use tar::Archive; use tauri::{ App, Emitter, Manager, Runtime, Wry, WindowEvent }; #[cfg(desktop)] use tauri::{ menu::{Menu, MenuItem, PredefinedMenuItem}, tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}, }; use tauri_plugin_store::Store; use crate::core::mcp::helpers::add_server_config; use super::{ extensions::commands::get_jan_extensions_path, mcp::helpers::run_mcp_commands, state::AppState, }; pub fn install_extensions(app: tauri::AppHandle, force: bool) -> Result<(), String> { // Skip extension installation on mobile platforms // Mobile uses pre-bundled extensions loaded via MobileCoreService in the frontend #[cfg(any(target_os = "android", target_os = "ios"))] { return Ok(()); } let extensions_path = get_jan_extensions_path(app.clone()); let pre_install_path = app .path() .resource_dir() .unwrap() .join("resources") .join("pre-install"); let mut clean_up = force; // Check IS_CLEAN environment variable to optionally skip extension install if std::env::var("IS_CLEAN").is_ok() { clean_up = true; } log::info!("Installing extensions. Clean up: {clean_up}"); if !clean_up && extensions_path.exists() { return Ok(()); } // Attempt to remove extensions folder if extensions_path.exists() { fs::remove_dir_all(&extensions_path).unwrap_or_else(|_| { log::info!("Failed to remove existing extensions folder, it may not exist."); }); } // Attempt to create it again if !extensions_path.exists() { fs::create_dir_all(&extensions_path).map_err(|e| e.to_string())?; } let extensions_json_path = extensions_path.join("extensions.json"); let mut extensions_list = if extensions_json_path.exists() { let existing_data = fs::read_to_string(&extensions_json_path).unwrap_or_else(|_| "[]".to_string()); serde_json::from_str::>(&existing_data).unwrap_or_else(|_| vec![]) } else { vec![] }; for entry in fs::read_dir(&pre_install_path).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if path.extension().is_some_and(|ext| ext == "tgz") { let tar_gz = File::open(&path).map_err(|e| e.to_string())?; let gz_decoder = GzDecoder::new(tar_gz); let mut archive = Archive::new(gz_decoder); let mut extension_name = None; let mut extension_manifest = None; extract_extension_manifest(&mut archive) .map_err(|e| e.to_string()) .and_then(|manifest| match manifest { Some(manifest) => { extension_name = manifest["name"].as_str().map(|s| s.to_string()); extension_manifest = Some(manifest); Ok(()) } None => Err("Manifest is None".to_string()), })?; let extension_name = extension_name.ok_or("package.json not found in archive")?; let extension_dir = extensions_path.join(extension_name.clone()); fs::create_dir_all(&extension_dir).map_err(|e| e.to_string())?; let tar_gz = File::open(&path).map_err(|e| e.to_string())?; let gz_decoder = GzDecoder::new(tar_gz); let mut archive = Archive::new(gz_decoder); for entry in archive.entries().map_err(|e| e.to_string())? { let mut entry = entry.map_err(|e| e.to_string())?; let file_path = entry.path().map_err(|e| e.to_string())?; let components: Vec<_> = file_path.components().collect(); if components.len() > 1 { let relative_path: PathBuf = components[1..].iter().collect(); let target_path = extension_dir.join(relative_path); if let Some(parent) = target_path.parent() { fs::create_dir_all(parent).map_err(|e| e.to_string())?; } let _result = entry.unpack(&target_path).map_err(|e| e.to_string())?; } } let main_entry = extension_manifest .as_ref() .and_then(|manifest| manifest["main"].as_str()) .unwrap_or("index.js"); let url = extension_dir.join(main_entry).to_string_lossy().to_string(); let new_extension = serde_json::json!({ "url": url, "name": extension_name.clone(), "origin": extension_dir.to_string_lossy(), "active": true, "description": extension_manifest .as_ref() .and_then(|manifest| manifest["description"].as_str()) .unwrap_or(""), "version": extension_manifest .as_ref() .and_then(|manifest| manifest["version"].as_str()) .unwrap_or(""), "productName": extension_manifest .as_ref() .and_then(|manifest| manifest["productName"].as_str()) .unwrap_or(""), }); extensions_list.push(new_extension); log::info!("Installed extension to {extension_dir:?}"); } } fs::write( &extensions_json_path, serde_json::to_string_pretty(&extensions_list).map_err(|e| e.to_string())?, ) .map_err(|e| e.to_string())?; Ok(()) } // Migrate MCP servers configuration pub fn migrate_mcp_servers( app_handle: tauri::AppHandle, store: Arc>, ) -> Result<(), String> { let mcp_version = store .get("mcp_version") .and_then(|v| v.as_i64()) .unwrap_or(0); if mcp_version < 1 { log::info!("Migrating MCP schema version 1"); let result = add_server_config( app_handle, "exa".to_string(), serde_json::json!({ "command": "npx", "args": ["-y", "exa-mcp-server"], "env": { "EXA_API_KEY": "YOUR_EXA_API_KEY_HERE" }, "active": false }), ); if let Err(e) = result { log::error!("Failed to add server config: {e}"); } } store.set("mcp_version", 1); store.save().expect("Failed to save store"); Ok(()) } pub fn extract_extension_manifest( archive: &mut Archive, ) -> Result, String> { let entry = archive .entries() .map_err(|e| e.to_string())? .filter_map(|e| e.ok()) // Ignore errors in individual entries .find(|entry| { if let Ok(file_path) = entry.path() { let path_str = file_path.to_string_lossy(); path_str == "package/package.json" || path_str == "package.json" } else { false } }); if let Some(mut entry) = entry { let mut content = String::new(); entry .read_to_string(&mut content) .map_err(|e| e.to_string())?; let package_json: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?; return Ok(Some(package_json)); } Ok(None) } pub fn setup_mcp(app: &App) { let state = app.state::(); let servers = state.mcp_servers.clone(); let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { if let Err(e) = run_mcp_commands(&app_handle, servers).await { log::error!("Failed to run mcp commands: {e}"); } app_handle .emit("mcp-update", "MCP servers updated") .unwrap(); }); } #[cfg(desktop)] pub fn setup_tray(app: &App) -> tauri::Result { let show_i = MenuItem::with_id(app.handle(), "open", "Open Jan", true, None::<&str>)?; let quit_i = MenuItem::with_id(app.handle(), "quit", "Quit", true, None::<&str>)?; let separator_i = PredefinedMenuItem::separator(app.handle())?; let menu = Menu::with_items(app.handle(), &[&show_i, &separator_i, &quit_i])?; TrayIconBuilder::with_id("tray") .icon(app.default_window_icon().unwrap().clone()) .menu(&menu) .show_menu_on_left_click(false) .on_tray_icon_event(|tray, event| match event { TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. } => { // let's show and focus the main window when the tray is clicked let app = tray.app_handle(); if let Some(window) = app.get_webview_window("main") { let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } } _ => { log::debug!("unhandled event {event:?}"); } }) .on_menu_event(|app, event| match event.id.as_ref() { "open" => { let window = app.get_webview_window("main").unwrap(); window.show().unwrap(); window.set_focus().unwrap(); } "quit" => { app.exit(0); } other => { println!("menu item {other} not handled"); } }) .build(app) } pub fn setup_theme_listener(app: &App) -> tauri::Result<()> { // Setup theme listener for main window if let Some(window) = app.get_webview_window("main") { setup_window_theme_listener(app.handle().clone(), window); } Ok(()) } fn setup_window_theme_listener( app_handle: tauri::AppHandle, window: tauri::WebviewWindow, ) { let window_label = window.label().to_string(); let app_handle_clone = app_handle.clone(); window.on_window_event(move |event| { if let WindowEvent::ThemeChanged(theme) = event { let theme_str = match theme { tauri::Theme::Light => "light", tauri::Theme::Dark => "dark", _ => "auto", }; log::info!("System theme changed to: {} for window: {}", theme_str, window_label); let _ = app_handle_clone.emit("theme-changed", theme_str); } }); }