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>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents an extra console window on Windows in release.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
xplane_cockpit_lib::run()
|
||||
}
|
||||
Reference in New Issue
Block a user