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 @@
|
||||
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; }
|
||||