10 Commits

Author SHA1 Message Date
karim be424a6c3c Bump desktop app to 0.1.5 (release with Lua auto-install, smoothing, airspace + Linux patchelf fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:32:57 +02:00
karim 138498956e fix(linux): repair patchelf-corrupted Bun sidecar in AppImage
linuxdeploy injects a RUNPATH ($ORIGIN/../lib) via patchelf into every
usr/bin executable when building the AppDir. The Bun-compiled xpbridge
sidecar (self-contained, JS/assets appended past the ELF) does not
survive ELF rewriting — the patched copy core-dumps on start, so the app
launches but the bridge never listens.

Add scripts/fix-linux-appimage.sh: extract the built AppImage, restore the
pristine repo sidecar, repack with linuxdeploy-plugin-appimage (which does
not patchelf), verify the sidecar is byte-identical, and regenerate the
updater .sig. Wired into scripts/build-linux.sh after `tauri build`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:23:59 +02:00
karim 9aba24978b Auto-install Lua, smooth all panels, airspace overlay + launcher region picker
FlyWithLua auto-install: bridge drops fms-sync/ui-sync/terrain-probe into
X-Plane's FlyWithLua Scripts dir on startup and self-updates (content-compare).
Graceful when no X-Plane / no FlyWithLua. /api/lua/install + status in health.
Desktop app bundles the scripts and passes LUA_SRC_DIR to the sidecar.

Smoothing: shared useEased/useEasedAngle hook (api/ease.js) with render-bail on
settle. VFR steam gauges now interpolate to 60fps instead of stepping at the
~10Hz value stream. MFD ownship no longer vibrates — position/heading eased in a
single rAF loop, follow-pan without animated-panTo pile-up (pauses on range zoom).

Airspace overlay: server/airspace.js loads per-region GeoJSON, classifies
(B/C/D/TMA/CTR/MOA/Restricted/Prohibited/Danger), bbox query, and downloads
regions on demand — FAA (US, key-free) and OpenAIP (Europe, user key). New
AIRSPACE softkey draws chart-coloured boundaries (B blue, C magenta, D dashed),
non-interactive so map-clicks still drop waypoints. Launcher gains a "Lufträume"
section to pick/download regions via the running bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:57:50 +02:00
karim b2fab0c374 G1000 MFD EIS: real two-bus volts, alternator/battery amps, engine hours
- electrical readout now shows M (main) and E (essential) bus volts from
  bus_volts[0]/[1], M (alternator generator_amps) and S (battery_amps) separately,
  and ENG hours from flight time — replacing the hardcoded duplicate volts / +0.0
  amps / 0.0 HRS placeholders (manual S.54 C172 layout)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:15:34 +02:00
karim 6738e6085b G1000: deepen VNAV/PFD — V-DEV & VS-TGT chevrons, GPS phase, designated toggle
- PFD VNAV: magenta flight-plan target altitude on the alt scale (S.110), V DEV
  deviation scale + chevron (left, shown in VNAV when not on an ILS), VS TGT
  chevron on the VSI (S.113)
- GPS phase annunciation is now dynamic: APR (approach leg) / TERM (<30 nm to
  destination) / ENR, instead of a fixed label
- flight-plan ALT can be toggled designated(blue) <-> reference(white) by clicking
  the cell (S.106); only designated altitudes drive the VNAV profile
- setPlan now preserves the dsgn/appr waypoint flags across the shared plan
- AFCS vertical mode labelled VPTH (manual) instead of VNV

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:07:06 +02:00
karim 053d362245 G1000: VNAV descent profile + designated-altitude flight plan colouring
- CURRENT VNV PROFILE panel on the MFD flight-plan page: active VNV waypoint +
  target altitude, VS TGT (−3° path), VS REQ, V DEV, FPA, TIME TO TOD (manual
  S.64 / S.107)
- enriched the VNAV computation (vsTgt / vDev / FPA / time-to-TOD) shared by the
  PFD VnavBox
- flight-plan ALT column now shows designated (VNAV) altitudes in blue (S.105)
- new Audio Panel + earlier manual-alignment batch already in this branch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:01:47 +02:00
karim 033a9d406a G1000: manual-accurate radios, baro units, declutter, minimums, OBS, audio panel
Aligned to the official X-Plane 1000 manual:
- NAV radio: active RIGHT / standby LEFT (boxed) per S.12 (COM already correct)
- ALT UNIT softkey (IN / HPA) in the PFD submenu, baro readout converts (S.20)
- DCLTR cycles 3 levels (land / +NDB / flight-plan only) with DCLTR-n label (S.56)
- TOPO and TERRAIN are now independent toggles (relief vs awareness overlay) (S.57)
- Barometric MINIMUMS: BARO MIN bug + readout on the altimeter, amber "MINIMUMS"
  annunciation at/below the decision altitude; set via TMR/REF (lifted to App)
- OBS mode: HSI course follows the CRS knob (magenta "OBS"), sequencing suspended
- New Audio Panel tab (COM mic/receive, MKR/DME/ADF, intercom, Display Backup) (S.91)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:55:56 +02:00
karim 38b048ad41 G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs
Sync (FlyWithLua companions in plugins/ + server/fmssync.js):
- FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua
- G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source,
  baro, map-range follow
- Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow
  MFD overlay vs aircraft altitude

PFD:
- AFCS mode annunciation bar from autopilot _status datarefs
- CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons
- magenta speed/altitude trend vectors, selected-altitude alerting
- time-based (frame-rate-independent) smoothing for attitude/heading/tapes

MFD:
- nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat,
  compass rose anchored to the ownship

Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES):
- flat, square, embedded G1000 look (no shadow/rounded/transparency)
- compact lower-right placement, no close X (softkey toggles), single window
- NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu

Service worker: network-first HTML so reloads pick up new builds (cache v2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:17:06 +02:00
karim 354ea5d44b PFD/cockpit polish + KAP140 autopilot + UI refinements
- PFD: full-screen 2D attitude, G1000 yellow+magenta chevron symbology, rAF
  60fps horizon smoothing, translucent tapes, slimmer softkey bar, header fixes
- Collapsible macOS-dark sidebar (Inter), VFR six-pack + engine cluster + tach
- KAP140 autopilot on the analog page; GMC-710 AFCS tab
- FMS rebuilt as an X-Plane-style CDU; PWA; settings panel (knob mode)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:20:16 +02:00
karim ebc33a78b7 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>
2026-06-01 15:07:03 +02:00
123 changed files with 17198 additions and 2 deletions
+29
View File
@@ -0,0 +1,29 @@
# dependencies
node_modules/
web/node_modules/
desktop/node_modules/
# build output
web/dist/
desktop/src-tauri/target/
target-linux/
desktop/src-tauri/gen/
desktop/latest.json
fms-out/
# generated bundle inputs (recreated by scripts/prep-desktop.sh)
desktop/src-tauri/binaries/
desktop/src-tauri/resources/web/
# SECRETS — never commit the updater signing private key / password
desktop/.tauri-signing.key
desktop/.tauri-signing.pw
# local agent + editor + misc
.claude/
screenshots/
*.log
.DS_Store
# local airspace test data (real data is installed into X-Plane via the launcher)
airspace-data/
+75 -2
View File
@@ -1,3 +1,76 @@
# xplane-cockpit # X-Plane Glass Cockpit (Web)
X-Plane G1000 web cockpit + desktop launcher Bring X-Plane 12 instruments — a G1000-style **PFD**, an **MFD**, and an
**autopilot** panel — to any iPad, tablet or laptop on your network. Pure web,
no app install on the tablets. Just open a browser.
```
X-Plane 12 Node bridge (this repo) your devices
┌──────────┐ ws ┌────────────────────┐ ws/http ┌──────────┐
│ Web API │◀──────▶│ resolves dataref │◀───────────▶│ iPad │
│ :8086 │ REST │ IDs, streams values │ │ laptop │
│ (local) │ │ serves the React UI │ │ phone │
└──────────┘ └────────────────────┘ └──────────┘
binds 0.0.0.0:8080
```
## Why a bridge?
X-Plane's built-in web server (v12.1.1+) only listens on `localhost`, dataref
IDs change every session, and CORS blocks browsers. The bridge runs **on the
same PC as X-Plane**, talks to it locally, and re-broadcasts everything to your
LAN — to as many tablets as you like at once.
## Requirements
- **X-Plane 12.1.1 or newer** (the web API ships built-in; nothing to enable).
- **Node.js 18+** on the PC running X-Plane (`node --version`).
## Setup (run these on the X-Plane PC)
```bash
cd X-PLANE-MOD
npm install # also installs the web app's deps
npm run build # builds the React UI into web/dist
npm start # starts the bridge on http://0.0.0.0:8080
```
## Open it
1. Make sure X-Plane is running and you're in a flight.
2. On a tablet/laptop on the **same Wi-Fi**, open:
`http://<PC-LAN-IP>:8080`
- Find the IP: macOS `ipconfig getifaddr en0` · Windows `ipconfig` (IPv4).
3. The status pill top-right shows **X-PLANE** (green) when data is flowing,
**NO SIM** if the bridge is up but X-Plane isn't reachable, **OFFLINE** if
the tablet can't reach the bridge.
4. Tip: on iPad, "Add to Home Screen" → it opens full-screen like a real app.
## Development (hot reload)
```bash
npm run dev:bridge # terminal 1 — the bridge on :8080
npm run dev:web # terminal 2 — Vite dev server with HMR (proxies to bridge)
```
Open the URL Vite prints. Edits to `web/src/**` reload instantly.
## Configuration
All env vars are optional (defaults shown):
| Var | Default | Meaning |
|-----|---------|---------|
| `BRIDGE_PORT` | `8080` | Port the UI/LAN server listens on |
| `XPLANE_HOST` | `localhost` | Where X-Plane's web API is |
| `XPLANE_PORT` | `8086` | X-Plane web API port |
## Adding instruments / datarefs
Everything is driven by [`server/config.js`](server/config.js):
- **`DATAREFS`** — values streamed to the UI (alias → `sim/...` name).
- **`WRITABLE_DATAREFS`** — values the UI may set (knobs/bugs).
- **`COMMANDS`** — buttons the UI may press (mode toggles).
Add a `sim/...` name there, then read `values.<alias>` in any component. For
**G1000-specific** gauges, add that aircraft's `laminar/...` or
`sim/cockpit2/...` datarefs the same way.
## Notes & limits
- Update rate is X-Plane's (~1020 Hz) — fine for instruments, this isn't a
scenery stream.
- The autopilot buttons fire X-Plane's own commands, so the sim stays the
source of truth. Mode-highlight bits (`AP_BITS` in `AutopilotPanel.jsx`) are
best-effort and can differ per aircraft.
- LAN only by design. Don't expose port 8080 to the public internet.
+15
View File
@@ -0,0 +1,15 @@
import { chromium } from 'playwright';
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: 1180, height: 820 } });
const errs = [];
p.on('pageerror', e => errs.push('PAGEERR: ' + e.message));
p.on('console', m => { if (m.type() === 'error') errs.push('CONSOLE: ' + m.text()); });
await p.goto('http://localhost:8099', { waitUntil: 'networkidle' });
await p.getByRole('button', { name: 'MFD', exact: true }).click();
await p.waitForTimeout(1500);
const apKeys = await p.$$eval('.ap-key', els => els.map(e => e.textContent));
const title = await p.$eval('.bezel-title', e => e.textContent).catch(() => null);
console.log('AP keys on MFD:', JSON.stringify(apKeys));
console.log('Bezel title:', title);
console.log('Errors:', errs.length ? errs.join(' | ') : 'NONE');
await b.close();
+21
View File
@@ -0,0 +1,21 @@
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1180, height: 820 } });
page.on('console', (m) => console.log('[console]', m.type(), m.text()));
page.on('pageerror', (e) => console.log('[pageerror]', e.message));
await page.goto('http://localhost:8099', { waitUntil: 'networkidle' });
await page.getByRole('button', { name: 'PFD', exact: true }).click();
await page.waitForTimeout(5000);
// report canvas presence + size
const info = await page.evaluate(() => {
const c = document.querySelector('.svt-canvas canvas');
const f = document.querySelector('.svt-pos');
return {
hasCanvas: !!c,
canvasSize: c ? [c.width, c.height] : null,
svtPos: f ? f.getBoundingClientRect() : null,
webgl: (() => { try { return !!document.createElement('canvas').getContext('webgl2'); } catch { return false; } })(),
};
});
console.log('[info]', JSON.stringify(info));
await browser.close();
+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.5"
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

