Files
xplane-cockpit/desktop/ui/main.js
T
karim 5f63c5032c Desktop: first-run setup wizard + FlyWithLua/Web-API/Lua-status guidance
Adds the four onboarding pieces that were missing:
- flywithlua_present Tauri command + wizard step that checks the plugin and
  links the FlyWithLua NG+ download when it's absent.
- Wizard step explaining how to enable X-Plane's Web/REST API (Settings>Network).
- FlyWithLua-Sync status row in the live diagnostics, from /api/health.lua
  ('N Skripte aktiv' / 'FlyWithLua fehlt' / 'kein X-Plane').
- 4-step guided wizard (X-Plane folder → FlyWithLua → Web-API → install+start)
  that auto-opens on first launch and is reachable via the header Einrichten
  button; the final step hands off to the normal server start (auto-installs Lua).

Verified the wizard DOM flow + the dLua status against a live bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:43:32 +02:00

319 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 102465535'; 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; }
}
// Human-readable FlyWithLua-install status from /api/health.lua.
function luaText(lua) {
if (!lua) return { t: '—', cls: '' };
if (lua.reason === 'no-xplane') return { t: 'kein X-Plane', cls: 'warn' };
if (lua.reason === 'no-flywithlua') return { t: 'FlyWithLua fehlt', cls: 'bad' };
if (lua.reason === 'no-source') return { t: 'Skripte fehlen', cls: 'bad' };
const n = (lua.installed?.length || 0) + (lua.updated?.length || 0) + (lua.unchanged?.length || 0);
return { t: `${n} Skripte aktiv`, cls: 'ok' };
}
function pollHealth(port) {
if (healthTimer) clearInterval(healthTimer);
const dXp = $('dXp'), dClients = $('dClients'), dNav = $('dNav'), dRefs = $('dRefs'), dLua = $('dLua');
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;
if (dLua) { const l = luaText(d.lua); dLua.textContent = l.t; dLua.className = l.cls; }
} 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'));
/* ---------------- first-run setup wizard ---------------- */
const FWL_URL = 'https://github.com/X-Friese/FlyWithLua/releases';
let wizStep = 1;
const wiz = $('wizard');
function showStep(n) {
wizStep = Math.max(1, Math.min(4, n));
document.querySelectorAll('.wiz-step').forEach((s) => s.classList.toggle('hidden', +s.dataset.step !== wizStep));
document.querySelectorAll('.wiz-steps span').forEach((s) => s.classList.toggle('on', +s.dataset.s <= wizStep));
$('wizBack').disabled = wizStep === 1;
$('wizNext').textContent = wizStep === 4 ? (demoEl.checked || $('wizDemo').checked ? 'Demo starten' : 'Lua installieren & starten') : 'Weiter';
if (wizStep === 2) checkFwl();
}
function openWizard() {
wiz.classList.remove('hidden');
$('wizPath').value = xpPath.value || '';
$('wizDemo').checked = demoEl.checked;
validateWizPath();
showStep(1);
}
function closeWizard() { wiz.classList.add('hidden'); localStorage.setItem('setupDone', '1'); }
async function validateWizPath() {
const p = $('wizPath').value.trim();
const h = $('wizPathHint');
if (!p) { h.textContent = 'Leer = Demo-Modus möglich.'; h.className = 'hint'; return; }
const ok = await invoke('valid_xplane_path', { path: p });
h.textContent = ok ? '✓ X-Plane erkannt' : '⚠ kein „Resources/default data" — Pfad prüfen';
h.className = 'hint ' + (ok ? 'ok' : 'bad');
}
async function checkFwl() {
const el = $('wizFwl'); const p = $('wizPath').value.trim();
if (!p) { el.textContent = '— (kein X-Plane gewählt; im Demo-Modus nicht nötig)'; el.className = 'wiz-status'; return; }
const present = await invoke('flywithlua_present', { path: p });
el.textContent = present ? '✓ FlyWithLua ist installiert' : '✗ FlyWithLua nicht gefunden';
el.className = 'wiz-status ' + (present ? 'ok' : 'bad');
}
$('setupBtn').addEventListener('click', openWizard);
$('wizClose').addEventListener('click', closeWizard);
$('wizBrowse').addEventListener('click', async () => {
try { const dir = await T.dialog.open({ directory: true, multiple: false, title: 'X-Plane 12 Ordner wählen' });
if (dir) { $('wizPath').value = dir; validateWizPath(); } } catch (e) { appendLog('dialog: ' + e); }
});
$('wizPath').addEventListener('input', validateWizPath);
$('wizFwlCheck').addEventListener('click', checkFwl);
$('wizFwlLink').addEventListener('click', (e) => { e.preventDefault(); openUrl(FWL_URL); });
$('wizDemo').addEventListener('change', () => { demoEl.checked = $('wizDemo').checked; showStep(wizStep); });
$('wizBack').addEventListener('click', () => showStep(wizStep - 1));
$('wizNext').addEventListener('click', async () => {
if (wizStep < 4) { showStep(wizStep + 1); return; }
// final step: carry the chosen path/demo to the main controls and start
xpPath.value = $('wizPath').value.trim();
demoEl.checked = $('wizDemo').checked;
await validatePath();
$('wizResult').textContent = 'Starte Server …'; $('wizResult').className = 'wiz-status';
closeWizard();
if (!running) startBtn.click();
});
init();
// Offer the wizard automatically on the very first launch.
if (!localStorage.getItem('setupDone')) setTimeout(openWizard, 400);