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:
2026-06-01 15:07:03 +02:00
commit ebc33a78b7
110 changed files with 14671 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzFGQTUzOEUyOURFOTkKUldTWjNpbU9VL294V1ZWZllVMzc5MGR6OVFVcGRkSTVkcG1LUDJXODJzT2psbFZoY2JYT0E3dEIK
+31
View File
@@ -0,0 +1,31 @@
# Linux build image for the Tauri app (x86_64). Used to cross-build an AppImage
# + .deb from the macOS dev machine via Docker (linux/amd64). The Node bridge
# sidecar is compiled on the host by Bun, so this image only needs the Rust /
# Tauri / GTK / WebKit toolchain.
FROM rust:1-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
libxdo-dev \
patchelf \
file \
wget \
curl \
xz-utils \
ca-certificates \
fuse \
desktop-file-utils \
xdg-utils \
&& rm -rf /var/lib/apt/lists/*
# Node + a GLOBAL Tauri CLI (so we never touch the mounted node_modules, which
# holds the host/macOS CLI binary).
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs && rm -rf /var/lib/apt/lists/* \
&& npm install -g @tauri-apps/cli@2
WORKDIR /work/desktop
+71
View File
@@ -0,0 +1,71 @@
# X-Plane Cockpit — Desktop App
A small launcher (Tauri) that runs the G1000 web cockpit's server on your PC and
shows the LAN address tablets/laptops open. The Node "bridge" server is bundled
as a Bun-compiled sidecar, so **nothing else needs to be installed**.
## Using it (for a tester)
1. Install & open **X-Plane Cockpit**.
- macOS: open the `.dmg`, drag the app to Applications. It's **ad-hoc signed**
(no Apple Developer ID), so on first launch use **right-click → Open** and
confirm, or run `xattr -dr com.apple.quarantine "/Applications/X-Plane Cockpit.app"`.
- Linux: make the `.AppImage` executable (`chmod +x`) and run it, or install the `.deb`.
2. Point it at your **X-Plane 12 folder** (it auto-detects common locations).
No X-Plane handy? Tick **Demo-Modus** to try the cockpit with synthetic data.
3. Make sure X-Plane's Web API is on: X-Plane → Settings → Network →
*Enable web server / API* (X-Plane 12.1.1+).
4. Click **Server starten**. Open the shown URL (e.g. `http://192.168.1.27:8080`)
on any tablet/laptop on the same Wi-Fi. The PFD/MFD/Map/FMS buttons open pages directly.
Updates: **Nach Updates suchen** in the footer pulls the latest release from Gitea.
> LAN only by design. Don't expose the port to the public internet.
## Building (for the developer)
From the repo root:
```bash
# 1. prep: build the web cockpit + compile the Bun sidecars (mac + linux)
bash scripts/prep-desktop.sh
# 2a. macOS app (native, ad-hoc signed) + updater artifacts
APPLE_SIGNING_IDENTITY="-" \
TAURI_SIGNING_PRIVATE_KEY="$(cat desktop/.tauri-signing.key)" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat desktop/.tauri-signing.pw)" \
npx --prefix desktop tauri build --target aarch64-apple-darwin
# 2b. Linux AppImage + .deb (x86_64) via Docker
bash scripts/build-linux.sh
# 3. publish to Gitea + refresh the updater's latest.json
GITEA_URL=https://git.kgva.ch GITEA_REPO=karim/xplane-cockpit \
GITEA_TOKEN=$(cat /tmp/gitea_token) \
node scripts/release-gitea.mjs
```
## Testing the auto-updater (two versions)
The updater only fires when `latest.json` advertises a version **newer** than the
installed one. So to test it end-to-end:
```bash
# 1. publish the baseline the tester installs
# (version 0.1.0 in tauri.conf.json) → release-gitea.mjs uploads assets + latest.json
# 2. install that 0.1.0 build on the test machine
# 3. bump the version, rebuild, re-release:
# edit desktop/src-tauri/tauri.conf.json "version": "0.1.1"
bash scripts/prep-desktop.sh
APPLE_SIGNING_IDENTITY="-" TAURI_SIGNING_PRIVATE_KEY="$(cat desktop/.tauri-signing.key)" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat desktop/.tauri-signing.pw)" \
npx --prefix desktop tauri build --target aarch64-apple-darwin
bash scripts/build-linux.sh
GITEA_TOKEN=$(cat /tmp/gitea_token) node scripts/release-gitea.mjs
# 4. in the installed 0.1.0 app: launch → silent check shows the update banner,
# or click "Nach Updates suchen" → Installieren → app relaunches as 0.1.1.
```
The updater signing keypair lives in `desktop/.tauri-signing.key(.pub/.pw)`
**keep the private key + password safe; they never go into the bundle or Gitea.**
The matching public key is embedded in `tauri.conf.json` (`plugins.updater.pubkey`).
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+247
View File
@@ -0,0 +1,247 @@
{
"name": "xplane-cockpit-desktop",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xplane-cockpit-desktop",
"version": "0.1.0",
"devDependencies": {
"@tauri-apps/cli": "^2"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.2.tgz",
"integrity": "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.11.2",
"@tauri-apps/cli-darwin-x64": "2.11.2",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.2",
"@tauri-apps/cli-linux-arm64-musl": "2.11.2",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-gnu": "2.11.2",
"@tauri-apps/cli-linux-x64-musl": "2.11.2",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.2",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.2",
"@tauri-apps/cli-win32-x64-msvc": "2.11.2"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.2.tgz",
"integrity": "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.2.tgz",
"integrity": "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.2.tgz",
"integrity": "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.2.tgz",
"integrity": "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.2.tgz",
"integrity": "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.2.tgz",
"integrity": "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.2.tgz",
"integrity": "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.2.tgz",
"integrity": "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.2.tgz",
"integrity": "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.2.tgz",
"integrity": "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.2.tgz",
"integrity": "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "xplane-cockpit-desktop",
"version": "0.1.0",
"private": true,
"scripts": {
"tauri": "tauri",
"build": "tauri build"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
}
}
+6153
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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
+3
View File
@@ -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"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

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>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+269
View File
@@ -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(&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();
}
}
+6
View File
@@ -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()
}
+64
View File
@@ -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"
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>X-Plane Cockpit</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="panel">
<header class="hd">
<div class="brand">G1000<span>·web</span></div>
<div id="status" class="status off"><span class="dot"></span><span id="statusText">Gestoppt</span></div>
</header>
<main>
<div id="updateBanner" class="update-banner hidden">
<div class="ub-text"><b id="ubTitle">Update verfügbar</b><span id="ubNotes"></span></div>
<div class="ub-actions">
<button id="ubInstall" class="btn ok sm">Installieren</button>
<button id="ubDismiss" class="btn ghost sm">Später</button>
</div>
</div>
<section class="card">
<label class="lbl">X-Plane 12 Ordner</label>
<div class="row">
<input id="xpPath" type="text" placeholder="z.B. /Users/du/X-Plane 12" spellcheck="false" />
<button id="browse" class="btn ghost">Suchen…</button>
</div>
<div id="xpHint" class="hint"></div>
<div class="row gap">
<div class="field">
<label class="lbl">Port</label>
<input id="port" type="number" value="8080" min="1024" max="65535" />
</div>
<label class="toggle">
<input id="demo" type="checkbox" />
<span>Demo-Modus (ohne X-Plane)</span>
</label>
</div>
<div id="portHint" class="hint"></div>
</section>
<button id="startBtn" class="btn primary big">Server starten</button>
<section id="liveCard" class="card live hidden">
<label class="lbl">Auf Tablets / Laptops öffnen</label>
<div class="url-row">
<code id="url"></code>
<button id="copy" class="btn ghost sm" title="Kopieren"></button>
</div>
<div class="quick">
<button class="btn ghost sm" data-page="pfd">PFD</button>
<button class="btn ghost sm" data-page="mfd">MFD</button>
<button class="btn ghost sm" data-page="map">Map</button>
<button class="btn ghost sm" data-page="fms">FMS</button>
</div>
<button id="openBtn" class="btn ok">Cockpit im Browser öffnen</button>
<div class="diag">
<div class="diag-row"><span>X-Plane</span><b id="dXp"></b></div>
<div class="diag-row"><span>Verbundene Geräte</span><b id="dClients"></b></div>
<div class="diag-row"><span>Navdata</span><b id="dNav"></b></div>
<div class="diag-row"><span>Datarefs</span><b id="dRefs"></b></div>
</div>
</section>
<details class="log-wrap">
<summary>Server-Log</summary>
<pre id="log"></pre>
</details>
</main>
<footer class="ft">
<span id="ver">v—</span>
<button id="updateBtn" class="link">Nach Updates suchen</button>
</footer>
</div>
<script src="main.js"></script>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
// Control-panel logic. Uses the global Tauri API (withGlobalTauri).
const T = window.__TAURI__ || {};
const invoke = T.core.invoke;
const listen = T.event.listen;
const $ = (id) => document.getElementById(id);
const xpPath = $('xpPath'), portEl = $('port'), demoEl = $('demo');
const startBtn = $('startBtn'), liveCard = $('liveCard'), urlEl = $('url');
const statusEl = $('status'), statusText = $('statusText'), logEl = $('log');
let running = false, healthTimer = null;
function setStatus(kind, text) {
statusEl.className = 'status ' + kind;
statusText.textContent = text;
}
async function validatePath() {
const p = xpPath.value.trim();
const hint = $('xpHint');
if (!p) { hint.textContent = ''; hint.className = 'hint'; return false; }
const ok = await invoke('valid_xplane_path', { path: p });
hint.textContent = ok ? '✓ X-Plane erkannt' : '⚠ kein „Resources/default data“ — Demo-Modus nutzen oder Pfad prüfen';
hint.className = 'hint ' + (ok ? 'ok' : 'bad');
return ok;
}
// Is the chosen port free? If not, offer the next free one.
async function validatePort() {
const hint = $('portHint');
const port = parseInt(portEl.value, 10) || 0;
if (port < 1024 || port > 65535) { hint.textContent = '⚠ Port 102465535'; hint.className = 'hint bad'; return false; }
const free = await invoke('port_free', { port });
if (free) { hint.textContent = '✓ Port frei'; hint.className = 'hint ok'; return true; }
const alt = await invoke('suggest_port', { start: port + 1 });
hint.innerHTML = `⚠ Port ${port} belegt — <a href="#" id="usePort">${alt} verwenden</a>`;
hint.className = 'hint bad';
const a = $('usePort');
if (a) a.onclick = (e) => { e.preventDefault(); portEl.value = alt; validatePort(); };
return false;
}
async function init() {
try { $('ver').textContent = 'v' + (await T.app.getVersion()); } catch {}
try {
const def = await invoke('default_xplane_path');
if (def) { xpPath.value = def; validatePath(); }
} catch {}
validatePort();
checkUpdate(true); // silent on launch
}
xpPath.addEventListener('change', validatePath);
xpPath.addEventListener('blur', validatePath);
portEl.addEventListener('change', validatePort);
portEl.addEventListener('blur', validatePort);
$('browse').addEventListener('click', async () => {
try {
const dir = await T.dialog.open({ directory: true, multiple: false, title: 'X-Plane 12 Ordner wählen' });
if (dir) { xpPath.value = dir; validatePath(); }
} catch (e) { appendLog('dialog: ' + e); }
});
startBtn.addEventListener('click', async () => {
if (running) return stop();
if (!(await validatePort())) return; // refuse a busy port up front
startBtn.disabled = true;
try {
const info = await invoke('start_server', {
xplanePath: xpPath.value.trim(),
port: parseInt(portEl.value, 10) || 8080,
demo: demoEl.checked,
});
running = true;
urlEl.textContent = info.url;
liveCard.classList.remove('hidden');
startBtn.textContent = 'Server stoppen';
startBtn.classList.add('stop');
setStatus('warn', 'Server läuft · warte auf Sim');
pollHealth(info.port);
} catch (e) {
appendLog('Fehler: ' + e);
setStatus('off', 'Fehler');
} finally {
startBtn.disabled = false;
}
});
async function stop() {
startBtn.disabled = true;
try { await invoke('stop_server'); } catch (e) { appendLog('stop: ' + e); }
resetUi();
startBtn.disabled = false;
}
function resetUi() {
running = false;
liveCard.classList.add('hidden');
startBtn.textContent = 'Server starten';
startBtn.classList.remove('stop');
setStatus('off', 'Gestoppt');
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
}
function pollHealth(port) {
if (healthTimer) clearInterval(healthTimer);
const dXp = $('dXp'), dClients = $('dClients'), dNav = $('dNav'), dRefs = $('dRefs');
const check = async () => {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/health`, { cache: 'no-store' });
const d = await r.json();
const sim = d.xpConnected;
if (sim) setStatus('run', demoEl.checked ? 'Demo läuft' : 'X-Plane verbunden');
else setStatus('warn', 'Server läuft · kein Sim');
dXp.textContent = sim ? (demoEl.checked ? 'Demo' : 'verbunden') : 'kein Sim';
dXp.className = sim ? 'ok' : 'warn';
dClients.textContent = d.clients ?? 0;
const n = d.nav || {};
dNav.textContent = n.loaded ? `${n.airports ?? 0} APT · ${n.navaids ?? 0} Navaids` : 'lädt…';
dRefs.textContent = d.datarefs ?? 0;
} catch { setStatus('warn', 'Server läuft'); }
};
check();
healthTimer = setInterval(check, 3000);
}
$('copy').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(urlEl.textContent); $('copy').textContent = '✓'; setTimeout(() => ($('copy').textContent = '⧉'), 1200); } catch {}
});
const openUrl = (u) => { try { T.opener.openUrl(u); } catch (e) { appendLog('open: ' + e); } };
$('openBtn').addEventListener('click', () => openUrl(urlEl.textContent));
document.querySelectorAll('.quick .btn').forEach((b) =>
b.addEventListener('click', () => openUrl(urlEl.textContent + '/#' + b.dataset.page)));
function appendLog(line) {
logEl.textContent += line;
if (logEl.textContent.length > 8000) logEl.textContent = logEl.textContent.slice(-6000);
logEl.scrollTop = logEl.scrollHeight;
}
listen('server-log', (e) => appendLog(e.payload));
listen('server-exited', () => { appendLog('\n[Server beendet]\n'); resetUi(); });
// Tray actions routed to the panel (which holds the current URL + start logic).
listen('tray-open', () => { if (urlEl.textContent && urlEl.textContent !== '—') openUrl(urlEl.textContent); });
listen('tray-toggle', () => startBtn.click());
/* ---------------- updates ---------------- */
let pendingUpdate = null;
async function checkUpdate(silent) {
const btn = $('updateBtn');
if (!silent) { btn.disabled = true; btn.textContent = 'Suche…'; }
try {
const update = await T.updater.check();
if (update) {
pendingUpdate = update;
$('ubTitle').textContent = `Update ${update.version} verfügbar`;
$('ubNotes').textContent = update.body || '';
$('updateBanner').classList.remove('hidden');
if (!document.querySelector('.update-badge')) {
const dot = document.createElement('span'); dot.className = 'update-badge'; btn.after(dot);
}
if (!silent) { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }
} else if (!silent) {
btn.textContent = 'Aktuell ✓';
setTimeout(() => { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }, 2500);
}
} catch (e) {
if (!silent) {
appendLog('update: ' + e);
btn.textContent = 'Update fehlgeschlagen';
setTimeout(() => { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }, 2500);
}
}
}
async function installUpdate() {
if (!pendingUpdate) return;
$('ubInstall').disabled = true; $('ubInstall').textContent = 'Lädt…';
try {
await pendingUpdate.downloadAndInstall();
await T.process.relaunch();
} catch (e) {
appendLog('install: ' + e);
$('ubInstall').disabled = false; $('ubInstall').textContent = 'Installieren';
}
}
$('updateBtn').addEventListener('click', () => checkUpdate(false));
$('ubInstall').addEventListener('click', installUpdate);
$('ubDismiss').addEventListener('click', () => $('updateBanner').classList.add('hidden'));
init();
+89
View File
@@ -0,0 +1,89 @@
/* macOS-style dark theme: neutral graphite surfaces, SF system font, subtle
separators, a single green accent for the running/start state. No blue. */
:root {
--bg: #1c1c1e; /* system background (dark) */
--bg2: #2c2c2e; /* elevated surface */
--bg3: #3a3a3c; /* control fill */
--line: #48484a; /* separators / borders */
--line-soft: #38383a;
--txt: #ffffff;
--txt2: #ebebf5;
--mut: #8e8e93; /* secondary label */
--green: #30d158; /* system green */
--green-d: #248a3d;
--amber: #ffd60a;
--red: #ff453a;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--txt);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased;
}
.panel { display: flex; flex-direction: column; height: 100vh; padding: 16px; gap: 14px; }
.hd { display: flex; align-items: center; justify-content: space-between; }
.brand { font-weight: 700; letter-spacing: .2px; font-size: 17px; }
.brand span { color: var(--mut); font-weight: 500; }
.status { display: flex; align-items: center; gap: 7px; font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line-soft); background: var(--bg2); color: var(--txt2); }
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mut); transition: background .2s; }
.status.run .dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
.status.warn .dot { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
main { flex: 1; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
.card { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 9px; }
.lbl { color: var(--mut); font-size: 11px; font-weight: 600; }
.row { display: flex; gap: 8px; align-items: center; }
.row.gap { gap: 16px; margin-top: 2px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input { width: 96px; }
input[type="text"], input[type="number"] {
flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--txt);
border-radius: 7px; padding: 8px 10px; font-size: 13px; font-family: inherit;
}
input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px rgba(48,209,88,.2); }
.toggle { display: flex; align-items: center; gap: 8px; color: var(--txt2); cursor: pointer; align-self: flex-end; padding-bottom: 8px; }
.toggle input { width: 15px; height: 15px; accent-color: var(--green); }
.hint { font-size: 12px; min-height: 16px; color: var(--mut); }
.hint.ok { color: var(--green); } .hint.bad { color: var(--amber); }
.hint a { color: var(--green); }
.btn { border: 1px solid var(--line); background: var(--bg3); color: var(--txt); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-family: inherit; cursor: pointer; transition: filter .12s, background .12s; }
.btn:hover { filter: brightness(1.18); }
.btn:active { transform: translateY(1px); }
.btn.ghost { background: transparent; color: var(--txt2); border-color: var(--line); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.big { padding: 13px; font-size: 15px; font-weight: 600; }
.btn.primary { background: var(--green); color: #042b10; border-color: transparent; font-weight: 600; }
.btn.big.stop { background: var(--red); color: #2a0603; }
.btn.ok { background: var(--green); color: #042b10; border-color: transparent; font-weight: 600; }
.btn:disabled { opacity: .45; cursor: default; }
.update-banner { display: flex; gap: 10px; align-items: center; justify-content: space-between; background: rgba(48,209,88,.10); border: 1px solid var(--green-d); border-radius: 12px; padding: 10px 12px; }
.update-banner.hidden { display: none; }
.ub-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ub-text b { color: var(--green); font-size: 13px; }
.ub-text span { color: var(--mut); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 230px; }
.ub-actions { display: flex; gap: 6px; flex: 0 0 auto; }
.live.hidden { display: none; }
.url-row { display: flex; gap: 8px; align-items: center; }
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.quick { display: flex; gap: 6px; }
.quick .btn { flex: 1; }
.diag { margin-top: 10px; border-top: 1px solid var(--line-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 5px; }
.diag-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--mut); }
.diag-row b { color: var(--txt2); font-weight: 600; }
.diag-row b.ok { color: var(--green); } .diag-row b.warn { color: var(--amber); }
.log-wrap { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 6px 12px; }
.log-wrap summary { color: var(--mut); font-size: 12px; cursor: pointer; padding: 4px 0; }
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
.ft { display: flex; align-items: center; justify-content: space-between; color: var(--mut); font-size: 12px; }
.link { background: none; border: none; color: var(--green); cursor: pointer; font-size: 12px; font-family: inherit; }
.link:hover { text-decoration: underline; }
.link:disabled { color: var(--mut); cursor: default; text-decoration: none; }
.update-badge { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-left: 6px; box-shadow: 0 0 6px var(--green); vertical-align: middle; }