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>
@@ -0,0 +1,33 @@
|
||||
[package]
|
||||
name = "xplane-cockpit"
|
||||
version = "0.1.3"
|
||||
description = "Desktop launcher for the X-Plane G1000 web cockpit"
|
||||
authors = ["karim"]
|
||||
edition = "2021"
|
||||
rust-version = "1.77"
|
||||
|
||||
[lib]
|
||||
name = "xplane_cockpit_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-process = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
local-ip-address = "0.6"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities for the control panel window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:allow-start-dragging",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [{ "name": "binaries/xpbridge", "sidecar": true, "args": true }]
|
||||
},
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-kill",
|
||||
"dialog:allow-open",
|
||||
"opener:allow-open-url",
|
||||
"updater:default",
|
||||
"process:allow-restart",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 893 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "X-Plane Cockpit",
|
||||
"version": "0.1.3",
|
||||
"identifier": "ch.kgva.xplanecockpit",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "X-Plane Cockpit",
|
||||
"width": 480,
|
||||
"height": 720,
|
||||
"minWidth": 420,
|
||||
"minHeight": 560,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": [
|
||||
"app",
|
||||
"dmg",
|
||||
"appimage",
|
||||
"deb"
|
||||
],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"externalBin": [
|
||||
"binaries/xpbridge"
|
||||
],
|
||||
"resources": {
|
||||
"resources/web": "web"
|
||||
},
|
||||
"createUpdaterArtifacts": true,
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15"
|
||||
},
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"endpoints": [
|
||||
"https://git.kgva.ch/karim/xplane-cockpit/releases/download/updater/latest.json"
|
||||
],
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzFGQTUzOEUyOURFOTkKUldTWjNpbU9VL294V1ZWZllVMzc5MGR6OVFVcGRkSTVkcG1LUDJXODJzT2psbFZoY2JYT0E3dEIK"
|
||||
}
|
||||
}
|
||||
}
|
||||