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>
This commit is contained in:
2026-06-01 15:07:03 +02:00
commit ebc33a78b7
110 changed files with 14671 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Build the Linux AppImage + .deb (x86_64) in Docker. Run from the repo root,
# AFTER scripts/prep-desktop.sh has produced the linux sidecar + web resources.
# On Apple Silicon this runs under qemu (linux/amd64) and is slow but works.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
IMG=xpcockpit-linux-builder
echo "==> building docker image ($IMG)"
docker build --platform linux/amd64 -f desktop/Dockerfile.linux -t "$IMG" desktop >/dev/null
# The updater signing key is a secret and is NOT in the repo. If it's present
# locally we sign + emit updater artifacts; otherwise we build plain installable
# bundles (appimage + deb) with updater artifacts disabled via a config override.
SIGN_ENV=(); BUNDLES="appimage,deb,updater"; CFG=""
if [[ -f desktop/.tauri-signing.key && -f desktop/.tauri-signing.pw ]]; then
SIGN_ENV=(-e "TAURI_SIGNING_PRIVATE_KEY=$(cat desktop/.tauri-signing.key)"
-e "TAURI_SIGNING_PRIVATE_KEY_PASSWORD=$(cat desktop/.tauri-signing.pw)")
echo "==> signing key found — emitting signed updater artifacts"
else
BUNDLES="appimage,deb"; CFG='--config {"bundle":{"createUpdaterArtifacts":false}}'
echo "==> no signing key — building plain appimage + deb (no updater artifacts)"
fi
echo "==> tauri build (x86_64-unknown-linux-gnu) in container — this takes a while under emulation"
docker run --rm --platform linux/amd64 \
-v "$ROOT":/work \
-e CARGO_TARGET_DIR=/work/target-linux \
-e APPIMAGE_EXTRACT_AND_RUN=1 \
-e ARCH=x86_64 \
"${SIGN_ENV[@]}" \
-w /work/desktop \
"$IMG" \
bash -c "export PATH=/usr/local/cargo/bin:\$PATH; tauri build --target x86_64-unknown-linux-gnu --bundles $BUNDLES $CFG"
echo "==> artifacts:"
find target-linux/x86_64-unknown-linux-gnu/release/bundle -maxdepth 2 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.AppImage.sig' -o -name '*.tar.gz' -o -name '*.sig' \) 2>/dev/null
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Prepare the Tauri bundle inputs: build the web cockpit, copy it in as a
# resource, and compile the Node bridge into Bun single-file sidecars named with
# the Tauri target triples. Run from the repo root.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
export PATH="$HOME/.bun/bin:$PATH"
echo "==> building web cockpit"
( cd web && npm run build >/dev/null )
echo "==> copying cockpit into desktop resources"
rm -rf desktop/src-tauri/resources/web
mkdir -p desktop/src-tauri/resources/web
cp -R web/dist/. desktop/src-tauri/resources/web/
echo "==> compiling bridge sidecars (Bun)"
mkdir -p desktop/src-tauri/binaries
bun build --compile --target=bun-darwin-arm64 server/bridge.js \
--outfile desktop/src-tauri/binaries/xpbridge-aarch64-apple-darwin
bun build --compile --target=bun-linux-x64-baseline server/bridge.js \
--outfile desktop/src-tauri/binaries/xpbridge-x86_64-unknown-linux-gnu
chmod +x desktop/src-tauri/binaries/xpbridge-*
echo "==> done"
ls -lh desktop/src-tauri/binaries/
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env node
// Publish a release to Gitea and (re)write the updater's latest.json.
//
// Reads the built artifacts (macOS .app.tar.gz + .sig, Linux .AppImage + .sig,
// plus .dmg / .deb for manual install), creates a versioned release with those
// assets, then builds latest.json (Tauri v2 updater format) pointing at the
// release download URLs and uploads it to a fixed-tag "updater" release so the
// app's updater endpoint URL stays constant.
//
// Env: GITEA_URL (e.g. https://git.kgva.ch), GITEA_REPO (owner/name),
// GITEA_TOKEN (or a token file at /tmp/gitea_token).
import fs from 'node:fs';
import path from 'node:path';
const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const GITEA_URL = (process.env.GITEA_URL || 'https://git.kgva.ch').replace(/\/$/, '');
const REPO = process.env.GITEA_REPO || 'karim/xplane-cockpit';
const TOKEN = process.env.GITEA_TOKEN
|| (fs.existsSync('/tmp/gitea_token') ? fs.readFileSync('/tmp/gitea_token', 'utf8').trim() : '');
const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'desktop/src-tauri/tauri.conf.json'), 'utf8')).version;
const UPDATER_TAG = 'updater';
if (!TOKEN) { console.error('No GITEA_TOKEN (env or /tmp/gitea_token).'); process.exit(1); }
const api = (p, opts = {}) => fetch(`${GITEA_URL}/api/v1${p}`, {
...opts,
headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json', ...(opts.headers || {}) },
});
// Collect built artifacts that exist (mac and/or linux builds may have run).
function findArtifacts() {
const macBundle = path.join(ROOT, 'desktop/src-tauri/target/aarch64-apple-darwin/release/bundle');
const linBundle = path.join(ROOT, 'target-linux/x86_64-unknown-linux-gnu/release/bundle');
const out = { assets: [], updater: {} };
const add = (file, platformKey) => {
if (!fs.existsSync(file)) return;
out.assets.push(file);
if (platformKey) {
const sig = file + '.sig';
if (fs.existsSync(sig)) out.updater[platformKey] = { file, sig: fs.readFileSync(sig, 'utf8').trim() };
}
};
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) : [];
// macOS
glob1(path.join(macBundle, 'macos'), /\.app\.tar\.gz$/).forEach((f) => add(f, 'darwin-aarch64'));
glob1(path.join(macBundle, 'dmg'), /\.dmg$/).forEach((f) => out.assets.push(f));
// Linux
glob1(path.join(linBundle, 'appimage'), /\.AppImage$/).forEach((f) => add(f, 'linux-x86_64'));
glob1(path.join(linBundle, 'deb'), /\.deb$/).forEach((f) => out.assets.push(f));
return out;
}
async function getReleaseByTag(tag) {
const r = await api(`/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`);
return r.ok ? r.json() : null;
}
async function ensureRelease(tag, name, body) {
let rel = await getReleaseByTag(tag);
if (rel) return rel;
const r = await api(`/repos/${REPO}/releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tag_name: tag, name, body, draft: false, prerelease: false }),
});
if (!r.ok) throw new Error(`create release ${tag}: ${r.status} ${await r.text()}`);
return r.json();
}
async function uploadAsset(rel, file, asName) {
const name = asName || path.basename(file);
// delete an existing asset with the same name first (Gitea won't overwrite)
for (const a of rel.assets || []) {
if (a.name === name) await api(`/repos/${REPO}/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' });
}
const blob = new Blob([fs.readFileSync(file)]);
const fd = new FormData();
fd.append('attachment', blob, name);
const r = await api(`/repos/${REPO}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: fd });
if (!r.ok) throw new Error(`upload ${name}: ${r.status} ${await r.text()}`);
console.log(' ↑', name);
return r.json();
}
const dlUrl = (tag, name) => `${GITEA_URL}/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`;
async function main() {
const { assets, updater } = findArtifacts();
if (!assets.length) { console.error('No build artifacts found — run the builds first.'); process.exit(1); }
console.log(`Release v${VERSION}${GITEA_URL}/${REPO}`);
console.log('Artifacts:', assets.map((a) => path.basename(a)).join(', '));
const verTag = `v${VERSION}`;
const rel = await ensureRelease(verTag, `X-Plane Cockpit ${verTag}`, `Automated release ${verTag}.`);
for (const f of assets) await uploadAsset(rel, f);
const relFresh = await getReleaseByTag(verTag); // refresh asset list
// Build latest.json referencing this release's updater artifacts.
const platforms = {};
for (const [key, { file, sig }] of Object.entries(updater)) {
platforms[key] = { signature: sig, url: dlUrl(verTag, path.basename(file)) };
}
const latest = {
version: VERSION,
notes: `X-Plane Cockpit ${verTag}`,
pub_date: new Date().toISOString(),
platforms,
};
const latestPath = path.join(ROOT, 'desktop/latest.json');
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
console.log('latest.json platforms:', Object.keys(platforms).join(', ') || '(none)');
// Upload latest.json to the fixed "updater" release (constant endpoint URL).
const upd = await ensureRelease(UPDATER_TAG, 'Updater channel', 'Rolling pointer used by the in-app updater.');
await uploadAsset(upd, latestPath, 'latest.json');
console.log('Updater endpoint:', dlUrl(UPDATER_TAG, 'latest.json'));
}
main().catch((e) => { console.error(e); process.exit(1); });