500 lines
16 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
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))
|
|
})
|