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
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState, useCallback } from 'react';
// Single WebSocket connection to the bridge. Streams dataref values + the
// shared flight plan in; sends commands / dataref writes / flight-plan edits
// out. Auto-reconnects.
export function useXplane() {
const [values, setValues] = useState({});
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
const [exportMsg, setExportMsg] = useState(null);
const [connected, setConnected] = useState(false); // socket to bridge
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
const wsRef = useRef(null);
useEffect(() => {
let closed = false;
let retry;
// Coalesce incoming value bursts into a single React update per animation
// frame — keeps the gauges smooth instead of re-rendering ~20×/sec.
let pending = null;
let raf = 0;
const flush = () => {
raf = 0;
if (pending) { const p = pending; pending = null; setValues((prev) => ({ ...prev, ...p })); }
};
const connect = () => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'values') {
pending = pending ? Object.assign(pending, msg.data) : { ...msg.data };
if (!raf) raf = requestAnimationFrame(flush);
}
else if (msg.type === 'status') setXpConnected(!!msg.xpConnected);
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
else if (msg.type === 'fp_export_result') setExportMsg(msg);
};
ws.onclose = () => {
setConnected(false);
setXpConnected(false);
if (!closed) retry = setTimeout(connect, 2000);
};
ws.onerror = () => ws.close();
};
connect();
return () => { closed = true; clearTimeout(retry); wsRef.current?.close(); };
}, []);
const send = useCallback((obj) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}, []);
const command = useCallback((name, duration = 0) => send({ type: 'command', name, duration }), [send]);
const setDataref = useCallback((name, value) => send({ type: 'setDataref', name, value }), [send]);
// flight-plan actions
const fp = {
add: (ident) => send({ type: 'fp_add', ident }),
addLatLon: (lat, lon) => send({ type: 'fp_add', ident: `${lat},${lon}` }),
remove: (index) => send({ type: 'fp_remove', index }),
setActive: (index) => send({ type: 'fp_active', index }),
clear: () => send({ type: 'fp_clear' }),
set: (plan) => send({ type: 'fp_set', plan }),
export: (name) => send({ type: 'fp_export', name }),
};
return { values, flightPlan, exportMsg, connected, xpConnected, command, setDataref, fp };
}
// Search X-Plane's nav database (waypoints/VOR/NDB/airports) via the bridge.
export async function navSearch(q) {
if (!q) return [];
try {
const r = await fetch(`/api/nav/search?q=${encodeURIComponent(q)}`);
return r.ok ? r.json() : [];
} catch {
return [];
}
}
// Convenience: read a numeric value with a fallback.
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);