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:
@@ -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; }
|
||||
Reference in New Issue
Block a user