9aba24978b
FlyWithLua auto-install: bridge drops fms-sync/ui-sync/terrain-probe into X-Plane's FlyWithLua Scripts dir on startup and self-updates (content-compare). Graceful when no X-Plane / no FlyWithLua. /api/lua/install + status in health. Desktop app bundles the scripts and passes LUA_SRC_DIR to the sidecar. Smoothing: shared useEased/useEasedAngle hook (api/ease.js) with render-bail on settle. VFR steam gauges now interpolate to 60fps instead of stepping at the ~10Hz value stream. MFD ownship no longer vibrates — position/heading eased in a single rAF loop, follow-pan without animated-panTo pile-up (pauses on range zoom). Airspace overlay: server/airspace.js loads per-region GeoJSON, classifies (B/C/D/TMA/CTR/MOA/Restricted/Prohibited/Danger), bbox query, and downloads regions on demand — FAA (US, key-free) and OpenAIP (Europe, user key). New AIRSPACE softkey draws chart-coloured boundaries (B blue, C magenta, D dashed), non-interactive so map-clicks still drop waypoints. Launcher gains a "Lufträume" section to pick/download regions via the running bridge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
246 lines
9.8 KiB
JavaScript
246 lines
9.8 KiB
JavaScript
// 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, serverPort = 0;
|
||
|
||
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;
|
||
serverPort = info.port;
|
||
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);
|
||
loadRegions();
|
||
} 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;
|
||
serverPort = 0;
|
||
const ar = $('aspRegions'); if (ar) ar.innerHTML = '';
|
||
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);
|
||
}
|
||
|
||
/* ---------------- airspace regions ---------------- */
|
||
const aspBase = () => `http://127.0.0.1:${serverPort}/api/airspace`;
|
||
|
||
async function loadRegions() {
|
||
const wrap = $('aspRegions');
|
||
if (!wrap || !serverPort) return;
|
||
try {
|
||
const r = await fetch(`${aspBase()}/regions`, { cache: 'no-store' });
|
||
const { regions } = await r.json();
|
||
wrap.innerHTML = '';
|
||
for (const reg of regions) {
|
||
const row = document.createElement('div');
|
||
row.className = 'asp-row';
|
||
const installed = reg.installed > 0;
|
||
row.innerHTML = `
|
||
<span class="asp-name">${reg.label}${reg.needsKey ? ' <em>· Key</em>' : ''}</span>
|
||
<span class="asp-count">${installed ? reg.installed + ' Zonen' : '—'}</span>
|
||
<button class="btn ghost sm" data-region="${reg.id}" data-key="${reg.needsKey ? 1 : 0}">${installed ? 'Aktualisieren' : 'Laden'}</button>`;
|
||
wrap.appendChild(row);
|
||
}
|
||
wrap.querySelectorAll('button[data-region]').forEach((btn) =>
|
||
btn.addEventListener('click', () => installRegion(btn)));
|
||
} catch (e) { appendLog('airspace: ' + e); }
|
||
}
|
||
|
||
async function installRegion(btn) {
|
||
const region = btn.dataset.region;
|
||
const needsKey = btn.dataset.key === '1';
|
||
const apiKey = $('aspKey').value.trim();
|
||
const hint = $('aspHint');
|
||
if (needsKey && !apiKey) { hint.textContent = '⚠ OpenAIP-API-Key oben eingeben'; hint.className = 'hint bad'; return; }
|
||
btn.disabled = true; const was = btn.textContent; btn.textContent = 'Lädt…';
|
||
hint.textContent = `Lade ${region.toUpperCase()} … (Fortschritt im Server-Log)`; hint.className = 'hint';
|
||
try {
|
||
const r = await fetch(`${aspBase()}/install`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ region, apiKey: needsKey ? apiKey : undefined }),
|
||
});
|
||
const d = await r.json();
|
||
if (d.ok) { hint.textContent = `✓ ${region.toUpperCase()}: ${d.features} Zonen geladen`; hint.className = 'hint ok'; }
|
||
else { hint.textContent = '⚠ ' + (d.error || 'Fehler'); hint.className = 'hint bad'; }
|
||
} catch (e) { hint.textContent = '⚠ ' + e; hint.className = 'hint bad'; }
|
||
finally { btn.disabled = false; btn.textContent = was; loadRegions(); }
|
||
}
|
||
|
||
$('aspKeyLink')?.addEventListener('click', (e) => { e.preventDefault(); openUrl('https://www.openaip.net/'); });
|
||
|
||
$('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();
|