//! Application start (replaces Electron `bootstrap()`): data-directory guard and backup, //! mandatory license dialog, then Core composition and the main window. use crate::commands::license::{LicenseFlow, LicenseSlot}; use crate::core::app::{BuildInfo, Core, CoreSlot}; use crate::core::availability::{MonitorHost, Outcome, Target, DEFAULT_INTERVAL_SECS, JUMP_TIMEOUT}; use crate::core::biometrics::BiometricService; use crate::core::config::ConfigStore; use crate::core::diagnostics::service::DiagnosticsService; use crate::core::i18n::I18n; use crate::core::legal::LicenseAcceptance; use crate::core::lock::LockController; use crate::core::logging::Logger; use crate::core::migration; use crate::core::modules::installer::ModuleInstaller; use crate::core::modules::registry::ModuleRegistry; use crate::core::modules::service::ModuleService; use crate::core::net::https::{HttpsClient, INSECURE_LEGACY_ORIGINS}; use crate::core::paths::AppPaths; use crate::core::profile_transfer::ProfileTransfer; use crate::core::profiles::ProfileService; use crate::core::services::Services; use crate::core::ssh::privileged::PrivilegedExecutor; use crate::core::ssh::session::SessionManager; use crate::core::ssh::windows_exec::WindowsExecutor; use crate::core::vault::VaultStore; use crate::security::caller::WindowRegistry; use serde_json::{json, Value}; use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; /// Held for the whole process lifetime. pub struct RuntimeGuard(#[allow(dead_code)] pub migration::RuntimeLock); pub struct Prepared { pub paths: AppPaths, pub logger: Arc, pub i18n: Arc, pub config: Arc, pub build: BuildInfo, } fn fatal(app: &AppHandle, message: &str) { let handle = app.clone(); app.dialog().message(message).title("CH-J Server Manager").kind(MessageDialogKind::Error).show(move |_| handle.exit(1)); } pub fn start(app: &AppHandle) { let resources = app.path().resource_dir().unwrap_or_default(); let paths = match AppPaths::resolve(app.path().config_dir().ok(), resources) { Ok(paths) => paths, Err(error) => return fatal(app, &error), }; if migration::legacy_electron_running(&paths.data) { return fatal( app, "CH-J Server Manager 0.1.0 is still running and uses the same data. Quit it first, then start the application again.\n\nCH-J Server Manager 0.1.0 stále běží nad stejnými daty. Nejprve ho ukončete a aplikaci spusťte znovu.", ); } match migration::acquire_runtime_lock(&paths.data) { Ok(lock) => { app.manage(RuntimeGuard(lock)); } Err(error) => return fatal(app, &error.message), } let logger = Arc::new(Logger::new(&paths.data)); let build = BuildInfo::current(&app.package_info().version.to_string()); let backup = migration::prepare_data_directory(&paths.data, &build.version, &build.build_id); match &backup { Ok(report) => logger.info("Data directory prepared.", serde_json::to_value(report).unwrap_or(Value::Null)), Err(error) => logger.error("Pre-update backup failed.", json!({ "code": error.code, "message": error.message })), } let assets = app.asset_resolver(); let i18n = Arc::new(I18n::load(|path| assets.get(path.to_string()).map(|asset| asset.bytes().to_vec()))); let config = Arc::new(ConfigStore::new(&paths.data, i18n.codes())); config.load(); let prepared = Prepared { paths, logger, i18n, config, build }; if let Err(error) = backup { // The data was not modified; continuing is the user's explicit decision. let handle = app.clone(); let message = format!( "The automatic backup before this update failed ({}). Your data was not changed.\n\nContinue without a new backup?\n\nAutomatická záloha před aktualizací selhala ({}). Data nebyla změněna. Pokračovat bez nové zálohy?", error.code, error.code ); app.dialog() .message(message) .title("CH-J Server Manager") .kind(MessageDialogKind::Warning) .buttons(MessageDialogButtons::OkCancelCustom("Continue / Pokračovat".into(), "Quit / Ukončit".into())) .show(move |proceed| if proceed { license_then_core(&handle, prepared) } else { handle.exit(0) }); return; } license_then_core(app, prepared); } fn license_then_core(app: &AppHandle, prepared: Prepared) { let acceptance = match LicenseAcceptance::new( &prepared.paths.data, &prepared.paths.licenses().join("APPLICATION_LICENSE.txt"), &prepared.paths.docs(), &prepared.build.version, &prepared.build.build_id, ) { Ok(acceptance) => Arc::new(acceptance), Err(error) => return fatal(app, &error.message), }; if acceptance.is_accepted() { compose(app, prepared); return; } let (tx, rx) = tokio::sync::oneshot::channel(); *app.state::().0.lock() = Some(LicenseFlow { acceptance, language: prepared.config.language(), presented: false, shown: false, accepted: false, done: Some(tx) }); if let Err(error) = crate::windows::create_license_window(app, &prepared.i18n, &prepared.config.language()) { return fatal(app, &error.to_string()); } let handle = app.clone(); tauri::async_runtime::spawn(async move { let accepted = rx.await.unwrap_or(false); if accepted { compose(&handle, prepared); } else { handle.exit(0); } }); } /// License window closed: resolve the flow (accepted or declined/closed). pub fn license_window_closed(app: &AppHandle) { let flow = app.state::().0.lock().take(); if let Some(mut flow) = flow { if let Some(done) = flow.done.take() { let _ = done.send(flow.accepted); } } } fn compose(app: &AppHandle, prepared: Prepared) { let Prepared { paths, logger, i18n, config, build } = prepared; let vault = Arc::new(VaultStore::new(&paths.data)); let profiles = Arc::new(ProfileService::new(vault.clone())); let main_emitter = { let handle = app.clone(); Arc::new(move |event: &str, payload: Value| { let _ = handle.emit_to(tauri::EventTarget::webview_window(crate::security::caller::MAIN_LABEL), event, payload); }) }; let sessions = SessionManager::new(profiles.clone(), logger.clone(), main_emitter.clone()); let base_urls: Vec = config.get()["updates"]["baseUrls"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); let https = match HttpsClient::new(&base_urls, &INSECURE_LEGACY_ORIGINS) { Ok(client) => Arc::new(client), Err(error) => return fatal(app, &error.message), }; let registry = Arc::new(ModuleRegistry::new(&paths.data, Some(paths.bundled_modules()), logger.clone())); let installer = ModuleInstaller::new(&paths.data, &build.version, crate::core::app::MODULE_API_VERSION); let biometrics = { let config_auth = config.clone(); let config_reason = config.clone(); let i18n_reason = i18n.clone(); Arc::new(BiometricService::new( &paths.data, &paths.resources, vault.clone(), Box::new(move || config_auth.get()["security"]["requireSystemAuthentication"] == true), Box::new(move || i18n_reason.t(&config_reason.language(), "native.biometric.reason")), )) }; let diagnostics = { let emitter = main_emitter.clone(); Arc::new(DiagnosticsService::new(&paths.data, paths.resources.clone(), Arc::new(move |event: Value| emitter("diagnostics:progress", event)))) }; let updates = { let config_for = config.clone(); let https_for = https.clone(); let logger_for = logger.clone(); let root = paths.data.join("updates").join("downloads"); let verifier = Arc::new(crate::core::updates::openpgp::OpenPgpVerifier::new(paths.resources.join("signing").join("ch-j-signing-public.asc"))); let (platform, arch) = crate::core::diagnostics::http::node_platform_arch(); Arc::new(crate::core::updates::service::UpdateService::new( &build.version, &build.build_id, &platform, &arch, config.clone(), Box::new(move || { let urls: Vec = config_for.get()["updates"]["baseUrls"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); crate::core::updates::provider::ReleaseProvider::new(&urls, https_for.clone(), root.clone(), verifier.clone(), logger_for.clone()) }), logger.clone(), )) }; let privileged = Arc::new(PrivilegedExecutor::new(sessions.clone())); let files = Arc::new(crate::core::files::service::RemoteFileService::new(sessions.clone(), privileged.clone(), logger.clone())); let transfers = { let handle = app.clone(); crate::core::files::transfers::TransferManager::new( sessions.clone(), files.clone(), Some(&paths.data), logger.clone(), // Transfer progress goes only to the module that owns the jobs; jobs keep running while it is closed. Arc::new(move |owner: &str, update: Value| { if let Some(core) = handle.state::().try_get() { for context in core.windows.modules().into_iter().filter(|c| c.id == owner) { core.emit_window(&context.label, "module:files:transfers:update", update.clone()); } } }), ) }; { let transfers = transfers.clone(); tauri::async_runtime::spawn(async move { transfers.start_session_listener() }); } let windows_executor = Arc::new(WindowsExecutor { sessions: sessions.clone() }); let users = Arc::new(crate::core::users::service::UserAdmin { sessions: sessions.clone(), linux: crate::core::users::linux::LinuxUsers { sessions: sessions.clone(), executor: privileged.clone(), logger: logger.clone() }, windows: crate::core::users::windows::WindowsUsers { sessions: sessions.clone(), executor: windows_executor.clone(), logger: logger.clone() }, }); let virtual_memory = Arc::new(crate::core::system::virtual_memory::VirtualMemoryService::new(sessions.clone(), privileged.clone(), logger.clone())); let disks = Arc::new(crate::core::disks::service::DiskService::new(sessions.clone(), privileged.clone(), logger.clone())); let services = Services { availability: Default::default(), disks, virtual_memory, users, files, transfers, updates, https, privileged, windows: windows_executor, sessions, modules: Arc::new(ModuleService::new(registry, installer)), biometrics, diagnostics, lock: LockController::default(), keys: Default::default(), hashing: { let handle = app.clone(); Arc::new(crate::core::hashing::service::LocalHashService::new( Arc::new(move |module_id: &str, job: Value| { if let Some(core) = handle.state::().try_get() { for context in core.windows.modules().into_iter().filter(|c| c.id == module_id) { core.emit_window(&context.label, "module:hashing:progress", job.clone()); } } }), Some(logger.clone()), )) }, }; let core = Arc::new(Core { app: app.clone(), paths, build, logger, i18n, config, vault, profiles, profile_transfer: ProfileTransfer::default(), windows: Arc::new(WindowRegistry::default()), services, }); match core.services.updates.cleanup_downloads() { Ok(true) => core.logger.info("Core update download cache cleaned on startup.", json!({})), Ok(false) => {} Err(error) => core.logger.warn("Core update download cache cleanup failed.", json!({ "message": error.message })), } match core.services.modules.cleanup_downloads(&core) { Ok(true) => core.logger.info("plugin package download cache cleaned on startup.", json!({})), Ok(false) => {} Err(error) => core.logger.warn("plugin package download cache cleanup failed.", json!({ "message": error.message })), } app.state::().set(core.clone()); match crate::windows::create_main_window(app, &core.i18n) { Ok(window) => { #[cfg(windows)] if let Ok(hwnd) = window.hwnd() { core.services.biometrics.set_window_handle(Some((hwnd.0 as usize).to_string())); } let _ = window; } Err(error) => return fatal(app, &error.to_string()), } let monitor = core.services.availability.clone(); let monitor_host = Arc::new(CoreMonitorHost(Arc::downgrade(&core))); tauri::async_runtime::spawn(monitor.run(monitor_host)); // Inactivity auto-lock check every 5 s. let checker = core.clone(); tauri::async_runtime::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_secs(5)).await; checker.services.lock.check(&checker).await; } }); core.logger.info( "Core initialized.", json!({ "version": core.build.version, "buildId": core.build.build_id, "platform": crate::windows::platform_name(), "arch": std::env::consts::ARCH, "storageRoot": core.paths.data.to_string_lossy(), "runtime": "tauri" }), ); } /// The availability monitor's view of the Core (`Weak`: the task must not keep the Core alive). struct CoreMonitorHost(std::sync::Weak); impl MonitorHost for CoreMonitorHost { fn available(&self) -> bool { self.0.upgrade().is_some_and(|core| core.vault.is_unlocked() && core.app.get_webview_window(crate::security::caller::MAIN_LABEL).is_some()) } fn settings(&self) -> (bool, std::time::Duration) { let config = self.0.upgrade().map(|core| core.config.get()).unwrap_or_default(); (config["monitoring"]["enabled"].as_bool() != Some(false), std::time::Duration::from_secs(config["monitoring"]["intervalSeconds"].as_u64().unwrap_or(DEFAULT_INTERVAL_SECS))) } fn targets(&self) -> Option> { let profiles = self.0.upgrade()?.profiles.list().ok()?; Some(profiles.iter().filter_map(Target::from_profile).collect()) } fn publish(&self, event: Value) { if let Some(core) = self.0.upgrade() { core.emit_main("availability:changed", event); } } fn probe_jump(&self, jump_id: &str, target: &Target) -> futures::future::BoxFuture<'static, Outcome> { let (core, jump_id, host, port) = (self.0.upgrade(), jump_id.to_string(), target.host.clone(), target.port); Box::pin(async move { match core { Some(core) => core.services.sessions.probe_through_jump(&jump_id, &host, port, JUMP_TIMEOUT).await, None => Outcome::Unverified { reason: "noJumpSession" }, } }) } } /// Orderly shutdown (Electron `before-quit`). pub fn shutdown(core: &Arc) { core.services.availability.stop(); core.services.transfers.shutdown(); core.services.biometrics.cancel(); core.services.diagnostics.stop_all(); let sessions = core.services.sessions.clone(); tauri::async_runtime::block_on(async move { let _ = tokio::time::timeout(std::time::Duration::from_secs(3), sessions.disconnect_all("app-quit")).await; }); core.vault.lock(); }