// X-Plane Cockpit desktop launcher. // // A small control panel that: (1) lets the user point at their X-Plane 12 // install, (2) starts/stops the bundled Node "bridge" server (a Bun-compiled // sidecar), and (3) shows the LAN URL tablets open to see the G1000 cockpit. // The cockpit web files travel with the app as a resource (WEB_DIST). A system // tray keeps the server running when the window is closed. use std::net::TcpListener; use std::path::PathBuf; use std::sync::Mutex; use serde::Serialize; use tauri::menu::{MenuBuilder, MenuItem, PredefinedMenuItem}; use tauri::tray::{TrayIconBuilder, TrayIconEvent}; use tauri::{Emitter, Manager, State}; use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_shell::ShellExt; #[derive(Default)] struct ServerState { child: Mutex>, port: Mutex, url: Mutex, } #[derive(Serialize, Clone)] struct ServerInfo { ip: String, port: u16, url: String, } fn lan_ipv4() -> String { local_ip_address::local_ip() .map(|ip| ip.to_string()) .unwrap_or_else(|_| "127.0.0.1".to_string()) } #[tauri::command] fn lan_ip() -> String { lan_ipv4() } // Can we bind this TCP port on the LAN interface? (i.e. is it free) fn is_port_free(port: u16) -> bool { TcpListener::bind(("0.0.0.0", port)).is_ok() } #[tauri::command] fn port_free(port: u16) -> bool { is_port_free(port) } // First free port at/after `start` (so the UI can offer an alternative). #[tauri::command] fn suggest_port(start: u16) -> u16 { let mut p = start.max(1024); for _ in 0..200 { if is_port_free(p) { return p; } p = p.saturating_add(1); } start } #[tauri::command] fn default_xplane_path() -> Option { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .unwrap_or_default(); let candidates = [ format!("{home}/X-Plane 12"), format!("{home}/Desktop/X-Plane 12"), "/Applications/X-Plane 12".to_string(), "C:/X-Plane 12".to_string(), "D:/X-Plane 12".to_string(), ]; candidates .into_iter() .find(|c| PathBuf::from(c).join("Resources").join("default data").is_dir()) } #[tauri::command] fn valid_xplane_path(path: String) -> bool { !path.is_empty() && PathBuf::from(&path) .join("Resources") .join("default data") .is_dir() } #[tauri::command] fn server_running(state: State) -> bool { state.child.lock().unwrap().is_some() } #[tauri::command] async fn start_server( app: tauri::AppHandle, state: State<'_, ServerState>, xplane_path: String, port: u16, demo: bool, ) -> Result { if state.child.lock().unwrap().is_some() { let p = *state.port.lock().unwrap(); let ip = lan_ipv4(); return Ok(ServerInfo { url: format!("http://{ip}:{p}"), ip, port: p }); } if !is_port_free(port) { return Err(format!("Port {port} ist belegt — wähle einen anderen.")); } let web_dist = app .path() .resolve("web", tauri::path::BaseDirectory::Resource) .map_err(|e| format!("resource path: {e}"))?; let mut cmd = app .shell() .sidecar("xpbridge") .map_err(|e| format!("sidecar: {e}"))? .env("BRIDGE_PORT", port.to_string()) .env("BRIDGE_HOST", "0.0.0.0") .env("WEB_DIST", web_dist.to_string_lossy().to_string()); if !xplane_path.is_empty() { cmd = cmd.env("XPLANE_ROOT", xplane_path); } if demo { cmd = cmd.env("DEMO", "1"); } let (mut rx, child) = cmd.spawn().map_err(|e| format!("spawn: {e}"))?; let app2 = app.clone(); tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { let line = match event { CommandEvent::Stdout(b) | CommandEvent::Stderr(b) => { String::from_utf8_lossy(&b).to_string() } CommandEvent::Terminated(_) => { let _ = app2.emit("server-exited", ()); break; } _ => continue, }; let _ = app2.emit("server-log", line); } }); let ip = lan_ipv4(); let url = format!("http://{ip}:{port}"); *state.child.lock().unwrap() = Some(child); *state.port.lock().unwrap() = port; *state.url.lock().unwrap() = url.clone(); Ok(ServerInfo { url, ip, port }) } #[tauri::command] fn stop_server(state: State) -> Result<(), String> { if let Some(child) = state.child.lock().unwrap().take() { child.kill().map_err(|e| format!("kill: {e}"))?; } *state.url.lock().unwrap() = String::new(); Ok(()) } fn kill_sidecar(app: &tauri::AppHandle) { if let Some(state) = app.try_state::() { if let Some(child) = state.child.lock().unwrap().take() { let _ = child.kill(); } } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(ServerState::default()) .invoke_handler(tauri::generate_handler![ lan_ip, port_free, suggest_port, default_xplane_path, valid_xplane_path, server_running, start_server, stop_server ]) .setup(|app| { build_tray(app.handle())?; Ok(()) }) // Closing the window hides it instead of quitting, so the server keeps // serving tablets in the background. Quit from the tray. .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); } }) .run(tauri::generate_context!()) .expect("error while running tauri application"); } fn build_tray(app: &tauri::AppHandle) -> tauri::Result<()> { let show = MenuItem::with_id(app, "show", "Panel anzeigen", true, None::<&str>)?; let open = MenuItem::with_id(app, "open", "Cockpit öffnen", true, None::<&str>)?; let copy = MenuItem::with_id(app, "copy", "URL kopieren", true, None::<&str>)?; let toggle = MenuItem::with_id(app, "toggle", "Server starten / stoppen", true, None::<&str>)?; let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; let sep = PredefinedMenuItem::separator(app)?; let menu = MenuBuilder::new(app) .item(&show) .item(&open) .item(©) .item(&sep) .item(&toggle) .item(&sep) .item(&quit) .build()?; TrayIconBuilder::with_id("main") .icon(app.default_window_icon().unwrap().clone()) .tooltip("X-Plane Cockpit") .menu(&menu) .show_menu_on_left_click(false) .on_menu_event(|app, event| match event.id().as_ref() { "show" => show_main(app), "open" => { let _ = app.emit("tray-open", ()); } "copy" => { if let Some(state) = app.try_state::() { let url = state.url.lock().unwrap().clone(); if !url.is_empty() { let _ = app.clipboard().write_text(url); } } } "toggle" => { let _ = app.emit("tray-toggle", ()); } "quit" => { kill_sidecar(app); app.exit(0); } _ => {} }) .on_tray_icon_event(|tray, event| { if let TrayIconEvent::Click { .. } = event { show_main(tray.app_handle()); } }) .build(app)?; Ok(()) } fn show_main(app: &tauri::AppHandle) { if let Some(win) = app.get_webview_window("main") { let _ = win.show(); let _ = win.set_focus(); } }