+277
View File
@@ -0,0 +1,277 @@
// 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}"))?;
// The FlyWithLua companion scripts ship as a bundled resource; tell the
// bridge where they live so it can auto-install them into X-Plane.
let lua_src = app
.path()
.resolve("plugins", 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())
.env("LUA_SRC_DIR", lua_src.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()
}
+65
View File
@@ -0,0 +1,65 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "X-Plane Cockpit",
"version": "0.1.5",
"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",
"resources/plugins": "plugins"
},
"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"
}
}
}
+93
View File
@@ -0,0 +1,93 @@
<!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>
<details class="asp-wrap">
<summary>Lufträume auf der Karte</summary>
<div class="asp-body">
<p class="asp-note">Wähle Regionen für die Luftraum-Anzeige (Class B/C/D, Restricted, MOA …). USA ist frei; Europa braucht einen kostenlosen <a href="#" id="aspKeyLink">OpenAIP-API-Key</a>.</p>
<input id="aspKey" type="password" placeholder="OpenAIP API-Key (für Europa)" spellcheck="false" />
<div id="aspRegions" class="asp-list"></div>
<div id="aspHint" class="hint"></div>
</div>
</details>
</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>
+245
View File
@@ -0,0 +1,245 @@
// 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, serverPort = 0;
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;
serverPort = info.port;
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);
loadRegions();
} 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;
serverPort = 0;
const ar = $('aspRegions'); if (ar) ar.innerHTML = '';
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);
}
/* ---------------- airspace regions ---------------- */
const aspBase = () => `http://127.0.0.1:${serverPort}/api/airspace`;
async function loadRegions() {
const wrap = $('aspRegions');
if (!wrap || !serverPort) return;
try {
const r = await fetch(`${aspBase()}/regions`, { cache: 'no-store' });
const { regions } = await r.json();
wrap.innerHTML = '';
for (const reg of regions) {
const row = document.createElement('div');
row.className = 'asp-row';
const installed = reg.installed > 0;
row.innerHTML = `
<span class="asp-name">${reg.label}${reg.needsKey ? ' <em>· Key</em>' : ''}</span>
<span class="asp-count">${installed ? reg.installed + ' Zonen' : '—'}</span>
<button class="btn ghost sm" data-region="${reg.id}" data-key="${reg.needsKey ? 1 : 0}">${installed ? 'Aktualisieren' : 'Laden'}</button>`;
wrap.appendChild(row);
}
wrap.querySelectorAll('button[data-region]').forEach((btn) =>
btn.addEventListener('click', () => installRegion(btn)));
} catch (e) { appendLog('airspace: ' + e); }
}
async function installRegion(btn) {
const region = btn.dataset.region;
const needsKey = btn.dataset.key === '1';
const apiKey = $('aspKey').value.trim();
const hint = $('aspHint');
if (needsKey && !apiKey) { hint.textContent = '⚠ OpenAIP-API-Key oben eingeben'; hint.className = 'hint bad'; return; }
btn.disabled = true; const was = btn.textContent; btn.textContent = 'Lädt…';
hint.textContent = `Lade ${region.toUpperCase()} … (Fortschritt im Server-Log)`; hint.className = 'hint';
try {
const r = await fetch(`${aspBase()}/install`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ region, apiKey: needsKey ? apiKey : undefined }),
});
const d = await r.json();
if (d.ok) { hint.textContent = `${region.toUpperCase()}: ${d.features} Zonen geladen`; hint.className = 'hint ok'; }
else { hint.textContent = '⚠ ' + (d.error || 'Fehler'); hint.className = 'hint bad'; }
} catch (e) { hint.textContent = '⚠ ' + e; hint.className = 'hint bad'; }
finally { btn.disabled = false; btn.textContent = was; loadRegions(); }
}
$('aspKeyLink')?.addEventListener('click', (e) => { e.preventDefault(); openUrl('https://www.openaip.net/'); });
$('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();
+106
View File
@@ -0,0 +1,106 @@
/* 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; }
/* airspace region picker (in the live card) */
.asp-wrap { margin-top: 12px; border-top: 1px solid var(--line-soft); padding-top: 10px; }
.asp-wrap > summary { cursor: pointer; color: var(--txt2); font-size: 13px; font-weight: 600; list-style: none; }
.asp-wrap > summary::-webkit-details-marker { display: none; }
.asp-wrap > summary::before { content: '▸ '; color: var(--mut); }
.asp-wrap[open] > summary::before { content: '▾ '; }
.asp-body { margin-top: 10px; display: flex; flex-direction: column; gap: 8px; }
.asp-note { color: var(--mut); font-size: 12px; line-height: 1.4; margin: 0; }
.asp-note a { color: var(--green); }
#aspKey { width: 100%; }
.asp-list { display: flex; flex-direction: column; gap: 6px; }
.asp-row { display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 10px;
background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 8px; padding: 7px 10px; }
.asp-name { color: var(--txt2); font-size: 13px; }
.asp-name em { color: var(--mut); font-style: normal; font-size: 11px; }
.asp-count { color: var(--mut); font-size: 12px; min-width: 56px; text-align: right; }
+900
View File
@@ -0,0 +1,900 @@
{
"name": "xplane-glass-cockpit",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "xplane-glass-cockpit",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"express": "^4.21.2",
"ws": "^8.18.0"
},
"devDependencies": {
"playwright": "^1.60.0"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"name": "xplane-glass-cockpit",
"version": "0.1.0",
"description": "Bring X-Plane 12 instruments (PFD/MFD, G1000-style, autopilot) to any tablet or laptop on your LAN via React.",
"type": "module",
"scripts": {
"postinstall": "cd web && npm install",
"build": "cd web && npm run build",
"start": "node server/bridge.js",
"dev:bridge": "node --watch server/bridge.js",
"dev:web": "cd web && npm run dev"
},
"dependencies": {
"express": "^4.21.2",
"ws": "^8.18.0"
},
"devDependencies": {
"playwright": "^1.60.0"
}
}
+38
View File
@@ -0,0 +1,38 @@
# FlyWithLua companion — FMS two-way sync
X-Plane's Web API can't write a flight plan into the FMS. `fms-sync.lua` runs
inside X-Plane (via FlyWithLua, which has the FMS SDK) and syncs the shared
cockpit plan ↔ the in-sim FMS through two files in `Output/fms-sync/`.
## Install (sim PC only)
1. Install **FlyWithLua NG+** (free): copy its plugin folder into
`<X-Plane>/Resources/plugins/FlyWithLua/`.
2. Start the bridge (desktop app or `node server/bridge.js`) **on the same PC**
as X-Plane. On startup it auto-copies these three scripts into
`<X-Plane>/Resources/plugins/FlyWithLua/Scripts/` and keeps them up to date
on every launch (it only writes changed/missing files):
- **`fms-sync.lua`** — flight-plan two-way sync
- **`ui-sync.lua`** — G1000 UI state (page / range / inset)
- **`terrain-probe.lua`** — terrain-awareness elevation grid for the MFD
3. In X-Plane: *FlyWithLua → Reload all Lua script files* (or restart).
The log shows `[glass-cockpit] FMS sync active`.
No manual copying needed. If you install FlyWithLua *after* the bridge is
already running, trigger a re-install without restarting via
`POST /api/lua/install`. The current install state is reported under `lua` in
`GET /api/health`. The bridge must run on the **same PC** as X-Plane so both see
`<X-Plane>/Output/fms-sync/`. (The auto-install honours `LUA_SRC_DIR` — the
desktop app sets it to the bundled scripts; otherwise it finds `plugins/` itself.)
## What you get
- **App → Sim:** load/build a plan in the web cockpit → it appears in the 3-D
G1000 and the autopilot can fly it.
- **Sim → App:** build/edit the plan in the real FMS → it shows on every tablet.
- **Terrain:** the MFD TERRAIN map colours real scenery elevation red/yellow
relative to your altitude (probed live by `terrain-probe.lua`).
A 3-decimal lat/lon signature de-dupes the round-trip, so the two sides never
loop. Waypoints are pushed as lat/lon legs (exact route; in-sim idents are
generic — route accuracy over cosmetics).
+113
View File
@@ -0,0 +1,113 @@
-- ============================================================================
-- X-Plane Glass Cockpit — FMS two-way sync (FlyWithLua companion)
-- ============================================================================
-- The web cockpit's bridge can't write the FMS via X-Plane's Web API. This
-- script runs INSIDE X-Plane (FlyWithLua) and has the FMS SDK, so it bridges
-- the shared plan <-> the in-sim FMS through two text files:
--
-- <X-Plane>/Output/fms-sync/to_sim.txt written by the bridge (our plan)
-- <X-Plane>/Output/fms-sync/from_sim.txt written here (the sim's plan)
--
-- A 3-decimal lat/lon signature de-dupes both sides so they never loop.
--
-- INSTALL: copy this file to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/
-- (install FlyWithLua NG+ first), then restart X-Plane or run
-- "FlyWithLua > Reload all Lua script files".
-- ============================================================================
local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/"
local TO_SIM = SYNC .. "to_sim.txt"
local FROM_SIM = SYNC .. "from_sim.txt"
local last_sig = nil
-- make sure the folder exists (bridge also creates it)
os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul')
-- 3-decimal lat/lon signature of a waypoint list ---------------------------
local function sig_of(wps)
local parts = {}
for i = 1, #wps do
parts[i] = string.format("%.3f,%.3f", wps[i].lat, wps[i].lon)
end
return table.concat(parts, ";")
end
local function read_file(p)
local f = io.open(p, "r"); if not f then return nil end
local s = f:read("*a"); f:close(); return s
end
local function write_file(p, s)
local f = io.open(p, "w"); if not f then return end
f:write(s); f:close()
end
-- parse the bridge file: skip "# sig" lines, take "lat lon alt id type" ------
local function parse(txt)
local wps = {}
if not txt then return wps end
for line in txt:gmatch("[^\r\n]+") do
if line:sub(1, 1) ~= "#" then
local lat, lon, alt, id = line:match("^%s*(-?%d+%.?%d*)%s+(-?%d+%.?%d*)%s+(-?%d+)%s+(%S+)")
if lat and lon then
wps[#wps + 1] = { lat = tonumber(lat), lon = tonumber(lon), alt = tonumber(alt) or 0, id = id or "WPT" }
end
end
end
return wps
end
-- read the current in-sim FMS plan ------------------------------------------
local function read_fms()
local wps = {}
local n = XPLMCountFMSEntries()
for i = 0, n - 1 do
-- FlyWithLua: type, id, ref, altitude, lat, lon
local _t, id, _ref, alt, lat, lon = XPLMGetFMSEntryInfo(i)
if lat and lon and (math.abs(lat) > 0.0001 or math.abs(lon) > 0.0001) then
wps[#wps + 1] = { lat = lat, lon = lon, alt = alt or 0, id = (id ~= "" and id) or "WPT" }
end
end
return wps
end
-- write our plan into the in-sim FMS ----------------------------------------
local function apply_to_fms(wps)
local old = XPLMCountFMSEntries()
for i = 1, #wps do
-- lat/lon entries keep our exact coords -> stable round-trip (no drift)
XPLMSetFMSEntryLatLon(i - 1, wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0))
end
for i = old - 1, #wps, -1 do XPLMClearFMSEntry(i) end -- trim leftovers
if #wps >= 1 then
XPLMSetDisplayedFMSEntry(0)
XPLMSetDestinationFMSEntry(#wps - 1)
end
end
local function serialize(wps)
local lines = { "# " .. sig_of(wps) }
for i = 1, #wps do
lines[#lines + 1] = string.format("%.6f %.6f %d %s WPT", wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0), wps[i].id)
end
return table.concat(lines, "\n") .. "\n"
end
-- main loop (~1×/sec): whichever side differs from the agreed plan wins ------
function fms_sync_tick()
local to_wps = parse(read_file(TO_SIM))
local tsig = sig_of(to_wps)
local fm_wps = read_fms()
local fsig = sig_of(fm_wps)
if tsig ~= "" and tsig ~= last_sig then
apply_to_fms(to_wps) -- App -> Sim
last_sig = tsig
elseif fsig ~= last_sig then
write_file(FROM_SIM, serialize(fm_wps)) -- Sim -> App
last_sig = fsig
end
end
do_often("fms_sync_tick()")
logMsg("[glass-cockpit] FMS sync active -> " .. SYNC)
+55
View File
@@ -0,0 +1,55 @@
-- ============================================================================
-- X-Plane Glass Cockpit — Terrain awareness probe (FlyWithLua companion)
-- ============================================================================
-- The web MFD can't read X-Plane's scenery elevation over the Web API. This
-- script samples a grid of terrain heights around the aircraft with X-Plane's
-- terrain probe and writes them to terrain.json in the sync folder; the bridge
-- streams it to the tablets, which colour it red/yellow vs aircraft altitude
-- (G1000 TAWS). See terrain-sync in server/fmssync.js.
--
-- INSTALL: copy to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/ (alongside
-- fms-sync.lua). Needs FlyWithLua NG+ (XPLM scenery-probe bindings).
-- ============================================================================
local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/"
local OUT = SYNC .. "terrain.json"
os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul')
local M_FT = 3.28084
local ROWS, COLS = 24, 24
local DLAT, DLON = 0.35, 0.5 -- half-box (deg) around the aircraft
local probe = XPLMCreateProbe(0) -- xplm_ProbeY
-- terrain elevation (ft MSL) at a lat/lon, via the vertical scenery probe
local function elev_ft(lat, lon)
local x, y, z = XPLMWorldToLocal(lat, lon, 0)
local res, _px, py = XPLMProbeTerrainXYZ(probe, x, y, z)
if res ~= 0 then return 0 end -- 0 = xplm_ProbeHitTerrain
local _plat, _plon, palt = XPLMLocalToWorld(x, py, z)
return math.max(0, math.floor(palt * M_FT))
end
function gc_terrain_tick()
local lat = get("sim/flightmodel/position/latitude")
local lon = get("sim/flightmodel/position/longitude")
local alt = math.floor(get("sim/flightmodel/position/elevation") * M_FT) -- true MSL
local n, s = lat + DLAT, lat - DLAT
local w, e = lon - DLON, lon + DLON
local cells = {}
for r = 0, ROWS - 1 do -- r = 0 → north (top)
local glat = n - (r / (ROWS - 1)) * (n - s)
for c = 0, COLS - 1 do -- c = 0 → west
local glon = w + (c / (COLS - 1)) * (e - w)
cells[#cells + 1] = elev_ft(glat, glon)
end
end
local f = io.open(OUT, "w")
if not f then return end
f:write(string.format(
'{"lat":%.5f,"lon":%.5f,"alt":%d,"n":%.5f,"s":%.5f,"w":%.5f,"e":%.5f,"rows":%d,"cols":%d,"elev":[%s]}',
lat, lon, alt, n, s, w, e, ROWS, COLS, table.concat(cells, ",")))
f:close()
end
do_often("gc_terrain_tick()") -- ~1×/sec
logMsg("[glass-cockpit] terrain probe active -> " .. OUT)
+66
View File
@@ -0,0 +1,66 @@
-- ============================================================================
-- X-Plane Glass Cockpit — G1000 UI-state publisher (FlyWithLua companion)
-- ============================================================================
-- The web G1000 mirrors the in-sim G1000's display state. Most of it already
-- flows over the Web API (attitude, radios, AP, CDI source, baro, ...). The few
-- bits that are G1000-internal (MFD page, map range, PFD inset) aren't standard
-- datarefs, so this script reads them and re-publishes them under our own
-- namespace, which the bridge then streams to every tablet:
--
-- glasscockpit/ui/mfd_page Int 0 = MAP, 1 = FPL, 2 = NRST
-- glasscockpit/ui/map_range_nm Float active map range in NM
-- glasscockpit/ui/inset Int PFD inset map on/off (0/1)
--
-- INSTALL: copy to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/ (alongside
-- fms-sync.lua). The web app follows these when present and falls back to its
-- own local control when they're absent — so it never breaks without the plugin.
-- ============================================================================
-- our published values (the create_dataref callbacks read these) ------------
local ui_mfd_page = -1 -- -1 = "unknown" -> web keeps local control
local ui_map_range_nm = -1
local ui_inset = -1
create_dataref("glasscockpit/ui/mfd_page", "Int", function() return ui_mfd_page end)
create_dataref("glasscockpit/ui/map_range_nm", "Float", function() return ui_map_range_nm end)
create_dataref("glasscockpit/ui/inset", "Int", function() return ui_inset end)
-- safe optional dataref readers (nil if the dataref doesn't exist) ----------
local function geti(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDatai(h) end end
local function getf(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDataf(h) end end
-- ============================================================================
-- TODO (confirm in YOUR sim): the exact G1000 source datarefs differ per
-- aircraft. Run the probe below once, read the X-Plane Log.txt, and plug the
-- right names in here. Until then these stay -1 and the web app uses its own
-- local page/range/inset (no harm).
-- ============================================================================
local function read_g1000_state()
-- MAP RANGE — many G1000s expose an NM range or an enum index. Try a couple
-- of common candidates; map_range may be an enum needing a lookup table.
local rng = getf("sim/cockpit2/EFIS/map_range") -- <-- verify name
if rng then ui_map_range_nm = rng end
-- PFD INSET on/off — G1000-internal, name varies:
-- local ins = geti("sim/cockpit2/EFIS/inset_map_on") -- <-- verify name
-- if ins then ui_inset = ins end
-- MFD PAGE group — G1000-internal, name varies:
-- local pg = geti("sim/cockpit2/EFIS/mfd_page") -- <-- verify name
-- if pg then ui_mfd_page = pg end
end
do_often("read_g1000_state()")
-- ---- one-shot probe: log every dataref whose name contains a keyword -------
-- Bind to a key/macro, fire once, then read Log.txt to discover the real names.
function gc_probe_g1000()
local hits = {}
for _, kw in ipairs({ "EFIS", "g1000", "GPS/g1000", "map_range", "inset", "mfd" }) do
logMsg("[glass-cockpit] probe keyword: " .. kw .. " (search Log.txt / DataRefEditor)")
end
logMsg("[glass-cockpit] tip: use the DataRefEditor or DataRefTool plugin and filter for 'EFIS' / 'g1000' to find map-range / inset / page datarefs, then edit ui-sync.lua")
end
add_macro("Glass Cockpit: probe G1000 datarefs", "gc_probe_g1000()")
logMsg("[glass-cockpit] UI-state publisher active (mfd_page / map_range_nm / inset)")
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Build the Linux AppImage + .deb (x86_64) in Docker. Run from the repo root,
# AFTER scripts/prep-desktop.sh has produced the linux sidecar + web resources.
# On Apple Silicon this runs under qemu (linux/amd64) and is slow but works.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
IMG=xpcockpit-linux-builder
echo "==> building docker image ($IMG)"
docker build --platform linux/amd64 -f desktop/Dockerfile.linux -t "$IMG" desktop >/dev/null
# The updater signing key is a secret and is NOT in the repo. If it's present
# locally we sign + emit updater artifacts; otherwise we build plain installable
# bundles (appimage + deb) with updater artifacts disabled via a config override.
SIGN_ENV=(); BUNDLES="appimage,deb,updater"; CFG=""
if [[ -f desktop/.tauri-signing.key && -f desktop/.tauri-signing.pw ]]; then
SIGN_ENV=(-e "TAURI_SIGNING_PRIVATE_KEY=$(cat desktop/.tauri-signing.key)"
-e "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$(cat desktop/.tauri-signing.pw)")
echo "==> signing key found — emitting signed updater artifacts"
else
BUNDLES="appimage,deb"; CFG='--config {"bundle":{"createUpdaterArtifacts":false}}'
echo "==> no signing key — building plain appimage + deb (no updater artifacts)"
fi
echo "==> tauri build (x86_64-unknown-linux-gnu) in container — this takes a while under emulation"
docker run --rm --platform linux/amd64 \
-v "$ROOT":/work \
-e CARGO_TARGET_DIR=/work/target-linux \
-e APPIMAGE_EXTRACT_AND_RUN=1 \
-e ARCH=x86_64 \
"${SIGN_ENV[@]}" \
-w /work/desktop \
"$IMG" \
bash -c "export PATH=/usr/local/cargo/bin:\$PATH; tauri build --target x86_64-unknown-linux-gnu --bundles $BUNDLES $CFG"
# Repair the Bun sidecar that linuxdeploy's patchelf corrupts inside the AppImage
# (see scripts/fix-linux-appimage.sh). Runs on the host against the mounted
# artifacts; regenerates the updater .sig when a signing key is present.
echo "==> repairing AppImage sidecar"
bash "$ROOT/scripts/fix-linux-appimage.sh"
echo "==> artifacts:"
find target-linux/x86_64-unknown-linux-gnu/release/bundle -maxdepth 2 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.AppImage.sig' -o -name '*.tar.gz' -o -name '*.sig' \) 2>/dev/null
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Post-process a Tauri-built Linux AppImage to repair the Bun sidecar.
#
# WHY: Tauri's AppImage step runs linuxdeploy, which patchelf-injects a RUNPATH
# ($ORIGIN/../lib) into every executable in usr/bin. The Bun-compiled sidecar
# `xpbridge` is a self-contained binary (~91 MB, JS/assets appended past the ELF)
# and does NOT survive ELF rewriting — the patched copy core-dumps on start, so
# the app launches but the bridge never listens. The repo/standalone binary is
# fine; only the AppImage copy is corrupt.
#
# FIX: replace the patched sidecar in the AppImage with the pristine repo binary,
# repack (the appimage plugin does NOT patchelf), and regenerate the updater .sig.
#
# Run AFTER `tauri build ... --bundles appimage[,updater]`. Idempotent.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target-linux}"
BUNDLE_DIR="$TARGET_DIR/x86_64-unknown-linux-gnu/release/bundle/appimage"
ORIG_SIDECAR="$ROOT/desktop/src-tauri/binaries/xpbridge-x86_64-unknown-linux-gnu"
PLUGIN="$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage"
[[ -f "$ORIG_SIDECAR" ]] || { echo "!! pristine sidecar missing: $ORIG_SIDECAR (run prep-desktop.sh)"; exit 1; }
[[ -x "$PLUGIN" ]] || { echo "!! linuxdeploy-plugin-appimage missing: $PLUGIN (run a tauri appimage build once to fetch it)"; exit 1; }
APPIMAGE="$(find "$BUNDLE_DIR" -maxdepth 1 -name '*.AppImage' | head -1)"
[[ -n "$APPIMAGE" ]] || { echo "!! no AppImage found in $BUNDLE_DIR"; exit 1; }
echo "==> repairing sidecar in: $(basename "$APPIMAGE")"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
( cd "$WORK" && APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE" --appimage-extract >/dev/null )
APPDIR="$WORK/squashfs-root"
SC="$(find "$APPDIR" -name xpbridge -type f | head -1)"
[[ -n "$SC" ]] || { echo "!! xpbridge not found inside AppImage"; exit 1; }
cp -f "$ORIG_SIDECAR" "$SC"; chmod +x "$SC"
echo "==> restored pristine sidecar ($(sha256sum "$SC" | cut -c1-12)...)"
# Repack. The appimage plugin embeds the AppDir verbatim — no patchelf — so the
# sidecar stays byte-identical to the repo binary.
OUT="$WORK/out.AppImage"
( cd "$WORK" && APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ARCH=x86_64 LDAI_OUTPUT="$OUT" \
"$PLUGIN" --appdir "$APPDIR" >/dev/null )
[[ -f "$OUT" ]] || { echo "!! repack produced no AppImage"; exit 1; }
# sanity: the repacked sidecar must run, not core-dump
( cd "$WORK" && rm -rf v && mkdir v && cd v && APPIMAGE_EXTRACT_AND_RUN=1 "$OUT" --appimage-extract >/dev/null )
NEWSC="$(find "$WORK/v/squashfs-root" -name xpbridge -type f | head -1)"
if [[ "$(sha256sum "$NEWSC" | cut -d' ' -f1)" != "$(sha256sum "$ORIG_SIDECAR" | cut -d' ' -f1)" ]]; then
echo "!! repacked sidecar hash != pristine — repack still mangled it"; exit 1
fi
echo "==> verified: repacked sidecar is byte-identical to repo binary"
mv -f "$OUT" "$APPIMAGE"
echo "==> replaced original AppImage (same name, updater url unchanged)"
# Regenerate the updater signature for the new file, if signing is configured.
SIG="$APPIMAGE.sig"
if [[ -f "$ROOT/desktop/.tauri-signing.key" && -f "$ROOT/desktop/.tauri-signing.pw" ]]; then
# signer writes <FILE>.sig next to the input automatically
TAURI_SIGNING_PRIVATE_KEY="$(cat "$ROOT/desktop/.tauri-signing.key")" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat "$ROOT/desktop/.tauri-signing.pw")" \
npx --prefix desktop tauri signer sign "$APPIMAGE" >/dev/null
echo "==> regenerated updater signature: $(basename "$SIG")"
else
[[ -f "$SIG" ]] && { rm -f "$SIG"; echo "==> no signing key — removed stale $(basename "$SIG")"; }
fi
echo "==> done. AppImage repaired."
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Prepare the Tauri bundle inputs: build the web cockpit, copy it in as a
# resource, and compile the Node bridge into Bun single-file sidecars named with
# the Tauri target triples. Run from the repo root.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export PATH="$HOME/.bun/bin:$PATH"
echo "==> building web cockpit"
( cd web && npm run build >/dev/null )
echo "==> copying cockpit into desktop resources"
rm -rf desktop/src-tauri/resources/web
mkdir -p desktop/src-tauri/resources/web
cp -R web/dist/. desktop/src-tauri/resources/web/
echo "==> copying FlyWithLua companion scripts into desktop resources"
rm -rf desktop/src-tauri/resources/plugins
mkdir -p desktop/src-tauri/resources/plugins
cp plugins/*.lua desktop/src-tauri/resources/plugins/
echo "==> compiling bridge sidecars (Bun)"
mkdir -p desktop/src-tauri/binaries
bun build --compile --target=bun-darwin-arm64 server/bridge.js \
--outfile desktop/src-tauri/binaries/xpbridge-aarch64-apple-darwin
bun build --compile --target=bun-linux-x64-baseline server/bridge.js \
--outfile desktop/src-tauri/binaries/xpbridge-x86_64-unknown-linux-gnu
chmod +x desktop/src-tauri/binaries/xpbridge-*
echo "==> done"
ls -lh desktop/src-tauri/binaries/
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Publish a release to Gitea and (re)write the updater's latest.json.
//
// Reads the built artifacts (macOS .app.tar.gz + .sig, Linux .AppImage + .sig,
// plus .dmg / .deb for manual install), creates a versioned release with those
// assets, then builds latest.json (Tauri v2 updater format) pointing at the
// release download URLs and uploads it to a fixed-tag "updater" release so the
// app's updater endpoint URL stays constant.
//
// Env: GITEA_URL (e.g. https://git.kgva.ch), GITEA_REPO (owner/name),
// GITEA_TOKEN (or a token file at /tmp/gitea_token).
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const GITEA_URL = (process.env.GITEA_URL || 'https://git.kgva.ch').replace(/\/$/, '');
const REPO = process.env.GITEA_REPO || 'karim/xplane-cockpit';
const TOKEN = process.env.GITEA_TOKEN
|| (fs.existsSync('/tmp/gitea_token') ? fs.readFileSync('/tmp/gitea_token', 'utf8').trim() : '');
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'desktop/src-tauri/tauri.conf.json'), 'utf8')).version;
const UPDATER_TAG = 'updater';
if (!TOKEN) { console.error('No GITEA_TOKEN (env or /tmp/gitea_token).'); process.exit(1); }
const api = (p, opts = {}) => fetch(`${GITEA_URL}/api/v1${p}`, {
...opts,
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json', ...(opts.headers || {}) },
});
// Collect built artifacts that exist (mac and/or linux builds may have run).
function findArtifacts() {
const macBundle = path.join(ROOT, 'desktop/src-tauri/target/aarch64-apple-darwin/release/bundle');
const linBundle = path.join(ROOT, 'target-linux/x86_64-unknown-linux-gnu/release/bundle');
const out = { assets: [], updater: {} };
const add = (file, platformKey) => {
if (!fs.existsSync(file)) return;
out.assets.push(file);
if (platformKey) {
const sig = file + '.sig';
if (fs.existsSync(sig)) out.updater[platformKey] = { file, sig: fs.readFileSync(sig, 'utf8').trim() };
}
};
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) : [];
// macOS
glob1(path.join(macBundle, 'macos'), /\.app\.tar\.gz$/).forEach((f) => add(f, 'darwin-aarch64'));
glob1(path.join(macBundle, 'dmg'), /\.dmg$/).forEach((f) => out.assets.push(f));
// Linux
glob1(path.join(linBundle, 'appimage'), /\.AppImage$/).forEach((f) => add(f, 'linux-x86_64'));
glob1(path.join(linBundle, 'deb'), /\.deb$/).forEach((f) => out.assets.push(f));
return out;
}
async function getReleaseByTag(tag) {
const r = await api(`/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`);
return r.ok ? r.json() : null;
}
async function ensureRelease(tag, name, body) {
let rel = await getReleaseByTag(tag);
if (rel) return rel;
const r = await api(`/repos/${REPO}/releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: tag, name, body, draft: false, prerelease: false }),
});
if (!r.ok) throw new Error(`create release ${tag}: ${r.status} ${await r.text()}`);
return r.json();
}
async function uploadAsset(rel, file, asName) {
const name = asName || path.basename(file);
// delete an existing asset with the same name first (Gitea won't overwrite)
for (const a of rel.assets || []) {
if (a.name === name) await api(`/repos/${REPO}/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
}
const blob = new Blob([fs.readFileSync(file)]);
const fd = new FormData();
fd.append('attachment', blob, name);
const r = await api(`/repos/${REPO}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(`upload ${name}: ${r.status} ${await r.text()}`);
console.log(' ↑', name);
return r.json();
}
const dlUrl = (tag, name) => `${GITEA_URL}/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
async function main() {
const { assets, updater } = findArtifacts();
if (!assets.length) { console.error('No build artifacts found — run the builds first.'); process.exit(1); }
console.log(`Release v${VERSION}${GITEA_URL}/${REPO}`);
console.log('Artifacts:', assets.map((a) => path.basename(a)).join(', '));
const verTag = `v${VERSION}`;
const rel = await ensureRelease(verTag, `X-Plane Cockpit ${verTag}`, `Automated release ${verTag}.`);
for (const f of assets) await uploadAsset(rel, f);
const relFresh = await getReleaseByTag(verTag); // refresh asset list
// Build latest.json referencing this release's updater artifacts.
const platforms = {};
for (const [key, { file, sig }] of Object.entries(updater)) {
platforms[key] = { signature: sig, url: dlUrl(verTag, path.basename(file)) };
}
const latest = {
version: VERSION,
notes: `X-Plane Cockpit ${verTag}`,
pub_date: new Date().toISOString(),
platforms,
};
const latestPath = path.join(ROOT, 'desktop/latest.json');
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
console.log('latest.json platforms:', Object.keys(platforms).join(', ') || '(none)');
// Upload latest.json to the fixed "updater" release (constant endpoint URL).
const upd = await ensureRelease(UPDATER_TAG, 'Updater channel', 'Rolling pointer used by the in-app updater.');
await uploadAsset(upd, latestPath, 'latest.json');
console.log('Updater endpoint:', dlUrl(UPDATER_TAG, 'latest.json'));
}
main().catch((e) => { console.error(e); process.exit(1); });
+194
View File
@@ -0,0 +1,194 @@
// Airspace overlay data. X-Plane ships no airspace boundaries in its nav data,
// so we keep them as GeoJSON files the user installs per region (chosen in the
// desktop launcher). This module:
// - resolves the airspace data dir (next to the FMS sync folder, overridable)
// - loads every *.geojson there into a flat, bbox-indexed feature list
// - answers bbox queries for the moving map (/api/airspace/bbox)
// - downloads region datasets on demand (FAA = key-free US; OpenAIP = others,
// needs the user's API key) and normalises them to one schema
//
// Normalised feature properties: { name, cls, lo, hi } where cls is a coarse
// class the map colours by: B|C|D|E|TMA|CTR|MOA|RESTRICTED|PROHIBITED|DANGER|OTHER.
import fs from 'node:fs';
import path from 'node:path';
import { xplaneRoot } from './navdata.js';
function dataDir() {
if (process.env.AIRSPACE_DIR) return process.env.AIRSPACE_DIR;
const r = xplaneRoot();
return r ? path.join(r, 'Output', 'fms-sync', 'airspace') : path.join(process.cwd(), 'airspace-data');
}
// flat store: { bbox:[s,w,n,e], geometry, props:{name,cls,lo,hi}, region }
let store = [];
let loaded = false;
function featureBbox(geom) {
let s = 90, w = 180, n = -90, e = -180;
const scan = (co) => {
if (typeof co[0] === 'number') { const [x, y] = co; if (y < s) s = y; if (y > n) n = y; if (x < w) w = x; if (x > e) e = x; }
else for (const c of co) scan(c);
};
try { scan(geom.coordinates); } catch { /* ignore */ }
return [s, w, n, e];
}
// Map many source schemas (FAA, OpenAIP, generic) onto one coarse class.
function classify(p = {}) {
const raw = String(
p.cls ?? p.CLASS ?? p.class ?? p.Class ?? p.LOCAL_TYPE ?? p.TYPE_CODE ?? p.type ?? ''
).toUpperCase();
const name = String(p.name ?? p.NAME ?? p.Name ?? p.IDENT ?? '').toUpperCase();
const hay = raw + ' ' + name;
if (/PROHIBIT/.test(hay)) return 'PROHIBITED';
if (/RESTRICT/.test(hay)) return 'RESTRICTED';
if (/\bMOA\b|MILITARY OPERATION/.test(hay)) return 'MOA';
if (/DANGER/.test(hay)) return 'DANGER';
if (/\bTMA\b/.test(hay)) return 'TMA';
if (/\bCTR\b|CONTROL ZONE/.test(hay)) return 'CTR';
// OpenAIP icaoClass: 0=A 1=B 2=C 3=D 4=E 5=F 6=G
if (p.icaoClass != null) return ['A', 'B', 'C', 'D', 'E', 'F', 'G'][p.icaoClass] || 'OTHER';
const m = raw.match(/\b([A-G])\b/) || raw.match(/CLASS\s*([A-G])/) || raw.match(/^([A-G])\d?$/);
if (m) return m[1];
return 'OTHER';
}
// Pull a readable altitude limit out of whatever fields a source uses.
function limit(p, kind) {
const lo = kind === 'lo'
? (p.lo ?? p.LOWER_VAL ?? p.lowerLimit?.value ?? p.LOWER_DESC ?? p.lower ?? null)
: (p.hi ?? p.UPPER_VAL ?? p.upperLimit?.value ?? p.UPPER_DESC ?? p.upper ?? null);
return lo == null ? null : (typeof lo === 'object' ? (lo.value ?? null) : lo);
}
function ingest(fc, region) {
const feats = Array.isArray(fc?.features) ? fc.features : [];
for (const f of feats) {
if (!f?.geometry?.coordinates) continue;
const p = f.properties || {};
store.push({
bbox: featureBbox(f.geometry),
geometry: f.geometry,
props: { name: p.name ?? p.NAME ?? p.Name ?? '', cls: classify(p), lo: limit(p, 'lo'), hi: limit(p, 'hi') },
region,
});
}
}
export function loadAirspace(log = console.log) {
store = [];
const dir = dataDir();
let files = [];
try { files = fs.readdirSync(dir).filter((f) => f.toLowerCase().endsWith('.geojson')); } catch { /* none yet */ }
for (const f of files) {
try { ingest(JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')), f.replace(/\.geojson$/i, '')); }
catch (e) { log(`airspace: ${f} parse failed: ${e.message}`); }
}
loaded = true;
if (store.length) log(`airspace: ${store.length} features from ${files.length} file(s) in ${dir}`);
return store.length;
}
// Features whose bbox intersects the query window (linear scan — a few thousand
// features, queried only on map move; cheap enough). Returns light DTOs.
export function airspaceBbox(s, w, n, e, limit = 400) {
if (!loaded) loadAirspace();
const out = [];
for (const a of store) {
const [as, aw, an, ae] = a.bbox;
if (an < s || as > n || ae < w || aw > e) continue;
out.push({ name: a.props.name, cls: a.props.cls, lo: a.props.lo, hi: a.props.hi, geometry: a.geometry });
if (out.length >= limit) break;
}
return out;
}
export function airspaceStatus() {
if (!loaded) loadAirspace();
const byRegion = {};
for (const a of store) byRegion[a.region] = (byRegion[a.region] || 0) + 1;
return { dir: dataDir(), features: store.length, regions: byRegion };
}
// ---- region downloads ------------------------------------------------------
// kind 'faa': paginated ArcGIS FeatureServer → GeoJSON (US, public domain, no key)
// kind 'openaip': OpenAIP REST by ICAO country code (needs the user's API key)
export const REGIONS = [
{ id: 'us', label: 'USA (FAA)', kind: 'faa', needsKey: false,
layers: ['https://services6.arcgis.com/ssFJjBXIUyZDrSYZ/arcgis/rest/services/Class_Airspace/FeatureServer/0'] },
{ id: 'ch', label: 'Schweiz', kind: 'openaip', country: 'CH', needsKey: true },
{ id: 'at', label: 'Österreich', kind: 'openaip', country: 'AT', needsKey: true },
{ id: 'de', label: 'Deutschland', kind: 'openaip', country: 'DE', needsKey: true },
{ id: 'fr', label: 'Frankreich', kind: 'openaip', country: 'FR', needsKey: true },
{ id: 'it', label: 'Italien', kind: 'openaip', country: 'IT', needsKey: true },
{ id: 'gb', label: 'Großbritannien', kind: 'openaip', country: 'GB', needsKey: true },
];
async function fetchFaa(layerUrl, log) {
const feats = [];
let offset = 0;
for (let page = 0; page < 80; page++) { // safety cap
const url = `${layerUrl}/query?where=1%3D1&outFields=*&returnGeometry=true&outSR=4326&f=geojson&resultRecordCount=1000&resultOffset=${offset}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`FAA HTTP ${res.status}`);
const fc = await res.json();
const got = fc.features?.length || 0;
feats.push(...(fc.features || []));
log(`airspace: FAA page ${page + 1} (+${got}, total ${feats.length})`);
if (got < 1000 && !fc.properties?.exceededTransferLimit) break;
offset += 1000;
}
return { type: 'FeatureCollection', features: feats };
}
async function fetchOpenAip(country, apiKey, log) {
const feats = [];
let pageNum = 1;
for (; pageNum <= 50; pageNum++) {
const url = `https://api.core.openaip.net/api/airspaces?country=${country}&limit=1000&page=${pageNum}`;
const res = await fetch(url, { headers: { 'x-openaip-api-key': apiKey } });
if (!res.ok) throw new Error(`OpenAIP HTTP ${res.status} (API-Key prüfen)`);
const body = await res.json();
const items = body.items || [];
for (const a of items) {
if (!a.geometry) continue;
feats.push({ type: 'Feature', geometry: a.geometry, properties: { name: a.name, icaoClass: a.icaoClass, type: a.type, lower: a.lowerLimit, upper: a.upperLimit } });
}
log(`airspace: OpenAIP ${country} page ${pageNum} (+${items.length}, total ${feats.length})`);
if (items.length < 1000 || pageNum >= (body.totalPages || 1)) break;
}
return { type: 'FeatureCollection', features: feats };
}
export async function installRegion(id, { apiKey, log = console.log } = {}) {
const region = REGIONS.find((r) => r.id === id);
if (!region) return { ok: false, error: `unknown region: ${id}` };
if (region.needsKey && !apiKey) return { ok: false, error: 'OpenAIP API-Key erforderlich' };
try {
let fc;
if (region.kind === 'faa') {
fc = { type: 'FeatureCollection', features: [] };
for (const layer of region.layers) {
const part = await fetchFaa(layer, log);
fc.features.push(...part.features);
}
} else {
fc = await fetchOpenAip(region.country, apiKey, log);
}
const dir = dataDir();
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${id}.geojson`);
fs.writeFileSync(file, JSON.stringify(fc));
loadAirspace(log); // reload index so the new data is live immediately
return { ok: true, id, features: fc.features.length, file };
} catch (e) {
return { ok: false, error: e.message };
}
}
export function regionList() {
const st = airspaceStatus();
return REGIONS.map((r) => ({ id: r.id, label: r.label, needsKey: r.needsKey, installed: (st.regions[r.id] || 0) }));
}
+381
View File
@@ -0,0 +1,381 @@
// X-Plane Glass Cockpit — Bridge
// -------------------------------------------------------------------------
// Connects to X-Plane 12's built-in web API (localhost only), resolves
// dataref/command names to per-session IDs, subscribes to live values, and
// fans them out over a LAN-facing WebSocket to any number of tablets/laptops.
// Also serves the built React UI.
import express from 'express';
import { WebSocketServer, WebSocket as WsClient } from 'ws';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { CONFIG, DATAREFS, WRITABLE_DATAREFS, COMMANDS } from './config.js';
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways, airwaysBbox as navAirways, xplaneRoot } from './navdata.js';
import { parseProcedures, procedureLegs as procLegs } from './procedures.js';
import * as fp from './flightplan.js';
import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js';
import { installLuaScripts } from './luainstall.js';
import { loadAirspace, airspaceBbox, airspaceStatus, regionList, installRegion } from './airspace.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
// files it bundles as a resource); otherwise default to ../web/dist.
const WEB_DIST = process.env.WEB_DIST || path.join(__dirname, '..', 'web', 'dist');
const REST = `http://${CONFIG.xplaneHost}:${CONFIG.xplanePort}${CONFIG.xplaneApiBase}`;
const WS_URL = `ws://${CONFIG.xplaneHost}:${CONFIG.xplanePort}${CONFIG.xplaneApiBase}`;
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
// ---- shared state ---------------------------------------------------------
const state = {
xpConnected: false,
values: {}, // alias -> latest value
drefIdToAlias: new Map(), // X-Plane dataref id -> our alias
drefNameToId: new Map(), // sim/... -> id
cmdNameToId: new Map(), // sim/... -> id
xpSocket: null,
reqId: 1,
lua: null, // last FlyWithLua-install report (see luainstall.js)
};
const clients = new Set(); // connected browser sockets
// ---- helpers --------------------------------------------------------------
function broadcast(obj) {
const msg = JSON.stringify(obj);
for (const c of clients) {
if (c.readyState === WsClient.OPEN) c.send(msg);
}
}
function broadcastPlan() {
const plan = fp.getPlan();
broadcast({ type: 'flightplan', data: plan });
pushToSim(plan); // hand the plan to the FlyWithLua FMS bridge (App → Sim)
}
async function fetchAllByName(resource, names) {
// X-Plane's list endpoints can be filtered by name. We query each name so we
// don't pull the full ~15k dataref catalogue.
const map = new Map();
await Promise.all(
[...new Set(names)].map(async (name) => {
try {
const url = `${REST}/${resource}?filter[name]=${encodeURIComponent(name)}`;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const body = await res.json();
const item = (body.data || []).find((d) => d.name === name);
if (item) map.set(name, item.id);
else log(`! ${resource} not found: ${name}`);
} catch (e) {
log(`! lookup failed for ${name}: ${e.message}`);
}
})
);
return map;
}
// ---- X-Plane connection ---------------------------------------------------
async function resolveIds() {
const drefNames = Object.values(DATAREFS);
const cmdNames = Object.values(COMMANDS);
state.drefNameToId = await fetchAllByName('datarefs', [
...drefNames,
...Object.values(WRITABLE_DATAREFS),
]);
state.cmdNameToId = await fetchAllByName('commands', cmdNames);
// build reverse map id -> alias for incoming updates
state.drefIdToAlias.clear();
for (const [alias, name] of Object.entries(DATAREFS)) {
const id = state.drefNameToId.get(name);
if (id != null) state.drefIdToAlias.set(id, alias);
}
log(`resolved ${state.drefNameToId.size} datarefs, ${state.cmdNameToId.size} commands`);
}
function subscribeValues() {
const datarefs = [];
for (const id of state.drefIdToAlias.keys()) datarefs.push({ id });
if (!datarefs.length) return;
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'dataref_subscribe_values',
params: { datarefs },
})
);
log(`subscribed to ${datarefs.length} datarefs`);
}
function connectXPlane() {
log(`connecting to X-Plane @ ${WS_URL} ...`);
let sock;
try {
sock = new WsClient(WS_URL);
} catch (e) {
log('X-Plane connect threw, retrying in 3s:', e.message);
return setTimeout(connectXPlane, 3000);
}
state.xpSocket = sock;
sock.on('open', async () => {
try {
await resolveIds();
subscribeValues();
state.xpConnected = true;
broadcast({ type: 'status', xpConnected: true });
log('X-Plane connected ✓');
} catch (e) {
log('setup after connect failed:', e.message);
}
});
sock.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
if (msg.type === 'dataref_update_values' && msg.data) {
const patch = {};
for (const [id, value] of Object.entries(msg.data)) {
const alias = state.drefIdToAlias.get(Number(id));
if (alias) { state.values[alias] = value; patch[alias] = value; }
}
if (Object.keys(patch).length) broadcast({ type: 'values', data: patch });
}
});
const onDown = (why) => {
if (state.xpConnected) log(`X-Plane disconnected (${why})`);
state.xpConnected = false;
broadcast({ type: 'status', xpConnected: false });
if (state.xpSocket === sock) state.xpSocket = null;
setTimeout(connectXPlane, 3000);
};
sock.on('close', () => onDown('close'));
sock.on('error', (e) => onDown(e.message));
}
// ---- commands coming FROM the browser ------------------------------------
function handleClientMessage(msg) {
// --- flight plan (works even without a sim connection) ---
if (msg.type === 'fp_set') { fp.setPlan(msg.plan); return broadcastPlan(); }
if (msg.type === 'fp_add') {
const r = fp.addWaypoint(msg.ident);
if (!r.ok) return; // silently ignore unknown idents
return broadcastPlan();
}
if (msg.type === 'fp_remove') { fp.removeWaypoint(msg.index); return broadcastPlan(); }
if (msg.type === 'fp_active') { fp.setActiveLeg(msg.index); return broadcastPlan(); }
if (msg.type === 'fp_load') {
const r = fp.loadFms(msg.name);
if (r.ok) return broadcastPlan();
return broadcast({ type: 'fp_export_result', ...r });
}
if (msg.type === 'fp_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
if (msg.type === 'fp_export') {
const r = fp.exportFms(msg.name || 'WEBFPL');
broadcast({ type: 'fp_export_result', ...r });
return;
}
// --- everything below talks to X-Plane; needs a live sim socket ---
if (!state.xpSocket || state.xpSocket.readyState !== WsClient.OPEN) return;
if (msg.type === 'command') {
const name = COMMANDS[msg.name];
const id = name && state.cmdNameToId.get(name);
if (id == null) return log(`! unknown command alias: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'command_set_is_active',
params: { commands: [{ id, is_active: true, duration: msg.duration ?? 0 }] },
})
);
} else if (msg.type === 'setDataref') {
const name = WRITABLE_DATAREFS[msg.name];
const id = name && state.drefNameToId.get(name);
if (id == null) return log(`! unknown writable dataref alias: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'dataref_set_values',
params: { datarefs: [{ id, value: Number(msg.value) }] },
})
);
}
}
// ---- HTTP + LAN WebSocket server -----------------------------------------
const app = express();
// Allow the desktop launcher (a different origin) to read the JSON API. LAN-only
// by design, so a wildcard here is harmless and keeps tablets/the app simple.
app.use('/api', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); });
app.get('/api/health', (_req, res) =>
res.json({ xpConnected: state.xpConnected, datarefs: state.drefIdToAlias.size, clients: clients.size, nav: navStatus(), lua: state.lua })
);
// Re-run the FlyWithLua companion install on demand (e.g. after installing
// FlyWithLua, or to push a freshly edited script without restarting the bridge).
app.post('/api/lua/install', (_req, res) => {
state.lua = installLuaScripts(xplaneRoot(), log);
res.json(state.lua);
});
// Waypoint / navaid / airport search from X-Plane's own nav database.
app.get('/api/nav/search', (req, res) => res.json(navSearch(req.query.q || '', 25)));
// NEAREST airports/navaids to a point (NRST page).
app.get('/api/nav/nearest', (req, res) =>
res.json(navNearest(+req.query.lat, +req.query.lon, { count: +req.query.count || 15, type: req.query.type || 'apt' }))
);
// Features inside a map window (airports/navaids/fixes) for the moving map.
app.get('/api/nav/bbox', (req, res) =>
res.json(navBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e,
(req.query.types || 'apt,vor,ndb').split(','), +req.query.limit || 800))
);
// Airways (Victor/Jet routes) inside a map window — for the MFD AIRWAYS overlay.
app.get('/api/nav/airways', (req, res) =>
res.json(navAirways(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 500))
);
// Airspace polygons inside a map window — for the MFD AIRSPACE overlay. Data
// comes from region GeoJSON files the user installs via the launcher (X-Plane
// ships none). See server/airspace.js.
app.get('/api/airspace/bbox', (req, res) =>
res.json(airspaceBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 400))
);
// Available airspace regions + how many features of each are installed.
app.get('/api/airspace/regions', (_req, res) => res.json({ regions: regionList(), status: airspaceStatus() }));
// Download + install a region's airspace (FAA US is key-free; OpenAIP needs key).
app.post('/api/airspace/install', express.json(), async (req, res) => {
const r = await installRegion(req.body?.region, { apiKey: req.body?.apiKey, log });
res.json(r);
});
// Runways near a point — drawn in the PFD synthetic-vision view.
app.get('/api/nav/runways', (req, res) =>
res.json(navRunways(+req.query.lat, +req.query.lon, +req.query.radius || 12))
);
// PROC: an airport's procedures (SIDs/STARs/approaches) and the resolved leg
// fixes for a chosen procedure+transition (from X-Plane's CIFP data).
app.get('/api/nav/procs', (req, res) => {
const p = parseProcedures(String(req.query.icao || ''));
if (!p) return res.status(404).json({ error: 'no procedures for ' + req.query.icao });
res.json({ icao: p.icao, runways: p.runways, sids: p.sids, stars: p.stars, approaches: p.approaches });
});
// Saved flight plans (Output/FMS plans) — list for the FPL "load" picker.
app.get('/api/fms/list', (_req, res) => res.json(fp.listPlans()));
app.get('/api/nav/proc', (req, res) =>
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
);
app.use(express.static(WEB_DIST));
// SPA fallback so client-side routes work.
app.get('*', (_req, res) => res.sendFile(path.join(WEB_DIST, 'index.html')));
const server = http.createServer(app);
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws) => {
clients.add(ws);
log(`browser connected (${clients.size} total)`);
// send current snapshot immediately so the UI isn't blank
ws.send(JSON.stringify({ type: 'status', xpConnected: state.xpConnected }));
ws.send(JSON.stringify({ type: 'values', data: state.values }));
ws.send(JSON.stringify({ type: 'flightplan', data: fp.getPlan() }));
ws.on('message', (raw) => {
try { handleClientMessage(JSON.parse(raw)); } catch { /* ignore */ }
});
ws.on('close', () => { clients.delete(ws); log(`browser left (${clients.size} total)`); });
ws.on('error', () => clients.delete(ws));
});
// ---- demo mode: synthetic values when there's no X-Plane (for previews) ---
function startDemo() {
log('DEMO mode — emitting synthetic values, not connecting to X-Plane');
state.xpConnected = true;
Object.assign(state.values, {
airspeed: 124, altitude: 5500, vspeed: 320, pitch: 4.5, roll: -12,
heading: 87, slip: 0.3, gForce: 1.04, oat: 9,
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
apEngaged: 1, apHdgBug: 90, apAltBug: 6000, apVsBug: 500, apSpdBug: 120,
// AFCS annunciation: AP on, HDG active + GPS armed (lateral), ALT active (vertical)
apMode: 2, hdgStatus: 2, gpssStatus: 1, altStatus: 2,
lat: 47.45, lon: -122.31, track: 90, groundspeed: 64, gpsDistNm: 18.4, gpsBearing: 92,
// radios (XP freq units: nav/com in 10 kHz, e.g. 11030 = 110.30)
nav1: 11380, nav1Sb: 11150, nav2: 11030, nav2Sb: 10890,
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
// HSI / data fields
obsCrs: 175, hsiDef: -0.6, hsiToFrom: 1, navBearing: 168, gsDef: 0.7,
nav1Brg: 210, nav1Dme: 12.4, nav2Brg: 320, nav2Dme: 0, // BRG1 (NAV1 VOR/DME) demo
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
cdiSrc: Number(process.env.DEMO_CDI ?? 2), // 0 VLOC1, 1 VLOC2, 2 GPS
...(process.env.DEMO_RANGE ? { uiMapRange: Number(process.env.DEMO_RANGE) } : {}),
// engine strip (arrays, like the sim)
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0, 27.8], amps: [-1.5], genAmps: [20.5], engHrs: 5040,
});
// a sample plan so the map/FMS show something in demo mode
fp.setPlan({ name: 'DEMO', waypoints: [
{ id: 'KSEA', lat: 47.449, lon: -122.309, type: 'APT' },
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
]});
pushToSim(fp.getPlan());
let t = 0;
const lat0 = 47.45, lon0 = -122.31, R = 0.05, w = 0.02; // gentle orbit around KSEA
const cosL = Math.cos(lat0 * Math.PI / 180);
let pLat = lat0, pLon = lon0;
setInterval(() => {
t += 0.1;
state.values.roll = -12 + Math.sin(t) * 4;
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
const newAlt = 5500 + Math.sin(t * 0.5) * 120;
state.values.vspeed = (newAlt - state.values.altitude) / (0.1 / 60); // fpm from Δalt/Δt
state.values.altitude = newAlt;
state.values.airspeed = 124 + Math.sin(t * 0.4) * 8;
// orbit so the aircraft visibly moves but stays near the demo flight plan
const lat = lat0 + Math.cos(t * w) * R;
const lon = lon0 + Math.sin(t * w) * R / cosL;
const trk = (Math.atan2((lon - pLon) * cosL, lat - pLat) * 180 / Math.PI + 360) % 360;
state.values.lat = lat; state.values.lon = lon;
state.values.track = trk; state.values.heading = trk;
pLat = lat; pLon = lon;
broadcast({ type: 'status', xpConnected: true });
broadcast({ type: 'values', data: state.values });
}, 100);
// synthetic terrain grid (a Cascades-style ridge rising eastward) so the MFD
// terrain-awareness colouring (yellow/red vs aircraft altitude) is visible
const emitTerrain = () => {
const lat = state.values.lat, lon = state.values.lon, alt = state.values.altitude;
const rows = 28, cols = 28, n = lat + 0.35, s = lat - 0.35, w = lon - 0.5, e = lon + 0.5;
const elev = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const fx = c / (cols - 1), fy = r / (rows - 1); // fx: 0 west → 1 east
let h = fx * 9000 - 1200 + Math.sin(fy * 6 + fx * 4) * 800 + Math.cos(fx * 9) * 400;
elev.push(Math.max(0, Math.round(h)));
}
broadcast({ type: 'terrain', data: { lat, lon, alt, n, s, w, e, rows, cols, elev } });
};
emitTerrain();
setInterval(emitTerrain, 1500);
}
server.listen(CONFIG.bridgePort, CONFIG.bridgeHost, () => {
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
loadNavData(); // async; FMS resolves idents once ready (sets root synchronously)
// Drop the FlyWithLua companion scripts into the sim so the user never copies
// files by hand; keeps the installed copies up to date on every start.
state.lua = installLuaScripts(xplaneRoot(), log);
loadAirspace(log); // installed region GeoJSON → /api/airspace/bbox overlay
// FMS two-way sync (Sim → App): adopt plans built/edited in the real G1000
startFmsSync({
getPlan: () => fp.getPlan(),
onSimPlan: (waypoints) => { fp.setPlan({ name: 'ACTIVE', waypoints, activeLeg: 1 }); broadcastPlan(); },
});
// Terrain awareness grid (from the FlyWithLua terrain probe) → MFD colouring
startTerrainSync((t) => broadcast({ type: 'terrain', data: t }));
if (process.env.DEMO) startDemo();
else connectXPlane();
});
+188
View File
@@ -0,0 +1,188 @@
// Central configuration: which X-Plane datarefs/commands the cockpit needs.
//
// These are *universal* datarefs that work on virtually every aircraft.
// To add a G1000- or aircraft-specific instrument, just add its dataref name
// here under DATAREFS (read) and/or WRITABLE_DATAREFS / COMMANDS (interact).
export const CONFIG = {
// Where X-Plane's built-in web server listens (on the same PC). X-Plane 12.1.1+.
xplaneHost: process.env.XPLANE_HOST || 'localhost',
xplanePort: Number(process.env.XPLANE_PORT || 8086),
xplaneApiBase: '/api/v3',
// Where THIS bridge serves the UI + relays data. 0.0.0.0 => reachable from the LAN.
bridgeHost: process.env.BRIDGE_HOST || '0.0.0.0',
bridgePort: Number(process.env.BRIDGE_PORT || 8080),
// How often X-Plane pushes value updates (it caps near 1020 Hz anyway).
updateHz: Number(process.env.UPDATE_HZ || 20),
};
// Datarefs we SUBSCRIBE to and stream to every client. Keyed by a short alias
// the frontend uses, so the long sim/... names live in exactly one place.
export const DATAREFS = {
// --- primary flight data ---
airspeed: 'sim/cockpit2/gauges/indicators/airspeed_kts_pilot',
altitude: 'sim/cockpit2/gauges/indicators/altitude_ft_pilot',
vspeed: 'sim/cockpit2/gauges/indicators/vvi_fpm_pilot',
pitch: 'sim/cockpit2/gauges/indicators/pitch_AHARS_deg_pilot',
roll: 'sim/cockpit2/gauges/indicators/roll_AHARS_deg_pilot',
heading: 'sim/cockpit2/gauges/indicators/heading_AHARS_deg_mag_pilot',
slip: 'sim/cockpit2/gauges/indicators/slip_deg',
gForce: 'sim/flightmodel/forces/g_nrml',
// --- position / navigation (for the moving map) ---
lat: 'sim/flightmodel/position/latitude',
lon: 'sim/flightmodel/position/longitude',
groundspeed: 'sim/flightmodel/position/groundspeed', // m/s
track: 'sim/cockpit2/gauges/indicators/ground_track_mag_pilot', // deg
gpsDistNm: 'sim/cockpit2/radios/indicators/gps_dme_distance_nm',
gpsBearing: 'sim/cockpit2/radios/indicators/gps_bearing_deg_mag',
// --- engine / misc (handy on an MFD) ---
fuelTotal: 'sim/cockpit2/fuel/fuel_quantity', // array
oat: 'sim/cockpit2/temperature/outside_air_temp_degc',
// --- G1000 PFD: radios (NAV/COM active + standby) ---
nav1: 'sim/cockpit2/radios/actuators/nav1_frequency_hz',
nav1Sb: 'sim/cockpit2/radios/actuators/nav1_standby_frequency_hz',
nav2: 'sim/cockpit2/radios/actuators/nav2_frequency_hz',
nav2Sb: 'sim/cockpit2/radios/actuators/nav2_standby_frequency_hz',
com1: 'sim/cockpit2/radios/actuators/com1_frequency_hz',
com1Sb: 'sim/cockpit2/radios/actuators/com1_standby_frequency_hz',
com2: 'sim/cockpit2/radios/actuators/com2_frequency_hz',
com2Sb: 'sim/cockpit2/radios/actuators/com2_standby_frequency_hz',
// --- G1000 PFD: HSI / CDI ---
obsCrs: 'sim/cockpit2/radios/actuators/nav1_obs_deg_mag_pilot',
gsDef: 'sim/cockpit/radios/nav1_vdef', // glideslope vertical deflection (dots)
hsiDef: 'sim/cockpit2/radios/indicators/hsi_hdef_dots_pilot',
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_pilot',
// --- bearing pointers (BRG1/BRG2) + DME + marker beacons ---
nav1Brg: 'sim/cockpit2/radios/indicators/nav1_bearing_deg_mag',
nav2Brg: 'sim/cockpit2/radios/indicators/nav2_bearing_deg_mag',
nav1Dme: 'sim/cockpit2/radios/indicators/nav1_dme_distance_nm',
nav2Dme: 'sim/cockpit2/radios/indicators/nav2_dme_distance_nm',
mkrOuter: 'sim/cockpit2/radios/indicators/outer_marker_lit',
mkrMiddle: 'sim/cockpit2/radios/indicators/middle_marker_lit',
mkrInner: 'sim/cockpit2/radios/indicators/inner_marker_lit',
// --- G1000 UI state (for display sync with the in-sim G1000) ---
// CDI/HSI source: 0 = NAV1/VLOC1, 1 = NAV2/VLOC2, 2 = GPS (standard dataref).
cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot',
// The rest are G1000-internal, so the FlyWithLua companion (ui-sync.lua)
// publishes them as custom datarefs. Absent until the plugin runs -> the web
// G1000 just keeps its own local UI state (graceful).
uiMfdPage: 'glasscockpit/ui/mfd_page', // 0 map, 1 fpl, 2 nrst
uiMapRange: 'glasscockpit/ui/map_range_nm', // active map range, NM
uiInset: 'glasscockpit/ui/inset', // PFD inset map on/off (0/1)
// --- G1000 PFD: data fields ---
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_pilot',
windSpd: 'sim/cockpit2/gauges/indicators/wind_speed_kts',
windDir: 'sim/cockpit2/gauges/indicators/wind_heading_deg_mag',
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code',
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode',
fdPitch: 'sim/cockpit2/autopilot/flight_director_pitch_deg',
fdRoll: 'sim/cockpit2/autopilot/flight_director_roll_deg',
// --- G1000 MFD: engine strip (arrays — UI reads index 0/1) ---
engRpm: 'sim/cockpit2/engine/indicators/engine_speed_rpm',
fuelFlow: 'sim/cockpit2/engine/indicators/fuel_flow_kg_sec',
oilTemp: 'sim/cockpit2/engine/indicators/oil_temperature_deg_C',
oilPress: 'sim/cockpit2/engine/indicators/oil_pressure_psi',
egt: 'sim/cockpit2/engine/indicators/EGT_deg_C',
fuelQty: 'sim/cockpit2/fuel/fuel_quantity',
volts: 'sim/cockpit2/electrical/bus_volts', // array: [0]=main bus, [1]=essential
amps: 'sim/cockpit2/electrical/battery_amps', // battery (S) amps
genAmps: 'sim/cockpit2/electrical/generator_amps', // alternator (M) amps
engHrs: 'sim/time/total_flight_time_sec', // proxy for engine/tach hours
// --- autopilot readouts (live values, so the panel reflects reality) ---
apState: 'sim/cockpit2/autopilot/autopilot_state', // bitfield of active modes
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
apEngaged: 'sim/cockpit2/autopilot/servos_on',
navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot',
// --- AFCS mode annunciation (the green/white mode strip on a real G1000) ---
// X-Plane's per-mode status datarefs: 0 = off, 1 = armed, 2 = active/captured.
// These mean the AFCS bar mirrors the sim exactly, no Lua needed.
apMode: 'sim/cockpit2/autopilot/autopilot_mode', // 0 off, 1 FD, 2 AP
hdgStatus: 'sim/cockpit2/autopilot/hdg_status',
navStatus: 'sim/cockpit2/autopilot/nav_status',
gpssStatus: 'sim/cockpit2/autopilot/gpss_status',
aprStatus: 'sim/cockpit2/autopilot/approach_status',
bcStatus: 'sim/cockpit2/autopilot/backcourse_status',
altStatus: 'sim/cockpit2/autopilot/alt_hold_status',
vsStatus: 'sim/cockpit2/autopilot/vvi_status',
flcStatus: 'sim/cockpit2/autopilot/speed_status',
gsStatus: 'sim/cockpit2/autopilot/glideslope_status',
vnavStatus: 'sim/cockpit2/autopilot/vnav_status',
};
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
export const WRITABLE_DATAREFS = {
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode', // 0 off,1 stby,2 on,3 alt
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code', // 4-digit squawk
};
// Commands the frontend may TRIGGER (autopilot mode buttons etc.).
export const COMMANDS = {
apToggle: 'sim/autopilot/servos_toggle',
fdToggle: 'sim/autopilot/fdir_toggle',
hdg: 'sim/autopilot/heading',
nav: 'sim/autopilot/NAV',
apr: 'sim/autopilot/approach',
altHold: 'sim/autopilot/altitude_hold',
vs: 'sim/autopilot/vertical_speed',
flc: 'sim/autopilot/level_change',
vnav: 'sim/autopilot/vnav',
backCourse:'sim/autopilot/back_course',
noseUp: 'sim/autopilot/nose_up',
noseDown: 'sim/autopilot/nose_down',
altUp: 'sim/autopilot/altitude_up',
altDown: 'sim/autopilot/altitude_down',
hdgUp: 'sim/autopilot/heading_up',
hdgDown: 'sim/autopilot/heading_down',
xpdrIdent: 'sim/transponder/transponder_ident',
};
// Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip.
// These work regardless of the dataref's frequency units, so the web tuner just
// fires them — no risky raw frequency writes.
for (const r of ['nav1', 'nav2', 'com1', 'com2']) {
COMMANDS[`${r}CoarseUp`] = `sim/radios/stby_${r}_coarse_up`;
COMMANDS[`${r}CoarseDown`] = `sim/radios/stby_${r}_coarse_down`;
COMMANDS[`${r}FineUp`] = `sim/radios/stby_${r}_fine_up`;
COMMANDS[`${r}FineDown`] = `sim/radios/stby_${r}_fine_down`;
COMMANDS[`${r}Swap`] = `sim/radios/${r}_standby_flip`;
}
// Every clickable G1000 bezel control maps to a real X-Plane command. The PFD
// is unit n1, the MFD is unit n3 (the default C172 layout). Aliases are
// prefixed pfd_/mfd_ so the frontend just says e.g. command('mfd_fpl').
const G1000_KEYS = [
...Array.from({ length: 12 }, (_, i) => `softkey${i + 1}`),
'direct', 'menu', 'fpl', 'proc', 'clr', 'ent', 'cursor',
'fms_outer_up', 'fms_outer_down', 'fms_inner_up', 'fms_inner_down',
'range_up', 'range_down', 'pan_push', 'pan_up', 'pan_down', 'pan_left', 'pan_right',
'hdg_up', 'hdg_down', 'hdg_sync',
'alt_outer_up', 'alt_outer_down', 'alt_inner_up', 'alt_inner_down',
'crs_up', 'crs_down', 'crs_sync', 'baro_up', 'baro_down',
'nav_outer_up', 'nav_outer_down', 'nav_inner_up', 'nav_inner_down', 'nav12', 'nvol_up', 'nvol_dn',
'com_outer_up', 'com_outer_down', 'com_inner_up', 'com_inner_down', 'com12', 'cvol_up', 'cvol_dn',
'ap', 'fd', 'hdg', 'alt', 'nav', 'vnv', 'apr', 'bc', 'vs', 'flc', 'nose_up', 'nose_down',
];
for (const [unit, prefix] of [['n1', 'pfd'], ['n3', 'mfd']]) {
for (const k of G1000_KEYS) COMMANDS[`${prefix}_${k}`] = `sim/GPS/g1000${unit}_${k}`;
}
+137
View File
@@ -0,0 +1,137 @@
// Shared flight plan: one plan, synced to every connected tablet. Can resolve
// idents via navdata, and export an X-Plane .fms file the sim can load.
import fs from 'node:fs';
import path from 'node:path';
import { lookup, xplaneRoot } from './navdata.js';
// waypoint: { id, lat, lon, type, alt? }
// activeLeg = index of the waypoint the active (magenta) leg flies TO. The leg
// runs from waypoints[activeLeg-1] to waypoints[activeLeg]. Defaults to 1.
let plan = { name: 'ACTIVE', waypoints: [], activeLeg: 1 };
const clampLeg = (i) => Math.max(1, Math.min(plan.waypoints.length - 1, i | 0));
export const getPlan = () => plan;
export function setPlan(next) {
const wps = Array.isArray(next?.waypoints)
? next.waypoints
.filter((w) => isFinite(w.lat) && isFinite(w.lon))
.map((w) => ({ id: String(w.id || 'WPT'), lat: +w.lat, lon: +w.lon, type: w.type || 'WPT', alt: w.alt ?? null, ...(w.dsgn != null ? { dsgn: !!w.dsgn } : {}), ...(w.appr ? { appr: true } : {}) }))
: [];
const wantLeg = Number.isFinite(next?.activeLeg) ? next.activeLeg : 1;
plan = { name: next?.name || 'ACTIVE', waypoints: wps, activeLeg: Math.max(1, Math.min(wps.length - 1, wantLeg)) || 1 };
return plan;
}
export function setActiveLeg(index) {
if (plan.waypoints.length >= 2) plan.activeLeg = clampLeg(index);
return plan;
}
// Add a waypoint by ident (resolved against navdata) or raw "lat,lon".
export function addWaypoint(input) {
const raw = String(input || '').trim();
const m = raw.match(/^(-?\d+(?:\.\d+)?)[ ,]+(-?\d+(?:\.\d+)?)$/);
if (m) {
plan.waypoints.push({ id: 'USR', lat: +m[1], lon: +m[2], type: 'USR', alt: null });
return { ok: true, plan };
}
const hit = lookup(raw);
if (!hit) return { ok: false, error: `unknown ident: ${raw}` };
plan.waypoints.push({ ...hit, alt: null });
return { ok: true, plan };
}
export function removeWaypoint(index) {
if (index >= 0 && index < plan.waypoints.length) plan.waypoints.splice(index, 1);
if (plan.waypoints.length >= 2) plan.activeLeg = clampLeg(plan.activeLeg);
return plan;
}
// ---- great-circle helpers (nm + degrees) ----
const R_NM = 3440.065;
const rad = (d) => (d * Math.PI) / 180;
const deg = (r) => (r * 180) / Math.PI;
export function legDistanceNm(a, b) {
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(s)));
}
export function legBearing(a, b) {
const y = Math.sin(rad(b.lon - a.lon)) * Math.cos(rad(b.lat));
const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) -
Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon));
return (deg(Math.atan2(y, x)) + 360) % 360;
}
// ---- X-Plane .fms (v1100) export ----
function fmsType(t) {
return { APT: 1, NDB: 2, VOR: 3, WPT: 11, USR: 28 }[t] || 11;
}
export function exportFms(name = 'WEBFPL') {
const wp = plan.waypoints;
if (wp.length < 2) return { ok: false, error: 'need at least 2 waypoints' };
const lines = ['I', '1100 Version', 'CYCLE 2501'];
lines.push(`ADEP ${wp[0].id}`);
lines.push(`ADES ${wp[wp.length - 1].id}`);
lines.push(`NUMENR ${wp.length}`);
for (const w of wp) {
const alt = w.alt ?? 0;
lines.push(`${fmsType(w.type)} ${w.id} ${alt.toFixed(6)} ${w.lat.toFixed(6)} ${w.lon.toFixed(6)}`);
}
const content = lines.join('\n') + '\n';
const dir = fmsDir();
try {
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${name}.fms`);
fs.writeFileSync(file, content);
return { ok: true, file, intoXplane: !!xplaneRoot() };
} catch (e) {
return { ok: false, error: e.message };
}
}
// ---- load saved X-Plane .fms plans (Output/FMS plans) ----
function fmsDir() {
const root = xplaneRoot();
return root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
}
const FMS_TYPE = { 1: 'APT', 2: 'NDB', 3: 'VOR', 11: 'WPT', 28: 'USR' };
// List the names of every saved .fms plan (X-Plane's own + our exports).
export function listPlans() {
try {
return fs.readdirSync(fmsDir())
.filter((f) => f.toLowerCase().endsWith('.fms'))
.map((f) => f.replace(/\.fms$/i, ''))
.sort((a, b) => a.localeCompare(b));
} catch { return []; }
}
// Parse a saved .fms (v1100/v3) into our waypoints and make it the active plan.
export function loadFms(name) {
const safe = String(name || '').replace(/[^\w .+-]/g, '');
const file = path.join(fmsDir(), `${safe}.fms`);
if (!fs.existsSync(file)) return { ok: false, error: `not found: ${safe}` };
const wps = [];
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const p = raw.trim().split(/\s+/);
// waypoint rows start with a numeric type code: <type> <ident> <alt> <lat> <lon>
if (p.length >= 5 && /^\d+$/.test(p[0]) && p[0] !== '1100') {
const lat = parseFloat(p[3]), lon = parseFloat(p[4]), alt = parseFloat(p[2]);
if (isFinite(lat) && isFinite(lon)) {
wps.push({ id: p[1], lat, lon, type: FMS_TYPE[+p[0]] || 'WPT', alt: alt > 0 ? Math.round(alt) : null });
}
}
}
if (wps.length < 1) return { ok: false, error: 'no waypoints in file' };
setPlan({ name: safe.toUpperCase(), waypoints: wps, activeLeg: 1 });
return { ok: true, plan, count: wps.length };
}
+85
View File
@@ -0,0 +1,85 @@
// Two-way flight-plan sync with X-Plane's in-sim FMS, bridged by a FlyWithLua
// companion script (see plugins/fms-sync.lua). X-Plane's Web API can't inject a
// flight plan into the FMS, so the Lua script (which has the FMS SDK) does it.
//
// Channel = two text files in <X-Plane>/Output/fms-sync/ (bridge + Lua run on
// the same PC). We write to_sim.txt (our plan); Lua applies it to the FMS and
// writes from_sim.txt (the sim's plan); we adopt sim-side changes. A position
// signature (3-decimal lat/lon) de-dupes so the two sides never loop.
import fs from 'node:fs';
import path from 'node:path';
import { xplaneRoot } from './navdata.js';
function dir() {
const r = xplaneRoot();
return r ? path.join(r, 'Output', 'fms-sync') : path.join(process.cwd(), 'fms-sync');
}
const toSimFile = () => path.join(dir(), 'to_sim.txt');
const fromSimFile = () => path.join(dir(), 'from_sim.txt');
// loop-guard signature: rounded lat/lon list (idents/alt ignored, and coords
// from our navdata == X-Plane's, so it stays stable across the round-trip)
const sig = (wps) => (wps || []).map((w) => `${(+w.lat).toFixed(3)},${(+w.lon).toFixed(3)}`).join(';');
let lastSig = null;
function serialize(wps) {
const body = (wps || [])
.map((w) => `${(+w.lat).toFixed(6)} ${(+w.lon).toFixed(6)} ${Math.round(w.alt || 0)} ${w.id || 'WPT'} ${w.type || 'WPT'}`)
.join('\n');
return `# ${sig(wps)}\n${body}\n`; // first line = sig comment, then waypoints
}
function parse(txt) {
const wps = [];
for (const ln of (txt || '').split(/\r?\n/)) {
const t = ln.trim();
if (!t || t.startsWith('#')) continue; // skip sig/comment line
const p = t.split(/\s+/);
const lat = +p[0], lon = +p[1];
if (p.length >= 2 && isFinite(lat) && isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
const alt = +p[2] || 0;
wps.push({ id: p[3] || 'WPT', lat, lon, type: p[4] || 'WPT', alt: alt > 0 ? alt : null });
}
}
return wps;
}
// our plan changed → hand it to the Lua script
export function pushToSim(plan) {
try {
fs.mkdirSync(dir(), { recursive: true });
fs.writeFileSync(toSimFile(), serialize(plan?.waypoints || []));
lastSig = sig(plan?.waypoints || []);
} catch { /* sim not local / no write access */ }
}
// Terrain elevation grid published by the FlyWithLua terrain probe
// (terrain.json in the sync dir). Polled and broadcast so the MFD can colour
// terrain awareness (red/yellow). Only re-broadcasts when it actually changes.
const terrainFile = () => path.join(dir(), 'terrain.json');
export function startTerrainSync(onTerrain, intervalMs = 1500) {
let lastMtime = 0;
setInterval(() => {
let st;
try { st = fs.statSync(terrainFile()); } catch { return; }
if (st.mtimeMs === lastMtime) return;
lastMtime = st.mtimeMs;
try {
const t = JSON.parse(fs.readFileSync(terrainFile(), 'utf8'));
if (t && Array.isArray(t.elev) && t.elev.length) onTerrain(t);
} catch { /* mid-write / malformed */ }
}, intervalMs);
}
// poll the Lua-written sim plan; adopt genuine sim-side changes
export function startFmsSync({ getPlan, onSimPlan }) {
pushToSim(getPlan());
setInterval(() => {
let txt;
try { txt = fs.readFileSync(fromSimFile(), 'utf8'); } catch { return; }
const wps = parse(txt);
const s = sig(wps);
if (wps.length && s && s !== lastSig) { lastSig = s; onSimPlan(wps); }
}, 1200);
}
+88
View File
@@ -0,0 +1,88 @@
// Auto-installs the FlyWithLua companion scripts into X-Plane on bridge start.
//
// The web cockpit needs three .lua helpers running INSIDE X-Plane (they have the
// FMS / scenery SDK the Web API lacks — see plugins/*.lua). Rather than make the
// user copy files by hand, the bridge drops them into the sim's FlyWithLua
// Scripts folder on startup and keeps them up to date (content-compare, so a new
// build self-updates the installed copy; unchanged files are left alone).
//
// Everything degrades gracefully: no X-Plane found, no FlyWithLua installed, or
// the script sources missing → we log a hint and carry on. We never create the
// FlyWithLua folder ourselves (its absence means the user must install
// FlyWithLua NG+ first; making an empty folder would only hide that).
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The companion scripts to install, in load-independent order.
const SCRIPTS = ['fms-sync.lua', 'ui-sync.lua', 'terrain-probe.lua'];
// Where do the canonical .lua sources live? plugins/ sits next to server/ in the
// repo; in the packaged desktop app it's bundled as a Tauri resource. Probe a
// few locations so both `node server/bridge.js` and the compiled sidecar work.
function sourceDir() {
const candidates = [
process.env.LUA_SRC_DIR,
path.join(__dirname, '..', 'plugins'), // repo: server/ -> ../plugins
path.join(process.cwd(), 'plugins'), // run from repo root
path.join(path.dirname(process.execPath), 'plugins'),
path.join(path.dirname(process.execPath), '..', 'Resources', 'plugins'),
].filter(Boolean);
for (const dir of candidates) {
try {
if (fs.existsSync(path.join(dir, SCRIPTS[0]))) return dir;
} catch { /* ignore */ }
}
return null;
}
// Install / update the scripts under <root>. Returns a report object; never throws.
export function installLuaScripts(root, log = console.log) {
if (!root) {
return { ok: false, reason: 'no-xplane', installed: [], updated: [], unchanged: [] };
}
const fwl = path.join(root, 'Resources', 'plugins', 'FlyWithLua');
if (!fs.existsSync(fwl)) {
log('lua-install: FlyWithLua not found — install FlyWithLua NG+ into ' +
`${path.join(root, 'Resources', 'plugins')} to enable FMS/terrain sync`);
return { ok: false, reason: 'no-flywithlua', installed: [], updated: [], unchanged: [] };
}
const src = sourceDir();
if (!src) {
log('lua-install: companion script sources not found (set LUA_SRC_DIR)');
return { ok: false, reason: 'no-source', installed: [], updated: [], unchanged: [] };
}
const dest = path.join(fwl, 'Scripts');
const report = { ok: true, reason: 'ok', dir: dest, installed: [], updated: [], unchanged: [], failed: [] };
try { fs.mkdirSync(dest, { recursive: true }); } catch { /* ignore */ }
for (const name of SCRIPTS) {
const from = path.join(src, name);
const to = path.join(dest, name);
try {
if (!fs.existsSync(from)) { report.failed.push(name); continue; }
const want = fs.readFileSync(from, 'utf8');
const have = fs.existsSync(to) ? fs.readFileSync(to, 'utf8') : null;
if (have === null) { fs.writeFileSync(to, want); report.installed.push(name); }
else if (have !== want) { fs.writeFileSync(to, want); report.updated.push(name); }
else { report.unchanged.push(name); }
} catch (e) {
report.failed.push(name);
log(`lua-install: ${name} failed: ${e.message}`);
}
}
const parts = [];
if (report.installed.length) parts.push(`installed ${report.installed.join(', ')}`);
if (report.updated.length) parts.push(`updated ${report.updated.join(', ')}`);
if (report.unchanged.length) parts.push(`${report.unchanged.length} up to date`);
log(`lua-install: ${parts.join('; ') || 'nothing to do'}${dest}`);
if (report.installed.length || report.updated.length) {
log('lua-install: reload in X-Plane — "FlyWithLua > Reload all Lua script files" (or restart)');
}
return report;
}
+300
View File
@@ -0,0 +1,300 @@
// Reads X-Plane's own navigation data so the FMS can resolve real waypoint /
// VOR / NDB / airport identifiers to coordinates — the same database the sim
// uses. Runs on the X-Plane PC (where the bridge lives), so the files are local.
//
// Everything degrades gracefully: if X-Plane / the files can't be found, the
// FMS still works with map-clicks and raw "LAT,LON" entry.
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
// Common install locations to probe. Override with XPLANE_ROOT.
function candidateRoots() {
const env = process.env.XPLANE_ROOT;
const home = process.env.HOME || process.env.USERPROFILE || '';
return [
env,
'C:/X-Plane 12', 'D:/X-Plane 12', 'E:/X-Plane 12',
'C:/X-Plane 11', 'D:/X-Plane 11',
path.join(home, 'X-Plane 12'),
path.join(home, 'Desktop', 'X-Plane 12'),
'/Applications/X-Plane 12',
].filter(Boolean);
}
function findRoot() {
for (const root of candidateRoots()) {
try {
if (fs.existsSync(path.join(root, 'Resources', 'default data'))) return root;
} catch { /* ignore */ }
}
return null;
}
// alias -> { lat, lon, type } ; type: WPT | VOR | NDB | APT
const index = new Map();
// Geographic stores for the moving map (bbox queries) and NEAREST search.
// Airports + navaids stay in flat arrays (small enough to scan); the far more
// numerous fixes go into 1°×1° buckets so a bbox query only scans nearby cells.
const airports = []; // { id, lat, lon, name, elev }
const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name }
const fixCells = new Map(); // "ilat,ilon" -> [{ id, lat, lon, type:'FIX' }]
const rwyByApt = new Map(); // ICAO -> [{ n1, la1, lo1, n2, la2, lo2, w }] (runway ends + width m)
const comByApt = new Map(); // ICAO -> { freq, label, prio } (best ATC/CTAF frequency)
const ilsApts = new Set(); // ICAOs that have an ILS/LOC approach (for NRST "ILS")
const awyCells = new Map(); // "ilat,ilon" (segment midpoint) -> [{ la1, lo1, la2, lo2, name }]
const state = { root: null, loaded: false, count: 0, awy: 0 };
function add(id, lat, lon, type, name) {
if (!id || !isFinite(lat) || !isFinite(lon)) return;
const key = id.toUpperCase();
if (!index.has(key)) index.set(key, { id: key, lat, lon, type, name: name || '' });
}
function pushFix(f) {
const k = `${Math.floor(f.lat)},${Math.floor(f.lon)}`;
let a = fixCells.get(k);
if (!a) { a = []; fixCells.set(k, a); }
a.push(f);
}
const R_NM = 3440.065; // earth radius in nautical miles
const rad = (d) => (d * Math.PI) / 180;
function distNm(la1, lo1, la2, lo2) {
const dLat = rad(la2 - la1), dLon = rad(lo2 - lo1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(rad(la1)) * Math.cos(rad(la2)) * Math.sin(dLon / 2) ** 2;
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
}
function bearingDeg(la1, lo1, la2, lo2) {
const y = Math.sin(rad(lo2 - lo1)) * Math.cos(rad(la2));
const x = Math.cos(rad(la1)) * Math.sin(rad(la2)) - Math.sin(rad(la1)) * Math.cos(rad(la2)) * Math.cos(rad(lo2 - lo1));
return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
}
async function parseFixes(file) {
if (!fs.existsSync(file)) return;
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
for await (const line of rl) {
const t = line.trim();
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
const p = t.split(/\s+/);
const lat = parseFloat(p[0]), lon = parseFloat(p[1]), id = p[2];
add(id, lat, lon, 'WPT');
if (id && isFinite(lat) && isFinite(lon)) pushFix({ id: id.toUpperCase(), lat, lon, type: 'FIX' });
}
}
async function parseNav(file) {
if (!fs.existsSync(file)) return;
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
for await (const line of rl) {
const t = line.trim();
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
const p = t.split(/\s+/);
const code = parseInt(p[0], 10);
if (code === 4 || code === 5) { // ILS/LOC localizer → airport has an ILS
const ic = (p[8] || '').toUpperCase();
if (ic && ic !== 'ENRT') ilsApts.add(ic);
continue;
}
if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME
const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7];
const type = code === 2 ? 'NDB' : 'VOR';
add(id, lat, lon, type, p.slice(10).join(' '));
if (id && isFinite(lat) && isFinite(lon)) {
// p[4] = frequency (VOR in 10 kHz e.g. 11630 → 116.30; NDB in kHz);
// name is everything after the airport/region columns.
navaids.push({ id: id.toUpperCase(), lat, lon, type, freq: parseInt(p[4], 10) || 0, name: p.slice(10).join(' ') });
}
}
}
// Airports: derive a reference point from each airport's first runway (row 100)
// in apt.dat. The "1" header row carries the ICAO but no coordinates.
async function parseAirports(file) {
if (!fs.existsSync(file)) return;
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
let icao = null, name = '', elev = 0, placed = false;
const place = (lat, lon) => {
if (!isFinite(lat) || !isFinite(lon)) return;
add(icao, lat, lon, 'APT', name);
airports.push({ id: icao.toUpperCase(), lat, lon, name, elev });
placed = true;
};
for await (const line of rl) {
const p = line.trim().split(/\s+/);
const code = parseInt(p[0], 10);
if (code === 1 || code === 16 || code === 17) { // land/sea/heliport header
icao = p[4]; elev = parseInt(p[1], 10) || 0; name = p.slice(5).join(' '); placed = false;
} else if (icao && code === 100) { // land runway (both ends)
const r = { n1: p[8], la1: parseFloat(p[9]), lo1: parseFloat(p[10]), n2: p[17], la2: parseFloat(p[18]), lo2: parseFloat(p[19]), w: parseFloat(p[1]) };
if (isFinite(r.la1) && isFinite(r.lo1) && isFinite(r.la2) && isFinite(r.lo2)) {
const key = icao.toUpperCase();
let a = rwyByApt.get(key); if (!a) { a = []; rwyByApt.set(key, a); } a.push(r);
if (!placed) place((r.la1 + r.la2) / 2, (r.lo1 + r.lo2) / 2);
}
} else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad
place(parseFloat(p[code === 101 ? 4 : 5]), parseFloat(p[code === 101 ? 5 : 6]));
} else if (icao && ((code >= 50 && code <= 56) || (code >= 1050 && code <= 1056))) {
// ATC / CTAF frequencies. Old codes 50-56, new 1050-1056. Freq is kHz
// (>100000) or MHz×100. Keep the most useful one (TWR > UNICOM > ATIS …).
const c = code > 1000 ? code - 1000 : code;
const raw = parseInt(p[1], 10);
if (isFinite(raw) && raw > 0) {
const mhz = raw > 100000 ? raw / 1000 : raw / 100;
const meta = { 54: ['TOWER', 5], 51: ['UNICOM', 4], 50: ['ATIS', 3], 53: ['GROUND', 2], 55: ['APP', 1], 56: ['DEP', 1], 52: ['CLNC', 1] }[c] || ['COM', 0];
const key = icao.toUpperCase(), prev = comByApt.get(key);
if (!prev || meta[1] > prev.prio) comByApt.set(key, { freq: mhz, label: meta[0], prio: meta[1] });
}
}
}
}
// Airways (earth_awy.dat): each row is a segment between two named waypoints.
// We resolve both endpoints to coordinates via the fix/navaid index (so this
// must run AFTER parseFixes/parseNav) and bucket segments by their midpoint
// cell for fast bbox queries — exactly like fixes.
async function parseAirways(file) {
if (!fs.existsSync(file)) return;
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
for await (const line of rl) {
const t = line.trim();
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
const p = t.split(/\s+/);
if (p.length < 10) continue;
const a = index.get((p[0] || '').toUpperCase());
const b = index.get((p[3] || '').toUpperCase());
if (!a || !b) continue; // endpoint not in our database
const name = p[p.length - 1];
const k = `${Math.floor((a.lat + b.lat) / 2)},${Math.floor((a.lon + b.lon) / 2)}`;
let arr = awyCells.get(k); if (!arr) { arr = []; awyCells.set(k, arr); }
arr.push({ la1: a.lat, lo1: a.lon, la2: b.lat, lo2: b.lon, name });
state.awy++;
}
}
export async function loadNavData() {
const root = findRoot();
state.root = root;
if (!root) {
console.log('navdata: X-Plane root not found (set XPLANE_ROOT) — FMS works with map-clicks / LAT,LON only');
state.loaded = true;
return;
}
console.log(`navdata: X-Plane at ${root} — parsing nav data ...`);
const dd = path.join(root, 'Resources', 'default data');
const cd = path.join(root, 'Custom Data'); // user nav data overrides if present
const pick = (name) => (fs.existsSync(path.join(cd, name)) ? path.join(cd, name) : path.join(dd, name));
try {
await parseFixes(pick('earth_fix.dat'));
await parseNav(pick('earth_nav.dat'));
// airways need the fix/navaid index above; parse in the background.
parseAirways(pick('earth_awy.dat'))
.then(() => console.log(`navdata: airways done (${state.awy} segments)`))
.catch((e) => console.log('navdata: airway parse skipped:', e.message));
// apt.dat is large; parse the global airports file in the background.
parseAirports(path.join(root, 'Global Scenery', 'Global Airports', 'Earth nav data', 'apt.dat'))
.then(() => { state.count = index.size; console.log(`navdata: airports done (${index.size} total entries)`); })
.catch((e) => console.log('navdata: airport parse skipped:', e.message));
} catch (e) {
console.log('navdata: parse error:', e.message);
}
state.count = index.size;
state.loaded = true;
console.log(`navdata: ${index.size} fixes/navaids ready`);
}
export function lookup(id) {
return index.get(String(id).toUpperCase()) || null;
}
export function search(q, limit = 20) {
const needle = String(q || '').toUpperCase().trim();
if (!needle) return [];
const exact = [], prefix = [];
for (const v of index.values()) {
if (v.id === needle) exact.push(v);
else if (v.id.startsWith(needle)) prefix.push(v);
if (exact.length + prefix.length > 400) break;
}
return [...exact, ...prefix].slice(0, limit);
}
// NEAREST: closest airports (default) or navaids to a point, with range/bearing.
export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) {
if (!isFinite(lat) || !isFinite(lon)) return [];
const isApt = !(type === 'vor' || type === 'ndb' || type === 'nav');
const src = isApt ? airports : navaids;
return src
.filter((f) => (type === 'vor' || type === 'ndb') ? f.type.toLowerCase() === type : true)
.map((f) => ({ ...f, dist: distNm(lat, lon, f.lat, f.lon), brg: Math.round(bearingDeg(lat, lon, f.lat, f.lon)) }))
.sort((a, b) => a.dist - b.dist)
.slice(0, count)
.map((f) => {
const o = { ...f, dist: +f.dist.toFixed(1) };
if (isApt) { // runway length, COM freq, approach type
const rs = rwyByApt.get(f.id);
let ft = 0;
if (rs) for (const r of rs) ft = Math.max(ft, distNm(r.la1, r.lo1, r.la2, r.lo2) * 6076.12);
o.rwyFt = Math.round(ft);
o.com = comByApt.get(f.id) || null;
o.app = ilsApts.has(f.id) ? 'ILS' : 'VFR';
}
return o;
});
}
// BBOX: every feature inside a lat/lon window, for the moving map to draw.
// types ⊆ { apt, vor, ndb, fix }. Output is capped so a wide view stays light.
export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) {
const out = [];
const inB = (f) => f.lat >= s && f.lat <= n && f.lon >= w && f.lon <= e;
if (types.includes('apt')) for (const f of airports) { if (inB(f)) { out.push({ ...f, type: 'APT' }); if (out.length >= limit) return out; } }
for (const f of navaids) { if (types.includes(f.type.toLowerCase()) && inB(f)) { out.push(f); if (out.length >= limit) return out; } }
if (types.includes('fix')) {
for (let la = Math.floor(s); la <= Math.floor(n); la++)
for (let lo = Math.floor(w); lo <= Math.floor(e); lo++) {
const a = fixCells.get(`${la},${lo}`);
if (!a) continue;
for (const f of a) { if (inB(f)) { out.push(f); if (out.length >= limit) return out; } }
}
}
return out;
}
// BBOX airways: every segment touching a lat/lon window (scan the midpoint
// cells overlapping the box, ±1 to catch segments crossing the edge).
export function airwaysBbox(s, w, n, e, limit = 500) {
const out = [];
const inB = (la, lo) => la >= s && la <= n && lo >= w && lo <= e;
for (let la = Math.floor(s) - 1; la <= Math.floor(n) + 1; la++)
for (let lo = Math.floor(w) - 1; lo <= Math.floor(e) + 1; lo++) {
const arr = awyCells.get(`${la},${lo}`);
if (!arr) continue;
for (const sg of arr) {
if (inB(sg.la1, sg.lo1) || inB(sg.la2, sg.lo2)) { out.push(sg); if (out.length >= limit) return out; }
}
}
return out;
}
// Runways of every airport within radiusNm — for the PFD's synthetic-vision view.
export function runwaysNear(lat, lon, radiusNm = 12) {
if (!isFinite(lat) || !isFinite(lon)) return [];
const out = [];
for (const a of airports) {
if (distNm(lat, lon, a.lat, a.lon) > radiusNm) continue;
const rs = rwyByApt.get(a.id);
if (rs) for (const r of rs) out.push({ apt: a.id, ...r });
}
return out;
}
export function navStatus() {
return { root: state.root, loaded: state.loaded, entries: index.size, airports: airports.length, navaids: navaids.length };
}
export function xplaneRoot() {
return state.root;
}
+141
View File
@@ -0,0 +1,141 @@
// Parses X-Plane's CIFP procedure data (SIDs / STARs / approaches) on demand —
// one small file per airport in Resources/default data/CIFP/<ICAO>.dat, in the
// ARINC-424-derived "XP CIFP" format. Fix idents are resolved to coordinates
// via the shared navdata index; runway thresholds come from the RWY records.
//
// Used by the G1000 PROC page: list a destination's procedures, then load a
// chosen procedure+transition's leg fixes into the active flight plan.
import fs from 'node:fs';
import path from 'node:path';
import { lookup, xplaneRoot } from './navdata.js';
// "N47274972" -> 47.4638.. / "W122183954" -> -122.3109..
function parseCoord(s) {
if (!s || s.length < 8) return null;
const hemi = s[0];
const neg = hemi === 'S' || hemi === 'W';
const digits = s.slice(1);
// lat = DDMMSSss (8) ; lon = DDDMMSSss (9)
const degLen = (hemi === 'E' || hemi === 'W') ? 3 : 2;
const dd = parseInt(digits.slice(0, degLen), 10);
const mm = parseInt(digits.slice(degLen, degLen + 2), 10);
const ss = parseInt(digits.slice(degLen + 2, degLen + 4), 10);
const hh = parseInt(digits.slice(degLen + 4, degLen + 6) || '0', 10);
if (!isFinite(dd) || !isFinite(mm)) return null;
const val = dd + mm / 60 + (ss + hh / 100) / 3600;
return neg ? -val : val;
}
function cifpFile(icao) {
const root = xplaneRoot();
if (!root) return null;
const f = path.join(root, 'Resources', 'default data', 'CIFP', `${icao.toUpperCase()}.dat`);
return fs.existsSync(f) ? f : null;
}
// Parse one airport's procedures into a structured summary + leg store.
// Returns null if the airport has no CIFP file.
export function parseProcedures(icao) {
const file = cifpFile(icao);
if (!file) return null;
const runways = {}; // RW16C -> { lat, lon, elev }
const groups = { SID: {}, STAR: {}, APPCH: {} };
// groups[type][procName] = { order:[trans...], legs:{ trans:[{fix,term,alt}] } }
const ensure = (type, name, trans) => {
const g = groups[type];
if (!g[name]) g[name] = { order: [], legs: {} };
if (!(trans in g[name].legs)) { g[name].legs[trans] = []; g[name].order.push(trans); }
return g[name].legs[trans];
};
for (const raw of fs.readFileSync(file, 'utf8').split('\n')) {
const line = raw.trim().replace(/;$/, '');
if (!line) continue;
const colon = line.indexOf(':');
if (colon < 0) continue;
const type = line.slice(0, colon);
const f = line.slice(colon + 1).split(',');
if (type === 'RWY') {
// RWY:RW16C, , ,00429, ,ISZI,3, ;N47274972,W122183954,0000
const id = f[0];
const tail = line.split(';')[1] || ''; // "N47274972,W122183954,0000"
const [latS, lonS] = tail.split(',');
const lat = parseCoord(latS), lon = parseCoord(lonS);
if (lat != null && lon != null) runways[id] = { lat, lon, elev: parseInt(f[3], 10) || 0 };
continue;
}
if (type !== 'SID' && type !== 'STAR' && type !== 'APPCH') continue;
// f[0]=seqno, f[1]=route type, f[2]=proc, f[3]=transition, f[4]=fix,
// f[11]=path/termination, f[22]=alt flag (+/-), f[23]=altitude.
const procName = f[2]; // BANGR9 / CHINS5 / I16C
const trans = (f[3] || '').trim(); // RW16C / PDT / ERYKA / '' (common)
const fix = (f[4] || '').trim(); // OTLIE / ANVIL / RW16C
const term = (f[11] || '').trim(); // path/termination: IF TF CF DF VA CA ...
const altFlag = (f[22] || '').trim();
const altVal = parseInt((f[23] || '').trim(), 10);
const alt = isFinite(altVal) && altVal > 0 ? altVal : null;
const legs = ensure(type, procName, trans || '(common)');
legs.push({ fix, term, alt, altFlag });
}
// Build the client-facing summary (names + their transitions).
const summarize = (g) => Object.entries(g).map(([name, v]) => ({
name, transitions: v.order.filter((t) => t !== '(common)'),
}));
return {
icao: icao.toUpperCase(),
runways: Object.keys(runways),
sids: summarize(groups.SID),
stars: summarize(groups.STAR),
approaches: summarize(groups.APPCH),
_groups: groups,
_rwy: runways,
};
}
// Resolve a chosen procedure+transition to a list of waypoints with coordinates.
// type: 'sid' | 'star' | 'approach'. Fixes are resolved via the navdata index;
// runway "fixes" (RWxx) and unresolved fixes fall back to the RWY threshold.
export function procedureLegs(icao, type, name, trans) {
const parsed = parseProcedures(icao);
if (!parsed) return [];
const TYPE = { sid: 'SID', star: 'STAR', approach: 'APPCH' }[String(type).toLowerCase()];
const g = parsed._groups[TYPE];
if (!g || !g[name]) return [];
const node = g[name];
// Compose the leg list: chosen transition first, then the common segment.
// (SID: runway/enroute transition then common climb-out; STAR: enroute entry
// then common arrival; approach: IAF transition then final-approach segment.)
const seq = [];
const wantTrans = trans && node.legs[trans] ? trans : node.order.find((t) => t !== '(common)');
if (wantTrans && node.legs[wantTrans]) seq.push(...node.legs[wantTrans]);
if (node.legs['(common)']) seq.push(...node.legs['(common)']);
const out = [];
const seen = new Set();
for (const leg of seq) {
if (!leg.fix) continue; // heading/altitude legs w/o a fix
if (seen.has(leg.fix)) continue; // de-dupe repeated fixes
let pt = null;
const isRwy = /^RW/.test(leg.fix);
if (isRwy && parsed._rwy[leg.fix]) pt = parsed._rwy[leg.fix];
else {
const hit = lookup(leg.fix);
if (hit) pt = { lat: hit.lat, lon: hit.lon };
}
if (!pt) continue; // unresolved fix → skip
seen.add(leg.fix);
out.push({ id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt });
// An approach ends at the runway threshold — drop the missed-approach legs.
if (TYPE === 'APPCH' && isRwy) break;
}
return out;
}
+17
View File
@@ -0,0 +1,17 @@
import { chromium } from 'playwright';
const PORT = process.env.BRIDGE_PORT || 8099;
const base = `http://localhost:${PORT}`;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1180, height: 820 }, deviceScaleFactor: 2 });
await page.goto(base, { waitUntil: 'networkidle' });
const tabs = [['pfd', 'PFD'], ['mfd', 'MFD'], ['map', 'Map'], ['fms', 'FMS'], ['ap', 'Autopilot']];
for (const [tab, label] of tabs) {
await page.getByRole('button', { name: label, exact: true }).click();
await page.waitForTimeout(tab === 'map' || tab === 'pfd' ? 4000 : 700); // tiles/terrain
await page.screenshot({ path: `/tmp/cockpit-${tab}.png` });
console.log(`shot: /tmp/cockpit-${tab}.png`);
}
await browser.close();
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="G1000" />
<meta name="theme-color" content="#000000" />
<!-- PWA: installable as a full-screen iPad/tablet app ("Zum Home-Bildschirm") -->
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<title>X-Plane Glass Cockpit</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Saira+Condensed:wght@400;500;600;700&family=Saira+Semi+Condensed:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {}));
}
</script>
</body>
</html>
+1973
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "xplane-glass-cockpit-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"leaflet": "^1.9.4",
"maplibre-gl": "^5.24.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "G1000 Glass Cockpit",
"short_name": "G1000",
"description": "X-Plane 12 G1000 glass cockpit — PFD / MFD / FMS over your LAN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+43
View File
@@ -0,0 +1,43 @@
// Minimal service worker: caches the app shell so the cockpit launches fast and
// survives brief network blips. Live data (the bridge WebSocket, /api, and map
// tiles) is never cached — only same-origin GET app assets.
const CACHE = 'g1000-shell-v2';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
// Only same-origin GET app shell; skip the API and let the WS pass through.
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api') || url.pathname === '/ws') return;
// The HTML entry is NETWORK-FIRST: a reload always gets the latest build (and
// thus the latest hashed assets). Falls back to cache only when offline.
const isDoc = e.request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('.html');
if (isDoc) {
e.respondWith(
fetch(e.request)
.then((res) => { caches.open(CACHE).then((c) => c.put(e.request, res.clone())); return res; })
.catch(() => caches.match(e.request).then((c) => c || caches.match('/')))
);
return;
}
// Hashed assets are immutable → stale-while-revalidate (fast + self-healing).
e.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(e.request);
const network = fetch(e.request)
.then((res) => { if (res && res.ok) cache.put(e.request, res.clone()); return res; })
.catch(() => cached);
return cached || network;
})
);
});

Some files were not shown because too many files have changed in this diff Show More