18 Commits

Author SHA1 Message Date
karim 502fa249a0 gitignore: ignore whole desktop resources dir (web + plugins build artifacts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:00:36 +02:00
karim 6d61c122e1 PROC: activate approach/missed/vector-to-final; DCLTR-2 declutters airspace
PROC menu actions were empty stubs. Now: procedures.js tags approach legs with
seg ('approach'|'missed' — everything past the runway threshold = missed,
previously dropped); Proc.jsx flags loaded legs appr/missed (preserved by
flightplan.setPlan) and the ACTIVATE APPROACH / MISSED APPROACH / VECTOR-TO-FINAL
items set the active (magenta) leg to the matching segment, with a hint when
nothing is loaded. Missed legs shown dimmed in the preview.

DCLTR-2 now hides the airspace (SUA) overlay, matching the manual (p.56), in
addition to the existing nav-symbol declutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:43:36 +02:00
karim fb6f0182cc PFD CDI softkey cycles nav source; demo reflects dataref writes
The CDI softkey was in the PFD root row but had no handler — it never cycled the
HSI/CDI source (GPS↔VLOC1↔VLOC2). Wire it to write cdiSrc
(HSI_source_select_pilot, now writable), cycling GPS→NAV1→NAV2.

Also fix a latent demo bug: the 'needs a live sim socket' guard sat above the
setDataref/command handlers, so in DEMO (no sim socket) every dataref write
(transponder code, baro, AP bugs, CDI) was silently dropped. Reflect writes
locally before that guard so cockpit controls respond without a sim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:34:20 +02:00
karim cd7197f06e KAP140: annunciate modes from per-mode status datarefs (same bitfield fix)
The steam-panel KAP140 LCD decoded the same unreliable autopilot_state bitfield
as the G1000 panel, so its lateral/vertical annunciation could be wrong. Read the
*_status datarefs instead, consistent with AutopilotPanel and the PFD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:25:52 +02:00
karim 1788e56e65 Autopilot panel: light mode keys from per-mode status datarefs
Key feedback came from decoding the autopilot_state bitfield, whose bit
positions don't reliably match X-Plane (e.g. bit 0 is auto-throttle, not the
flight director), so several keys (FD/NAV/APR/BC/VS/VNV/FLC) never lit even when
the mode was engaged. Read the dedicated *_status datarefs (off/armed/active) —
the same source the PFD AFCS bar uses — so every key and the lateral/vertical
annunciator reflect the real mode. NAV also lights on GPS-coupled (gpss) mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:51:44 +02:00
karim 5b7cf13e9d SVT: align synthetic terrain horizon with the attitude horizon
The 3D terrain showed almost no sky — the horizon sat far above the attitude
horizon line. Base camera pitch was 72°, but with MapLibre's 36.87° vertical FOV
the flat horizon only reaches the attitude line (28% of the SVT box) at ~82°.
Invert the perspective to derive the camera pitch from aircraft pitch so the
synthetic horizon lands exactly on the attitude horizon and tracks it 1:1
(accounting for the 1.5× canvas scale). Raise maxPitch to 85.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:46:25 +02:00
karim 4a71e5f03d release: only upload artifacts matching the current version (skip stale bundles)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:16:43 +02:00
karim 806a82383e release: merge latest.json platforms across split mac/linux releases
So a per-machine release (macOS here, Linux on Arch) of the same version keeps
both platform entries instead of clobbering the one not built this run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:20:52 +02:00
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 17286 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/
# 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/
+139
View File
@@ -0,0 +1,139 @@
#!/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() };
}
};
// Guard against stale bundles from older builds lingering in the output dirs:
// any artifact whose filename carries a version must match the current one.
// (The .app.tar.gz has no version in its name, so it's always accepted.)
const verRe = new RegExp(`_${VERSION.replace(/\./g, '\\.')}_`);
const verOk = (f) => !/_\d+\.\d+\.\d+_/.test(path.basename(f)) || verRe.test(path.basename(f));
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)).filter(verOk) : [];
// 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)) };
}
// Merge with the currently-published latest.json so a split release (e.g. macOS
// on one machine, Linux on another) doesn't drop the platform we didn't build
// this run — as long as both build the SAME version. A published older version
// is ignored (a fresh version starts clean; the other platform re-publishes).
let merged = platforms;
try {
const cur = await fetch(dlUrl(UPDATER_TAG, 'latest.json')).then((r) => (r.ok ? r.json() : null));
if (cur && cur.version === VERSION && cur.platforms) {
const kept = Object.keys(cur.platforms).filter((k) => !platforms[k]);
merged = { ...cur.platforms, ...platforms };
if (kept.length) console.log('Merged kept platforms from published latest.json:', kept.join(', '));
}
} catch { /* offline / first ever release */ }
const latest = {
version: VERSION,
notes: `X-Plane Cockpit ${verTag}`,
pub_date: new Date().toISOString(),
platforms: merged,
};
const latestPath = path.join(ROOT, 'desktop/latest.json');
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
console.log('latest.json platforms:', Object.keys(merged).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) }));
}
+392
View File
@@ -0,0 +1,392 @@
// 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;
}
// Demo / no-sim: reflect dataref writes locally so cockpit controls (CDI,
// transponder, baro, AP bugs …) still respond, then stop — there's no sim to
// forward to. Must run BEFORE the live-socket guard below.
if (msg.type === 'setDataref' && (process.env.DEMO || !state.xpSocket)) {
const name = WRITABLE_DATAREFS[msg.name];
if (!name) return log(`! unknown writable dataref alias: ${msg.name}`);
state.values[msg.name] = Number(msg.value);
return broadcast({ type: 'values', data: { [msg.name]: Number(msg.value) } });
}
// --- 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];
if (!name) return log(`! unknown writable dataref alias: ${msg.name}`);
const id = state.drefNameToId.get(name);
if (id == null) return log(`! writable dataref not resolved yet: ${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();
});
+189
View File
@@ -0,0 +1,189 @@
// 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
cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot', // 0 NAV1 · 1 NAV2 · 2 GPS (CDI softkey cycles it)
};
// 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 } : {}), ...(w.missed ? { missed: 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;
}
+146
View File
@@ -0,0 +1,146 @@
// 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 approaches, the runway threshold is the Missed-Approach Point: legs after
// it are the missed-approach segment. We tag (rather than drop) them so the
// FMS can hold them un-sequenced and "Activate Missed Approach" can fly them.
let inMissed = false;
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);
const wp = { id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt };
if (TYPE === 'APPCH') wp.seg = inMissed ? 'missed' : 'approach';
out.push(wp);
if (TYPE === 'APPCH' && isRwy) inMissed = true; // everything past the runway = missed
}
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