Files
xplane-cockpit/desktop/src-tauri/src/lib.rs
T
karim ebc33a78b7 Initial commit: X-Plane G1000 web cockpit + bridge + Tauri desktop app
- server/: Node bridge (datarefs/commands, navdata, CIFP procedures, flight plan)
- web/: React cockpit (PFD/MFD/Map, VFR six-pack, AFCS, FMS CDU), PWA, collapsible sidebar
- desktop/: Tauri 2 launcher (Bun sidecar, system tray, updater) + Linux build via Docker
- scripts/: prep-desktop, build-linux, Gitea release + latest.json

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:07:03 +02:00

270 lines
8.1 KiB
Rust

// 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<Option<CommandChild>>,
port: Mutex<u16>,
url: Mutex<String>,
}
#[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<String> {
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<ServerState>) -> 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<ServerInfo, String> {
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<ServerState>) -> 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::<ServerState>() {
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(&copy)
.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::<ServerState>() {
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();
}
}