ebc33a78b7
- 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>
270 lines
8.1 KiB
Rust
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(©)
|
|
.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();
|
|
}
|
|
}
|