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>
@@ -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
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# X-Plane Glass Cockpit (Web)
|
||||||
|
|
||||||
|
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 (~10–20 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.
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU5MzFGQTUzOEUyOURFOTkKUldTWjNpbU9VL294V1ZWZllVMzc5MGR6OVFVcGRkSTVkcG1LUDJXODJzT2psbFZoY2JYT0E3dEIK
|
||||||
@@ -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
|
||||||
@@ -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`).
|
||||||
|
After Width: | Height: | Size: 18 KiB |
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
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>
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 893 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -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(©)
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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 1024–65535'; 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();
|
||||||
@@ -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; }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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/
|
||||||
@@ -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); });
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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 10–20 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}`;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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 UP</button>
|
||||||
|
<button className="apk sm" onClick={() => command('noseDown')}>NOSE DN</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 0–7).
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"><------ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 0–10–20–F (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>
|
||||||
|
);
|
||||||
|
}
|
||||||