Files
flying-jib/ui/app.js
T
2026-07-31 02:05:43 -04:00

500 lines
16 KiB
JavaScript

/**
* Flying Jib desktop GUI — loopback control API client (ADR-0014).
*/
const STATE_FILE = 'gui-control.json'
const PHASE_LABELS = {
idle: 'Idle',
'world-selected': 'Ready',
local: 'Local Squid',
hosting: 'Hosting',
joined: 'Joined',
error: 'Error',
starting: 'Starting…'
}
let control = null
let settings = null
let lastLogId = 0
let statusTimer = null
let logTimer = null
let busy = false
async function healthOk(meta) {
if (!meta?.baseUrl || !meta?.token) return null
const health = await fetch(`${meta.baseUrl}/health`).catch(() => null)
if (!health?.ok) return null
const body = await health.json().catch(() => ({}))
return {
baseUrl: meta.baseUrl,
token: meta.token,
port: meta.port,
phase: body.phase || 'idle'
}
}
async function getControlEndpoint() {
for (let i = 0; i < 50; i++) {
try {
const res = await fetch('/api/control-meta').catch(() => null)
if (res?.ok) {
const meta = await res.json().catch(() => null)
const ok = await healthOk(meta)
if (ok) return ok
}
} catch {
// not electron shell
}
try {
const storage = globalThis.Pear?.config?.storage
if (storage) {
const fs = await import('fs')
const path = await import('path')
const p = path.join(storage, STATE_FILE)
if (fs.existsSync(p)) {
const meta = JSON.parse(fs.readFileSync(p, 'utf8'))
const ok = await healthOk(meta)
if (ok) return ok
}
}
} catch {
// pear fs path unavailable
}
setSessionPill('starting', 'Connecting…')
await new Promise((r) => setTimeout(r, 100))
}
return null
}
function api(route, opts = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Flying-Jib-Token': control.token,
...(opts.headers || {})
}
return fetch(`${control.baseUrl}${route}`, { ...opts, headers }).then(async (res) => {
const body = await res.json().catch(() => ({}))
if (!res.ok || body.ok === false) {
throw new Error(body.error || `HTTP ${res.status}`)
}
return body.result
})
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function showBootError(msg) {
const el = document.getElementById('boot-error')
el.hidden = false
el.textContent = msg
}
function toast(message, kind = 'info') {
const root = document.getElementById('toast-root')
const el = document.createElement('div')
el.className = `toast ${kind}`
el.textContent = message
root.appendChild(el)
setTimeout(() => {
el.remove()
}, 4200)
}
function setSessionPill(phase, label) {
const pill = document.getElementById('session-pill')
const text = document.getElementById('session-pill-label')
pill.dataset.phase = phase || 'idle'
text.textContent = label || PHASE_LABELS[phase] || phase || 'Idle'
}
function setBusy(btn, on) {
busy = on
if (!btn) return
const label = btn.querySelector('.btn-label')
const spinner = btn.querySelector('.btn-spinner')
btn.disabled = on
if (label) label.hidden = on
if (spinner) spinner.hidden = !on
}
function showWorkspace() {
document.getElementById('workspace').hidden = false
document.getElementById('hero').classList.add('is-collapsed')
}
function setPanel(name) {
showWorkspace()
for (const tab of document.querySelectorAll('.tab')) {
const on = tab.dataset.panel === name
tab.classList.toggle('is-active', on)
tab.setAttribute('aria-selected', on ? 'true' : 'false')
}
for (const panel of document.querySelectorAll('.panel')) {
const on = panel.id === `panel-${name}`
panel.hidden = !on
panel.classList.toggle('is-active', on)
}
if (name === 'status') refreshStatus()
if (name === 'worlds' || name === 'host') refreshWorlds()
if (name === 'logs') refreshLogs(true)
if (name === 'settings') fillSettingsForm()
}
function applySettingsToForms() {
if (!settings) return
const ver = document.getElementById('create-version')
const hostPort = document.getElementById('host-port')
const hostDisplay = document.getElementById('host-display')
const joinPort = document.getElementById('join-port')
const joinDisplay = document.getElementById('join-display')
if (ver && !ver.dataset.touched) ver.value = settings.defaultVersion
if (hostPort && !hostPort.dataset.touched) hostPort.value = String(settings.defaultPort)
if (hostDisplay && !hostDisplay.dataset.touched) hostDisplay.value = settings.displayName
if (joinPort && !joinPort.dataset.touched) joinPort.value = String(settings.defaultPort)
if (joinDisplay && !joinDisplay.dataset.touched) joinDisplay.value = settings.displayName
}
function fillSettingsForm() {
if (!settings) return
document.getElementById('settings-display').value = settings.displayName || ''
document.getElementById('settings-port').value = String(settings.defaultPort || 25565)
document.getElementById('settings-version').value = settings.defaultVersion || '1.21.1'
document.getElementById('settings-ttl').value = String(settings.inviteTtlHours ?? 168)
}
async function loadSettings() {
settings = await api('/settings')
applySettingsToForms()
fillSettingsForm()
}
async function refreshWorlds() {
if (!control) return
const worlds = await api('/worlds')
const list = document.getElementById('world-list')
const select = document.getElementById('host-world')
list.innerHTML = ''
select.innerHTML = ''
if (!worlds.length) {
list.innerHTML = '<li class="empty">No worlds yet — create one above.</li>'
return
}
for (const w of worlds) {
const li = document.createElement('li')
li.innerHTML = `<div><strong>${escapeHtml(w.name)}</strong><span>${escapeHtml(w.version || '')} · ${escapeHtml(w.id)}</span></div>
<button type="button" class="btn ghost sm" data-host-world="${escapeHtml(w.id)}">Host</button>`
list.appendChild(li)
const opt = document.createElement('option')
opt.value = w.id
opt.textContent = `${w.name} (${w.version})`
select.appendChild(opt)
}
list.querySelectorAll('[data-host-world]').forEach((btn) => {
btn.addEventListener('click', () => {
select.value = btn.getAttribute('data-host-world')
setPanel('host')
})
})
}
function renderStatusCards(status) {
const phase = status.phase || 'idle'
const squid = status.squid || {}
const peers = Array.isArray(status.peers) ? status.peers : []
const cards = [
{ k: 'Phase', v: PHASE_LABELS[phase] || phase, cls: phase === 'hosting' || phase === 'joined' ? 'ok' : phase === 'idle' ? '' : 'warn' },
{ k: 'World', v: status.world || '—' },
{ k: 'Display name', v: status.displayName || '—' },
{
k: 'Squid',
v: squid.running ? `127.0.0.1:${squid.port || '?'}` : 'Stopped',
cls: squid.running ? 'ok' : ''
},
{
k: 'Tunnel',
v: status.tunnelHost ? 'Hosting' : status.tunnelClient ? 'Client' : 'None',
cls: status.tunnelHost || status.tunnelClient ? 'ok' : ''
},
{ k: 'Peers', v: String(peers.length) },
{ k: 'Mesh', v: status.mesh?.meshId || status.mesh?.key || '—' },
{ k: 'Storage', v: status.storage || '—' }
]
const root = document.getElementById('status-cards')
root.innerHTML = cards
.map(
(c) =>
`<div class="status-card"><span class="k">${escapeHtml(c.k)}</span><span class="v ${c.cls || ''}">${escapeHtml(c.v)}</span></div>`
)
.join('')
}
async function refreshStatus() {
if (!control) return
const pre = document.getElementById('status-pre')
try {
const status = await api('/status')
setSessionPill(status.phase || 'idle')
renderStatusCards(status)
pre.textContent = JSON.stringify(status, null, 2)
if (status.settings) {
settings = status.settings
document.getElementById('settings-storage').textContent = `Storage: ${status.storage || '—'}`
}
} catch (err) {
pre.textContent = String(err.message || err)
setSessionPill('error', 'Control error')
}
}
function appendLogLines(entries) {
const view = document.getElementById('log-view')
const autoscroll = document.getElementById('logs-autoscroll')?.checked
for (const e of entries) {
if (e.id <= lastLogId) continue
lastLogId = e.id
const row = document.createElement('div')
row.className = 'log-line'
const t = (e.at || '').slice(11, 19) || '—'
row.innerHTML = `<span class="t">${escapeHtml(t)}</span><span class="l ${escapeHtml(e.level || 'info')}">${escapeHtml(e.level || 'info')}</span><span class="m">${escapeHtml(e.message)}</span>`
view.appendChild(row)
}
if (autoscroll) view.scrollTop = view.scrollHeight
}
async function refreshLogs(full = false) {
if (!control) return
try {
const since = full ? 0 : lastLogId
if (full) {
document.getElementById('log-view').innerHTML = ''
lastLogId = 0
}
const entries = await api(`/logs?since=${since}`)
appendLogLines(entries)
} catch {
// ignore transient
}
}
async function pollHealth() {
if (!control) return
try {
const res = await fetch(`${control.baseUrl}/health`)
const body = await res.json()
if (body.phase) setSessionPill(body.phase)
} catch {
setSessionPill('error', 'Offline')
}
}
function startPolling() {
statusTimer = setInterval(() => {
pollHealth()
const statusPanel = document.getElementById('panel-status')
if (statusPanel && !statusPanel.hidden) refreshStatus().catch(() => {})
}, 2500)
logTimer = setInterval(() => {
refreshLogs(false).catch(() => {})
}, 1200)
}
async function boot() {
setSessionPill('starting', 'Connecting…')
control = await getControlEndpoint()
if (!control) {
setSessionPill('error', 'No backend')
showBootError('Could not reach Bare GUI control. Is the desktop backend running (npm start)?')
return
}
document.documentElement.dataset.fjControl = control.baseUrl
setSessionPill(control.phase || 'idle')
try {
await loadSettings()
} catch (err) {
toast(String(err.message || err), 'error')
}
document.querySelectorAll('[data-panel]').forEach((el) => {
el.addEventListener('click', () => setPanel(el.dataset.panel))
})
;['create-version', 'host-port', 'host-display', 'join-port', 'join-display'].forEach((id) => {
const el = document.getElementById(id)
el?.addEventListener('input', () => {
el.dataset.touched = '1'
})
})
document.getElementById('btn-refresh-worlds').addEventListener('click', () => {
refreshWorlds().catch((err) => toast(err.message, 'error'))
})
document.getElementById('form-create').addEventListener('submit', async (e) => {
e.preventDefault()
if (busy) return
const fd = new FormData(e.target)
const btn = document.getElementById('btn-create')
setBusy(btn, true)
try {
await api('/worlds', {
method: 'POST',
body: JSON.stringify({
name: fd.get('name'),
version: fd.get('version') || settings?.defaultVersion || '1.21.1'
})
})
e.target.reset()
applySettingsToForms()
await refreshWorlds()
toast('World created', 'success')
setPanel('host')
} catch (err) {
toast(String(err.message || err), 'error')
} finally {
setBusy(btn, false)
}
})
document.getElementById('form-host').addEventListener('submit', async (e) => {
e.preventDefault()
if (busy) return
const fd = new FormData(e.target)
const btn = document.getElementById('btn-host')
setBusy(btn, true)
try {
const result = await api('/host', {
method: 'POST',
body: JSON.stringify({
world: fd.get('world'),
port: Number(fd.get('port')) || settings?.defaultPort || 25565,
displayName: fd.get('displayName') || settings?.displayName || 'host',
noExpire: Boolean(fd.get('noExpire'))
})
})
const box = document.getElementById('host-result')
box.hidden = false
document.getElementById('host-invite').value = result.invite || ''
document.getElementById('host-hint').textContent =
`Squid on 127.0.0.1:${result.squid?.port || fd.get('port')}. Share the invite privately, then connect Java Edition there.`
setSessionPill('hosting')
toast('Hosting — invite ready', 'success')
refreshLogs(false)
} catch (err) {
toast(String(err.message || err), 'error')
} finally {
setBusy(btn, false)
}
})
document.getElementById('btn-copy-invite').addEventListener('click', async () => {
const text = document.getElementById('host-invite').value
try {
await navigator.clipboard.writeText(text)
toast('Invite copied', 'success')
} catch {
document.getElementById('host-invite').select()
toast('Select and copy the invite manually', 'info')
}
})
document.getElementById('form-join').addEventListener('submit', async (e) => {
e.preventDefault()
if (busy) return
const fd = new FormData(e.target)
const btn = document.getElementById('btn-join')
setBusy(btn, true)
try {
const result = await api('/join', {
method: 'POST',
body: JSON.stringify({
invite: String(fd.get('invite') || '').trim(),
port: Number(fd.get('port')) || settings?.defaultPort || 25565,
displayName: fd.get('displayName') || settings?.displayName || 'player'
})
})
const hint = document.getElementById('join-hint')
hint.hidden = false
hint.textContent = `Tunnel up. Minecraft → Multiplayer → Direct Connection → 127.0.0.1:${result.localPort || result.port || fd.get('port')}`
setSessionPill('joined')
toast('Joined — open Minecraft', 'success')
refreshLogs(false)
} catch (err) {
toast(String(err.message || err), 'error')
} finally {
setBusy(btn, false)
}
})
document.getElementById('btn-refresh').addEventListener('click', () => {
refreshStatus().catch((err) => toast(err.message, 'error'))
})
document.getElementById('btn-stop').addEventListener('click', async () => {
if (busy) return
try {
await api('/stop', { method: 'POST', body: '{}' })
document.getElementById('host-result').hidden = true
document.getElementById('join-hint').hidden = true
await refreshStatus()
toast('Session stopped', 'success')
} catch (err) {
toast(String(err.message || err), 'error')
}
})
document.getElementById('btn-clear-logs').addEventListener('click', async () => {
try {
await api('/logs/clear', { method: 'POST', body: '{}' })
lastLogId = 0
document.getElementById('log-view').innerHTML = ''
await refreshLogs(true)
} catch (err) {
toast(err.message, 'error')
}
})
document.getElementById('form-settings').addEventListener('submit', async (e) => {
e.preventDefault()
const fd = new FormData(e.target)
try {
settings = await api('/settings', {
method: 'POST',
body: JSON.stringify({
displayName: fd.get('displayName'),
defaultPort: Number(fd.get('defaultPort')),
defaultVersion: fd.get('defaultVersion'),
inviteTtlHours: Number(fd.get('inviteTtlHours'))
})
})
;['host-port', 'host-display', 'join-port', 'join-display', 'create-version'].forEach((id) => {
const el = document.getElementById(id)
if (el) delete el.dataset.touched
})
applySettingsToForms()
toast('Settings saved', 'success')
} catch (err) {
toast(String(err.message || err), 'error')
}
})
await refreshWorlds()
await refreshStatus()
await refreshLogs(true)
startPolling()
}
boot().catch((err) => {
setSessionPill('error', 'Boot failed')
showBootError(String(err.message || err))
})