1 Commits

Author SHA1 Message Date
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
110 changed files with 14670 additions and 2 deletions
+26
View File
@@ -0,0 +1,26 @@
# dependencies
node_modules/
web/node_modules/
desktop/node_modules/
# build output
web/dist/
desktop/src-tauri/target/
target-linux/
desktop/src-tauri/gen/
desktop/latest.json
fms-out/
# generated bundle inputs (recreated by scripts/prep-desktop.sh)
desktop/src-tauri/binaries/
desktop/src-tauri/resources/web/
# SECRETS — never commit the updater signing private key / password
desktop/.tauri-signing.key
desktop/.tauri-signing.pw
# local agent + editor + misc
.claude/
screenshots/
*.log
.DS_Store
+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.3"
description = "Desktop launcher for the X-Plane G1000 web cockpit"
authors = ["karim"]
edition = "2021"
rust-version = "1.77"
[lib]
name = "xplane_cockpit_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-shell = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
tauri-plugin-clipboard-manager = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
local-ip-address = "0.6"
[profile.release]
panic = "abort"
codegen-units = 1
lto = true
opt-level = "s"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,22 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the control panel window",
"windows": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:window:allow-start-dragging",
{
"identifier": "shell:allow-execute",
"allow": [{ "name": "binaries/xpbridge", "sidecar": true, "args": true }]
},
"shell:allow-spawn",
"shell:allow-kill",
"dialog:allow-open",
"opener:allow-open-url",
"updater:default",
"process:allow-restart",
"clipboard-manager:allow-write-text"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 893 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+269
View File
@@ -0,0 +1,269 @@
// X-Plane Cockpit desktop launcher.
//
// A small control panel that: (1) lets the user point at their X-Plane 12
// install, (2) starts/stops the bundled Node "bridge" server (a Bun-compiled
// sidecar), and (3) shows the LAN URL tablets open to see the G1000 cockpit.
// The cockpit web files travel with the app as a resource (WEB_DIST). A system
// tray keeps the server running when the window is closed.
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::Mutex;
use serde::Serialize;
use tauri::menu::{MenuBuilder, MenuItem, PredefinedMenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{Emitter, Manager, State};
use tauri_plugin_clipboard_manager::ClipboardExt;
use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
#[derive(Default)]
struct ServerState {
child: Mutex<Option<CommandChild>>,
port: Mutex<u16>,
url: Mutex<String>,
}
#[derive(Serialize, Clone)]
struct ServerInfo {
ip: String,
port: u16,
url: String,
}
fn lan_ipv4() -> String {
local_ip_address::local_ip()
.map(|ip| ip.to_string())
.unwrap_or_else(|_| "127.0.0.1".to_string())
}
#[tauri::command]
fn lan_ip() -> String {
lan_ipv4()
}
// Can we bind this TCP port on the LAN interface? (i.e. is it free)
fn is_port_free(port: u16) -> bool {
TcpListener::bind(("0.0.0.0", port)).is_ok()
}
#[tauri::command]
fn port_free(port: u16) -> bool {
is_port_free(port)
}
// First free port at/after `start` (so the UI can offer an alternative).
#[tauri::command]
fn suggest_port(start: u16) -> u16 {
let mut p = start.max(1024);
for _ in 0..200 {
if is_port_free(p) {
return p;
}
p = p.saturating_add(1);
}
start
}
#[tauri::command]
fn default_xplane_path() -> Option<String> {
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
let candidates = [
format!("{home}/X-Plane 12"),
format!("{home}/Desktop/X-Plane 12"),
"/Applications/X-Plane 12".to_string(),
"C:/X-Plane 12".to_string(),
"D:/X-Plane 12".to_string(),
];
candidates
.into_iter()
.find(|c| PathBuf::from(c).join("Resources").join("default data").is_dir())
}
#[tauri::command]
fn valid_xplane_path(path: String) -> bool {
!path.is_empty()
&& PathBuf::from(&path)
.join("Resources")
.join("default data")
.is_dir()
}
#[tauri::command]
fn server_running(state: State<ServerState>) -> bool {
state.child.lock().unwrap().is_some()
}
#[tauri::command]
async fn start_server(
app: tauri::AppHandle,
state: State<'_, ServerState>,
xplane_path: String,
port: u16,
demo: bool,
) -> Result<ServerInfo, String> {
if state.child.lock().unwrap().is_some() {
let p = *state.port.lock().unwrap();
let ip = lan_ipv4();
return Ok(ServerInfo { url: format!("http://{ip}:{p}"), ip, port: p });
}
if !is_port_free(port) {
return Err(format!("Port {port} ist belegt — wähle einen anderen."));
}
let web_dist = app
.path()
.resolve("web", tauri::path::BaseDirectory::Resource)
.map_err(|e| format!("resource path: {e}"))?;
let mut cmd = app
.shell()
.sidecar("xpbridge")
.map_err(|e| format!("sidecar: {e}"))?
.env("BRIDGE_PORT", port.to_string())
.env("BRIDGE_HOST", "0.0.0.0")
.env("WEB_DIST", web_dist.to_string_lossy().to_string());
if !xplane_path.is_empty() {
cmd = cmd.env("XPLANE_ROOT", xplane_path);
}
if demo {
cmd = cmd.env("DEMO", "1");
}
let (mut rx, child) = cmd.spawn().map_err(|e| format!("spawn: {e}"))?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
while let Some(event) = rx.recv().await {
let line = match event {
CommandEvent::Stdout(b) | CommandEvent::Stderr(b) => {
String::from_utf8_lossy(&b).to_string()
}
CommandEvent::Terminated(_) => {
let _ = app2.emit("server-exited", ());
break;
}
_ => continue,
};
let _ = app2.emit("server-log", line);
}
});
let ip = lan_ipv4();
let url = format!("http://{ip}:{port}");
*state.child.lock().unwrap() = Some(child);
*state.port.lock().unwrap() = port;
*state.url.lock().unwrap() = url.clone();
Ok(ServerInfo { url, ip, port })
}
#[tauri::command]
fn stop_server(state: State<ServerState>) -> Result<(), String> {
if let Some(child) = state.child.lock().unwrap().take() {
child.kill().map_err(|e| format!("kill: {e}"))?;
}
*state.url.lock().unwrap() = String::new();
Ok(())
}
fn kill_sidecar(app: &tauri::AppHandle) {
if let Some(state) = app.try_state::<ServerState>() {
if let Some(child) = state.child.lock().unwrap().take() {
let _ = child.kill();
}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_process::init())
.manage(ServerState::default())
.invoke_handler(tauri::generate_handler![
lan_ip,
port_free,
suggest_port,
default_xplane_path,
valid_xplane_path,
server_running,
start_server,
stop_server
])
.setup(|app| {
build_tray(app.handle())?;
Ok(())
})
// Closing the window hides it instead of quitting, so the server keeps
// serving tablets in the background. Quit from the tray.
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn build_tray(app: &tauri::AppHandle) -> tauri::Result<()> {
let show = MenuItem::with_id(app, "show", "Panel anzeigen", true, None::<&str>)?;
let open = MenuItem::with_id(app, "open", "Cockpit öffnen", true, None::<&str>)?;
let copy = MenuItem::with_id(app, "copy", "URL kopieren", true, None::<&str>)?;
let toggle = MenuItem::with_id(app, "toggle", "Server starten / stoppen", true, None::<&str>)?;
let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let menu = MenuBuilder::new(app)
.item(&show)
.item(&open)
.item(&copy)
.item(&sep)
.item(&toggle)
.item(&sep)
.item(&quit)
.build()?;
TrayIconBuilder::with_id("main")
.icon(app.default_window_icon().unwrap().clone())
.tooltip("X-Plane Cockpit")
.menu(&menu)
.show_menu_on_left_click(false)
.on_menu_event(|app, event| match event.id().as_ref() {
"show" => show_main(app),
"open" => { let _ = app.emit("tray-open", ()); }
"copy" => {
if let Some(state) = app.try_state::<ServerState>() {
let url = state.url.lock().unwrap().clone();
if !url.is_empty() {
let _ = app.clipboard().write_text(url);
}
}
}
"toggle" => { let _ = app.emit("tray-toggle", ()); }
"quit" => { kill_sidecar(app); app.exit(0); }
_ => {}
})
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click { .. } = event {
show_main(tray.app_handle());
}
})
.build(app)?;
Ok(())
}
fn show_main(app: &tauri::AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.set_focus();
}
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents an extra console window on Windows in release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
xplane_cockpit_lib::run()
}
+64
View File
@@ -0,0 +1,64 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "X-Plane Cockpit",
"version": "0.1.3",
"identifier": "ch.kgva.xplanecockpit",
"build": {
"frontendDist": "../ui"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"title": "X-Plane Cockpit",
"width": 480,
"height": 720,
"minWidth": 420,
"minHeight": 560,
"resizable": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": [
"app",
"dmg",
"appimage",
"deb"
],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"externalBin": [
"binaries/xpbridge"
],
"resources": {
"resources/web": "web"
},
"createUpdaterArtifacts": true,
"macOS": {
"minimumSystemVersion": "10.15"
},
"linux": {
"appimage": {
"bundleMediaFramework": false
}
}
},
"plugins": {
"updater": {
"endpoints": [
"https://git.kgva.ch/karim/xplane-cockpit/releases/download/updater/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzFGQTUzOEUyOURFOTkKUldTWjNpbU9VL294V1ZWZllVMzc5MGR6OVFVcGRkSTVkcG1LUDJXODJzT2psbFZoY2JYT0E3dEIK"
}
}
}
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>X-Plane Cockpit</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="panel">
<header class="hd">
<div class="brand">G1000<span>·web</span></div>
<div id="status" class="status off"><span class="dot"></span><span id="statusText">Gestoppt</span></div>
</header>
<main>
<div id="updateBanner" class="update-banner hidden">
<div class="ub-text"><b id="ubTitle">Update verfügbar</b><span id="ubNotes"></span></div>
<div class="ub-actions">
<button id="ubInstall" class="btn ok sm">Installieren</button>
<button id="ubDismiss" class="btn ghost sm">Später</button>
</div>
</div>
<section class="card">
<label class="lbl">X-Plane 12 Ordner</label>
<div class="row">
<input id="xpPath" type="text" placeholder="z.B. /Users/du/X-Plane 12" spellcheck="false" />
<button id="browse" class="btn ghost">Suchen…</button>
</div>
<div id="xpHint" class="hint"></div>
<div class="row gap">
<div class="field">
<label class="lbl">Port</label>
<input id="port" type="number" value="8080" min="1024" max="65535" />
</div>
<label class="toggle">
<input id="demo" type="checkbox" />
<span>Demo-Modus (ohne X-Plane)</span>
</label>
</div>
<div id="portHint" class="hint"></div>
</section>
<button id="startBtn" class="btn primary big">Server starten</button>
<section id="liveCard" class="card live hidden">
<label class="lbl">Auf Tablets / Laptops öffnen</label>
<div class="url-row">
<code id="url"></code>
<button id="copy" class="btn ghost sm" title="Kopieren"></button>
</div>
<div class="quick">
<button class="btn ghost sm" data-page="pfd">PFD</button>
<button class="btn ghost sm" data-page="mfd">MFD</button>
<button class="btn ghost sm" data-page="map">Map</button>
<button class="btn ghost sm" data-page="fms">FMS</button>
</div>
<button id="openBtn" class="btn ok">Cockpit im Browser öffnen</button>
<div class="diag">
<div class="diag-row"><span>X-Plane</span><b id="dXp"></b></div>
<div class="diag-row"><span>Verbundene Geräte</span><b id="dClients"></b></div>
<div class="diag-row"><span>Navdata</span><b id="dNav"></b></div>
<div class="diag-row"><span>Datarefs</span><b id="dRefs"></b></div>
</div>
</section>
<details class="log-wrap">
<summary>Server-Log</summary>
<pre id="log"></pre>
</details>
</main>
<footer class="ft">
<span id="ver">v—</span>
<button id="updateBtn" class="link">Nach Updates suchen</button>
</footer>
</div>
<script src="main.js"></script>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
// Control-panel logic. Uses the global Tauri API (withGlobalTauri).
const T = window.__TAURI__ || {};
const invoke = T.core.invoke;
const listen = T.event.listen;
const $ = (id) => document.getElementById(id);
const xpPath = $('xpPath'), portEl = $('port'), demoEl = $('demo');
const startBtn = $('startBtn'), liveCard = $('liveCard'), urlEl = $('url');
const statusEl = $('status'), statusText = $('statusText'), logEl = $('log');
let running = false, healthTimer = null;
function setStatus(kind, text) {
statusEl.className = 'status ' + kind;
statusText.textContent = text;
}
async function validatePath() {
const p = xpPath.value.trim();
const hint = $('xpHint');
if (!p) { hint.textContent = ''; hint.className = 'hint'; return false; }
const ok = await invoke('valid_xplane_path', { path: p });
hint.textContent = ok ? '✓ X-Plane erkannt' : '⚠ kein „Resources/default data“ — Demo-Modus nutzen oder Pfad prüfen';
hint.className = 'hint ' + (ok ? 'ok' : 'bad');
return ok;
}
// Is the chosen port free? If not, offer the next free one.
async function validatePort() {
const hint = $('portHint');
const port = parseInt(portEl.value, 10) || 0;
if (port < 1024 || port > 65535) { hint.textContent = '⚠ Port 102465535'; hint.className = 'hint bad'; return false; }
const free = await invoke('port_free', { port });
if (free) { hint.textContent = '✓ Port frei'; hint.className = 'hint ok'; return true; }
const alt = await invoke('suggest_port', { start: port + 1 });
hint.innerHTML = `⚠ Port ${port} belegt — <a href="#" id="usePort">${alt} verwenden</a>`;
hint.className = 'hint bad';
const a = $('usePort');
if (a) a.onclick = (e) => { e.preventDefault(); portEl.value = alt; validatePort(); };
return false;
}
async function init() {
try { $('ver').textContent = 'v' + (await T.app.getVersion()); } catch {}
try {
const def = await invoke('default_xplane_path');
if (def) { xpPath.value = def; validatePath(); }
} catch {}
validatePort();
checkUpdate(true); // silent on launch
}
xpPath.addEventListener('change', validatePath);
xpPath.addEventListener('blur', validatePath);
portEl.addEventListener('change', validatePort);
portEl.addEventListener('blur', validatePort);
$('browse').addEventListener('click', async () => {
try {
const dir = await T.dialog.open({ directory: true, multiple: false, title: 'X-Plane 12 Ordner wählen' });
if (dir) { xpPath.value = dir; validatePath(); }
} catch (e) { appendLog('dialog: ' + e); }
});
startBtn.addEventListener('click', async () => {
if (running) return stop();
if (!(await validatePort())) return; // refuse a busy port up front
startBtn.disabled = true;
try {
const info = await invoke('start_server', {
xplanePath: xpPath.value.trim(),
port: parseInt(portEl.value, 10) || 8080,
demo: demoEl.checked,
});
running = true;
urlEl.textContent = info.url;
liveCard.classList.remove('hidden');
startBtn.textContent = 'Server stoppen';
startBtn.classList.add('stop');
setStatus('warn', 'Server läuft · warte auf Sim');
pollHealth(info.port);
} catch (e) {
appendLog('Fehler: ' + e);
setStatus('off', 'Fehler');
} finally {
startBtn.disabled = false;
}
});
async function stop() {
startBtn.disabled = true;
try { await invoke('stop_server'); } catch (e) { appendLog('stop: ' + e); }
resetUi();
startBtn.disabled = false;
}
function resetUi() {
running = false;
liveCard.classList.add('hidden');
startBtn.textContent = 'Server starten';
startBtn.classList.remove('stop');
setStatus('off', 'Gestoppt');
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
}
function pollHealth(port) {
if (healthTimer) clearInterval(healthTimer);
const dXp = $('dXp'), dClients = $('dClients'), dNav = $('dNav'), dRefs = $('dRefs');
const check = async () => {
try {
const r = await fetch(`http://127.0.0.1:${port}/api/health`, { cache: 'no-store' });
const d = await r.json();
const sim = d.xpConnected;
if (sim) setStatus('run', demoEl.checked ? 'Demo läuft' : 'X-Plane verbunden');
else setStatus('warn', 'Server läuft · kein Sim');
dXp.textContent = sim ? (demoEl.checked ? 'Demo' : 'verbunden') : 'kein Sim';
dXp.className = sim ? 'ok' : 'warn';
dClients.textContent = d.clients ?? 0;
const n = d.nav || {};
dNav.textContent = n.loaded ? `${n.airports ?? 0} APT · ${n.navaids ?? 0} Navaids` : 'lädt…';
dRefs.textContent = d.datarefs ?? 0;
} catch { setStatus('warn', 'Server läuft'); }
};
check();
healthTimer = setInterval(check, 3000);
}
$('copy').addEventListener('click', async () => {
try { await navigator.clipboard.writeText(urlEl.textContent); $('copy').textContent = '✓'; setTimeout(() => ($('copy').textContent = '⧉'), 1200); } catch {}
});
const openUrl = (u) => { try { T.opener.openUrl(u); } catch (e) { appendLog('open: ' + e); } };
$('openBtn').addEventListener('click', () => openUrl(urlEl.textContent));
document.querySelectorAll('.quick .btn').forEach((b) =>
b.addEventListener('click', () => openUrl(urlEl.textContent + '/#' + b.dataset.page)));
function appendLog(line) {
logEl.textContent += line;
if (logEl.textContent.length > 8000) logEl.textContent = logEl.textContent.slice(-6000);
logEl.scrollTop = logEl.scrollHeight;
}
listen('server-log', (e) => appendLog(e.payload));
listen('server-exited', () => { appendLog('\n[Server beendet]\n'); resetUi(); });
// Tray actions routed to the panel (which holds the current URL + start logic).
listen('tray-open', () => { if (urlEl.textContent && urlEl.textContent !== '—') openUrl(urlEl.textContent); });
listen('tray-toggle', () => startBtn.click());
/* ---------------- updates ---------------- */
let pendingUpdate = null;
async function checkUpdate(silent) {
const btn = $('updateBtn');
if (!silent) { btn.disabled = true; btn.textContent = 'Suche…'; }
try {
const update = await T.updater.check();
if (update) {
pendingUpdate = update;
$('ubTitle').textContent = `Update ${update.version} verfügbar`;
$('ubNotes').textContent = update.body || '';
$('updateBanner').classList.remove('hidden');
if (!document.querySelector('.update-badge')) {
const dot = document.createElement('span'); dot.className = 'update-badge'; btn.after(dot);
}
if (!silent) { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }
} else if (!silent) {
btn.textContent = 'Aktuell ✓';
setTimeout(() => { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }, 2500);
}
} catch (e) {
if (!silent) {
appendLog('update: ' + e);
btn.textContent = 'Update fehlgeschlagen';
setTimeout(() => { btn.textContent = 'Nach Updates suchen'; btn.disabled = false; }, 2500);
}
}
}
async function installUpdate() {
if (!pendingUpdate) return;
$('ubInstall').disabled = true; $('ubInstall').textContent = 'Lädt…';
try {
await pendingUpdate.downloadAndInstall();
await T.process.relaunch();
} catch (e) {
appendLog('install: ' + e);
$('ubInstall').disabled = false; $('ubInstall').textContent = 'Installieren';
}
}
$('updateBtn').addEventListener('click', () => checkUpdate(false));
$('ubInstall').addEventListener('click', installUpdate);
$('ubDismiss').addEventListener('click', () => $('updateBanner').classList.add('hidden'));
init();
+89
View File
@@ -0,0 +1,89 @@
/* macOS-style dark theme: neutral graphite surfaces, SF system font, subtle
separators, a single green accent for the running/start state. No blue. */
:root {
--bg: #1c1c1e; /* system background (dark) */
--bg2: #2c2c2e; /* elevated surface */
--bg3: #3a3a3c; /* control fill */
--line: #48484a; /* separators / borders */
--line-soft: #38383a;
--txt: #ffffff;
--txt2: #ebebf5;
--mut: #8e8e93; /* secondary label */
--green: #30d158; /* system green */
--green-d: #248a3d;
--amber: #ffd60a;
--red: #ff453a;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg);
color: var(--txt);
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased;
}
.panel { display: flex; flex-direction: column; height: 100vh; padding: 16px; gap: 14px; }
.hd { display: flex; align-items: center; justify-content: space-between; }
.brand { font-weight: 700; letter-spacing: .2px; font-size: 17px; }
.brand span { color: var(--mut); font-weight: 500; }
.status { display: flex; align-items: center; gap: 7px; font-size: 12px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line-soft); background: var(--bg2); color: var(--txt2); }
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--mut); transition: background .2s; }
.status.run .dot { background: var(--green); box-shadow: 0 0 8px var(--green); }
.status.warn .dot { background: var(--amber); box-shadow: 0 0 8px var(--amber); }
main { flex: 1; display: flex; flex-direction: column; gap: 12px; overflow-y: auto; }
.card { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 9px; }
.lbl { color: var(--mut); font-size: 11px; font-weight: 600; }
.row { display: flex; gap: 8px; align-items: center; }
.row.gap { gap: 16px; margin-top: 2px; }
.field { display: flex; flex-direction: column; gap: 6px; }
.field input { width: 96px; }
input[type="text"], input[type="number"] {
flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--txt);
border-radius: 7px; padding: 8px 10px; font-size: 13px; font-family: inherit;
}
input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px rgba(48,209,88,.2); }
.toggle { display: flex; align-items: center; gap: 8px; color: var(--txt2); cursor: pointer; align-self: flex-end; padding-bottom: 8px; }
.toggle input { width: 15px; height: 15px; accent-color: var(--green); }
.hint { font-size: 12px; min-height: 16px; color: var(--mut); }
.hint.ok { color: var(--green); } .hint.bad { color: var(--amber); }
.hint a { color: var(--green); }
.btn { border: 1px solid var(--line); background: var(--bg3); color: var(--txt); border-radius: 8px; padding: 8px 14px; font-size: 13px; font-family: inherit; cursor: pointer; transition: filter .12s, background .12s; }
.btn:hover { filter: brightness(1.18); }
.btn:active { transform: translateY(1px); }
.btn.ghost { background: transparent; color: var(--txt2); border-color: var(--line); }
.btn.sm { padding: 5px 10px; font-size: 12px; }
.btn.big { padding: 13px; font-size: 15px; font-weight: 600; }
.btn.primary { background: var(--green); color: #042b10; border-color: transparent; font-weight: 600; }
.btn.big.stop { background: var(--red); color: #2a0603; }
.btn.ok { background: var(--green); color: #042b10; border-color: transparent; font-weight: 600; }
.btn:disabled { opacity: .45; cursor: default; }
.update-banner { display: flex; gap: 10px; align-items: center; justify-content: space-between; background: rgba(48,209,88,.10); border: 1px solid var(--green-d); border-radius: 12px; padding: 10px 12px; }
.update-banner.hidden { display: none; }
.ub-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ub-text b { color: var(--green); font-size: 13px; }
.ub-text span { color: var(--mut); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 230px; }
.ub-actions { display: flex; gap: 6px; flex: 0 0 auto; }
.live.hidden { display: none; }
.url-row { display: flex; gap: 8px; align-items: center; }
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
.quick { display: flex; gap: 6px; }
.quick .btn { flex: 1; }
.diag { margin-top: 10px; border-top: 1px solid var(--line-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 5px; }
.diag-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--mut); }
.diag-row b { color: var(--txt2); font-weight: 600; }
.diag-row b.ok { color: var(--green); } .diag-row b.warn { color: var(--amber); }
.log-wrap { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 6px 12px; }
.log-wrap summary { color: var(--mut); font-size: 12px; cursor: pointer; padding: 4px 0; }
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
.ft { display: flex; align-items: center; justify-content: space-between; color: var(--mut); font-size: 12px; }
.link { background: none; border: none; color: var(--green); cursor: pointer; font-size: 12px; font-family: inherit; }
.link:hover { text-decoration: underline; }
.link:disabled { color: var(--mut); cursor: default; text-decoration: none; }
.update-badge { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--green); margin-left: 6px; box-shadow: 0 0 6px var(--green); vertical-align: middle; }
+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"
}
}
+39
View File
@@ -0,0 +1,39 @@
#!/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"
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
+27
View File
@@ -0,0 +1,27 @@
#!/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 "==> compiling bridge sidecars (Bun)"
mkdir -p desktop/src-tauri/binaries
bun build --compile --target=bun-darwin-arm64 server/bridge.js \
--outfile desktop/src-tauri/binaries/xpbridge-aarch64-apple-darwin
bun build --compile --target=bun-linux-x64-baseline server/bridge.js \
--outfile desktop/src-tauri/binaries/xpbridge-x86_64-unknown-linux-gnu
chmod +x desktop/src-tauri/binaries/xpbridge-*
echo "==> done"
ls -lh desktop/src-tauri/binaries/
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Publish a release to Gitea and (re)write the updater's latest.json.
//
// Reads the built artifacts (macOS .app.tar.gz + .sig, Linux .AppImage + .sig,
// plus .dmg / .deb for manual install), creates a versioned release with those
// assets, then builds latest.json (Tauri v2 updater format) pointing at the
// release download URLs and uploads it to a fixed-tag "updater" release so the
// app's updater endpoint URL stays constant.
//
// Env: GITEA_URL (e.g. https://git.kgva.ch), GITEA_REPO (owner/name),
// GITEA_TOKEN (or a token file at /tmp/gitea_token).
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const GITEA_URL = (process.env.GITEA_URL || 'https://git.kgva.ch').replace(/\/$/, '');
const REPO = process.env.GITEA_REPO || 'karim/xplane-cockpit';
const TOKEN = process.env.GITEA_TOKEN
|| (fs.existsSync('/tmp/gitea_token') ? fs.readFileSync('/tmp/gitea_token', 'utf8').trim() : '');
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'desktop/src-tauri/tauri.conf.json'), 'utf8')).version;
const UPDATER_TAG = 'updater';
if (!TOKEN) { console.error('No GITEA_TOKEN (env or /tmp/gitea_token).'); process.exit(1); }
const api = (p, opts = {}) => fetch(`${GITEA_URL}/api/v1${p}`, {
...opts,
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json', ...(opts.headers || {}) },
});
// Collect built artifacts that exist (mac and/or linux builds may have run).
function findArtifacts() {
const macBundle = path.join(ROOT, 'desktop/src-tauri/target/aarch64-apple-darwin/release/bundle');
const linBundle = path.join(ROOT, 'target-linux/x86_64-unknown-linux-gnu/release/bundle');
const out = { assets: [], updater: {} };
const add = (file, platformKey) => {
if (!fs.existsSync(file)) return;
out.assets.push(file);
if (platformKey) {
const sig = file + '.sig';
if (fs.existsSync(sig)) out.updater[platformKey] = { file, sig: fs.readFileSync(sig, 'utf8').trim() };
}
};
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) : [];
// macOS
glob1(path.join(macBundle, 'macos'), /\.app\.tar\.gz$/).forEach((f) => add(f, 'darwin-aarch64'));
glob1(path.join(macBundle, 'dmg'), /\.dmg$/).forEach((f) => out.assets.push(f));
// Linux
glob1(path.join(linBundle, 'appimage'), /\.AppImage$/).forEach((f) => add(f, 'linux-x86_64'));
glob1(path.join(linBundle, 'deb'), /\.deb$/).forEach((f) => out.assets.push(f));
return out;
}
async function getReleaseByTag(tag) {
const r = await api(`/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`);
return r.ok ? r.json() : null;
}
async function ensureRelease(tag, name, body) {
let rel = await getReleaseByTag(tag);
if (rel) return rel;
const r = await api(`/repos/${REPO}/releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: tag, name, body, draft: false, prerelease: false }),
});
if (!r.ok) throw new Error(`create release ${tag}: ${r.status} ${await r.text()}`);
return r.json();
}
async function uploadAsset(rel, file, asName) {
const name = asName || path.basename(file);
// delete an existing asset with the same name first (Gitea won't overwrite)
for (const a of rel.assets || []) {
if (a.name === name) await api(`/repos/${REPO}/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
}
const blob = new Blob([fs.readFileSync(file)]);
const fd = new FormData();
fd.append('attachment', blob, name);
const r = await api(`/repos/${REPO}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(`upload ${name}: ${r.status} ${await r.text()}`);
console.log(' ↑', name);
return r.json();
}
const dlUrl = (tag, name) => `${GITEA_URL}/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
async function main() {
const { assets, updater } = findArtifacts();
if (!assets.length) { console.error('No build artifacts found — run the builds first.'); process.exit(1); }
console.log(`Release v${VERSION}${GITEA_URL}/${REPO}`);
console.log('Artifacts:', assets.map((a) => path.basename(a)).join(', '));
const verTag = `v${VERSION}`;
const rel = await ensureRelease(verTag, `X-Plane Cockpit ${verTag}`, `Automated release ${verTag}.`);
for (const f of assets) await uploadAsset(rel, f);
const relFresh = await getReleaseByTag(verTag); // refresh asset list
// Build latest.json referencing this release's updater artifacts.
const platforms = {};
for (const [key, { file, sig }] of Object.entries(updater)) {
platforms[key] = { signature: sig, url: dlUrl(verTag, path.basename(file)) };
}
const latest = {
version: VERSION,
notes: `X-Plane Cockpit ${verTag}`,
pub_date: new Date().toISOString(),
platforms,
};
const latestPath = path.join(ROOT, 'desktop/latest.json');
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
console.log('latest.json platforms:', Object.keys(platforms).join(', ') || '(none)');
// Upload latest.json to the fixed "updater" release (constant endpoint URL).
const upd = await ensureRelease(UPDATER_TAG, 'Updater channel', 'Rolling pointer used by the in-app updater.');
await uploadAsset(upd, latestPath, 'latest.json');
console.log('Updater endpoint:', dlUrl(UPDATER_TAG, 'latest.json'));
}
main().catch((e) => { console.error(e); process.exit(1); });
+305
View File
@@ -0,0 +1,305 @@
// 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 } from './navdata.js';
import { parseProcedures, procedureLegs as procLegs } from './procedures.js';
import * as fp from './flightplan.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,
};
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() {
broadcast({ type: 'flightplan', data: fp.getPlan() });
}
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_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
if (msg.type === 'fp_export') {
const r = fp.exportFms(msg.name || 'WEBFPL');
broadcast({ type: 'fp_export_result', ...r });
return;
}
// --- everything below talks to X-Plane; needs a live sim socket ---
if (!state.xpSocket || state.xpSocket.readyState !== WsClient.OPEN) return;
if (msg.type === 'command') {
const name = COMMANDS[msg.name];
const id = name && state.cmdNameToId.get(name);
if (id == null) return log(`! unknown command alias: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'command_set_is_active',
params: { commands: [{ id, is_active: true, duration: msg.duration ?? 0 }] },
})
);
} else if (msg.type === 'setDataref') {
const name = WRITABLE_DATAREFS[msg.name];
const id = name && state.drefNameToId.get(name);
if (id == null) return log(`! unknown writable dataref alias: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
type: 'dataref_set_values',
params: { datarefs: [{ id, value: Number(msg.value) }] },
})
);
}
}
// ---- HTTP + LAN WebSocket server -----------------------------------------
const app = express();
// Allow the desktop launcher (a different origin) to read the JSON API. LAN-only
// by design, so a wildcard here is harmless and keeps tablets/the app simple.
app.use('/api', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*'); next(); });
app.get('/api/health', (_req, res) =>
res.json({ xpConnected: state.xpConnected, datarefs: state.drefIdToAlias.size, clients: clients.size, nav: navStatus() })
);
// 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))
);
// 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 });
});
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,
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: 11030, nav1Sb: 11150, nav2: 11380, 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,
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
// engine strip (arrays, like the sim)
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
fuelQty: [60, 58], volts: [28.0], amps: [12],
});
// 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 },
]});
let t = 0;
setInterval(() => {
t += 0.1;
state.values.roll = -12 + Math.sin(t) * 4;
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
state.values.heading = (87 + Math.sin(t * 0.3) * 3 + 360) % 360;
state.values.track = state.values.heading;
state.values.altitude = 5500 + Math.sin(t * 0.5) * 40;
state.values.airspeed = 124 + Math.sin(t * 0.4) * 3;
// creep south-east so the aircraft visibly moves on the map
state.values.lat -= 0.0006;
state.values.lon -= 0.0009;
broadcast({ type: 'status', xpConnected: true });
broadcast({ type: 'values', data: state.values });
}, 100);
}
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
if (process.env.DEMO) startDemo();
else connectXPlane();
});
+141
View File
@@ -0,0 +1,141 @@
// 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',
// --- 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',
amps: 'sim/cockpit2/electrical/battery_amps',
// --- 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',
};
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
export const WRITABLE_DATAREFS = {
apHdgBug: 'sim/cockpit2/autopilot/heading_dial_deg_mag_pilot',
apAltBug: 'sim/cockpit2/autopilot/altitude_dial_ft_pilot',
apVsBug: 'sim/cockpit2/autopilot/vvi_dial_fpm',
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
xpdrMode: 'sim/cockpit2/radios/actuators/transponder_mode', // 0 off,1 stby,2 on,3 alt
xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code', // 4-digit squawk
};
// Commands the frontend may TRIGGER (autopilot mode buttons etc.).
export const COMMANDS = {
apToggle: 'sim/autopilot/servos_toggle',
fdToggle: 'sim/autopilot/fdir_toggle',
hdg: 'sim/autopilot/heading',
nav: 'sim/autopilot/NAV',
apr: 'sim/autopilot/approach',
altHold: 'sim/autopilot/altitude_hold',
vs: 'sim/autopilot/vertical_speed',
flc: 'sim/autopilot/level_change',
vnav: 'sim/autopilot/vnav',
backCourse:'sim/autopilot/back_course',
noseUp: 'sim/autopilot/nose_up',
noseDown: 'sim/autopilot/nose_down',
altUp: 'sim/autopilot/altitude_up',
altDown: 'sim/autopilot/altitude_down',
hdgUp: 'sim/autopilot/heading_up',
hdgDown: 'sim/autopilot/heading_down',
xpdrIdent: 'sim/transponder/transponder_ident',
};
// 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}`;
}
+100
View File
@@ -0,0 +1,100 @@
// 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 }))
: [];
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 root = xplaneRoot();
const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
try {
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${name}.fms`);
fs.writeFileSync(file, content);
return { ok: true, file, intoXplane: !!root };
} catch (e) {
return { ok: false, error: e.message };
}
}
+226
View File
@@ -0,0 +1,226 @@
// 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 state = { root: null, loaded: false, count: 0 };
function add(id, lat, lon, type) {
if (!id || !isFinite(lat) || !isFinite(lon)) return;
const key = id.toUpperCase();
if (!index.has(key)) index.set(key, { id: key, lat, lon, type });
}
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 !== 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);
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');
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]));
}
}
}
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'));
// 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 src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports;
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) => ({ ...f, dist: +f.dist.toFixed(1) }));
}
// 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;
}
// Runways of every airport within radiusNm — for the PFD's synthetic-vision view.
export function runwaysNear(lat, lon, radiusNm = 12) {
if (!isFinite(lat) || !isFinite(lon)) return [];
const out = [];
for (const a of airports) {
if (distNm(lat, lon, a.lat, a.lon) > radiusNm) continue;
const rs = rwyByApt.get(a.id);
if (rs) for (const r of rs) out.push({ apt: a.id, ...r });
}
return out;
}
export function navStatus() {
return { root: state.root, loaded: state.loaded, entries: index.size, airports: airports.length, navaids: navaids.length };
}
export function xplaneRoot() {
return state.root;
}
+141
View File
@@ -0,0 +1,141 @@
// Parses X-Plane's CIFP procedure data (SIDs / STARs / approaches) on demand —
// one small file per airport in Resources/default data/CIFP/<ICAO>.dat, in the
// ARINC-424-derived "XP CIFP" format. Fix idents are resolved to coordinates
// via the shared navdata index; runway thresholds come from the RWY records.
//
// Used by the G1000 PROC page: list a destination's procedures, then load a
// chosen procedure+transition's leg fixes into the active flight plan.
import fs from 'node:fs';
import path from 'node:path';
import { lookup, xplaneRoot } from './navdata.js';
// "N47274972" -> 47.4638.. / "W122183954" -> -122.3109..
function parseCoord(s) {
if (!s || s.length < 8) return null;
const hemi = s[0];
const neg = hemi === 'S' || hemi === 'W';
const digits = s.slice(1);
// lat = DDMMSSss (8) ; lon = DDDMMSSss (9)
const degLen = (hemi === 'E' || hemi === 'W') ? 3 : 2;
const dd = parseInt(digits.slice(0, degLen), 10);
const mm = parseInt(digits.slice(degLen, degLen + 2), 10);
const ss = parseInt(digits.slice(degLen + 2, degLen + 4), 10);
const hh = parseInt(digits.slice(degLen + 4, degLen + 6) || '0', 10);
if (!isFinite(dd) || !isFinite(mm)) return null;
const val = dd + mm / 60 + (ss + hh / 100) / 3600;
return neg ? -val : val;
}
function cifpFile(icao) {
const root = xplaneRoot();
if (!root) return null;
const f = path.join(root, 'Resources', 'default data', 'CIFP', `${icao.toUpperCase()}.dat`);
return fs.existsSync(f) ? f : null;
}
// Parse one airport's procedures into a structured summary + leg store.
// Returns null if the airport has no CIFP file.
export function parseProcedures(icao) {
const file = cifpFile(icao);
if (!file) return null;
const runways = {}; // RW16C -> { lat, lon, elev }
const groups = { SID: {}, STAR: {}, APPCH: {} };
// groups[type][procName] = { order:[trans...], legs:{ trans:[{fix,term,alt}] } }
const ensure = (type, name, trans) => {
const g = groups[type];
if (!g[name]) g[name] = { order: [], legs: {} };
if (!(trans in g[name].legs)) { g[name].legs[trans] = []; g[name].order.push(trans); }
return g[name].legs[trans];
};
for (const raw of fs.readFileSync(file, 'utf8').split('\n')) {
const line = raw.trim().replace(/;$/, '');
if (!line) continue;
const colon = line.indexOf(':');
if (colon < 0) continue;
const type = line.slice(0, colon);
const f = line.slice(colon + 1).split(',');
if (type === 'RWY') {
// RWY:RW16C, , ,00429, ,ISZI,3, ;N47274972,W122183954,0000
const id = f[0];
const tail = line.split(';')[1] || ''; // "N47274972,W122183954,0000"
const [latS, lonS] = tail.split(',');
const lat = parseCoord(latS), lon = parseCoord(lonS);
if (lat != null && lon != null) runways[id] = { lat, lon, elev: parseInt(f[3], 10) || 0 };
continue;
}
if (type !== 'SID' && type !== 'STAR' && type !== 'APPCH') continue;
// f[0]=seqno, f[1]=route type, f[2]=proc, f[3]=transition, f[4]=fix,
// f[11]=path/termination, f[22]=alt flag (+/-), f[23]=altitude.
const procName = f[2]; // BANGR9 / CHINS5 / I16C
const trans = (f[3] || '').trim(); // RW16C / PDT / ERYKA / '' (common)
const fix = (f[4] || '').trim(); // OTLIE / ANVIL / RW16C
const term = (f[11] || '').trim(); // path/termination: IF TF CF DF VA CA ...
const altFlag = (f[22] || '').trim();
const altVal = parseInt((f[23] || '').trim(), 10);
const alt = isFinite(altVal) && altVal > 0 ? altVal : null;
const legs = ensure(type, procName, trans || '(common)');
legs.push({ fix, term, alt, altFlag });
}
// Build the client-facing summary (names + their transitions).
const summarize = (g) => Object.entries(g).map(([name, v]) => ({
name, transitions: v.order.filter((t) => t !== '(common)'),
}));
return {
icao: icao.toUpperCase(),
runways: Object.keys(runways),
sids: summarize(groups.SID),
stars: summarize(groups.STAR),
approaches: summarize(groups.APPCH),
_groups: groups,
_rwy: runways,
};
}
// Resolve a chosen procedure+transition to a list of waypoints with coordinates.
// type: 'sid' | 'star' | 'approach'. Fixes are resolved via the navdata index;
// runway "fixes" (RWxx) and unresolved fixes fall back to the RWY threshold.
export function procedureLegs(icao, type, name, trans) {
const parsed = parseProcedures(icao);
if (!parsed) return [];
const TYPE = { sid: 'SID', star: 'STAR', approach: 'APPCH' }[String(type).toLowerCase()];
const g = parsed._groups[TYPE];
if (!g || !g[name]) return [];
const node = g[name];
// Compose the leg list: chosen transition first, then the common segment.
// (SID: runway/enroute transition then common climb-out; STAR: enroute entry
// then common arrival; approach: IAF transition then final-approach segment.)
const seq = [];
const wantTrans = trans && node.legs[trans] ? trans : node.order.find((t) => t !== '(common)');
if (wantTrans && node.legs[wantTrans]) seq.push(...node.legs[wantTrans]);
if (node.legs['(common)']) seq.push(...node.legs['(common)']);
const out = [];
const seen = new Set();
for (const leg of seq) {
if (!leg.fix) continue; // heading/altitude legs w/o a fix
if (seen.has(leg.fix)) continue; // de-dupe repeated fixes
let pt = null;
const isRwy = /^RW/.test(leg.fix);
if (isRwy && parsed._rwy[leg.fix]) pt = parsed._rwy[leg.fix];
else {
const hit = lookup(leg.fix);
if (hit) pt = { lat: hit.lat, lon: hit.lon };
}
if (!pt) continue; // unresolved fix → skip
seen.add(leg.fix);
out.push({ id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt });
// An approach ends at the runway threshold — drop the missed-approach legs.
if (TYPE === 'APPCH' && isRwy) break;
}
return out;
}
+17
View File
@@ -0,0 +1,17 @@
import { chromium } from 'playwright';
const PORT = process.env.BRIDGE_PORT || 8099;
const base = `http://localhost:${PORT}`;
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1180, height: 820 }, deviceScaleFactor: 2 });
await page.goto(base, { waitUntil: 'networkidle' });
const tabs = [['pfd', 'PFD'], ['mfd', 'MFD'], ['map', 'Map'], ['fms', 'FMS'], ['ap', 'Autopilot']];
for (const [tab, label] of tabs) {
await page.getByRole('button', { name: label, exact: true }).click();
await page.waitForTimeout(tab === 'map' || tab === 'pfd' ? 4000 : 700); // tiles/terrain
await page.screenshot({ path: `/tmp/cockpit-${tab}.png` });
console.log(`shot: /tmp/cockpit-${tab}.png`);
}
await browser.close();
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="G1000" />
<meta name="theme-color" content="#000000" />
<!-- PWA: installable as a full-screen iPad/tablet app ("Zum Home-Bildschirm") -->
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/icons/icon-192.png" />
<title>X-Plane Glass Cockpit</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Saira+Condensed:wght@400;500;600;700&family=Saira+Semi+Condensed:wght@500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => navigator.serviceWorker.register('/sw.js').catch(() => {}));
}
</script>
</body>
</html>
+1973
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "xplane-glass-cockpit-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"leaflet": "^1.9.4",
"maplibre-gl": "^5.24.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^5.4.11"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "G1000 Glass Cockpit",
"short_name": "G1000",
"description": "X-Plane 12 G1000 glass cockpit — PFD / MFD / FMS over your LAN",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "landscape",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+31
View File
@@ -0,0 +1,31 @@
// 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-v1';
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;
// Stale-while-revalidate: serve cache fast, refresh in the background.
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;
})
);
});
+115
View File
@@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { useXplane } from './api/useXplane.js';
import PFD from './components/PFD.jsx';
import AutopilotPanel from './components/AutopilotPanel.jsx';
import MFD from './components/MFD.jsx';
import MapView from './components/MapView.jsx';
import CDU from './components/CDU.jsx';
import VFR from './components/VFR.jsx';
import Bezel from './components/Bezel.jsx';
import DirectTo from './components/DirectTo.jsx';
import Proc from './components/Proc.jsx';
// Compact line icons for the nav rail (stroke = currentColor).
const ICONS = {
pfd: 'M11 3a8 8 0 100 16 8 8 0 000-16zM3.5 11h15M7 8l1.5 1M15 8l-1.5 1',
mfd: 'M3 6l5-2 6 2 5-2v12l-5 2-6-2-5 2zM8 4v12M14 6v12',
map: 'M11 2c-3.3 0-6 2.6-6 5.9 0 4.4 6 11.1 6 11.1s6-6.7 6-11.1C17 4.6 14.3 2 11 2z',
fms: 'M4 6h14M4 11h14M4 16h9',
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3',
vfr: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 11l4.5-3',
};
function Icon({ name }) {
return (
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={ICONS[name]} />
{name === 'map' && <circle cx="11" cy="8" r="2" />}
</svg>
);
}
const TABS = [
{ id: 'pfd', label: 'PFD' },
{ id: 'mfd', label: 'MFD' },
{ id: 'map', label: 'Map' },
{ id: 'fms', label: 'FMS' },
{ id: 'vfr', label: 'VFR' },
{ id: 'ap', label: 'Autopilot' },
];
export default function App() {
const xp = useXplane();
const [tab, setTab] = useState(() => location.hash.replace('#', '') || 'pfd');
// Collapsible nav rail: narrow (icons) ↔ wide (icons + labels), remembered.
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
const [svt3d, setSvt3d] = useState(true);
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
const [inset, setInset] = useState(false);
// INSET map options (base layer + declutter), set from the INSET submenu.
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
// The NRST (nearest airports/navaids) window, toggled by the PFD NRST softkey.
const [nrst, setNrst] = useState(false);
// The TMR/REF (timer / references) window, toggled by the PFD TMR/REF softkey.
const [tmr, setTmr] = useState(false);
// MFD map mode (base layer), switched via the Map-Opt softkeys.
const [mapMode, setMapMode] = useState({ base: 'topo' });
// Direct-To (D→) dialog — opened from the bezel on either GDU.
const [dto, setDto] = useState(false);
// PROC (procedures: SID/STAR/approach) dialog — opened from the bezel.
const [proc, setProc] = useState(false);
const connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
return (
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
<aside className="sidebar">
<button className="sb-top" onClick={toggleNav} title="Menü ein-/ausklappen">
<span className="brand">G<span>1000</span></span>
<span className="sb-chev">{navWide ? '◂' : '▸'}</span>
</button>
<nav className="snav">
{TABS.map((t) => (
<button key={t.id} className={tab === t.id ? 'snav-i active' : 'snav-i'}
onClick={() => go(t.id)} title={t.label}>
<Icon name={t.id} />
<span className="snav-lbl">{t.label}</span>
</button>
))}
</nav>
<div className={`sb-conn ${connKind}`} title={connText}>
<span className="dot" />
<span className="snav-lbl">{connText}</span>
</div>
</aside>
<main className="screen">
{tab === 'pfd' && (
<Bezel variant="pfd" xp={xp} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
<PFD values={xp.values} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setNrst(false)}
tmr={tmr} onCloseTmr={() => setTmr(false)} flightPlan={xp.flightPlan} fp={xp.fp} />
</Bezel>
)}
{tab === 'mfd' && (
<Bezel variant="mfd" xp={xp} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
</Bezel>
)}
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
{tab === 'fms' && <CDU xp={xp} />}
{tab === 'vfr' && <VFR values={xp.values} />}
{tab === 'ap' && <AutopilotPanel xp={xp} />}
</main>
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState, useCallback } from 'react';
// Single WebSocket connection to the bridge. Streams dataref values + the
// shared flight plan in; sends commands / dataref writes / flight-plan edits
// out. Auto-reconnects.
export function useXplane() {
const [values, setValues] = useState({});
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
const [exportMsg, setExportMsg] = useState(null);
const [connected, setConnected] = useState(false); // socket to bridge
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
const wsRef = useRef(null);
useEffect(() => {
let closed = false;
let retry;
// Coalesce incoming value bursts into a single React update per animation
// frame — keeps the gauges smooth instead of re-rendering ~20×/sec.
let pending = null;
let raf = 0;
const flush = () => {
raf = 0;
if (pending) { const p = pending; pending = null; setValues((prev) => ({ ...prev, ...p })); }
};
const connect = () => {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/ws`);
wsRef.current = ws;
ws.onopen = () => setConnected(true);
ws.onmessage = (ev) => {
const msg = JSON.parse(ev.data);
if (msg.type === 'values') {
pending = pending ? Object.assign(pending, msg.data) : { ...msg.data };
if (!raf) raf = requestAnimationFrame(flush);
}
else if (msg.type === 'status') setXpConnected(!!msg.xpConnected);
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
else if (msg.type === 'fp_export_result') setExportMsg(msg);
};
ws.onclose = () => {
setConnected(false);
setXpConnected(false);
if (!closed) retry = setTimeout(connect, 2000);
};
ws.onerror = () => ws.close();
};
connect();
return () => { closed = true; clearTimeout(retry); wsRef.current?.close(); };
}, []);
const send = useCallback((obj) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}, []);
const command = useCallback((name, duration = 0) => send({ type: 'command', name, duration }), [send]);
const setDataref = useCallback((name, value) => send({ type: 'setDataref', name, value }), [send]);
// flight-plan actions
const fp = {
add: (ident) => send({ type: 'fp_add', ident }),
addLatLon: (lat, lon) => send({ type: 'fp_add', ident: `${lat},${lon}` }),
remove: (index) => send({ type: 'fp_remove', index }),
setActive: (index) => send({ type: 'fp_active', index }),
clear: () => send({ type: 'fp_clear' }),
set: (plan) => send({ type: 'fp_set', plan }),
export: (name) => send({ type: 'fp_export', name }),
};
return { values, flightPlan, exportMsg, connected, xpConnected, command, setDataref, fp };
}
// Search X-Plane's nav database (waypoints/VOR/NDB/airports) via the bridge.
export async function navSearch(q) {
if (!q) return [];
try {
const r = await fetch(`/api/nav/search?q=${encodeURIComponent(q)}`);
return r.ok ? r.json() : [];
} catch {
return [];
}
}
// Convenience: read a numeric value with a fallback.
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import { num } from '../api/useXplane.js';
// Autopilot / AFCS mode-control panel — styled like a Garmin GMC-710 mode
// controller: an annunciator bar on top (active = green, armed = white), a row
// of lit mode keys, and selectors (HDG / ALT / VS / IAS) with knob steppers.
// Buttons fire X-Plane's own autopilot commands; the sim stays the source of truth.
const AP_BITS = {
fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6,
nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18,
};
const on = (s, b) => (num(s) & b) !== 0;
export default function AutopilotPanel({ xp }) {
const { values: V, command, setDataref } = xp;
const s = num(V.apState);
const eng = num(V.apEngaged) > 0;
const lateral = on(s, AP_BITS.apr) ? 'APR' : on(s, AP_BITS.nav) ? 'NAV' : on(s, AP_BITS.hdg) ? 'HDG' : 'ROL';
const vertical = on(s, AP_BITS.flc) ? 'FLC' : on(s, AP_BITS.vs) ? 'VS' : on(s, AP_BITS.vnav) ? 'VNV' : on(s, AP_BITS.altHold) ? 'ALT' : 'PIT';
const Key = ({ label, cmd, active }) => (
<button className={`apk ${active ? 'on' : ''}`} onClick={() => command(cmd)}>{label}</button>
);
const Sel = ({ label, value, unit, alias, step, dn, up, pad }) => (
<div className="apsel">
<div className="apsel-lbl">{label}</div>
<div className="apsel-val">{pad ? String(((Math.round(value) % 360) + 360) % 360).padStart(3, '0') : Math.round(value)}<span>{unit}</span></div>
<div className="apsel-knob">
<button onClick={() => (dn ? command(dn) : setDataref(alias, value - step))}></button>
<span className="knobdot" />
<button onClick={() => (up ? command(up) : setDataref(alias, value + step))}></button>
</div>
</div>
);
return (
<div className="afcs">
<div className="afcs-unit">
{/* annunciator bar */}
<div className="afcs-ann">
<span className={`ann ${eng ? 'on' : ''}`}>AP</span>
<span className={`ann ${on(s, AP_BITS.fd) ? 'on' : ''}`}>FD</span>
<span className="ann-sep" />
<span className="ann mode on">{lateral}</span>
<span className="ann-gap" />
<span className="ann mode on">{vertical}</span>
<span className="ann val">{Math.round(num(V.apAltBug))}<i>FT</i></span>
</div>
{/* mode keys */}
<div className="afcs-keys">
<Key label="AP" cmd="apToggle" active={eng} />
<Key label="FD" cmd="fdToggle" active={on(s, AP_BITS.fd)} />
<Key label="HDG" cmd="hdg" active={on(s, AP_BITS.hdg)} />
<Key label="NAV" cmd="nav" active={on(s, AP_BITS.nav)} />
<Key label="APR" cmd="apr" active={on(s, AP_BITS.apr)} />
<Key label="BC" cmd="backCourse" active={on(s, AP_BITS.bc)} />
<Key label="ALT" cmd="altHold" active={on(s, AP_BITS.altHold)} />
<Key label="VS" cmd="vs" active={on(s, AP_BITS.vs)} />
<Key label="VNV" cmd="vnav" active={on(s, AP_BITS.vnav)} />
<Key label="FLC" cmd="flc" active={on(s, AP_BITS.flc)} />
</div>
{/* selectors */}
<div className="afcs-sels">
<Sel label="HDG" value={num(V.apHdgBug)} unit="°" pad dn="hdgDown" up="hdgUp" />
<Sel label="ALT" value={num(V.apAltBug)} unit="FT" dn="altDown" up="altUp" />
<Sel label="VS" value={num(V.apVsBug)} unit="FPM" alias="apVsBug" step={100} />
<Sel label="IAS" value={num(V.apSpdBug)} unit="KT" alias="apSpdBug" step={1} />
</div>
<div className="afcs-pitch">
<span>PITCH</span>
<button className="apk sm" onClick={() => command('noseUp')}>NOSE&nbsp;UP</button>
<button className="apk sm" onClick={() => command('noseDown')}>NOSE&nbsp;DN</button>
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
// The physical GDU bezel of X-Plane's "XPLANE 1000" (its G1000 clone):
// title bar, knob columns, the 12 softkeys along the bottom — and, on the MFD,
// the autopilot mode controller built into the left bezel (just like the sim).
//
// EVERY control is clickable and fires the matching real X-Plane command
// (sim/GPS/g1000n1_* on the PFD, g1000n3_* on the MFD) via xp.command().
//
// The PFD softkeys are a two-level menu, exactly like the real unit:
// root → [INSET · PFD · CDI · DME · XPDR · IDENT · TMR/REF · NRST · CAUTION]
// PFD → [PATHWAY · SYN TERR · HRZN HDG · APTSIGNS · … · BACK]
// SYN TERR toggles the 3D synthetic-vision terrain on/off.
const PFD_MENU = {
root: ['', 'INSET', '', 'PFD', '', 'CDI', 'DME', 'XPDR', 'IDENT', 'TMR/REF', 'NRST', 'CAUTION'],
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', '', '', '', '', '', '', '', 'BACK'],
// XPDR submenu: standby/on/alt modes, VFR (1200), CODE entry, IDENT.
xpdr: ['STBY', 'ON', 'ALT', 'VFR', '', 'CODE', 'IDENT', '', '', '', '', 'BACK'],
// CODE entry turns the softkeys into the octal squawk keypad (digits 07).
xpdrcode: ['0', '1', '2', '3', '4', '5', '6', '7', 'BKSP', '', 'BACK', ''],
// INSET submenu: on/off, declutter, base layer, OFF, back.
inset: ['INSET', 'DCLTR', '', 'TOPO', 'TERRAIN', '', '', '', '', '', 'OFF', 'BACK'],
};
// MFD softkeys are a two-level menu like the real unit. MAP opens the Map-Opt
// page; TOPO/TERRAIN/OSM switch the base map; BACK returns. (OSM is our tuned
// extra layer in an otherwise-empty slot.)
const MFD_MENU = {
root: ['SYSTEM', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
system: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
};
// autopilot_state bitfield (best-effort; tweak per aircraft)
const AP_BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18 };
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, onDirect, onProc, mapMode, onMapMode, children }) {
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
const [page, setPage] = useState('root'); // softkey menu page
const [squawk, setSquawk] = useState(''); // XPDR code being typed
const menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
const keys = menu[page] || menu.root;
const setBase = (b) => onMapMode && onMapMode((m) => ({ ...m, base: m.base === b ? 'dark' : b }));
const xpdrMode = num(xp?.values?.xpdrMode);
const setMode = (m) => xp && xp.setDataref('xpdrMode', m);
const typeDigit = (d) => {
const next = (squawk + d).slice(-4);
setSquawk(next);
if (next.length === 4) { // 4 octal digits → write & exit
xp && xp.setDataref('xpdrCode', parseInt(next, 10));
setSquawk(''); setPage('xpdr');
}
};
const onSoftkey = (i, label) => {
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
if (variant === 'mfd') {
if (label === 'MAP') setPage('mapopt');
else if (label === 'SYSTEM') setPage('system');
else if (label === 'BACK') setPage('root');
else if (label === 'TOPO') setBase('topo');
else if (label === 'TERRAIN') setBase('terrain');
else if (label === 'OSM') setBase('osm');
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
} else {
if (label === 'PFD') setPage('pfd');
else if (label === 'BACK') setPage(page === 'xpdrcode' ? 'xpdr' : 'root');
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
else if (label === 'INSET') {
if (page === 'root') { onSetInset && onSetInset(true); setPage('inset'); }
else onSetInset && onSetInset(!inset); // toggle from within the submenu
}
else if (label === 'OFF') { onSetInset && onSetInset(false); setPage('root'); }
else if (label === 'DCLTR') onInsetMode && onInsetMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: 'topo' }));
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
else if (label === 'NRST') onToggleNrst && onToggleNrst();
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
else if (label === 'XPDR') setPage('xpdr');
else if (label === 'STBY') setMode(1);
else if (label === 'ON') setMode(2);
else if (label === 'ALT') setMode(3);
else if (label === 'VFR') xp && xp.setDataref('xpdrCode', 1200);
else if (label === 'CODE') { setSquawk(''); setPage('xpdrcode'); }
else if (label === 'IDENT') xp && xp.command('xpdrIdent');
else if (label === 'BKSP') setSquawk((s) => s.slice(0, -1));
else if (page === 'xpdrcode' && /^[0-7]$/.test(label)) typeDigit(label);
}
};
// which softkey is "lit" right now
const isOn = (label) => {
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'DCLTR' && mapMode?.dcltr > 0);
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|| (label === 'STBY' && xpdrMode === 1) || (label === 'ON' && xpdrMode === 2) || (label === 'ALT' && xpdrMode === 3)
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
|| (label === 'DCLTR' && insetMode?.dcltr > 0);
};
return (
<div className="bezel">
<div className="bezel-knobs left">
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire}
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12" />
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire}
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
{variant === 'mfd' && xp && <APController xp={xp} />}
<Knob label="ALT" sub="" big fire={fire}
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
</div>
<div className="bezel-core">
<div className="bezel-title">XPLANE 1000</div>
<div className="bezel-screen">{children}</div>
{page === 'xpdrcode' && (
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
)}
<div className="softkeys">
{keys.map((s, i) => (
<button
key={i}
disabled={!s}
onClick={() => onSoftkey(i, s)}
className={`softkey ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}
>{s}</button>
))}
</div>
</div>
<div className="bezel-knobs right">
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire}
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12" />
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire}
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire}
outer={['range_up', 'range_down']} push="pan_push" pan />
<div className="bezel-grid">
<BtnG fire={fire} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} cmd="menu">MENU</BtnG>
<BtnG fire={fire} cmd="fpl">FPL</BtnG><BtnG fire={fire} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} cmd="clr">CLR</BtnG><BtnG fire={fire} cmd="ent">ENT</BtnG>
</div>
<Knob label="FMS" sub="PUSH CRSR" big fire={fire}
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor" />
</div>
</div>
);
}
function BtnG({ fire, cmd, onClick, children }) {
return <button className="bezel-btn sm" onClick={() => { fire(cmd); onClick && onClick(); }}>{children}</button>;
}
// Autopilot mode controller (left bezel of the MFD). Buttons fire real X-Plane
// commands; active modes light up from autopilot_state / servos_on.
function APController({ xp }) {
const st = num(xp.values.apState);
const on = (bit) => (st & bit) !== 0;
const eng = num(xp.values.apEngaged) > 0;
const B = ({ label, cmd, active }) => (
<button className={`ap-key ${active ? 'on' : ''}`} onClick={() => xp.command(cmd)}>{label}</button>
);
return (
<div className="ap-controller">
<B label="AP" cmd="apToggle" active={eng} />
<B label="FD" cmd="fdToggle" active={on(AP_BITS.fd)} />
<B label="HDG" cmd="hdg" active={on(AP_BITS.hdg)} />
<B label="ALT" cmd="altHold" active={on(AP_BITS.altHold)} />
<B label="NAV" cmd="nav" active={on(AP_BITS.nav)} />
<B label="VNV" cmd="vnav" active={on(AP_BITS.vnav)} />
<B label="APR" cmd="apr" active={on(AP_BITS.apr)} />
<B label="BC" cmd="backCourse" active={on(AP_BITS.bc)} />
<B label="VS" cmd="vs" active={on(AP_BITS.vs)} />
<B label="FLC" cmd="flc" active={on(AP_BITS.flc)} />
<B label="NOSE UP" cmd="noseUp" />
<B label="NOSE DN" cmd="noseDown" />
</div>
);
}
// Concentric G1000 knob. The outer ring rotates via the side arrows ( ) and
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
// pans with a directional cross.
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire }) {
const onWheel = (e) => {
if (!outer) return;
e.preventDefault();
const set = (e.shiftKey && inner) ? inner : outer;
fire(e.deltaY < 0 ? set[0] : set[1]);
};
return (
<div className={`knob-wrap ${big ? 'big' : ''}`}>
<span className="knob-lbl">{label}</span>
<div className="knob-cluster">
{inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
{outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}></button>}
<button
className={`knob outer ${joy ? 'joy' : ''}`}
onWheel={onWheel}
onClick={() => push && fire(push)}
title={push ? 'PUSH' : ''}
>
<span className="knob inner" />
{joy && <div className="joy-cross"></div>}
</button>
{outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}></button>}
{inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
</div>
{pan && (
<div className="pan-pad">
<button onClick={() => fire('pan_up')}></button>
<button onClick={() => fire('pan_left')}></button>
<button onClick={() => fire('pan_right')}></button>
<button onClick={() => fire('pan_down')}></button>
</div>
)}
{sub && <span className="knob-sub">{sub}</span>}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { num, navSearch } from '../api/useXplane.js';
// FMS as an X-Plane-style CDU/FMC: a green screen showing the active flight plan
// as legs, six line-select keys per side, a scratchpad, and an alphanumeric
// keypad. Edits go through the shared flight plan (the same one the PFD/MFD use).
//
// LSK (left, per row):
// • scratchpad has an ident → insert that waypoint at the row
// • DEL armed → delete the leg at the row
// • otherwise → make that leg the active (magenta) leg (Direct-To)
// EXEC exports the plan to X-Plane as an .fms file.
const R_NM = 3440.065, rad = (d) => d * Math.PI / 180, deg = (r) => r * 180 / Math.PI;
function distNm(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)));
}
function brng(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;
}
const ROWS = 5; // legs visible per page
export default function CDU({ xp }) {
const { flightPlan, fp, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
const [scr, setScr] = useState('');
const [del, setDel] = useState(false);
const [msg, setMsg] = useState('');
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil((wps.length + 1) / ROWS));
const start = page * ROWS;
const type = (ch) => { setMsg(''); setScr((s) => (s + ch).slice(0, 8)); };
const clr = () => { if (scr) setScr((s) => s.slice(0, -1)); else { setDel(false); setMsg(''); } };
// resolve an ident and splice it into the plan at `index`
const insertAt = async (ident, index) => {
const hits = await navSearch(ident);
const hit = hits[0];
if (!hit) { setMsg('NOT IN DATABASE'); return; }
const next = wps.slice();
next.splice(index, 0, { id: hit.id, lat: hit.lat, lon: hit.lon, type: hit.type || 'WPT', alt: null });
fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
setScr('');
};
const lsk = (rowIdx) => {
const i = start + rowIdx;
if (scr) { insertAt(scr, Math.min(i, wps.length)); return; }
if (del) { if (i < wps.length) fp.remove(i); setDel(false); return; }
if (i >= 1 && i < wps.length) fp.setActive(i);
};
const exec = () => { if (wps.length >= 2) fp.export('WEBFPL'); else setMsg('NEED 2 WAYPOINTS'); };
// build the visible rows
const rows = [];
for (let r = 0; r < ROWS; r++) {
const i = start + r;
if (i < wps.length) {
const w = wps[i], prev = wps[i - 1];
const d = prev ? distNm(prev, w) : 0;
const dtk = prev ? Math.round(brng(prev, w)) : null;
rows.push({ i, id: w.id, type: w.type, d, dtk, orig: i === 0, act: i === active });
} else if (i === wps.length) {
rows.push({ i, empty: true });
} else {
rows.push({ i, blank: true });
}
}
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const KEYS = [A.slice(0, 7), A.slice(7, 14), A.slice(14, 21), A.slice(21, 26).concat([' ']), ['1', '2', '3', '4', '5'], ['6', '7', '8', '9', '0']];
const Lsk = ({ side, r }) => <button className={`cdu-lsk ${side}`} onClick={() => lsk(r)} aria-label={`LSK ${r + 1}${side}`} />;
return (
<div className="cdu">
<div className="cdu-unit">
<div className="cdu-screenwrap">
<div className="cdu-lsks left">{[0, 1, 2, 3, 4].map((r) => <Lsk key={r} side="L" r={r} />)}</div>
<div className="cdu-screen">
<div className="cdu-hdr">
<span>{del ? 'DELETE' : 'ACT FPL'}</span>
<span>{page + 1}/{pages}</span>
</div>
<div className="cdu-cols"><span>WPT</span><span>DTK</span><span>DIST</span></div>
{rows.map((row) => (
<div className={`cdu-row ${row.act ? 'act' : ''}`} key={row.i}>
{row.blank ? <span className="cdu-empty">·</span>
: row.empty ? <span className="cdu-add">&lt;------ ENTER WPT</span>
: (<>
<span className="cdu-wpt">{row.id}<i>{row.type}</i></span>
<span className="cdu-dtk">{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}</span>
<span className="cdu-dist">{row.orig ? 'ORIG' : `${row.d.toFixed(1)}`}</span>
</>)}
</div>
))}
<div className="cdu-scratch">
<span className="cdu-sp">{scr || (del ? 'DELETE—SEL LEG' : '')}</span>
{msg && <span className="cdu-msg">{msg}</span>}
{exportMsg && !msg && <span className="cdu-msg ok">{exportMsg.ok ? 'EXPORTED ✓' : exportMsg.error}</span>}
</div>
</div>
<div className="cdu-lsks right">{[0, 1, 2, 3, 4].map((r) => <Lsk key={r} side="R" r={r} />)}</div>
</div>
<div className="cdu-fn">
<button className="cdu-k fn" onClick={() => setPage((p) => Math.max(0, p - 1))}>PREV</button>
<button className="cdu-k fn" onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}>NEXT</button>
<button className={`cdu-k fn ${del ? 'arm' : ''}`} onClick={() => { setDel((d) => !d); setScr(''); }}>DEL</button>
<button className="cdu-k fn" onClick={clr}>CLR</button>
<button className="cdu-k fn exec" onClick={exec}>EXEC</button>
</div>
<div className="cdu-pad">
{KEYS.map((rowK, ri) => (
<div className="cdu-padrow" key={ri}>
{rowK.map((k) => (
<button key={k} className="cdu-k" onClick={() => type(k === ' ' ? ' ' : k)}>{k === ' ' ? 'SP' : k}</button>
))}
</div>
))}
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useRef, useState } from 'react';
import { num, navSearch } from '../api/useXplane.js';
// G1000 Direct-To (D→) dialog. Type or pick a waypoint ident; ACTIVATE flies a
// direct magenta leg from the present position to it. We model that by setting
// the shared flight plan to [PPOS → target] (the map/HSI already draw the leg)
// and also firing the in-sim "direct" command so the real G1000 follows along.
const R_NM = 3440.065;
const rad = (d) => (d * Math.PI) / 180;
function distBrg(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;
const dist = 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
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));
const brg = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
return { dist, brg };
}
export default function DirectTo({ xp, onClose }) {
const { values, fp, command } = xp;
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
const [sel, setSel] = useState(null); // chosen { id, lat, lon, type }
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
// Live ident search against X-Plane's nav database.
useEffect(() => {
const q = entry.trim();
if (q.length < 2 || /[,\s]/.test(q)) { setHits([]); return; }
let alive = true;
navSearch(q).then((r) => alive && setHits(r.slice(0, 6)));
return () => { alive = false; };
}, [entry]);
const lat = num(values.lat), lon = num(values.lon);
const preview = sel && isFinite(lat) ? distBrg(lat, lon, sel.lat, sel.lon) : null;
const activate = () => {
if (!sel) return;
fp.set({ name: 'ACTIVE', waypoints: [
{ id: 'PPOS', lat, lon, type: 'USR' },
{ id: sel.id, lat: sel.lat, lon: sel.lon, type: sel.type || 'WPT' },
] });
command('direct'); // mirror to the in-sim G1000
onClose();
};
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="dlg dto" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head"><span className="dto-arrow">D</span> DIRECT TO</div>
<div className="dto-body">
<label className="dto-lbl">WAYPOINT</label>
<input
ref={inputRef}
className="dto-input"
value={entry}
onChange={(e) => { setEntry(e.target.value.toUpperCase()); setSel(null); }}
onKeyDown={(e) => { if (e.key === 'Enter' && sel) activate(); if (e.key === 'Escape') onClose(); }}
placeholder="IDENT (z.B. KSEA, SEA, ELN)"
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
/>
{hits.length > 0 && (
<div className="dto-hits">
{hits.map((h) => (
<button key={h.id + h.lat} className={sel && sel.id === h.id ? 'on' : ''}
onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
</button>
))}
</div>
)}
{sel && (
<div className="dto-sel">
<span className="dto-id">{sel.id}</span>
<span className="dto-type">{sel.type}</span>
{preview && <span className="dto-vec">{String(Math.round(preview.brg)).padStart(3, '0')}° · {preview.dist.toFixed(1)} NM</span>}
</div>
)}
</div>
<div className="dlg-actions">
<button className="fbtn" onClick={onClose}>CANCEL</button>
<button className="fbtn add" disabled={!sel} onClick={activate}>ACTIVATE</button>
</div>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { num, navSearch } from '../api/useXplane.js';
const R_NM = 3440.065;
const rad = (d) => (d * Math.PI) / 180;
const deg = (r) => (r * 180) / Math.PI;
function distNm(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)));
}
function bearing(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;
}
export default function FMS({ xp }) {
const { flightPlan, fp, values, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
// live ident search against X-Plane's nav database
useEffect(() => {
const q = entry.trim();
if (q.length < 2 || /[,\s]/.test(q)) { setHits([]); return; }
let alive = true;
navSearch(q).then((r) => alive && setHits(r.slice(0, 6)));
return () => { alive = false; };
}, [entry]);
const add = (id) => { fp.add(id || entry.trim()); setEntry(''); setHits([]); };
let total = 0;
const rows = wps.map((w, i) => {
const prev = wps[i - 1];
const d = prev ? distNm(prev, w) : 0;
const brg = prev ? bearing(prev, w) : null;
total += d;
return { w, i, d, brg };
});
const gs = num(values.groundspeed) * 1.94384;
const ete = gs > 20 ? total / gs : null; // hours
const active = Math.max(1, Math.min(wps.length - 1, flightPlan?.activeLeg ?? 1));
return (
<div className="fms">
<div className="fms-head">
<span>FLIGHT PLAN</span>
<span className="fms-total">
{total.toFixed(0)} NM{ete ? ` · ETE ${fmtHrs(ete)}` : ''}
</span>
</div>
<div className="fms-rows">
<div className="fms-row fms-colhead">
<span>#</span><span>WPT</span><span>DTK</span><span>DIST</span><span></span>
</div>
{rows.length === 0 && <div className="fms-empty">Kein Flugplan Wegpunkt eingeben oder auf der Map tippen.</div>}
{rows.map(({ w, i, d, brg }) => (
<div className={`fms-row ${i === 0 ? 'orig' : ''} ${i === active ? 'active' : ''}`} key={i}
onClick={() => i >= 1 && fp.setActive(i)} title={i >= 1 ? 'Als aktives Bein setzen' : ''}>
<span className="idx">{i + 1}</span>
<span className="wid">{w.id}<i className="wtype">{w.type}</i></span>
<span className="dtk">{brg == null ? '—' : `${String(Math.round(brg)).padStart(3, '0')}°`}</span>
<span className="dist">{i === 0 ? 'ORIG' : `${d.toFixed(1)}`}</span>
<button className="del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}></button>
</div>
))}
</div>
<div className="fms-scratch">
{hits.length > 0 && (
<div className="fms-hits">
{hits.map((h) => (
<button key={h.id + h.lat} onClick={() => add(h.id)}>
<b>{h.id}</b> <i>{h.type}</i> <span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
</button>
))}
</div>
)}
<div className="fms-input">
<input
value={entry}
onChange={(e) => setEntry(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && add()}
placeholder="IDENT (z.B. KSEA, SEA) oder LAT,LON"
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
/>
<button className="fbtn add" onClick={() => add()}>ADD</button>
</div>
<div className="fms-actions">
<button className="fbtn" onClick={() => fp.clear()} disabled={!wps.length}>CLEAR</button>
<button className="fbtn export" onClick={() => fp.export('WEBFPL')} disabled={wps.length < 2}>
EXPORT X-PLANE (.fms)
</button>
</div>
{exportMsg && (
<div className={`fms-export ${exportMsg.ok ? 'ok' : 'err'}`}>
{exportMsg.ok
? (exportMsg.intoXplane
? `✓ Gespeichert in X-Plane: ${shorten(exportMsg.file)} — im Flieger-FMS unter „Load" wählen.`
: `✓ Datei geschrieben: ${shorten(exportMsg.file)} (X-Plane-Ordner nicht gefunden — XPLANE_ROOT setzen).`)
: `${exportMsg.error}`}
</div>
)}
</div>
</div>
);
}
function fmtHrs(h) {
const m = Math.round(h * 60);
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
}
function shorten(p) {
return p && p.length > 48 ? '…' + p.slice(-46) : p;
}
+210
View File
@@ -0,0 +1,210 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.jsx';
const arr = (v, i = 0, d = 0) => (Array.isArray(v) ? num(v[i], d) : num(v, d));
const KG_PER_GAL = 2.72; // avgas
const navF = (v) => (num(v) / 100).toFixed(2);
const comF = (v) => (num(v) / 100).toFixed(3);
// G1000 MFD — full-width NAV/COM bar on top, the engine instrument strip (EIS)
// down the left as real bar gauges, and the moving map (X-Plane nav data) with
// G1000 chrome (compass rose, range, NORTH UP, mode) filling the rest.
export default function MFD({ values: V, flightPlan, fp, mapMode }) {
const [rangeNm, setRangeNm] = useState(8);
return (
<div className="mfd-g1000">
<MfdTopBar V={V} />
<div className="mfd-body">
<EisStrip V={V} />
<div className="mfd-map">
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
</div>
</div>
</div>
);
}
/* ---------------- top NAV/COM bar ---------------- */
function MfdTopBar({ V }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const trk = String(Math.round(num(V.track)) % 360).padStart(3, '0');
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="16" textAnchor="middle"></text>;
return (
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
<rect x="0" y="0" width="1000" height="70" fill="#000" />
{[300, 660].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="68" stroke="#333" strokeWidth="1.5" />)}
<line x1="0" y1="70" x2="1000" y2="70" stroke="#3a3a3a" strokeWidth="2" />
{/* NAV1 / NAV2 */}
<text x="10" y="27" fill="#fff" fontSize="13">NAV1</text>
<rect x="50" y="11" width="80" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1)}</text>
{swap(150, 27)}
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1Sb)}</text>
<text x="10" y="58" fill="#fff" fontSize="13">NAV2</text>
<text x="126" y="58" fill="#fff" fontSize="17" textAnchor="end">{navF(V.nav2)}</text>
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2Sb)}</text>
{/* centre: GS/DTK/TRK/ETE + active mode line */}
<text x="312" y="27" fill="#fff" fontSize="13">GS</text>
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
<text x="378" y="27" fill="#0c9" fontSize="11">KT</text>
<text x="410" y="27" fill="#fff" fontSize="13">DTK</text>
<text x="520" y="27" fill="#fff" fontSize="13">TRK</text>
<text x="560" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{trk}°</text>
<text x="610" y="27" fill="#fff" fontSize="13">ETE</text>
<text x="480" y="58" fill="#0ff" fontSize="15" textAnchor="middle">NAV DEFAULT NAV</text>
{/* COM1 / COM2 */}
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
{swap(818, 27)}
<rect x="846" y="11" width="92" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="936" y="27" fill="#0ff" fontSize="17" textAnchor="end">{comF(V.com1Sb)}</text>
<text x="994" y="27" fill="#fff" fontSize="12" textAnchor="end">COM1</text>
<text x="690" y="58" fill="#fff" fontSize="17">{comF(V.com2)}</text>
<text x="936" y="58" fill="#fff" fontSize="17" textAnchor="end">{comF(V.com2Sb)}</text>
<text x="994" y="58" fill="#fff" fontSize="12" textAnchor="end">COM2</text>
</svg>
);
}
/* ---------------- engine instrument strip (EIS) ---------------- */
function EisStrip({ V }) {
const rpm = arr(V.engRpm);
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
const oilPsi = arr(V.oilPress);
const oilF = arr(V.oilTemp) * 9 / 5 + 32;
const egtF = arr(V.egt) * 9 / 5 + 32;
const fuelL = arr(V.fuelQty, 0) / KG_PER_GAL;
const fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
const volts = arr(V.volts, 0, 28);
const amps = arr(V.amps);
return (
<svg className="eis-svg" viewBox="0 0 190 540" preserveAspectRatio="xMidYMin meet" fontFamily="monospace">
<rect x="0" y="0" width="190" height="540" fill="#0a0a0a" />
<RpmArc rpm={rpm} />
<Bar y={132} label="FFLOW GPH" val={ffGph.toFixed(1)} min={0} max={20} value={ffGph}
zones={[{ from: 0, to: 17, c: '#0c0' }, { from: 17, to: 20, c: '#c00' }]} />
<Bar y={170} label="OIL PSI" val={Math.round(oilPsi)} min={0} max={100} value={oilPsi}
zones={[{ from: 0, to: 20, c: '#c00' }, { from: 20, to: 100, c: '#0c0' }]} />
<Bar y={208} label="OIL °F" val={Math.round(oilF)} min={75} max={250} value={oilF}
zones={[{ from: 100, to: 245, c: '#0c0' }]} />
<Bar y={246} label="EGT °F" val={Math.round(egtF)} min={800} max={1650} value={egtF} zones={[]} />
<Bar y={284} label="VAC" min={0} max={10} value={5}
zones={[{ from: 4.5, to: 5.5, c: '#0c0' }]} />
<FuelBar y={330} left={fuelL} right={fuelR} />
<text x="8" y="412" fill="#39d3c0" fontSize="12">ENG</text>
<text x="182" y="412" fill="#fff" fontSize="14" textAnchor="end">0.0 HRS</text>
<text x="95" y="438" fill="#39d3c0" fontSize="12" textAnchor="middle"> ELECTRICAL </text>
<text x="20" y="462" fill="#fff" fontSize="12">M</text>
<text x="95" y="462" fill="#39d3c0" fontSize="12" textAnchor="middle">BUS</text>
<text x="170" y="462" fill="#fff" fontSize="12" textAnchor="end">E</text>
<text x="18" y="482" fill="#fff" fontSize="15">{volts.toFixed(1)}</text>
<text x="95" y="482" fill="#39d3c0" fontSize="11" textAnchor="middle">VOLTS</text>
<text x="172" y="482" fill="#fff" fontSize="15" textAnchor="end">{volts.toFixed(1)}</text>
<text x="20" y="506" fill="#fff" fontSize="12">M</text>
<text x="95" y="506" fill="#39d3c0" fontSize="12" textAnchor="middle">BATT</text>
<text x="170" y="506" fill="#fff" fontSize="12" textAnchor="end">S</text>
<text x="18" y="526" fill="#fff" fontSize="15">{amps >= 0 ? '+' : ''}{amps.toFixed(1)}</text>
<text x="95" y="526" fill="#39d3c0" fontSize="11" textAnchor="middle">AMPS</text>
<text x="172" y="526" fill="#fff" fontSize="15" textAnchor="end">+0.0</text>
</svg>
);
}
function Bar({ y, label, val, min, max, value, zones }) {
const x0 = 8, x1 = 182, bw = x1 - x0;
const px = (v) => x0 + bw * Math.max(0, Math.min(1, (v - min) / (max - min)));
const p = px(value);
return (
<g>
<text x={x0} y={y} fill="#39d3c0" fontSize="12">{label}</text>
{val != null && <text x={x1} y={y} fill="#fff" fontSize="16" fontWeight="bold" textAnchor="end">{val}</text>}
<rect x={x0} y={y + 9} width={bw} height="5" fill="#2a2a2a" />
{zones.map((z, i) => <rect key={i} x={px(z.from)} y={y + 9} width={Math.max(0, px(z.to) - px(z.from))} height="5" fill={z.c} />)}
<polygon points={`${p},${y + 9} ${p - 5},${y + 1} ${p + 5},${y + 1}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
</g>
);
}
// Fuel quantity: one bar per the C172's two tanks, with L and R pointers on a
// shared 01020F (gal) scale; yellow/red caution zone at the low end.
function FuelBar({ y, left, right }) {
const x0 = 8, x1 = 182, bw = x1 - x0, max = 26.5;
const px = (g) => x0 + bw * Math.max(0, Math.min(1, g / max));
const tick = (g, lbl) => (
<g key={lbl}>
<line x1={px(g)} y1={y + 16} x2={px(g)} y2={y + 20} stroke="#777" strokeWidth="1" />
<text x={px(g)} y={y + 31} fill="#aaa" fontSize="10" textAnchor="middle">{lbl}</text>
</g>
);
const ptr = (g, lbl) => (
<g>
<polygon points={`${px(g)},${y + 8} ${px(g) - 5},${y} ${px(g) + 5},${y}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
<text x={px(g)} y={y - 2} fill="#fff" fontSize="9" textAnchor="middle">{lbl}</text>
</g>
);
return (
<g>
<text x={x0} y={y - 6} fill="#39d3c0" fontSize="12">FUEL QTY GAL</text>
<rect x={x0} y={y + 8} width={bw} height="6" fill="#2a2a2a" />
<rect x={px(0)} y={y + 8} width={px(2.5) - px(0)} height="6" fill="#c00" />
<rect x={px(2.5)} y={y + 8} width={px(5) - px(2.5)} height="6" fill="#dd0" />
<rect x={px(5)} y={y + 8} width={px(max) - px(5)} height="6" fill="#0c0" />
{tick(0, '0')}{tick(8.83, '10')}{tick(17.66, '20')}{tick(max, 'F')}
{ptr(left, 'L')}{ptr(right, 'R')}
</g>
);
}
function RpmArc({ rpm }) {
const max = 2700, frac = Math.max(0, Math.min(1, rpm / max));
const a0 = -210, a1 = 30, ang = a0 + (a1 - a0) * frac;
const cx = 95, cy = 62, r = 42;
const pt = (deg, rr) => [cx + rr * Math.cos((deg * Math.PI) / 180), cy + rr * Math.sin((deg * Math.PI) / 180)];
const arc = (s, e, color, w) => {
const [x0, y0] = pt(s, r), [x1, y1] = pt(e, r);
return <path d={`M${x0} ${y0} A${r} ${r} 0 ${e - s > 180 ? 1 : 0} 1 ${x1} ${y1}`} fill="none" stroke={color} strokeWidth={w} />;
};
const [nx, ny] = pt(ang, r - 2);
return (
<g fontFamily="monospace">
{arc(a0, a1, '#2a2a2a', 7)}
{arc(a0, -30, '#0c0', 7)}
{arc(0, a1, '#c00', 7)}
<line x1={cx} y1={cy} x2={nx} y2={ny} stroke="#fff" strokeWidth="2.5" />
<circle cx={cx} cy={cy} r="3" fill="#fff" />
<text x={cx} y={cy + 14} fill="#39d3c0" fontSize="12" textAnchor="middle">RPM</text>
<text x={cx} y={cy + 40} fill="#fff" fontSize="26" fontWeight="bold" textAnchor="middle">{Math.round(rpm)}</text>
</g>
);
}
/* ---------------- map chrome overlay (compass rose / range / mode) ---------------- */
const NICE = [0.5, 1, 1.5, 2, 2.5, 4, 5, 7.5, 10, 15, 20, 25, 40, 50, 75, 100, 150, 200, 250, 500];
function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r = s; return r; }
function MapChrome({ V, rangeNm }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const rng = niceRange(rangeNm);
const cx = 160, cy = 160, r = 150;
const ticks = [];
for (let d = 0; d < 360; d += 10) {
const a = ((d - 90) * Math.PI) / 180;
const big = d % 30 === 0;
const r2 = r - (big ? 12 : 7);
ticks.push(<line key={d} x1={cx + r * Math.cos(a)} y1={cy + r * Math.sin(a)} x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#cfd6dd" strokeWidth={big ? 2 : 1} />);
if (big) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
ticks.push(<text key={'l' + d} x={cx + (r - 26) * Math.cos(a)} y={cy + (r - 26) * Math.sin(a) + 5} fill="#fff" fontSize="15" textAnchor="middle" fontFamily="monospace">{lbl}</text>);
}
}
return (
<div className="map-chrome">
<svg className="map-rose" viewBox="0 0 320 320">{ticks}</svg>
<div className="mc-tr"><b>{gs} KT</b><span>NORTH UP</span></div>
<div className="mc-range">{rng} NM</div>
<div className="mc-mode">NAV <em className="on" /><em /><em /><em /><em /></div>
</div>
);
}

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