Galaxy UI

Add Galaxy web assets and client settings interfaces with their bundled vendor notices.

Co-authored-by: Danny <dannyirwin31@yahoo.com>
Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
Co-authored-by: firestarsdog <229254897+firestarsdog@users.noreply.github.com>

Co-authored-by: Dom <168790843+firestar5683@users.noreply.github.com>
Co-authored-by: prabhaavp <prabhaav@msu.edu>
This commit is contained in:
firestar5683
2026-09-25 14:53:21 -05:00
parent 00364fd645
commit fab3ec9aeb
119 changed files with 35355 additions and 22 deletions
@@ -0,0 +1,29 @@
"""Galaxy-only, next-drive developer preferences."""
from openpilot.starpilot.longitudinal.voacc import KEY, DESCRIPTION
from openpilot.starpilot.saved_document import commit_exact
from openpilot.starpilot.saved_source import read_saved
from openpilot.starpilot.ui.feature_settings_state import FeatureRow, FeatureSettingsState
class DeveloperSettingsOwner:
def __init__(self, params, authorized):
self.params = params
self.authorized = authorized
def snapshot(self):
raw, readable = read_saved(self.params, KEY, 8)
valid = readable and raw in (None, b"0", b"1")
parked = self.authorized()
row = FeatureRow(KEY, "Use VOACC", ("On" if raw == b"1" else "Off") if valid else "Invalid saved value",
raw, ("Off", "On") if valid else (), available=parked and valid,
reason=(DESCRIPTION if parked else "Park the vehicle to change this setting.") if valid else
"Invalid saved value", default_value="Off")
return FeatureSettingsState("developer", "Developer driving options", "Changes apply next drive.", (row,), parked)
def apply(self, request):
if request.key != KEY or request.value not in ("Off", "On") or request.expected not in (None, b"0", b"1"):
return False
result = commit_exact(self.params, key=KEY, max_bytes=8, raw=b"1" if request.value == "On" else b"0",
expected=request.expected, authorized=self.authorized, temp_prefix=".voacc-")
return result.committed and result.verified
@@ -0,0 +1,71 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true,
...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) })
try {
const page = await browser.newPage()
const errors = [], saves = []
let enabled = false, parked = true
page.on('pageerror', error => errors.push(error.message))
await page.route('**/developer-settings-test', route => route.fulfill({ contentType: 'text/html', body:
'<link rel="stylesheet" href="css/galaxy.css"><link rel="stylesheet" href="css/settings.css"><div id="test"></div>' }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
let data
if (path.endsWith('/connect/provider')) data = { active: 'comma', selected: 'comma', revision: 'a'.repeat(64),
restartRequired: false, canSelect: parked, providers: [
{ id: 'comma', label: 'comma', url: 'https://connect.comma.ai' },
{ id: 'konik', label: 'Konik', url: 'https://stable.konik.ai' }] }
else if (path.endsWith('/pages/developer')) data = { page: 'developer', title: 'Developer driving options',
view: 'developer', parked, rows: [{ label: 'Use VOACC', value: enabled ? 'On' : 'Off', choices: ['Off', 'On'],
available: parked, action: true, reason: 'Vision-only lead selection. Applies next drive.', defaultValue: 'Off' }] }
else if (path.endsWith('/pages/hub')) data = { page: 'hub', title: 'Driving Controls', view: 'hub', parked,
rows: [['slc', 'Speed Limit Controller'], ['curve', 'Curve Speed Controller'], ['conditional', 'Conditional Driving Modes'],
['profiles', 'Long Planner'], ['aggressive', 'Aggressive Personality']].map(([page, label]) => ({ page, label, available: true })) }
else if (path.endsWith('/preview')) {
saves.push(route.request().postDataJSON())
data = { intent: 'voacc', proposed: 'On', question: 'Set Use VOACC to On?' }
} else if (path.endsWith('/confirm')) { enabled = true; data = { saved: true } }
else return route.fulfill({ status: 404, json: { error: 'Unexpected endpoint' } })
return route.fulfill({ json: data })
})
await page.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + 'developer-settings-test')
await page.evaluate(async () => {
const { createApp } = await import('./vendor/vue/vue.esm-browser.js')
const { SettingsPage } = await import('./js/settings.js')
window.mount = section => {
window.app?.unmount()
window.app = createApp(SettingsPage, { mode: 'local', unauthorized() {}, initialSection: section })
window.vm = window.app.mount('#test')
}
mount('developer')
})
const toggle = page.getByRole('switch', { name: 'Use VOACC', exact: true })
await toggle.waitFor()
assert.equal(await toggle.isChecked(), false)
await toggle.click()
await page.waitForFunction(() => vm.state.status === 'ready' && vm.state.data.rows[0].value === 'On')
assert.equal(await toggle.isChecked(), true)
assert.equal(saves[0].value, 'On')
for (const width of [320, 1280]) {
await page.setViewportSize({ width, height: 900 })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
}
await page.getByRole('button', { name: 'Longitudinal (Speed & Following)', exact: true }).click()
await page.getByText('Long Planner', { exact: true }).waitFor()
await page.waitForFunction(() => document.querySelectorAll('.gx-panel-stage .gx-row').length === 5)
const order = await page.locator('.gx-panel-stage .gx-row').allTextContents()
assert.equal(order.length, 5)
for (const [index, label] of ['Long Planner', 'Conditional Driving Modes', 'Curve Speed Controller',
'Speed Limit Controller', 'Aggressive Personality'].entries()) assert.ok(order[index].includes(label))
assert.equal(await toggle.count(), 0)
parked = false
await page.getByRole('button', { name: 'Developer', exact: true }).click()
await toggle.waitFor()
assert.equal(await toggle.isDisabled(), true)
assert.deepEqual(errors, [])
console.log('Developer VOACC: saved toggle, parked guard, phone/desktop layout and longitudinal ordering passed.')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,94 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const { execFileSync } = require('node:child_process')
const { readFileSync, mkdirSync } = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const web = path.resolve(__dirname, '../../web')
const fixtures = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c', `
import json
from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata
from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, layout_metadata_for_viewport
doc=default_document()
for layout in doc['layouts'].values():
for widget in layout.values(): widget['enabled']=True
native=dict(document=doc,defaults=doc,metadata=customization_metadata(),revision='scenes',editable=True,valid=True,activeProfile='large',supportedProfiles=['large','compact'])
layout=default_layout_for_viewport((2880,1080))
for widget in layout['widgets'].values(): widget['enabled']=True
projection=dict(version=1,document=layout,defaults=layout,metadata=layout_metadata_for_viewport((2880,1080)),screen=dict(width=1280,height=720,margin_width=0,margin_height=240),revision='aa-scenes',editable=True,valid=True,colors=dict(palette=doc['palette'],widgetColors=doc['widgetColors']['large'],roadColors={}))
print(json.dumps(dict(native=native,projection=projection)))
`], { encoding: 'utf8' }))
;(async () => {
const browser = await chromium.launch({ headless: true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) })
const output = process.env.SCENE_SCREENSHOTS
const nativeArt = process.env.NATIVE_SCENE_ART
if (output) mkdirSync(output, { recursive: true })
try {
const page = await browser.newPage({ viewport: { width: 1280, height: 1000 } })
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('http://scenes.test/**', async route => {
const pathname = new URL(route.request().url()).pathname
if (pathname === '/api/ui/layout/preview') {
if (!nativeArt) return route.fulfill({ status: 503, json: { error: 'Scene sketch test' } })
const { profile, scene, art } = route.request().postDataJSON()
return route.fulfill({ contentType: 'image/png', body: readFileSync(path.join(nativeArt, `${profile}-${scene}-${art === true ? 'art' : 'backdrop'}.png`)) })
}
if (pathname.startsWith('/api/')) return route.fulfill({ json: pathname === '/api/android-auto/layout' ? fixtures.projection : fixtures.native })
if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: `
<meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/css/galaxy.css"><link rel="stylesheet" href="/css/settings.css">
<main class="gx-content"><div id="app"></div></main><script type="module">
import {createApp} from '/vendor/vue/vue.esm-browser.js'; import {OnroadLayoutPage} from '/js/onroad-layout.js';
window.editor=createApp(OnroadLayoutPage,{mode:'local',projection:location.search.includes('projection'),unauthorized(){}}).mount('#app');</script>` })
return route.fulfill({ body: readFileSync(path.join(web, pathname)), contentType: pathname.endsWith('.js') ? 'text/javascript' : pathname.endsWith('.css') ? 'text/css' : 'application/octet-stream' })
})
for (const target of ['large', 'compact', 'projection']) {
await page.goto('http://scenes.test/' + (target === 'projection' ? '?projection' : ''))
await page.locator('.gx-layout__layer').first().waitFor()
if (target === 'compact') await page.getByRole('button', { name: /^Small/ }).click()
const scenes = await page.evaluate(() => window.editor.availableScenes)
const original = await page.evaluate(() => JSON.stringify(window.editor.state.draft))
for (const scene of scenes) {
// Choose scenes through the real accessible dropdown.
await page.getByRole('combobox', { name: 'Preview scene', exact: true }).click()
await page.getByRole('option', { name: scene.label, exact: true }).click()
// After the first render, a scene switch keeps native artwork up instead of falling back to outlines.
if (nativeArt && scene !== scenes[0]) assert.equal(await page.locator('[data-preview-source="placeholder"]').count(), 0, `${target}/${scene.id}: no outlines while loading`)
if (nativeArt) await page.waitForFunction(() => window.editor.state.backdrop.scene === window.editor.state.scene &&
window.editor.state.backdrop.status === 'ready' && window.editor.state.art.scene === window.editor.state.scene && window.editor.state.art.status === 'ready')
const state = await page.evaluate(() => ({ scene: window.editor.state.scene, border: window.editor.sceneBorder,
visible: window.editor.renderWidgets.map(widget => widget.id), draft: JSON.stringify(window.editor.state.draft),
general: window.editor.layerWidgets.map(widget => widget.id), hidden: window.editor.layerWidgets.filter(widget => window.editor.sceneHidden(widget.id)).map(widget => widget.id) }))
assert.equal(state.scene, scene.id)
assert.equal(state.draft, original, `${target}/${scene.id}: scene changes never edit the saved layout`)
if (nativeArt) assert.equal(await page.locator('[data-preview-source="device-backdrop"]').count(), 1)
else assert.equal(await page.locator('[data-preview-source="scene-border"]').getAttribute('stroke'), state.border)
if (scene.id === 'general') assert.deepEqual([...state.visible].sort(), [...state.general].sort())
for (const id of state.hidden) {
assert(!state.visible.includes(id), `${target}/${scene.id}/${id}: hidden widgets have no hit box`)
const name = await page.evaluate(id => window.editor.profile.widgets[id].label, id)
assert.equal(await page.getByRole('button', { name: `${name} is hidden in this scene. Choose General edit to arrange it.`, exact: true }).isDisabled(), true)
}
if (target !== 'large') assert.equal(state.visible.includes('stopped_timer'), ['general', 'stopped'].includes(scene.id))
if (scene.id === 'slc_pending') {
assert.equal(state.visible.includes('speed_limit_actions'), true)
const actions = page.locator('.gx-layout__widget[aria-label^="Speed limit actions"]')
assert.equal(await actions.locator(`[data-preview-source="${nativeArt ? 'runtime' : 'placeholder'}"]`).count(), 1)
} else if (scene.id !== 'general') {
assert.equal(await page.locator('.gx-layout-widget-art').getByText('Apply 54', { exact: true }).count(), 0)
}
if (output) await page.locator('svg.gx-layout__preview').screenshot({ path: path.join(output, `${target}-${scene.id}.png`) })
}
await page.evaluate(() => { window.editor.state.scene = 'general'; window.editor.togglePreviewVisibility('driver_monitor'); window.editor.state.scene = 'engaged' })
assert.equal(await page.evaluate(() => window.editor.state.hiddenPreviewWidgets[window.editor.state.profile].driver_monitor), true)
assert.equal(await page.evaluate(() => window.editor.sceneHidden('driver_monitor') || !window.editor.previewHidden('driver_monitor')), true)
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 900 })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
}
}
assert.deepEqual(errors, [])
console.log('All three layout scene menus, border colors, automatic eyes, timer/CEM/actions, scene isolation, draft preservation and mobile sizing passed.')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exit(1) })
File diff suppressed because one or more lines are too long
@@ -178,7 +178,7 @@ assert.equal(localStates.at(-1).localAccess, false)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
assert.match(app, /authState\.status === 'authenticated' && !authState\.localAccess/)
assert.match(app, /state\.monitorMode === 'local' && authState\.status !== 'authenticated'/)
assert.match(app, /sessionExpired\(\) \{ auth\.expired\(\); auth\.check\(\) \}/)
assert.match(app, /sessionExpired\(\) \{ if \(initializing\) return; auth\.expired\(\); initialize\(\) \}/)
const oldMonitor = deferred(), nextMonitor = deferred(), lateStates = []
let lateUnauthorized = 0, monitorRequests = 0
@@ -0,0 +1,80 @@
import assert from "node:assert/strict"
import { SettingsPage } from "../web/js/settings.js"
import { OnroadLayoutPage } from "../web/js/onroad-layout.js"
const listeners = new Map()
globalThis.window = { scrollTo() {}, addEventListener(name, callback) { listeners.set(name, callback) } }
let current = new URL("https://galaxy.test/app#/cameras/pip")
globalThis.location = { get hash() { return current.hash }, pathname: "/app", search: "" }
const entries = [{ url: current.href, state: null }]
let cursor = 0
globalThis.history = {
get state() { return entries[cursor].state },
replaceState(state, _title, url) { current = new URL(url, current); entries[cursor] = { state, url: current.href } },
pushState(state, _title, url) { entries.splice(cursor + 1); current = new URL(url, current); entries.push({ state, url: current.href }); cursor++ },
go(delta) { if (cursor + delta >= 0 && cursor + delta < entries.length) { cursor += delta; current = new URL(entries[cursor].url); listeners.get("popstate")() } },
back() { if (cursor > 0) { cursor--; current = new URL(entries[cursor].url); listeners.get("popstate")() } },
forward() { if (cursor < entries.length - 1) { cursor++; current = new URL(entries[cursor].url); listeners.get("popstate")() } },
}
const { route, navigate, navigateBack, startRouter } = await import("../web/js/router.js")
startRouter()
navigateBack()
assert.equal(route.path, "/") // A direct link cannot navigate outside Galaxy.
navigate("/settings"); navigate("/appearance"); navigate("/theme_maker")
navigateBack(); assert.equal(route.path, "/appearance")
navigateBack(); assert.equal(route.path, "/settings")
history.forward(); assert.equal(route.path, "/appearance")
navigate("/cameras"); navigate("/cameras/pip")
navigateBack(); assert.equal(route.path, "/cameras")
const length = entries.length
navigate("/cameras"); assert.equal(entries.length, length)
let returned = 0, cancelled = 0
const settings = { busy: false, state: { data: { page: "standard/following" } }, initialPage: "hub", atSectionRoot: false,
back() { returned++ }, feed: { cancel() { cancelled++ } } }
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
assert.equal(returned, 1)
settings.atSectionRoot = true
assert.equal(SettingsPage.methods.navigateBack.call(settings), false)
settings.state.pending = {}; settings.busy = true
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
assert.equal(cancelled, 1)
let closed = 0
const editor = { busy: false, dirty: true, state: { drag: null, discard: null },
leave: OnroadLayoutPage.methods.leave, $emit(event) { if (event === "close") closed++ } }
settings.state = { layoutOpen: true }; settings.busy = false
settings.$refs = { layoutEditor: { requestLeave(action) { OnroadLayoutPage.methods.requestLeave.call(editor, action) } } }
assert.equal(SettingsPage.methods.navigateBack.call(settings), true)
assert.equal(editor.state.discard, "back"); assert.equal(closed, 0)
editor.state.discard = null // Cancel discard keeps the editor.
assert.equal(closed, 0)
editor.leave("back"); assert.equal(closed, 1)
console.log("Galaxy Back history and editor guard passed")
// Parent is the page actually visited, not a guessed personality parent.
const nested = { initialPage: 'hub', state: { section: 'longitudinal', query: '', parents: [], data: {page: 'hub'} },
activeSection: {id: 'longitudinal'}, feed: {load(page) { nested.state.data = {page} }} }
SettingsPage.methods.open.call(nested, 'profiles')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'hub')
SettingsPage.methods.open.call(nested, 'standard')
SettingsPage.methods.open.call(nested, 'standard/following')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'standard')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'hub', 'direct personality returns to Longitudinal hub')
SettingsPage.methods.open.call(nested, 'profiles')
SettingsPage.methods.open.call(nested, 'standard')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page, 'profiles', 'personality opened through planner returns there')
SettingsPage.methods.selectSection.call({...nested,busy:false,feed:{active:true,load(){}}}, {id:'wheel',pages:['wheel']})
assert.deepEqual(nested.state.parents, [], 'changing sections retires nested history')
nested.initialPage = 'profiles'; nested.state.data = {page:'profiles'}
SettingsPage.methods.open.call(nested,'standard')
SettingsPage.methods.open.call(nested,'standard/following')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page,'standard','dedicated tools also retain each nested parent')
SettingsPage.methods.back.call(nested)
assert.equal(nested.state.data.page,'profiles')
@@ -29,14 +29,15 @@ assert.equal(page.state.query, "")
assert.match(CamerasPage.template, /Adjust the camera crop with a live cabin preview/)
assert.doesNotMatch(CamerasPage.template, /live camera preview is unavailable/)
assert.match(CamerasPage.template, /Sentry[\s\S]*View motion events/)
assert.match(CamerasPage.template, /Saved motion settings/)
assert.match(CamerasPage.template, /V-ASM[\s\S]*Open saved settings/)
assert.match(CamerasPage.template, /live camera image and current warning status are unavailable/)
assert.match(CamerasPage.template, /saved motion settings/)
assert.doesNotMatch(CamerasPage.template, /go\('\/cameras\/sentry-settings'\)/)
assert.match(CamerasPage.template, /MenuTile[^>]*title="V-ASM"/)
assert.match(CamerasPage.template, /Preview the cabin camera/)
assert.match(CamerasPage.template, /snapshots.capture\(camera\)/)
assert.match(SettingsPage.template, /\{\{ title \}\}/)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
assert.match(app, /<PipPage v-else-if="route\.path === '\/cameras\/pip'"/)
assert.match(app, /route\.path === '\/cameras\/sentry-settings'[\s\S]*initial-page="sentry"/)
assert.match(app, /SentryEventsPage[^>]*\/cameras\/events[^>]*\/cameras\/sentry-settings/)
assert.match(app, /CamerasPage v-else-if="route\.path === '\/cameras'"/)
const captures = [], snapshots = [], revoked = []
@@ -64,3 +65,25 @@ timer()
captures[2].resolve({ status: 401 })
await timed
assert.match(snapshots.at(-1).error, /timed out/)
// Browsers throw "Illegal invocation" when setTimeout/clearTimeout run with a `this`
// other than the global object; Node does not, so mimic the browser check here.
// The default timers once ran as feed methods, which killed every PiP/VASM/Cameras
// snapshot before its request with no visible error.
const realSetTimeout = globalThis.setTimeout, realClearTimeout = globalThis.clearTimeout
const strict = (real) => function (...args) {
if (this !== undefined && this !== globalThis) throw new TypeError("Illegal invocation")
return real(...args)
}
globalThis.setTimeout = strict(realSetTimeout)
globalThis.clearTimeout = strict(realClearTimeout)
try {
const defaults = new CameraSnapshotFeed({ publish: () => {}, unauthorized: () => {}, createURL: () => "blob:x", revokeURL: () => {},
fetcher: async () => ({ status: 200, ok: true, headers: { get: () => "image/jpeg" }, blob: async () => ({ size: 3 }) }) })
await defaults.capture("cabin")
assert.equal(defaults.url, "blob:x", "default timers must not throw before the request")
defaults.stop()
} finally {
globalThis.setTimeout = realSetTimeout
globalThis.clearTimeout = realClearTimeout
}
@@ -0,0 +1,109 @@
"""Galaxy developer preference admission, exact saves and drive-start selection."""
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from typing import cast
import tempfile
import unittest
from openpilot.starpilot.galaxy.developer_settings import DeveloperSettingsOwner
from openpilot.starpilot.galaxy.settings import AuthorityContext, ContextSource, SettingsChanged, SettingsGateway
from openpilot.starpilot.longitudinal.voacc import KEY, selected
from openpilot.starpilot.ui.feature_settings_state import FeatureSettingsRequest
class FileParams:
def __init__(self, root):
self.root = Path(root) / "d"
self.root.mkdir()
def get_param_path(self, key):
return str(self.root / key)
class DeveloperSettingsTest(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.params = FileParams(directory.name)
self.path = Path(self.params.get_param_path(KEY))
self.cp = SimpleNamespace(openpilotLongitudinalControl=True, notCar=False, dashcamOnly=False, passive=False)
self.parked = True
self.owner = DeveloperSettingsOwner(self.params, lambda: self.parked)
def test_default_off_strict_selection_and_vehicle_admission(self):
self.assertEqual(self.owner.snapshot().rows[0].value, "Off")
self.assertFalse(selected(self.params, self.cp))
for raw in (b"0", b"", b"true", b"1\n", b"1" * 9):
with self.subTest(raw=raw):
self.path.write_bytes(raw)
self.assertFalse(selected(self.params, self.cp))
self.path.write_bytes(b"1")
self.assertTrue(selected(self.params, self.cp))
self.assertFalse(selected(self.params, None))
self.assertFalse(selected(self.params, SimpleNamespace()))
for field, value in (("openpilotLongitudinalControl", False), ("notCar", True),
("dashcamOnly", True), ("passive", True)):
with self.subTest(field=field):
cp = SimpleNamespace(**{**vars(self.cp), field: value})
self.assertFalse(selected(self.params, cp))
def test_built_native_registry_and_actual_params_save(self):
from openpilot.common.params import Params, ParamKeyType
params = Params(str(self.params.root.parent / "native-params"))
self.assertTrue(params.check_key(KEY))
self.assertEqual(params.get_type(KEY), ParamKeyType.BOOL)
self.assertIs(params.get_default_value(KEY), False)
owner = DeveloperSettingsOwner(params, lambda: True)
row = owner.snapshot().rows[0]
self.assertEqual(row.value, "Off")
self.assertTrue(owner.apply(FeatureSettingsRequest(KEY, row.source, "On")))
self.assertTrue(params.get_bool(KEY))
self.assertTrue(selected(params, self.cp))
def test_exact_save_rejects_races_invalid_values_and_onroad(self):
self.assertTrue(self.owner.apply(FeatureSettingsRequest(KEY, None, "On")))
self.assertEqual(self.path.read_bytes(), b"1")
self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, None, "Off")))
self.parked = False
self.assertFalse(self.owner.snapshot().rows[0].available)
self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, b"1", "Off")))
self.parked = True
self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, b"1", "yes")))
self.assertFalse(self.owner.apply(FeatureSettingsRequest("AlwaysOnLateral", None, "On")))
self.assertTrue(self.owner.apply(FeatureSettingsRequest(KEY, b"1", "Off")))
self.assertEqual(self.path.read_bytes(), b"0")
self.path.write_bytes(b"garbage")
self.assertFalse(self.owner.snapshot().rows[0].available)
self.assertFalse(self.owner.apply(FeatureSettingsRequest(KEY, b"garbage", "On")))
def test_real_gateway_preview_save_default_and_authority_recheck(self):
context = SimpleNamespace(value=AuthorityContext(True, None, None))
context.sample = lambda: context.value
gateway = SettingsGateway(self.params, cast(ContextSource, context), clock=lambda: 10.)
token, generation = "session", b"generation"
def preview(value):
page = gateway.page("developer", token, generation)
self.assertEqual(page["rows"][0]["label"], "Use VOACC")
self.assertEqual(page["rows"][0]["defaultValue"], "Off")
self.assertIn("next drive", page["rows"][0]["reason"])
return gateway.preview(page["view"], 0, 0, token, generation, value=value)["intent"]
intent = preview("On")
self.assertTrue(gateway.confirm(intent, token, generation))
self.assertEqual(self.path.read_bytes(), b"1")
intent = preview("Off")
context.value = replace(context.value, parked=False)
with self.assertRaises(SettingsChanged):
gateway.confirm(intent, token, generation)
self.assertEqual(self.path.read_bytes(), b"1")
context.value = replace(context.value, parked=True)
page = gateway.page("developer", token, generation)
intent = gateway.preview(page["view"], 0, 0, token, generation, reset_default=True)
self.assertTrue(gateway.confirm(intent["intent"], token, generation))
self.assertEqual(self.path.read_bytes(), b"0")
intent = preview("On")
with self.assertRaises(SettingsChanged):
gateway.confirm(intent, token, generation, session_valid=lambda: False)
@@ -0,0 +1,55 @@
import assert from "node:assert/strict"
import { DeviceRestartWatch } from "../web/js/device-restart.js"
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
function fixture({ paused = false } = {}) {
const timers = new Map(), requests = []
let nextTimer = 0, reloads = 0
const watch = new DeviceRestartWatch({ hidden: () => false, paused: () => paused,
request: () => new Promise((resolve, reject) => requests.push({ resolve, reject })),
later: fn => { const id = ++nextTimer; timers.set(id, fn); return id },
cancel: id => timers.delete(id), reload: () => reloads++ })
return { watch, timers, requests, tick() { const [id, fn] = timers.entries().next().value; timers.delete(id); fn() }, get reloads() { return reloads } }
}
const preview = fixture()
preview.watch.track(null)
assert.equal(preview.timers.size, 0)
const blip = fixture()
blip.watch.track("one")
blip.tick()
blip.requests[0].reject(new TypeError("offline"))
await flush()
blip.tick()
blip.requests[1].resolve({ schemaVersion: 1, monitor: "local", instance: "one" })
await flush()
assert.equal(blip.reloads, 0)
assert.equal(blip.timers.size, 1)
const reboot = fixture()
reboot.watch.track("one")
reboot.tick()
reboot.requests[0].reject(new TypeError("offline"))
await flush()
reboot.tick()
reboot.requests[1].resolve({ schemaVersion: 1, monitor: "local", instance: "two" })
await flush()
assert.equal(reboot.reloads, 1)
assert.equal(reboot.timers.size, 0)
const startup = fixture()
startup.watch.track("one")
startup.watch.track("two")
assert.equal(startup.reloads, 1)
const woken = fixture()
woken.watch.track("one")
woken.watch.wake()
assert.equal(woken.requests.length, 1)
assert.equal(woken.timers.size, 0)
const updating = fixture({ paused: true })
updating.watch.track("one")
updating.watch.track("two")
assert.equal(updating.reloads, 0)
@@ -0,0 +1,47 @@
import assert from 'node:assert/strict'
import { compile } from '../web/vendor/vue/vue.esm-browser.js'
import { GalaxyHotspot, validHotspot } from '../web/js/hotspot.js'
const value = { version: 1, revision: 'a'.repeat(64), editable: true, active: false, state: 'disabled', reason: '',
url: 'http://172.31.254.1:8082/', config: { enabled: false, ssid: 'TheGalaxy-6b86', password: '' } }
assert.equal(validHotspot(value), true)
assert.equal(validHotspot({ ...value, config: { ...value.config, ssid: 'Custom network' } }), false)
assert.ok(!GalaxyHotspot.template.includes('v-model="draft.ssid"'))
assert.ok(GalaxyHotspot.template.includes('Broadcast hotspot automatically'))
assert.equal(validHotspot({ ...value, url: 'javascript:alert(1)' }), false)
assert.equal(validHotspot({ ...value, config: { ...value.config, password: 'bad\npassword' } }), false)
compile(GalaxyHotspot.template, { decodeEntities: value => value })
function instance() {
const vm = { ...GalaxyHotspot.data(), mode: 'local', unauthorized() {} }
for (const [name, fn] of Object.entries(GalaxyHotspot.methods)) vm[name] = fn.bind(vm)
Object.defineProperty(vm, 'dirty', { get: () => GalaxyHotspot.computed.dirty.call(vm) })
return vm
}
const vm = instance()
vm.adopt(value)
assert.equal(vm.dirty, false)
vm.draft.password = 'my-own-password'
vm.adopt({ ...value, revision: 'b'.repeat(64), config: { ...value.config, password: 'another-password' } })
assert.equal(vm.draft.password, 'my-own-password')
assert.equal(vm.revision, value.revision) // Polls must not bless a stale draft.
vm.adopt(value, true)
assert.equal(vm.dirty, false)
let requests = 0, resolve
globalThis.fetch = (_url, options) => { requests++; return new Promise(done => { resolve = done }) }
vm.mode = 'sample'; vm.start()
assert.equal(requests, 0)
vm.active = true
const reading = vm.refresh()
vm.stop()
resolve({ ok: true, json: async () => value })
await reading
assert.equal(vm.snapshot, null)
assert.equal(vm.draft, null)
let auth = 0
vm.unauthorized = () => auth++
globalThis.fetch = async () => ({ ok: false, status: 401, json: async () => ({ error: 'Sign in' }) })
vm.active = true
await vm.refresh()
assert.equal(auth, 1)
assert.equal(vm.active, false)
assert.equal(vm.timer, null)
console.log('Hotspot: schema, Vue template, preview exclusion, stale drafts, unmount race, credential clearing and authentication passed')
@@ -0,0 +1,69 @@
import assert from "node:assert/strict"
import { presentedSetting, laneStrengthDisplay, laneStrengthSaved, GalaxySettingRow } from "../web/js/galaxy-setting-row.js"
const row = { displayScale: "lane-strength", minimum: 33, maximum: 200, step: 1, value: "100", defaultValue: "100", unit: "%", available: true }
assert.deepEqual([33, 100, 200].map(v => laneStrengthDisplay(v, row)), [1, 5, 10])
assert.deepEqual([1, 5, 10].map(v => laneStrengthSaved(v, row)), [33, 100, 200])
const display = presentedSetting(row)
assert.equal(display.minimum, 1); assert.equal(display.maximum, 10); assert.equal(display.step, 1)
assert.equal(display.value, 5); assert.equal(display.defaultValue, 5); assert.equal(display.unit, "")
for (let value = 33; value <= 200; value++) assert.equal(laneStrengthSaved(laneStrengthDisplay(value, row), row), value)
assert.equal(laneStrengthSaved(0, row), null); assert.equal(laneStrengthSaved(11, row), null)
assert.equal(presentedSetting({ value: "100" }).value, "100")
const saves = []
const context = { row, presentedRow: display, locked: false, saveValue: async (index, value) => saves.push(value), index: 0 }
await GalaxySettingRow.methods.commit.call(context, 5)
assert.deepEqual(saves, [])
await GalaxySettingRow.methods.commit.call(context, 10)
assert.deepEqual(saves, [200])
assert.equal(row.value, "100")
console.log("Lane strength presentation and unchanged raw transport verified")
// Mount the actual shared component with Vue's renderer, including its template.
const { createRenderer, nextTick } = await import('../web/vendor/vue/vue.esm-browser.js')
const node = (type, text = '') => ({ type, text, props: {}, children: [], parent: null })
const renderer = createRenderer({
createElement: type => node(type), createText: text => node('text', text), createComment: text => node('comment', text),
setText: (item, text) => { item.text = text }, setElementText: (item, text) => { item.text = text },
patchProp: (item, key, before, after) => { item.props[key] = after },
insert: (item, parent, anchor = null) => { item.parent = parent; const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, item) },
remove: item => { const i = item.parent?.children.indexOf(item); if (i >= 0) item.parent.children.splice(i, 1) },
parentNode: item => item.parent, nextSibling: () => null,
})
// Vue's browser compiler defaults to a document-backed HTML entity decoder.
// This exact template contains no entity references: reject any future named or
// numeric reference (including optional-semicolon forms), then preserve its text.
// The actual Vue compiler and actual component template still run unchanged.
const entityReference = /&(?:#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*)/
assert.doesNotMatch(GalaxySettingRow.template, entityReference)
const mountActualRow = (props, host) => {
const app = renderer.createApp(GalaxySettingRow, props)
app.config.compilerOptions.decodeEntities = raw => {
assert.doesNotMatch(raw, entityReference)
return raw
}
return app.mount(host)
}
const all = item => [item, ...item.children.flatMap(all)]
for (const surface of ['desktop-sidebar', 'mobile']) {
const host = node(surface), commits = []
const vm = mountActualRow({ row: { ...row, label: 'Lane Centering Strength', action: true }, index: 3,
saveValue: async (index, value) => commits.push([index, value]) }, host)
await nextTick()
const slider = all(host).find(item => item.type === 'input' && item.props.type === 'range')
assert.ok(slider, `${surface} renders actual slider`)
assert.equal(Number(slider.props.min), 1); assert.equal(Number(slider.props.max), 10)
assert.equal(Number(slider.props.step), 1); assert.equal(Number(slider.props.value), 5)
assert.equal(commits.length, 0, 'mount never rewrites old bytes')
for (const invalid of [0, 11, NaN]) await vm.commit(invalid)
assert.equal(commits.length, 0, "invalid actual component commits have no effect")
await vm.commit(5); assert.equal(commits.length, 0)
await vm.commit(10); assert.deepEqual(commits, [[3, 200]])
vm.$.appContext.app.unmount()
}
const disabledHost = node('disabled')
const disabledCommits = []
const disabledVm = mountActualRow({ row: { ...row, available: false, action: false, label: 'Lane Centering Strength' },
index: 0, saveValue: async value => disabledCommits.push(value) }, disabledHost)
await disabledVm.commit(10)
assert.equal(disabledCommits.length, 0, 'unavailable setting has no effect')
disabledVm.$.appContext.app.unmount()
@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import { OnroadLayoutPage } from "../web/js/onroad-layout.js"
for (const projection of [false, true]) {
assert.equal(OnroadLayoutPage.computed.inlineLeavePrompt.call({ projection, pendingLeave: true, state: { leavePresentation: "inline" } }), true)
}
for (const projection of [false, true]) {
const vm = { projection, pendingLeave: false, state: { discard: "reload", leavePresentation: "inline" } }
assert.equal(OnroadLayoutPage.computed.inlineLeavePrompt.call(vm), true)
assert.equal(OnroadLayoutPage.computed.modalLeavePrompt.call(vm), false)
vm.state.discard = null
assert.equal(OnroadLayoutPage.computed.inlineLeavePrompt.call(vm), false)
}
let flashes = 0
const pendingAction = () => {}
const pending = { inlineLeavePrompt: true, state: { discard: pendingAction }, flashLeavePrompt() { flashes++ } }
OnroadLayoutPage.methods.requestLeave.call(pending, () => {})
assert.equal(flashes, 1)
assert.equal(pending.state.discard, pendingAction)
const originalWindow = globalThis.window
try {
let reduced = false, focuses = 0
const scrolls = []
globalThis.window = { matchMedia: () => ({ matches: reduced }) }
const vm = { $refs: { leaveBar: {
scrollIntoView(options) { scrolls.push(options) },
querySelector() { return { focus(options) { assert.equal(options.preventScroll, true); focuses++ } } },
} } }
OnroadLayoutPage.methods.flashLeavePrompt.call(vm)
assert.equal(scrolls.at(-1).behavior, "smooth")
reduced = true
OnroadLayoutPage.methods.flashLeavePrompt.call(vm)
assert.equal(scrolls.at(-1).behavior, "instant")
assert.equal(focuses, 2)
} finally {
if (originalWindow === undefined) delete globalThis.window
else globalThis.window = originalWindow
}
console.log("Layout leave attention: both targets, repeated navigation, focus restoration, reduced motion passed")
@@ -0,0 +1,75 @@
import assert from "node:assert/strict"
import { OnroadLayoutPage } from "../web/js/onroad-layout.js"
const originalStorage = globalThis.localStorage
const saved = new Map()
const editor = projection => {
const vm = { ...OnroadLayoutPage.setup({ projection, mode: "local", unauthorized() {} }), projection }
for (const name of ["sceneHidden", "previewHidden", "togglePreviewVisibility"])
vm[name] = OnroadLayoutPage.methods[name].bind(vm)
return vm
}
try {
globalThis.localStorage = { getItem: key => saved.get(key) ?? null, setItem: (key, value) => saved.set(key, value) }
for (const projection of [false, true]) {
const vm = editor(projection)
assert.equal(vm.state.scene, "general")
assert.equal(vm.previewHidden("stopped_timer"), false)
assert.equal(vm.previewHidden("current_speed"), false, "targets have separate preferences")
vm.togglePreviewVisibility("stopped_timer")
vm.togglePreviewVisibility("current_speed")
vm.state.profile = "compact"
vm.togglePreviewVisibility("driver_monitor")
const reopened = editor(projection)
assert.equal(reopened.previewHidden("stopped_timer"), true, "General edit eye preferences survive reopening")
assert.equal(reopened.previewHidden("current_speed"), true)
assert.equal(reopened.previewHidden("driver_monitor"), false)
reopened.state.profile = "compact"
assert.equal(reopened.previewHidden("driver_monitor"), true)
assert.equal(reopened.previewHidden("current_speed"), false, "profiles have separate preferences")
assert.equal(reopened.state.draft, null, "visibility is independent of the saved driving layout")
reopened.state.scene = "stopped"
assert.equal(reopened.previewHidden("stopped_timer"), false, "General edit cannot hide the stopped scene's timer")
reopened.togglePreviewVisibility("stopped_timer")
assert.equal(reopened.previewHidden("stopped_timer"), true)
reopened.state.scene = "engaged"
assert.equal(reopened.previewHidden("stopped_timer"), true)
reopened.togglePreviewVisibility("stopped_timer")
assert.equal(reopened.previewHidden("stopped_timer"), true, "Driving scenes cannot show an impossible timer")
reopened.togglePreviewVisibility("current_speed")
reopened.state.scene = "experimental"
assert.equal(reopened.previewHidden("current_speed"), false, "Eye preferences do not leak between scenes")
assert.equal(reopened.previewHidden("stopped_timer"), true)
}
const aa = editor(true)
const before = [...saved]
aa.togglePreviewVisibility("car_exit")
assert.equal(aa.previewHidden("car_exit"), false)
assert.deepEqual([...saved], before)
saved.delete('gx-layout-preview-visibility-v2:comma')
saved.set('gx-layout-preview-visibility-v1:comma', JSON.stringify({ large: { current_speed: true }, compact: { stopped_timer: true } }))
const migrated = editor(false)
assert.equal(migrated.previewHidden('current_speed'), true, 'Old preview preferences become General edit preferences')
migrated.state.profile = 'compact'
assert.equal(migrated.previewHidden('stopped_timer'), false, 'The old automatic timer default does not hide General edit samples')
for (const stored of ["broken JSON", "null", JSON.stringify({ large: [true], compact: "invalid" }),
JSON.stringify({ large: { stopped_timer: "false", current_speed: 1 } })]) {
globalThis.localStorage.getItem = () => stored
const vm = editor(false)
assert.equal(vm.previewHidden("stopped_timer"), false)
assert.equal(vm.previewHidden("current_speed"), false)
}
globalThis.localStorage = {
getItem() { throw new Error("Storage unavailable") },
setItem() { throw new Error("Storage unavailable") },
}
const unavailable = editor(false)
unavailable.togglePreviewVisibility("current_speed")
assert.equal(unavailable.previewHidden("current_speed"), true)
} finally {
if (originalStorage === undefined) delete globalThis.localStorage
else globalThis.localStorage = originalStorage
}
console.log("Preview visibility: General edit persistence, scene/target/profile isolation, timer eligibility, required exit and unavailable storage passed")
@@ -0,0 +1,75 @@
import assert from "node:assert/strict"
import { SNAP_DISTANCE, snapTargets, snapMove, snapResize, snapThreshold } from "../web/js/layout-snap.js"
const copy = value => structuredClone(value)
// Every helper call also checks that its inputs are unchanged.
function pure(fn, ...args) {
const before = args.map(arg => typeof arg === "function" ? arg : copy(arg))
const result = fn(...args)
assert.deepEqual(args, before)
return result
}
const profile = { width: 1000, height: 600, bounds: { x: 30, y: 30, width: 900, height: 540 }, reservedZones: [], widgets: {
moving: { label: "Moving", kind: "clock", width: 100, height: 60 },
home: { label: "Home", kind: "favorite", width: 100, height: 60 },
} }
const layout = { moving: { x: 200, y: 200, enabled: true }, home: { x: 300, y: 350, enabled: true } }
const targets = pure(snapTargets, profile, layout, "moving", () => true)
const bounds = targets.filter(target => target.kind !== "widget")
const box = { x: 34, y: 113, width: 100, height: 60 }
let result = pure(snapMove, box, bounds, 6)
assert.equal(result.x, 30)
assert.equal(result.y, 113)
assert.deepEqual(result.guides, [{ axis: "x", value: 30, kind: "bounds", label: "the edge" }])
assert.deepEqual(pure(snapMove, { ...box, x: 37 }, bounds, 6), { x: 37, y: 113, guides: [] })
result = pure(snapMove, { ...box, x: 433 }, bounds, 6)
assert.equal(result.x, 430)
assert.equal(result.guides[0].kind, "center")
result = pure(snapMove, { ...box, x: 404 }, targets, 6)
assert.equal(result.x, 400) // Start to another widget's end.
assert.equal(result.guides[0].label, "Home")
assert.deepEqual(pure(snapMove, { ...box, x: 253 }, targets, 6), { x: 253, y: 113, guides: [] })
assert.deepEqual(pure(snapMove, box, bounds, 0), { x: 34, y: 113, guides: [] })
const target = (value, line = "start", kind = "widget", axis = "x", label = "Home") => ({ axis, value, line, kind, label })
result = pure(snapMove, { ...box, x: 100 }, [target(104), target(146, "center", "center")], 6)
assert.equal(result.x, 96) // Equal distance prefers bounds center.
assert.equal(result.guides[0].kind, "center")
for (const kinds of [["zone", "widget"], ["widget", "bounds"], ["bounds", "center"]]) {
const first = target(104, "start", kinds[0]), second = target(96, "start", kinds[1])
assert.equal(pure(snapMove, { ...box, x: 100 }, [first, second], 6).x, 96)
}
assert.equal(pure(snapMove, { ...box, x: 100 }, [target(104), target(96)], 6).x, 104)
result = pure(snapMove, { ...box, x: 104 }, [target(100), target(200, "end"), target(100, "end", "bounds")], 6)
assert.deepEqual(result.guides, [
{ axis: "x", value: 100, kind: "bounds", label: "Home" },
{ axis: "x", value: 200, kind: "widget", label: "Home" },
])
const exclusions = copy(profile), placements = copy(layout)
for (const [id, kind, enabled] of [["disabled", "clock", false], ["hidden", "clock", true], ["torque", "torque_bar", true],
["pip", "pip_camera", true], ["invalid", "clock", true]]) {
exclusions.widgets[id] = { label: id, kind, layer: "underlay", width: 60, height: 60 }
placements[id] = { x: id === "invalid" ? Infinity : 100, y: 100, enabled }
}
exclusions.widgets.missing = { label: "missing", kind: "clock", width: 60, height: 60 }
const filtered = pure(snapTargets, exclusions, placements, "moving", id => id !== "hidden")
assert.deepEqual([...new Set(filtered.filter(item => item.kind === "widget").map(item => item.label))], ["Home", "pip"])
assert.deepEqual(pure(snapTargets, profile, layout, "absent", () => true), [])
const sidebar = copy(profile)
sidebar.widgets.moving.bounds = { x: 940, y: 0, width: 60, height: 600 }
assert.deepEqual(pure(snapTargets, sidebar, layout, "moving", () => false).filter(item => item.axis === "x").map(item => item.value), [940, 1000, 970])
const zones = copy(profile)
zones.reservedZones.push({ x: 150, y: 160, width: 100, height: 80 })
assert.deepEqual(pure(snapTargets, zones, layout, "moving", () => false).filter(item => item.kind === "zone").map(item => item.value), [150, 250, 160, 240])
const resizeBox = { x: 100, y: 150, width: 97, height: 146 }
result = pure(snapResize, resizeBox, [target(200), target(300, "end", "widget", "y")], 6)
assert.deepEqual(result, { width: 100, height: 150, guides: [
{ axis: "x", value: 200, kind: "widget", label: "Home" },
{ axis: "y", value: 300, kind: "widget", label: "Home" },
] })
assert.deepEqual(pure(snapResize, resizeBox, [target(200, "center")], 6), { width: 97, height: 146, guides: [] })
assert.deepEqual(SNAP_DISTANCE, { touch: 10, fine: 6 })
for (const [pointer, expected] of [["touch", 60], ["mouse", 36], ["pen", 36]])
assert.equal(pure(snapThreshold, { width: 2160 }, { width: 360 }, pointer), expected)
assert.equal(pure(snapThreshold, profile, { width: 0 }, "touch"), 0)
assert.equal(pure(snapThreshold, profile, { width: -1 }, "touch"), 0)
console.log("Layout snapping: targets, matching, priority, guides, resize, scaling and purity passed")
@@ -0,0 +1,70 @@
import assert from "node:assert/strict"
import { CameraSnapshotFeed, LiveCameraPreview } from "../web/js/cameras.js"
// A server or body that ignores AbortSignal must release the polling caller.
for (const bodyStalls of [false, true]) {
let expire, signal
const updates = []
const feed = new CameraSnapshotFeed({ publish: value => updates.push(value), unauthorized() {},
later: fn => { expire = fn; return 1 }, cancelTimer() {},
fetcher: async (_url, options) => {
signal = options.signal
return bodyStalls ? { ok: true, status: 200, headers: new Headers({ 'Content-Type': 'image/jpeg' }),
blob: () => new Promise(() => {}) } : new Promise(() => {})
},
})
const pending = feed.capture("cabin")
await Promise.resolve()
expire()
await pending
assert.equal(signal.aborted, true)
assert.equal(updates.at(-1).capturing, false)
assert.match(updates.at(-1).error, /timed out/)
assert.equal(feed.request, null)
}
// Retired image callbacks cannot restore an image after hiding/unmounting.
let image, stopped = 0
const updates = []
const preview = new LiveCameraPreview({ publish: value => updates.push(value), unauthorized() {},
redraw() {}, enabled: () => true, imageFactory: () => image = {} })
preview.stopped = false
preview.receive({ image: 'blob:frame' })
preview.snapshots.stop = () => stopped++
preview.clear()
image.onload()
assert.equal(preview.image, null)
assert.equal(updates.at(-1).imageName, '')
assert.equal(stopped, 1)
console.log('Live camera: stalled fetch/body settle, explicit failures, stale decode cleanup passed')
const canceled = new CameraSnapshotFeed({ publish() {}, unauthorized() {}, fetcher: () => new Promise(() => {}) })
const canceledCapture = canceled.capture("cabin")
canceled.stop()
await canceledCapture
assert.equal(canceled.request, null)
// Editors warm up once, then keep the frame without background capture or flicker.
const timers = new Map()
let nextTimer = 0, captures = 0
const originalDocument = globalThis.document
const listeners = new Map()
globalThis.document = { hidden: false, addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: name => listeners.delete(name) }
const snapshot = new LiveCameraPreview({ publish() {}, unauthorized() {}, redraw() {}, enabled: () => true,
later: (fn, ms) => { timers.set(++nextTimer, { fn, ms }); return nextTimer }, cancelTimer: id => timers.delete(id) })
snapshot.snapshots.capture = async () => { captures++; snapshot.image = { src: 'last-good-frame' } }
snapshot.snapshots.stop = () => {}
snapshot.start()
await Promise.resolve()
const warmup = [...timers.values()].find(timer => timer.ms === 5000)
assert.ok(warmup)
warmup.fn()
const heldFrame = snapshot.image
await snapshot.poll()
globalThis.document.hidden = true; listeners.get('visibilitychange')()
globalThis.document.hidden = false; listeners.get('visibilitychange')()
assert.equal(captures, 1)
assert.equal(snapshot.image, heldFrame, 'visibility changes preserve the held snapshot')
snapshot.stop()
globalThis.document = originalDocument
console.log('Camera editor warm-up: five-second deadline, frozen frame and visibility stability passed')
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
import { LocalAccess, LocalAccessFeed, validLocalAccess } from "../web/js/local-access.js"
import { GalaxyPage } from "../web/js/galaxy.js"
import { Home } from "../web/js/home.js"
const snapshot = { available: true, addresses: [{ interface: 'wlan0', label: 'Wi-Fi', url: 'http://192.168.20.5:8082/' }], reason: '' }
assert.equal(validLocalAccess(snapshot), true)
assert.equal(validLocalAccess({ available: false, addresses: [], reason: 'No active addresses' }), true)
@@ -16,14 +16,14 @@ const feed = new LocalAccessFeed({ publish: value => updates.push(value), onUnau
await feed.start('sample')
await feed.refresh()
assert.equal(requests.length, 0)
assert.match(updates.at(-1).error, /sample mode/)
assert.match(updates.at(-1).error, /preview/)
await feed.start('local')
assert.equal(requests.length, 1)
assert.equal(requests[0].url, './api/local-access')
assert.equal(requests[0].options.credentials, 'same-origin')
assert.equal(timers[0].ms, 4000)
assert.equal(timers.at(-1).ms, 4000)
assert.deepEqual(updates.at(-2).data, snapshot)
assert.equal(timers.length, 1) // One deadline only; no periodic polling timer.
assert.equal(timers.length, 2) // Background synchronization plus a bounded request.
await feed.refresh()
assert.equal(requests.length, 2)
feed.fetcher = async () => ({ status: 401 })
@@ -47,15 +47,17 @@ const boundedRead = bounded.start('local')
assert.equal(await bounded.refresh(), undefined) // Duplicate refresh is suppressed.
deadline()
await boundedRead
assert.match(timeoutUpdates.at(-2).error, /timed out/)
assert.match(timeoutUpdates.at(-2).error, /Reconnecting/)
assert.equal(timeoutUpdates.at(-1).loading, false)
bounded.stop()
compile(LocalAccess.template, { decodeEntities: value => value })
const setup = LocalAccess.setup({ mode: 'sample', onUnauthorized() {} })
await setup.feed.start('sample')
assert.match(setup.state.error, /sample mode/)
assert.match(setup.state.error, /preview/)
setup.feed.stop()
assert.equal(GalaxyPage.components.LocalAccess, LocalAccess)
assert.ok(GalaxyPage.template.includes(':on-unauthorized="unauthorized"'))
assert.ok(LocalAccess.template.includes('your browser must be able to reach its network'))
console.log('Local access: actual Vue component/setup, bounded manual-only read, sample exclusion, authentication, safe links and unmount race passed')
assert.equal(Home.components.LocalAccess, LocalAccess)
assert.ok(Home.template.includes(':on-unauthorized="unauthorized"'))
assert.ok(LocalAccess.template.includes('Connect your phone to the Galaxy hotspot'))
assert.ok(LocalAccess.template.includes('full http:// address into a new browser tab'))
assert.ok(LocalAccess.template.includes('public Galaxy link uses the Internet tunnel'))
console.log('Local access: actual Vue component/setup, bounded automatically synchronized read, sample exclusion, authentication, safe links and unmount race passed')
@@ -0,0 +1,182 @@
import assert from "node:assert/strict"
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
import { FavoriteChoices, NavigationClient, NavigationPage, validDestination } from "../web/js/navigation.js"
const home = { id: "h1", name: "12 Elm St", latitude: 40.1, longitude: -90.1 }
const gym = { id: "g1", name: "Elm Fitness", latitude: 40.3, longitude: -90.3 }
const status = { enabled: true, isMetric: false, hasKey: true, status: "noDestination", revision: "r1", destination: null,
favorites: [home, gym], route: [], instruction: null, location: null }
const suggestion = (id, searchId) => ({ id, name: "Coffee " + id, description: "Main St", searchId })
assert.equal(validDestination(home), true)
// Exercise the picker template as well as its parent methods: each choice must
// follow the declared available prop, including after connectivity is lost.
const renderChoices = compile(FavoriteChoices.template)
for (const available of [true, false]) {
const picker = renderChoices({ available, $emit() {} }, [])
assert.deepEqual(picker.children[0].children.map(button => button.props.disabled), Array(3).fill(!available))
}
// Suggestions share one search session while typing and never cancel status polling.
const requests = []
const client = new NavigationClient({ publish() {}, later: () => 1, cancelTimer() {},
fetcher: async (url, options = {}) => {
const body = options.body ? JSON.parse(options.body) : null
requests.push({ url, body, signal: options.signal })
const payload = url.endsWith("/status") ? status : { results: [suggestion("a", body.searchId), suggestion("b", body.searchId)] }
return { ok: true, status: 200, json: async () => payload }
} })
await client.start()
assert.equal(client.data.revision, "r1")
const first = await client.suggest("cof")
const second = await client.suggest("coffee")
const typed = requests.filter((request) => request.body?.autocomplete === true)
assert.equal(typed.length, 2)
assert.equal(typed[0].body.searchId, typed[1].body.searchId, "one session for the whole typing burst")
assert.equal(typed[0].body.clientId, client.clientId)
assert.equal(first.length, 2)
assert.deepEqual(second.map((place) => place.name), ["Coffee a", "Coffee b"])
const searchRequests = requests.length
assert.deepEqual((await client.search("coffee")).results, second)
assert.equal(requests.length, searchRequests, "Search reuses matching suggestions without another provider request")
assert.equal(client.searchId, typed[0].body.searchId, "reused places keep their retrieval session")
await client.suggest("coffee")
assert.deepEqual(await client.suggest("co"), [], "two letters are too few to suggest")
assert.equal(client.suggestions.length, 0)
// A status poll while typing does not abort the suggestion request (and vice versa).
const slow = []
const parallel = new NavigationClient({ publish() {}, later: () => 1, cancelTimer() {},
fetcher: (url, options = {}) => new Promise((resolve) => slow.push({ url, options, resolve })) })
parallel.active = true
parallel.data = status
const pendingSuggest = parallel.suggest("coffee")
const pendingStatus = parallel.run("status")
assert.equal(slow.length, 2)
assert.equal(slow[0].options.signal.aborted, false, "status polling leaves the suggestion request alone")
slow[0].resolve({ ok: true, status: 200, json: async () => ({ results: [suggestion("c", JSON.parse(slow[0].options.body).searchId)] }) })
slow[1].resolve({ ok: true, status: 200, json: async () => status })
assert.equal((await pendingSuggest).length, 1)
await pendingStatus
parallel.stop()
// Choosing a suggestion or running a full search ends the typing session.
const chosen = client.suggestions[0] || second[0]
await client.choose(chosen)
const select = requests.at(-1)
assert.deepEqual(select.body, { action: "selectPlace", revision: "r1", id: "a", searchId: typed[0].body.searchId })
await client.suggest("tea")
const afterChoice = requests.filter((request) => request.body?.autocomplete === true).at(-1)
assert.notEqual(afterChoice.body.searchId, typed[0].body.searchId, "a new typing burst gets a new session")
await client.search("12 Elm St")
assert.equal(requests.at(-1).body.autocomplete, undefined, "the Search button is a full search")
assert.ok(requests.at(-1).body.searchId, "with its own search session, as upstream does")
assert.equal(client.suggestions.length, 0)
client.stop()
// A refused session (expired or settings changed) is replaced on the next keystroke.
let refuse = true
const refused = new NavigationClient({ publish() {}, later: () => 1, cancelTimer() {},
fetcher: async (_url, options) => ({ ok: !refuse, status: refuse ? 400 : 200,
json: async () => refuse ? { error: "Start a new destination search" } : { results: [suggestion("d", JSON.parse(options.body).searchId)] } }) })
refused.active = true
refused.data = status
assert.deepEqual(await refused.suggest("coffee"), [])
const stale = refused.autocompleteId
assert.equal(stale, null)
refuse = false
assert.equal((await refused.suggest("coffee")).length, 1)
// Page logic: saved and recent places match first in the dropdown; an empty box lists them all.
const computed = NavigationPage.computed
const recent = { id: "r1", name: "Elm Diner", address: "5 Oak Ave", latitude: 40.5, longitude: -90.5 }
const evaluate = (state) => {
const page = { suggestOpen: true, suggestions: [], results: [], ...state }
for (const name of ["ready", "favorites", "savedIds", "recents", "shortcuts", "localMatches", "showQuick", "showSuggestions", "showShortcuts"])
page[name] = computed[name].call(page)
return page
}
let page = evaluate({ data: { ...status, recents: [recent, home] }, query: "elm" })
assert.deepEqual(page.recents.map((place) => place.id), ["r1"], "recents leave out places that are already saved")
assert.deepEqual(page.localMatches.map((place) => place.id), ["h1", "g1", "r1"])
assert.equal(page.showSuggestions, true)
assert.deepEqual(evaluate({ data: status, query: "oak" }).localMatches, [], "only this page's places are matched")
assert.deepEqual(evaluate({ data: { ...status, recents: [recent] }, query: "oak" }).localMatches.map((place) => place.id), ["r1"], "addresses match too")
assert.equal(evaluate({ data: status, query: "zzz" }).showSuggestions, false)
page = evaluate({ data: { ...status, recents: [recent] }, query: "" })
assert.equal(page.showQuick, true)
assert.equal(page.showShortcuts, false, "one-tap places hide while the list is open")
page = evaluate({ data: { ...status, recents: [recent] }, query: "", suggestOpen: false })
assert.deepEqual([page.showQuick, page.showShortcuts], [false, true])
assert.equal(evaluate({ data: { ...status, destination: home }, query: "", suggestOpen: false }).showShortcuts, false)
const methods = NavigationPage.methods
const labeled = { ...home, label: "home" }
assert.deepEqual([methods.placeName(labeled), methods.placeDetail(labeled)], ["Home", "12 Elm St"])
assert.equal(methods.placeIcon.call({ isFavorite: () => true }, labeled), "bi-house-fill")
const actions = []
await methods.setLabel.call({ client: { action: async (...args) => actions.push(args) } }, labeled, "home")
assert.deepEqual(actions.at(-1), ["labelFavorite", { id: "h1", label: null }], "choosing the current label clears it")
const usage = { searchSessions: { used: 12, limit: 495, free: 500 }, geocoding: { used: 4, limit: 99000, free: 100000 },
directions: { used: 3, limit: 99000, free: 100000 }, staticTiles: { used: 0, limit: 198000, free: 200000 } }
assert.deepEqual(computed.usageRows.call({ data: { mapboxUsage: usage } }).map((row) => [row.label, row.used, row.limit]),
[["Searches", 12, 495], ["Address lookups", 4, 99000], ["Routes", 3, 99000], ["Map views", 0, 198000]])
compile(NavigationPage.template, { decodeEntities: (value) => value.replaceAll("&amp;", "&") })
compile(FavoriteChoices.template, { decodeEntities: value => value.replaceAll("&amp;", "&") })
const favoriteActions = [], favoritePage = { favorites: [], savedSuggestionPlaces: {}, favoritePicker: "selected",
client: { save: async (place, label) => { favoriteActions.push([place, label]); return {favorites:[{...home,label}]} } } }
await methods.saveAs.call(favoritePage, home, "home")
assert.deepEqual(favoriteActions[0], [home, "home"])
assert.equal(favoritePage.favoritePicker, null)
favoritePage.favorites = [{...home,label:"home"}]
favoritePage.client.action = async (...args) => { favoriteActions.push(args); return {favorites:[home]} }
await methods.saveAs.call(favoritePage, home, null)
assert.deepEqual(favoriteActions.at(-1), ["labelFavorite", {id:home.id,label:null}], "Other clears Home/Work without removing the favorite")
favoritePage.favoritePicker = "selected"
favoritePage.client.action = async () => null
await methods.saveAs.call(favoritePage, home, "work")
assert.equal(favoritePage.favoritePicker, "selected", "failed saves leave the inline choices open")
console.log("Navigation autocomplete: one session per typing burst, parallel polling, session reset, usage and template passed")
// Stars select categories; the separate remove action clears persisted IDs and suggestion markers.
{
const suggestion = { id: 'search-result', searchId: 'search-session', name: 'Cafe' }
const saved = { id: 'saved-cafe', name: 'Cafe', latitude: 36, longitude: -115 }
const page = { favorites: [saved], savedSuggestionPlaces: { [suggestion.id]: saved.id }, favoritePicker: 'old-picker' }
Object.defineProperty(page, 'savedIds', { get: () => new Set(page.favorites.map(place => place.id)) })
for (const name of ['isFavorite', 'favoriteKey', 'openFavorite', 'removeFavorite', 'saveAs']) page[name] = methods[name].bind(page)
const removed = [], savedPlaces = []
page.client = {
action: async (action, value) => { removed.push([action, value]); page.favorites = []; return { favorites: [] } },
save: async (place, label) => { savedPlaces.push([place, label]); return { favorites: [] } },
}
assert.equal(page.isFavorite(suggestion), true)
page.openFavorite(suggestion)
assert.equal(page.favoritePicker, page.favoriteKey(suggestion))
assert.deepEqual(removed, [], 'the star opens categories without removing a favorite')
await page.removeFavorite(suggestion)
assert.deepEqual(removed, [['removeFavorite', { id: saved.id }]])
assert.equal(page.isFavorite(suggestion), false)
assert.deepEqual(page.savedSuggestionPlaces, {})
assert.equal(page.favoritePicker, null)
const other = { ...saved, id: 'another-place', name: 'Park' }
page.openFavorite(other)
assert.equal(page.favoritePicker, page.favoriteKey(other))
await page.saveAs(other, null)
assert.deepEqual(savedPlaces, [[other, null]], 'another Other favorite can be saved after removing one')
page.savedSuggestionPlaces[suggestion.id] = saved.id
assert.equal(page.isFavorite(suggestion), false, 'a stale suggestion marker cannot override a refreshed saved list')
page.favorites = [saved]
page.client.action = async () => null
await page.removeFavorite(suggestion)
assert.equal(page.isFavorite(suggestion), true, 'failed removal preserves the saved place for retry')
assert.equal(page.savedSuggestionPlaces[suggestion.id], saved.id)
await page.openFavorite(saved)
assert.equal(page.favoritePicker, page.favoriteKey(saved), 'the filled star opens the category picker')
}
assert.match(NavigationPage.template, /@click="removeFavorite\(place\)"><i class="bi bi-x-lg"/)
assert.doesNotMatch(NavigationPage.template, /bi-three-dots/)
console.log('Favorites: category picker, separate removal, another save, refreshed state and failed-removal retry passed')
@@ -0,0 +1,146 @@
import assert from "node:assert/strict"
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
import { OfflineRoadMapsPanel, OfflineRoadsClient, areaLabel, estimateArea, formatBytes, radiusLabel, radiusRange, toKm, validOffline } from "../web/js/offline-road-maps.js"
import { RasterMap } from "../web/js/navigation-map.js"
import { NavigationPage } from "../web/js/navigation.js"
const constants = { minRadiusKm: 2, maxRadiusKm: 200, defaultRadiusKm: 25, averageDownloadBytes: 38000, averageStoredBytes: 6500, maxAreas: 24 }
const area = { id: "a".repeat(32), name: "Las Vegas", latitude: 36.11, longitude: -115.17, radiusKm: 25, created: 1, refreshed: 0,
progress: { state: "downloading", total: 400, done: 100, failed: 0 } }
const snapshot = (extra = {}) => ({ areas: [area], settings: { saveDriven: true }, position: { latitude: 36.1, longitude: -115.2 },
service: { running: true, network: "wifi", failure: null, noSpace: false, hasKey: true },
bytes: { saved: 5e6, driven: 2e6, cache: 1e6 }, limits: { saved: 3e9, driven: 6e8, cache: 1.5e8 },
usage: { tiles: 1234, freeTiles: 200000 }, route: null, constants, ...extra })
assert.equal(validOffline(snapshot()), true)
assert.equal(validOffline(snapshot({ position: null })), true)
for (const bad of [{ areas: {} }, { settings: { saveDriven: "yes" } }, { position: { latitude: 99, longitude: 0 } },
{ areas: [{ ...area, id: "../x" }] }, { areas: [{ ...area, progress: { state: "exploded", total: 1, done: 0 } }] },
{ constants: { ...constants, maxAreas: "24" } }, { service: null }])
assert.equal(validOffline(snapshot(bad)), false, JSON.stringify(bad))
// The same circle-of-tiles estimate as offline_roads.estimate (Python), e.g. 10 km at 36.1°N is 97 tiles.
assert.equal(estimateArea(36.1, 10, constants).tiles, 97)
assert.equal(estimateArea(36.1, 10, constants).downloadBytes, 97 * 38000)
assert.ok(estimateArea(60, 10, constants).tiles > estimateArea(0, 10, constants).tiles, "tiles shrink toward the poles")
assert.equal(formatBytes(0), "0 KB")
assert.equal(formatBytes(512 * 1024), "512 KB")
assert.equal(formatBytes(5.5 * 1024 ** 2), "5.5 MB")
assert.equal(formatBytes(3 * 1024 ** 3), "3.00 GB")
assert.equal(areaLabel(area), "Downloading · 25%")
assert.equal(areaLabel({ ...area, progress: null }), "Waiting for the downloader")
assert.equal(areaLabel({ ...area, progress: { state: "complete", total: 4, done: 4, failed: 0 } }), "Saved for offline")
assert.equal(areaLabel({ ...area, progress: { state: "incomplete", total: 4, done: 3, failed: 1 } }), "1 tiles missing · retries later")
// Imperial devices pick whole miles; areas are still saved in km within the device's limits.
assert.equal(radiusLabel(24.14, false), "15 mi")
assert.equal(radiusLabel(25, true), "25 km")
assert.deepEqual(radiusRange(constants, false), { min: 2, max: 124, initial: 15 })
assert.deepEqual(radiusRange(constants, true), { min: 2, max: 200, initial: 25 })
assert.equal(toKm(15, false), 24.1)
assert.ok(toKm(2, false) >= constants.minRadiusKm && toKm(124, false) <= constants.maxRadiusKm)
assert.equal(toKm(25, true), 25)
// Map: a coarser tile stands in while zooming, trackpad wheels step one level per gesture, redraws share a frame.
globalThis.ResizeObserver ??= class { observe() {} disconnect() {} }
const frames = []
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length }
globalThis.cancelAnimationFrame = () => {}
const listeners = {}
const canvas = { clientWidth: 400, clientHeight: 300, width: 0, height: 0, addEventListener: (name, fn) => { listeners[name] = fn },
removeEventListener() {}, setPointerCapture() {}, getBoundingClientRect: () => ({ left: 0, top: 0, width: 400, height: 300 }),
getContext: () => new Proxy({}, { get: (_, key) => key === "drawImage" ? (...args) => drawn.push(args) : () => {}, set: () => true }) }
const drawn = []
const map = new RasterMap(canvas)
map.load = () => {}
const parent = { close() {} }
map.tiles.set("11/600/800", parent)
const cover = map.placeholder(12, 1201, 1600)
assert.equal(cover[0], parent)
assert.deepEqual(cover.slice(1), [256, 0, 256], "the right quarter of the parent tile")
assert.equal(map.placeholder(12, 50, 50), null)
map.zoom = 3
let now = 1000
const realPerformance = globalThis.performance
globalThis.performance = { now: () => now }
for (let i = 0; i < 40; i++) listeners.wheel({ preventDefault() {}, deltaY: -12, deltaMode: 0 })
assert.equal(map.zoom, 4, "one trackpad flick is one zoom level, not ten")
now += 200
for (let i = 0; i < 9; i++) listeners.wheel({ preventDefault() {}, deltaY: -12, deltaMode: 0 })
assert.equal(map.zoom, 5)
globalThis.performance = realPerformance
frames.length = 0
map.requestDraw(); map.requestDraw(); map.requestDraw()
assert.equal(frames.length, 1, "pointer moves within a frame draw once")
let picked = null
map.onTap = point => { picked = point }
listeners.keydown({ key: "Enter", preventDefault() {} })
assert.deepEqual(picked, map.center, "keyboard users can select the map center")
map.close()
// Client: load, act, and hand 401s to the sign-in flow.
const requests = []
let reply = { status: 200, body: snapshot() }
const updates = []
const client = new OfflineRoadsClient({ publish: (update) => updates.push(update), later: () => 1, cancel() {},
fetcher: async (url, options) => { requests.push({ url, method: options.method || "GET", body: options.body ? JSON.parse(options.body) : null })
return { ok: reply.status < 400, status: reply.status, json: async () => reply.body } } })
await client.start()
assert.equal(requests[0].url, "./api/navigation/offline")
assert.deepEqual(updates.at(-1).data.areas.map((row) => row.name), ["Las Vegas"])
reply = { status: 200, body: snapshot({ areas: [] }) }
const result = await client.action({ action: "deleteArea", id: area.id })
assert.deepEqual(requests.at(-1), { url: "./api/navigation/offline", method: "POST", body: { action: "deleteArea", id: area.id } })
assert.deepEqual(result.areas, [])
assert.equal(updates.at(-1).busy, false)
reply = { status: 400, body: { error: "Choose a place and a radius between 2 and 200 km" } }
assert.equal(await client.action({ action: "addArea", area: {} }), null)
assert.equal(updates.at(-1).error, "Choose a place and a radius between 2 and 200 km")
reply = { status: 200, body: { areas: "nope" } }
await client.load()
assert.match(updates.at(-1).error, /unexpected answer/)
let signedOut = false
const guarded = new OfflineRoadsClient({ publish() {}, unauthorized: () => { signedOut = true }, later: () => 1, cancel() {},
fetcher: async () => ({ ok: false, status: 401, json: async () => ({}) }) })
await guarded.start()
assert.equal(signedOut, true)
assert.equal(guarded.active, false)
client.stop()
// An older poll cannot undo a completed setting change, even if fetch ignores abort.
const pending = []
const racing = new OfflineRoadsClient({ publish() {}, later: () => 1, cancel() {},
fetcher: () => new Promise(resolve => pending.push(resolve)) })
const oldPoll = racing.start()
const changed = racing.action({ action: "settings", patch: { saveDriven: false } })
const response = body => ({ ok: true, status: 200, json: async () => body })
pending[1](response(snapshot({ settings: { saveDriven: false } })))
await changed
pending[0](response(snapshot()))
await oldPoll
assert.equal(racing.data.settings.saveDriven, false)
const stoppedPoll = racing.load()
racing.stop()
pending[2](response(snapshot()))
await stoppedPoll
assert.equal(racing.data.settings.saveDriven, false, "stopped requests cannot publish")
let timeout
const stalled = new OfflineRoadsClient({ publish() {}, cancel() {}, later: (fn, ms) => { if (ms === 10000) timeout = fn; return 1 },
fetcher: (_url, { signal }) => new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(new DOMException("Timed out", "AbortError")))) })
stalled.active = true
const timedOut = stalled.action({ action: "settings", patch: { saveDriven: true } })
timeout()
await timedOut
assert.equal(stalled.busy, false, "an unresponsive action releases the controls")
assert.ok(stalled.error)
stalled.stop()
// Templates compile with the real Vue compiler, and the Offline Maps tab shows both panels.
const decodeEntities = (value) => value.replaceAll("&amp;", "&")
compile(OfflineRoadMapsPanel.template, { decodeEntities })
compile(NavigationPage.template, { decodeEntities })
assert.ok(NavigationPage.template.includes("<OfflineRoadMapsPanel"))
assert.ok(NavigationPage.template.includes("<MapOperationsPanel"))
console.log("Offline road maps: validation, estimates, client actions, auth and templates passed")
@@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import { validOperation, formatBytes, MapOperationsPanel } from '../web/js/map-operations.js'
const generation = 'a'.repeat(64)
const status = { schemaVersion: 1, ownerSession: 'owner', operationId: 'owner:1', state: 'completed', regionToken: 'us_state.Oklahoma',
bounds: [35, -98, 35.25, -97.75], completedGroups: 1, totalGroups: 1, transferredBytes: 256, transferBudgetBytes: 1024,
preparedGeneration: generation, selectedGeneration: generation, errorCode: null, selectedForNextShadowStart: true,
selectedSnapshot: { generation, storedBytes: 1024, tileCount: 1, bounds: [35, -98, 35.25, -97.75] } }
assert.equal(validOperation(status), true)
for (const info of [undefined, null, { ...status.selectedSnapshot, storedBytes: 0 },
{ ...status.selectedSnapshot, tileCount: 0 }, { ...status.selectedSnapshot, generation: 'b'.repeat(64) },
{ ...status.selectedSnapshot, bounds: null }, { ...status.selectedSnapshot, storedBytes: Number.NaN }]) {
assert.equal(validOperation({ ...status, selectedSnapshot: info }), false)
}
assert.equal(formatBytes(512), '512 B')
assert.equal(formatBytes(1024), '1.0 KiB')
assert.equal(formatBytes(2 * 1024 ** 2), '2.0 MiB')
assert.equal(formatBytes(2 * 1024 ** 3), '2.0 GiB')
assert.match(MapOperationsPanel.template, /Offline map database/)
assert.match(MapOperationsPanel.template, /selectedSnapshot.storedBytes/)
assert.match(MapOperationsPanel.template, /selectedSnapshot.tileCount/)
assert.doesNotMatch(MapOperationsPanel.template, /Download complete\. This map will be used/)
console.log('PASS: nonempty package identity required for completed projection; actual size and units rendered')
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { deleteRecords, storedSize } from '../web/js/record-deletion.js'
const empty = { schemaVersion: 1, reports: [], reportCount: 0, storageBytes: 0, more: false }
function fixture(replies = [empty]) {
const states = [], requests = []
const feed = { active: true, generation: 0, request: null, deleting: false,
poller: { stop() {}, start() {} }, later: () => 1, cancel: () => {},
publish: state => states.push(state), stop() { this.active = false; this.generation++; this.request?.abort() }, unauthorized() {},
fetcher: async (url, options) => { requests.push({ url, options }); return { ok: true, status: 200, json: async () => replies.shift() } } }
const accept = data => states.push(data)
const valid = data => data?.schemaVersion === 1 && Array.isArray(data.reports)
return { feed, requests, states, options: { accept, valid, subject: 'crash reports' } }
}
const cancel = fixture()
assert.equal(await deleteRecords(cancel.feed, '/delete', { all: true, confirmed: true }, { ...cancel.options, confirm: () => false }), false)
assert.equal(cancel.requests.length, 0)
const individual = fixture()
assert.equal(await deleteRecords(individual.feed, '/delete', { id: 'selected' }, individual.options), true)
assert.equal(individual.requests[0].options.method, 'POST')
assert.deepEqual(JSON.parse(individual.requests[0].options.body), { id: 'selected' })
assert.equal(individual.states.find(state => state.storageBytes === 0).reportCount, 0)
assert.equal(individual.states[0].preview, null)
assert.equal(individual.feed.deleting, false)
const batches = fixture([{ ...empty, more: true, storageBytes: 5 }, empty])
let message
assert.equal(await deleteRecords(batches.feed, '/delete', { all: true, confirmed: true }, {
...batches.options, confirm: value => { message = value; return true },
}), true)
assert.match(message, /Delete all crash reports.*cannot be undone/)
assert.equal(batches.requests.length, 2)
assert.deepEqual(batches.states.filter(state => state.storageBytes !== undefined).map(state => state.storageBytes), [5, 0])
const stale = fixture()
let reply
stale.feed.fetcher = () => new Promise(resolve => { reply = resolve })
const pending = deleteRecords(stale.feed, '/delete', { id: 'selected' }, stale.options)
stale.feed.stop()
reply({ ok: true, status: 200, json: async () => empty })
assert.equal(await pending, false)
assert.equal(stale.states.some(state => state.reports), false)
assert.equal(storedSize(7), '7 B')
assert.equal(storedSize(2048), '2.0 KB')
for (const file of ['sentry.js', 'logs.js']) {
const source = readFileSync(new URL('../web/js/' + file, import.meta.url), 'utf8')
assert.match(source, /Delete All/)
assert.match(source, />Delete<\/button>/)
assert.match(source, /storageBytes/)
assert.match(source, /:disabled="(?:deleting|crashes.deleting)"/)
assert.doesNotMatch(source, /v-html/)
}
console.log('record deletion confirmation, refresh, lifecycle and UI hooks passed')
const { SentryEventsFeed } = await import('../web/js/sentry.js')
const { CrashReportsFeed } = await import('../web/js/crash-reports.js')
for (const kind of ['sentry', 'crash']) {
const eventId = 'a'.repeat(32)
let inventory = kind === 'sentry'
? { schemaVersion: 1, source: 'local', scanIncomplete: false, capacity: 512, storageBytes: 10, storageIncomplete: false,
events: [{ eventId, kind: 'warning', systemTimeMs: 1700000000000 }] }
: { schemaVersion: 1, reports: [{ id: 'opaque-id', name: 'report', size: 10, modifiedAt: 1 }], reportCount: 1,
storageBytes: 10, storageIncomplete: false, scanIncomplete: false, listLimited: false }
const updates = [], calls = []
const Feed = kind === 'sentry' ? SentryEventsFeed : CrashReportsFeed
const feed = new Feed({ publish: update => updates.push(update), later: () => 1, cancel: () => {}, cancelTimer: () => {},
fetcher: async (url, options) => {
calls.push({ url, options })
if (options.method === 'POST') inventory = { ...inventory, events: [], reports: [], reportCount: 0, storageBytes: 0, more: false }
return { ok: true, status: 200, json: async () => inventory }
} })
await feed.start()
assert.equal(await feed.remove(kind === 'sentry' ? eventId : 'opaque-id'), true)
assert.equal(calls.at(-1).url, kind === 'sentry' ? './api/sentry/events/delete' : './api/crash-reports/delete')
assert.equal(kind === 'sentry' ? feed.data.events.length : feed.reports.length, 0)
assert.equal(kind === 'sentry' ? feed.data.storageBytes : updates.find(update => update.storageBytes === 0).storageBytes, 0)
feed.stop()
assert.equal(feed.deleting, false)
}
@@ -0,0 +1,289 @@
import assert from 'node:assert/strict'
import { ReplayClient, ReplayViewer, replayableSegment, validReplayStatus, replayTime, replayBufferTime, replaySeekPosition, replaySeekGaps } from '../web/js/replay.js'
const ownerId = '1'.repeat(32), sessionId = '2'.repeat(32), clientId = '3'.repeat(32)
const status = { schemaVersion: 1, ownerId, sessionId, state: 'playing', revision: 3, epoch: 2,
position: 12, start: 0, end: 60, rate: 1, paused: false, offroad: true, owned: true, codec: null, sourceWarnings: [] }
assert.equal(validReplayStatus(status), true)
const idle = { ...status, state: "idle", sessionId: null, owned: false }
delete idle.codec
assert.equal(validReplayStatus(idle), true, "idle and another viewer legitimately omit codec")
for (const change of [{ position: Infinity }, { end: -1 }, { rate: 4 }, { ownerId: '../owner' }, { epoch: -1 }, { offroad: undefined }, { state: 'invented' }]) {
assert.equal(validReplayStatus({ ...status, ...change }), false)
}
assert.equal(replayTime(65), '1:05')
assert.match(ReplayViewer.template, /<video/)
assert.doesNotMatch(ReplayViewer.template, /<img|takeover|recordings\/media/)
const video = { pause() {}, play: () => Promise.resolve(), removeAttribute() {}, load() {}, currentTime: 0 }
const updates = [], requests = [], timers = new Map()
let nextTimer = 0, unauthorized = 0
const client = new ReplayClient({ clientId, video: () => video, unauthorized: () => unauthorized++, publish: value => updates.push(value),
later: (fn, ms) => { timers.set(++nextTimer, { fn, ms }); return nextTimer }, cancelTimer: id => timers.delete(id),
fetcher: async (url, options) => {
requests.push({ url, options })
return { ok: true, status: 200, json: async () => status }
} })
client.active = true
client.status = status
client.ownedSession = sessionId
client.receive({ ...status, revision: 2, epoch: 1 })
assert.equal(client.status.epoch, 2, 'late status cannot restore an earlier seek epoch')
client.busy = true
await client.stop()
assert.match(requests[0].url, /\/stop$/)
assert.equal(JSON.parse(requests[0].options.body).sessionId, sessionId, 'Stop remains available during another action')
class Buffer extends EventTarget {
appendBuffer() {}
remove() {}
}
client.buffer = new Buffer()
const pending = client.append(new Uint8Array([1]), client.mediaGeneration)
client.retireMedia()
await assert.rejects(pending, /decode/)
assert.equal(client.buffer, null, 'retirement releases even a stalled decoder append')
assert.equal(client.source, null)
client.ownedSession = sessionId
client.receive({ ...status, offroad: false, revision: 4 })
assert.equal(client.source, null, "loss of live offroad evidence retires browser media immediately")
client.buffer = new Buffer()
const removed = client.updateBuffer(buffer => buffer.remove(0, 1), client.mediaGeneration)
client.retireMedia()
await assert.rejects(removed, /decode/)
client.close(false)
const revoked = new ReplayClient({ clientId, video: () => video, unauthorized: () => unauthorized++, publish() {},
later: () => 1, cancelTimer() {}, fetcher: async () => ({ ok: false, status: 401, json: async () => ({ error: 'Sign in' }) }) })
revoked.active = true
await revoked.refresh()
assert.equal(unauthorized, 1)
assert.equal(revoked.active, false)
assert.equal(revoked.source, null)
console.log('Replay: validated state, stale epoch rejection, busy Stop, stalled MSE retirement and auth loss passed')
// An old SourceBuffer callback cannot start a retired epoch, and oversized media
// is rejected before reading its body into memory.
const originalSource = globalThis.MediaSource
class Source extends EventTarget {
static isTypeSupported() { return true }
addSourceBuffer() { return new Buffer() }
}
globalThis.MediaSource = Source
let bodyReads = 0
const mediaRequests = []
const mediaClient = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
mediaSource: () => new Source(), createURL: () => 'blob:replay', revokeURL() {},
later: () => 1, cancelTimer() {}, fetcher: async (url) => {
mediaRequests.push(url)
return { ok: true, status: 200, headers: new Headers({ 'Content-Type': 'video/mp4',
'Content-Length': String(1024 * 1024 + 1), 'X-Replay-Epoch': '2', 'X-Replay-Next-Sequence': '1' }),
arrayBuffer: async () => { bodyReads++; return new ArrayBuffer(0) } }
} })
mediaClient.active = true
mediaClient.status = status
mediaClient.ownedSession = sessionId
mediaClient.beginMedia({ ...status, epoch: 1, codec: 'avc1.42E01F' })
const oldSource = mediaClient.source
mediaClient.beginMedia({ ...status, codec: 'avc1.42E01F' })
oldSource.dispatchEvent(new Event('sourceopen'))
assert.equal(mediaRequests.length, 0)
mediaClient.source.dispatchEvent(new Event('sourceopen'))
for (let i = 0; i < 8; i++) await Promise.resolve()
assert.equal(mediaRequests.length, 1)
assert.match(mediaRequests[0], /epoch=2/)
assert.equal(bodyReads, 0)
assert.equal(mediaClient.source, null)
assert.match(mediaClient.error, /budget/)
mediaClient.close(false)
console.log('Replay MSE: retired sourceopen ignored and oversized fragment rejected before body allocation passed')
// Closing while Start is in flight must stop a late accepted session instead
// of losing its identity when the component retires the request.
let acceptStart
const cancellationRequests = []
const canceledStart = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: (url, options) => {
cancellationRequests.push({ url, options })
return url.endsWith('/start') ? new Promise(resolve => { acceptStart = resolve }) : Promise.resolve({ ok: true, status: 200 })
} })
canceledStart.active = true; canceledStart.status = status; canceledStart.routeId = 'route'
const starting = canceledStart.start()
canceledStart.close()
acceptStart({ ok: true, status: 200, json: async () => status })
await starting
assert.equal(cancellationRequests.length, 2)
assert.match(cancellationRequests[1].url, /\/stop$/)
assert.equal(JSON.parse(cancellationRequests[1].options.body).sessionId, sessionId)
assert.equal(canceledStart.ownedSession, null)
console.log('Replay: closing during Start stops a late accepted session passed')
const acknowledgments = []
const controlClient = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: async (_url, options) => {
acknowledgments.push(JSON.parse(options.body))
return { ok: true, status: 200, json: async () => ({ ...status, revision: 4, lastRequestId: 'old' }) }
} })
controlClient.active = true; controlClient.status = status; controlClient.ownedSession = sessionId
await controlClient.control('pause', true)
assert.equal(controlClient.busy, true, 'accepted control waits for the engine acknowledgement')
controlClient.receive({ ...status, revision: 4, paused: true, lastRequestId: acknowledgments[0].requestId })
assert.equal(controlClient.busy, false)
controlClient.buffer = new Buffer()
controlClient.source = new Source()
controlClient.receive({ ...status, revision: 5, state: 'seeking', mediaReady: false })
assert.equal(controlClient.source, null, 'accepted seek removes the old video before the next epoch arrives')
controlClient.close(false)
console.log('Replay: pending control acknowledgement and immediate seek media retirement passed')
const bufferedVideo = { currentTime: 12.5, paused: false, pause() { this.paused = true },
removeAttribute() {}, load() { this.currentTime = 0 } }
const bufferedClient = new ReplayClient({ clientId, video: () => bufferedVideo, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, revokeURL() {} })
bufferedClient.active = true; bufferedClient.status = status; bufferedClient.ownedSession = sessionId
bufferedClient.source = new Source()
bufferedClient.buffer = { buffered: { length: 1, start: () => 12, end: () => 13.3 } }
const bufferedSource = bufferedClient.source, bufferedGeneration = bufferedClient.mediaGeneration
bufferedClient.receive({ ...status, revision: 4, state: 'buffering', frameReady: true, mediaReady: true })
assert.equal(bufferedClient.source, bufferedSource, 'production buffering preserves valid buffered media')
assert.equal(bufferedClient.mediaGeneration, bufferedGeneration)
assert.equal(bufferedVideo.currentTime, 12.5)
assert.equal(bufferedVideo.paused, false)
bufferedClient.receive({ ...status, revision: 5, state: 'seeking', frameReady: false, mediaReady: false })
assert.equal(bufferedClient.source, null, 'an accepted same-epoch seek still retires buffered media')
assert.equal(bufferedVideo.currentTime, 0)
bufferedClient.close(false)
console.log('Replay: production buffering preserves playback and an accepted seek still invalidates it passed')
controlClient.active = true; controlClient.startAttempted = true; controlClient.routeId = 'test-route'; controlClient.ownedSession = null
controlClient.status = { ...status, revision: 5 }
controlClient.receive({ ...status, revision: 6, routeId: 'test-route', owned: true })
assert.equal(controlClient.ownedSession, sessionId, 'status reconciles a lost accepted Start response for this exact viewer and route')
controlClient.receive({ ...status, revision: 7, state: 'ended', paused: true, routeId: 'test-route' })
assert.equal(controlClient.ownedSession, sessionId)
assert.equal(ReplayViewer.computed.playing.call({ client: controlClient, replay: controlClient.status }), true, 'ended replay keeps seek and Stop controls')
assert.match(ReplayViewer.template, /Return to start/)
controlClient.close(false)
console.log('Replay: uncertain Start reconciled and ended session stays seekable passed')
const pausedRanges = { length: 3, start: i => [0, .5, 1][i], end: i => [.05, .55, 1.05][i] }
assert.equal(replayBufferTime(pausedRanges), 1, 'paused sparse frames choose a real sample rather than the gap before it')
const resumedRange = { length: 1, start: () => 10, end: () => 11 }
assert.equal(replayBufferTime(resumedRange), 10.8, 'continuous playback retains bounded live lag')
console.log('Replay: sparse paused buffered ranges choose a decodable timestamp passed')
assert.equal(replayBufferTime(resumedRange, true), 10.999, 'Pause displays the final composed sample instead of consumer lag')
const gapped = { ...status, seekRanges: [[2, 10], [20, 40], [50, 58]] }
assert.equal(validReplayStatus(gapped), true)
for (const ranges of [[[0, 70]], [[10, 2]], [[0, 20], [10, 30]], [[0, NaN]], 'invalid']) {
assert.equal(validReplayStatus({ ...status, seekRanges: ranges }), false)
}
assert.equal(replaySeekPosition(gapped, 16), 20)
assert.equal(replaySeekPosition(gapped, 12), 10)
assert.equal(replaySeekPosition(gapped, 15), 10, 'equal distances choose the earlier boundary')
assert.equal(replaySeekPosition(gapped, -1), 2)
assert.equal(replaySeekPosition(gapped, 80), 58)
assert.equal(replaySeekPosition(gapped, 30), 30)
assert.deepEqual(replaySeekGaps(gapped), [[0, 2], [10, 20], [40, 50], [58, 60]])
assert.deepEqual(replaySeekGaps({ ...status, seekRanges: [] }), [])
assert.match(ReplayViewer.template, /Missing recordings:/)
const rejectedSeek = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: async (url, options) => {
if (options.method === 'POST') {
assert.equal(JSON.parse(options.body).position, 20, 'all client seek actions clamp through recorded ranges')
return { ok: false, status: 409, json: async () => ({ code: 'invalid_position', error: 'Recording changed' }) }
}
return { ok: true, status: 200, json: async () => gapped }
} })
rejectedSeek.active = true; rejectedSeek.status = gapped; rejectedSeek.ownedSession = sessionId
const existingVideo = rejectedSeek.source = {}
await rejectedSeek.control('seek', 16)
assert.equal(rejectedSeek.source, existingVideo, 'rejected seek keeps the previous playable video')
assert.equal(rejectedSeek.busy, false)
assert.equal(rejectedSeek.ownedSession, sessionId)
rejectedSeek.close(false)
console.log('Replay: recorded seek gaps clamp, display, and rejected seek recovery passed')
globalThis.MediaSource = originalSource
// Opening the selected route is the only launch step; polling must not restart it.
globalThis.MediaSource = Source
try {
for (const [offroad, playable, state, expected] of [[true, true, 'idle', 1], [false, true, 'idle', 0],
[true, false, 'idle', 0], [true, true, 'playing', 0]]) {
const launched = []
const automatic = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: async (url, options) => {
if (url.includes('/detail?')) return { ok: true, status: 200, json: async () => ({ playable }) }
if (url.endsWith('/start')) launched.push(JSON.parse(options.body))
return { ok: true, status: 200, json: async () => ({ ...idle, offroad, state }) }
} })
await automatic.open('selected-route')
await automatic.refresh()
assert.equal(launched.length, expected, 'auto-start requires playable, parked, idle replay and runs once')
if (expected) assert.equal(launched[0].routeId, 'selected-route')
automatic.close(false)
}
for (const segment of [null, 0, 2]) {
const starts = []
const selected = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: async (url, options) => {
if (url.includes('/detail?')) return { ok: true, status: 200, json: async () => ({ playable: true }) }
if (url.endsWith('/start')) starts.push(JSON.parse(options.body))
return { ok: true, status: 200, json: async () => idle }
} })
await selected.open('selected-route', segment)
assert.equal(starts.length, 1)
if (segment === null) assert.equal(Object.hasOwn(starts[0], 'segment'), false)
else assert.equal(starts[0].segment, segment, 'exact segment numbers, including zero, reach the shared owner')
selected.close(false)
}
let finishDetail
const closed = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: url => url.includes('/detail?') ? new Promise(resolve => { finishDetail = resolve }) :
Promise.resolve({ ok: true, status: 200, json: async () => idle }) })
let starts = 0
closed.start = () => starts++
const opening = closed.open('selected-route')
closed.close()
finishDetail({ ok: true, status: 200, json: async () => ({ playable: true }) })
await opening
assert.equal(starts, 0, 'closing during availability checks cannot launch replay')
let finishStatus, statusReads = 0
const retried = new ReplayClient({ clientId, video: () => video, unauthorized() {}, publish() {},
later: () => 1, cancelTimer() {}, fetcher: url => {
if (url.includes('/status?') && ++statusReads === 1) return new Promise(resolve => { finishStatus = resolve })
return Promise.resolve({ ok: true, status: 200, json: async () => url.includes('/detail?') ? { playable: true } : idle })
} })
const launches = []
retried.start = () => launches.push(retried.routeId)
const firstOpen = retried.open('old-route')
await retried.open('new-route')
finishStatus({ ok: true, status: 200, json: async () => idle })
await firstOpen
assert.deepEqual(launches, ['new-route'], 'reopening during an old status request launches only the new route')
retried.close(false)
} finally { globalThis.MediaSource = originalSource }
assert.doesNotMatch(ReplayViewer.template, />Replay route<|>Refresh status</)
assert.match(ReplayViewer.template, /ref="player"[\s\S]*gx-replay__media-controls/)
console.log('Replay: automatic launch, parked/playable/busy guards and close-during-open passed')
const selectedRates = []
const speedControl = { client: { control: (action, value) => selectedRates.push([action, value]) }, replay: { rate: .5 } }
for (const rate of [.5, 1, 2]) {
speedControl.replay.rate = rate
ReplayViewer.methods.cycleRate.call(speedControl)
}
assert.deepEqual(selectedRates, [['rate', 1], ['rate', 2], ['rate', .5]])
assert.ok(ReplayViewer.template.indexOf('gx-replay__timeline') < ReplayViewer.template.indexOf('class="gx-replay__controls"'))
assert.match(ReplayViewer.template, /@click="seek\(replay.position-10\)">-10 s/)
assert.match(ReplayViewer.template, /@click="seek\(replay.position\+10\)">\+10 s/)
assert.match(ReplayViewer.template, /@click="close">Stop/)
assert.doesNotMatch(ReplayViewer.template, /GalaxySelect/)
console.log('Replay controls: native button order, seek actions, speed cycling and Stop-to-close passed')
for (const [files, expected] of [[{ rlog: true, fcamera: true }, true], [{ qlog: true, qcamera: true }, true],
[{ rlog: true, ecamera: true }, false], [{ rlog: true }, false], [{ fcamera: true }, false]]) {
assert.equal(replayableSegment({ files }), expected)
}
@@ -0,0 +1,256 @@
import assert from 'node:assert/strict'
import { ReplayClient, ReplayViewer } from '../web/js/replay.js'
const originalSource = globalThis.MediaSource, originalManaged = globalThis.ManagedMediaSource
const mime = 'video/mp4; codecs="avc1.42801f"'
const status = { schemaVersion: 1, ownerId: '1'.repeat(32), sessionId: '2'.repeat(32), state: 'playing',
revision: 3, epoch: 2, position: 12, start: 0, end: 60, rate: 1, paused: false, offroad: true,
owned: true, codec: 'avc1.42801f', media: { firstSequence: 1 } }
const ranges = (start, end) => ({ length: 1, start: () => start, end: () => end })
const settle = async () => { for (let i = 0; i < 20; i++) await Promise.resolve() }
class Buffer extends EventTarget {
buffered = { length: 0 }
appendBuffer(bytes) {
if (new Uint8Array(bytes)[0]) this.buffered = ranges(0, .05)
queueMicrotask(() => this.dispatchEvent(new Event('updateend')))
}
}
class Source extends EventTarget {
readyState = 'open'
endOfStream() { this.readyState = 'ended' }
static isTypeSupported(type) { return type === mime }
addSourceBuffer(type) { assert.equal(type, mime); return new Buffer() }
}
class Managed extends Source {}
class Unsupported extends Source { static isTypeSupported() { return false } }
function viewer() {
const video = Object.assign(new EventTarget(), { currentTime: 0, paused: true, seeking: false,
pause() { this.paused = true }, play() { this.paused = false; return Promise.resolve() },
removeAttribute() {}, load() {} })
const requests = [], timers = new Map()
let timer = 0
const client = new ReplayClient({ clientId: '3'.repeat(32), video: () => video, publish() {}, unauthorized() {},
createURL: () => 'blob:replay', revokeURL() {}, later: (fn, ms) => { timers.set(++timer, { fn, ms }); return timer },
cancelTimer: id => timers.delete(id), fetcher: async url => {
requests.push(url)
const sequence = Number(new URL(url, 'http://replay.test').searchParams.get('sequence'))
if (sequence >= 2) return { status: 202 }
return { ok: true, status: 200, headers: new Headers({ 'Content-Type': 'video/mp4', 'Content-Length': '1',
'X-Replay-Epoch': '2', 'X-Replay-Next-Sequence': String(sequence + 1) }),
arrayBuffer: async () => new Uint8Array([sequence]).buffer }
} })
client.active = true; client.status = status; client.ownedSession = status.sessionId
return { client, video, requests, timers }
}
try {
for (const [classic, managed, selected] of [[Source, undefined, Source], [undefined, Managed, Managed],
[Source, Managed, Source], [Unsupported, Managed, Managed]]) {
globalThis.MediaSource = classic; globalThis.ManagedMediaSource = managed
const { client, video } = viewer()
client.beginMedia(status)
assert.equal(client.source.constructor, selected, 'capability check and construction use the same supported API')
assert.equal(client.error, '')
if (selected === Managed) assert.equal(video.disableRemotePlayback, true, 'managed playback is enabled before attaching video')
client.close(false)
}
const preparing = viewer()
preparing.client.beginMedia({ ...status, codec: null, mediaReady: false })
assert.equal(preparing.requests.length, 0, 'unready video cannot be mistaken for an unsupported codec and stop preparation')
preparing.client.close(false)
globalThis.MediaSource = globalThis.ManagedMediaSource = undefined
const unavailable = viewer()
await unavailable.client.start()
assert.equal(unavailable.requests.length, 0, 'unsupported streaming does not start device work')
assert.match(unavailable.client.error, /streaming is unavailable/)
unavailable.client.beginMedia(status)
assert.match(unavailable.requests.at(-1), /\/stop$/, 'an unplayable owned stream releases device work')
const failedGeneration = unavailable.client.mediaGeneration
unavailable.client.receive({ ...status, revision: 4 })
assert.equal(unavailable.client.mediaGeneration, failedGeneration, 'status polling does not retry unsupported playback')
globalThis.MediaSource = Unsupported
unavailable.client.beginMedia(status)
assert.match(unavailable.client.error, /H\.264 video/, 'codec rejection differs from missing streaming API')
unavailable.client.close(false)
globalThis.MediaSource = undefined; globalThis.ManagedMediaSource = Managed
const { client, video, requests, timers } = viewer()
client.error = 'Previous error'
client.beginMedia(status)
assert.equal(client.error, '', 'explicit retry clears its previous error')
const source = client.source
source.dispatchEvent(new Event('startstreaming'))
assert.equal(requests.length, 0, 'managed demand waits for sourceopen')
assert.equal(timers.size, 1, 'early managed demand retains the sourceopen timeout')
source.dispatchEvent(new Event('sourceopen'))
await settle()
assert.match(requests[0], /sequence=0/)
source.dispatchEvent(new Event('endstreaming'))
client.status = { ...status, paused: true }
await client.loadMedia(client.mediaGeneration, status.sessionId, status.epoch)
assert.equal(client.buffer.buffered.length, 1, 'a paused or newly opened viewer can bootstrap its first frame')
const afterBootstrap = requests.length
await client.loadMedia(client.mediaGeneration, status.sessionId, status.epoch)
assert.equal(requests.length, afterBootstrap, 'managed endstreaming stops requests once the paused preview is buffered')
client.status = status
source.dispatchEvent(new Event('startstreaming'))
source.dispatchEvent(new Event('startstreaming'))
await settle()
assert.equal(requests.length, afterBootstrap + 1, 'duplicate managed demand retains one in-flight request')
source.dispatchEvent(new Event('endstreaming'))
video.seeking = true
video.dispatchEvent(new Event('seeking'))
source.dispatchEvent(new Event('endstreaming'))
await settle()
assert.equal(requests.length, afterBootstrap + 2, 'seeking resumes without Safari emitting startstreaming')
assert.equal(client.mediaDemand, true, 'endstreaming during a seek cannot latch the loader off')
video.seeking = false
source.dispatchEvent(new Event('endstreaming'))
client.status = { ...status, paused: true }
client.receive({ ...status, revision: 4 })
await settle()
assert.equal(requests.length, afterBootstrap + 3, 'Play resumes without waiting for managed demand')
let pausedSeeks = 0, pausedTime = .049
Object.defineProperty(video, 'currentTime', { get: () => pausedTime,
set(value) { pausedSeeks++; pausedTime = value }, configurable: true })
client.receive({ ...status, paused: true, revision: 5 })
client.receive({ ...status, paused: true, revision: 6 })
assert.equal(pausedSeeks, 0, 'paused status polling does not repeatedly seek to an unchanged frame')
const buffer = client.buffer, evicted = new Event('bufferedchange')
video.currentTime = .025
evicted.removedRanges = ranges(0, .01)
buffer.dispatchEvent(evicted)
assert.equal(client.source, source, 'removing already played data retains playback')
evicted.removedRanges = ranges(0, .05)
buffer.buffered = { length: 0 }
buffer.dispatchEvent(evicted)
assert.notEqual(client.source, source, 'evicting the current video reopens at an available keyframe')
const replacement = client.source, beforeStaleEvents = requests.length
source.dispatchEvent(new Event('startstreaming')); buffer.dispatchEvent(evicted)
assert.equal(client.source, replacement, 'retired managed callbacks cannot replace the current epoch')
client.close(false)
replacement.dispatchEvent(new Event('sourceopen')); video.dispatchEvent(new Event('seeking'))
await settle()
assert.equal(requests.length, beforeStaleEvents, 'closing retires managed callbacks and removes the seek listener')
assert.equal(timers.size, 0)
const automatic = viewer()
automatic.client.status = { ...status, paused: true, state: 'paused' }
automatic.client.autoplay = true
const resumes = []
automatic.client.control = async (action, value) => {
assert.equal(automatic.client.buffer.buffered.length, 1, 'auto-play waits for a real buffered frame')
resumes.push([action, value])
automatic.client.status = status
}
automatic.client.beginMedia(automatic.client.status)
assert.equal(resumes.length, 0, 'opening a source does not resume an unbuffered route')
automatic.client.source.dispatchEvent(new Event('sourceopen'))
await settle()
await automatic.client.loadMedia(automatic.client.mediaGeneration, status.sessionId, status.epoch)
assert.deepEqual(resumes, [['pause', false]])
await automatic.client.loadMedia(automatic.client.mediaGeneration, status.sessionId, status.epoch)
assert.equal(resumes.length, 1, 'later fragments cannot repeatedly resume the route')
automatic.client.close(false)
const manual = viewer()
manual.client.autoplay = true
manual.client.ownedSession = null
manual.client.control('pause', true)
assert.equal(manual.client.autoplay, false, 'manual controls cancel automatic playback')
manual.client.close(false)
const stalled = viewer()
stalled.client.beginMedia(status)
const timeout = [...stalled.timers.values()].find(({ ms }) => ms === 4000)
timeout.fn()
assert.match(stalled.client.error, /could not open/)
assert.equal(stalled.client.source, null)
assert.equal(stalled.client.videoFailed, true, 'a source that cannot open waits for an explicit retry')
stalled.client.close(false)
const interrupted = viewer()
interrupted.client.beginMedia(status)
interrupted.client.mediaFailed(new Error('Replay video was incomplete.'), interrupted.client.mediaGeneration)
interrupted.client.receive({ ...status, revision: 4 })
assert.ok(interrupted.client.source, 'temporary media delivery failures recover from current status')
interrupted.client.close(false)
for (const Api of [Source, Managed]) {
globalThis.MediaSource = Api === Managed ? undefined : Api
globalThis.ManagedMediaSource = Api === Managed ? Managed : undefined
const playback = viewer()
playback.client.beginMedia(status)
playback.client.source.dispatchEvent(new Event('sourceopen'))
await settle()
await playback.client.loadMedia(playback.client.mediaGeneration, status.sessionId, status.epoch)
assert.equal(playback.video.paused, true, 'one frame cannot start browser playback')
playback.client.buffer.buffered = ranges(0, .6)
playback.client.receive({ ...status, revision: 4 })
assert.equal(playback.video.paused, false, 'status updates use the same buffered playback gate')
const generation = playback.client.mediaGeneration
playback.video.currentTime = .6
playback.client.videoWaiting()
assert.equal(playback.video.paused, true)
assert.equal(playback.client.status.paused, false, 'buffering pauses only the browser, leaving the producer running')
assert.equal(playback.client.mediaGeneration, generation, 'an underrun retains the decoder and buffered video')
playback.client.buffer.buffered = { length: 2, start: i => [0, 1][i], end: i => [.7, 2][i] }
playback.client.playVideo()
assert.equal(playback.video.paused, true, 'a gap cannot count toward the playback reserve')
playback.client.buffer.buffered = ranges(0, 1.2)
playback.client.playVideo()
assert.equal(playback.video.paused, false, 'refilling the contiguous range resumes playback')
playback.client.receive({ ...status, paused: true, state: 'paused', revision: 5 })
assert.equal(playback.video.paused, true, 'user Pause remains immediate')
playback.client.receive({ ...status, revision: 6 })
assert.equal(playback.video.paused, true, 'Play rebuilds reserve after the paused preview')
playback.client.receive({ ...status, revision: 7, state: 'ended', paused: true,
media: { ...status.media, lastSequence: playback.client.sequence - 1 } })
assert.equal(playback.video.paused, false, 'a short buffered tail finishes without waiting for future frames')
assert.equal(playback.client.source.readyState, 'ended', 'only a fully appended route finalizes its media source')
playback.client.close(false)
playback.client.videoWaiting()
await settle()
assert.equal(playback.timers.size, 0, 'retired waiting callbacks cannot restart the loader')
}
globalThis.MediaSource = undefined; globalThis.ManagedMediaSource = Managed
const bounded = viewer()
bounded.client.beginMedia(status)
bounded.client.source.dispatchEvent(new Event('sourceopen'))
await settle()
await bounded.client.loadMedia(bounded.client.mediaGeneration, status.sessionId, status.epoch)
await bounded.client.loadMedia(bounded.client.mediaGeneration, status.sessionId, status.epoch)
const nextLoad = [...bounded.timers.values()].find(({ ms }) => ms !== 4000)
assert.equal(nextLoad.ms, 50, 'pending fragments retry after 50ms during playback')
bounded.client.buffer.buffered = ranges(0, 1.4)
bounded.video.currentTime = .1
const beforeFull = bounded.requests.length
await bounded.client.loadMedia(bounded.client.mediaGeneration, status.sessionId, status.epoch)
assert.equal(bounded.requests.length, beforeFull, 'a full forward buffer applies backpressure without an HTTP request')
assert.equal(bounded.video.currentTime, .1, 'buffering does not skip valid replay frames')
bounded.video.currentTime = .6
bounded.client.mediaDemand = true
await bounded.client.loadMedia(bounded.client.mediaGeneration, status.sessionId, status.epoch)
assert.equal(bounded.requests.length, beforeFull + 1, 'consuming the buffer releases backpressure')
bounded.client.close(false)
const finishing = viewer()
finishing.client.beginMedia(status)
finishing.client.source.dispatchEvent(new Event('sourceopen'))
await settle()
await finishing.client.loadMedia(finishing.client.mediaGeneration, status.sessionId, status.epoch)
finishing.client.source.dispatchEvent(new Event('endstreaming'))
const beforeFinish = finishing.requests.length
finishing.client.receive({ ...status, state: 'ended', paused: true, revision: 4, media: { firstSequence: 1, lastSequence: 2 } })
await settle()
assert.equal(finishing.requests.length, beforeFinish + 1, 'route end requests its remaining fragment even after managed demand stopped')
assert.equal(finishing.client.source.readyState, 'open', 'an unfetched tail cannot be mistaken for end of stream')
finishing.client.close(false)
await settle()
assert.equal(finishing.timers.size, 0)
assert.doesNotMatch(ReplayViewer.template, /<video[^>]*\bautoplay\b/, 'HTML autoplay cannot bypass the playback gate')
assert.match(ReplayViewer.template, /!replayError.*preparing/, 'preparation does not mask a browser playback failure')
console.log('Replay media: classic/managed capability selection, activation, bounded demand, seeking, eviction, retirement and errors passed')
} finally {
globalThis.MediaSource = originalSource; globalThis.ManagedMediaSource = originalManaged
}
@@ -0,0 +1,76 @@
import assert from "node:assert/strict"
import { SettingsFeed } from "../web/js/settings.js"
import { GalaxySettingRow } from "../web/js/galaxy-setting-row.js"
import { compile } from "../web/vendor/vue/vue.esm-browser.js"
const requests = [], updates = []
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
const feed = new SettingsFeed({ publish: state => updates.push(state), later: () => 1, cancelTimer: () => {},
fetcher: (url, options) => new Promise(resolve => requests.push({ url, options, resolve })) })
async function reply(index, body, status = 200) {
requests[index].resolve({ ok: status === 200, status, json: async () => body }); await flush()
}
const row = { label: "Use StarPilot Widgets", value: "Off", defaultValue: "On", resetAvailable: true,
choices: ["Off", "On"], page: "", available: true, action: true, revision: "same-settings" }
const page = { page: "appearance", parked: true, view: "opaque-view", rows: [row] }
feed.start("appearance")
await reply(0, page)
feed.resetDefault(0)
assert.equal(requests[1].url, "./api/settings/reset-default")
assert.deepEqual(JSON.parse(requests[1].options.body), { view: "opaque-view", row: 0 })
await reply(1, { intent: "reset-intent", question: "Reset to default?", proposed: "On" })
assert.equal(updates.at(-1).pending, null, "defaults never display a per-setting confirmation")
assert.equal(requests[2].url, "./api/settings/confirm")
assert.deepEqual(JSON.parse(requests[2].options.body), { intent: "reset-intent", confirmed: true })
await reply(2, { saved: true })
await reply(3, { ...page, view: "fresh", rows: [{ ...row, value: "On", resetAvailable: false }] })
feed.resetDefault(0)
assert.equal(requests.length, 4, "an unavailable default cannot be submitted")
feed.stop()
assert.ok(GalaxySettingRow.emits.includes("reset-default"))
const renderTemplate = compile(GalaxySettingRow.template, { decodeEntities: raw => raw })
const rowContext = context => ({ ...context, presentedRow: GalaxySettingRow.computed.presentedRow.call(context) })
const render = (context, cache) => renderTemplate(rowContext(context), cache)
function buttons(node) {
if (!node || typeof node !== "object") return []
return [...(node.type === "button" ? [node] : []), ...(Array.isArray(node.children) ? node.children.flatMap(buttons) : [])]
}
const context = { row, index: 2, control: "switch", currentValue: "Off", dimmed: false, locked: false, busy: false, updating: false,
showDefault: true, onSwitch: () => {}, $emit: (...args) => events.push(args) }
const events = []
const button = buttons(render(context, [])).find(node => node.children === "Default")
assert.ok(button, "the button is actually rendered for a setting with a default")
assert.equal(button.props.disabled, false)
assert.equal(button.props["aria-label"], "Reset Use StarPilot Widgets to default")
button.props.onClick()
assert.deepEqual(events, [["reset-default", 2]])
const savingRow = render({ ...context, locked: true, busy: true }, [])
assert.equal(savingRow.props.inert, true, "saving blocks interaction without dimming controls")
assert.equal(buttons(savingRow).find(node => node.children === "Default").props.disabled, false)
assert.equal(buttons(render({ ...context, dimmed: true }, [])).find(node => node.children === "Default").props.disabled, true)
assert.equal(buttons(render({ ...context, row: { ...row, defaultValue: null }, showDefault: false }, [])).some(node => node.children === "Default"), false)
// A successful mutation invalidates every active consumer without user refreshes.
let savedValue = "Off"
const dependent = []
const sharedFetcher = async (url) => ({ ok: true, status: 200, json: async () => {
if (url.endsWith("/reset-default")) return { intent: "shared-default" }
if (url.endsWith("/confirm")) { savedValue = "On"; return { saved: true } }
const pageName = url.split("/").at(-1)
return { page: pageName, view: pageName + savedValue, parked: true, rows: [{ ...row, value: savedValue }] }
} })
const source = new SettingsFeed({ publish() {}, fetcher: sharedFetcher, later: () => 1, cancelTimer() {} })
const consumer = new SettingsFeed({ publish: value => dependent.push(value), fetcher: sharedFetcher, later: () => 1, cancelTimer() {} })
await source.start("appearance")
await consumer.start("pip")
await source.resetDefault(0)
await flush()
assert.equal(dependent.at(-1).data.rows[0].value, "On")
source.stop()
consumer.stop()
assert.equal(GalaxySettingRow.computed.showDefault.call(rowContext({ row })), true)
assert.equal(GalaxySettingRow.computed.showDefault.call(rowContext({ row: { ...row, value: 'On' } })), false)
assert.equal(GalaxySettingRow.computed.showDefault.call(rowContext({ row: { ...row, value: '0.9', defaultValue: '0.90' } })), false)
assert.equal(buttons(render({ ...context, showDefault: false }, [])).some(node => node.children === 'Default'), false)
@@ -0,0 +1,43 @@
import assert from "node:assert/strict"
import { readFileSync } from "node:fs"
import { SettingsRecoveryPage } from "../web/js/settings-recovery.js"
const initial = { view: "view-1", rows: [{ id: 0, page: "appearance", label: "Wheel colors", current: "On", default: "Off", resetAvailable: true }] }
const calls = []
let saved = false
const oldFetch = globalThis.fetch
globalThis.fetch = async (path, options) => {
calls.push({ path, options, body: options.body ? JSON.parse(options.body) : null })
const value = path.endsWith("/preview") ? { intent: "confirmation-1", question: "Reset selected settings?", labels: ["Wheel colors"] }
: path.endsWith("/confirm") ? (saved = true, { saved: true, applied: ["Wheel colors"], refresh: true })
: (saved ? { view: "view-2", rows: [] } : initial)
return { ok: true, status: 200, json: async () => value }
}
try {
const state = { ...SettingsRecoveryPage.data(), mode: "local", unauthorized: () => assert.fail("unexpected logout") }
for (const [name, method] of Object.entries(SettingsRecoveryPage.methods)) state[name] = method.bind(state)
await state.refresh()
assert.equal(state.snapshot.rows[0].default, "Off")
await state.review([0])
assert.equal(saved, false)
assert.equal(state.pending.intent, "confirmation-1")
assert.deepEqual(calls.at(-1).body, { view: "view-1", selected: [0] })
await state.confirm()
assert.equal(saved, true)
assert.equal(state.snapshot.rows.length, 0)
assert.equal(state.pending, null)
assert.deepEqual(calls.find(call => call.path.endsWith("/confirm")).body, { intent: "confirmation-1", confirmed: true })
assert.match(SettingsRecoveryPage.template, /Reset Selected/)
assert.match(SettingsRecoveryPage.template, /Reset All Non-Default Settings/)
assert.match(SettingsRecoveryPage.template, /review\(\[row.id\]\)/)
assert.match(SettingsRecoveryPage.template, /Current:/)
assert.match(SettingsRecoveryPage.template, /!row.resetAvailable/)
assert.match(SettingsRecoveryPage.template, /filter\(row => row.resetAvailable\)/)
assert.match(SettingsRecoveryPage.template, /Some settings are unavailable for this vehicle/)
const app = readFileSync(new URL("../web/js/app.js", import.meta.url), "utf8")
const tools = readFileSync(new URL("../web/js/diagnostic-tools.js", import.meta.url), "utf8")
assert.match(app, /route.path === '\/settings\/recovery'/)
assert.match(tools, /Show Non-Default Values/)
assert.match(tools, /Reset to Default/)
assert.match(tools, /navigate', '\/settings\/recovery'/)
} finally { globalThis.fetch = oldFetch }
@@ -0,0 +1,14 @@
import assert from "node:assert/strict"
import { validDocument } from "../web/js/onroad-layout.js"
const metadata = { profiles: Object.fromEntries(["large", "compact"].map(name => [name, {
bounds: {x:0,y:0,width:100,height:100}, widgets:{}, reservedZones:[]
}])) }
const document = { version:7, palette:{cardFill:"#000000A6",cardBorder:"#C4CDD0B4",text:"#FFFFFFFF"},
layouts:{large:{},compact:{}},widgetColors:{large:{},compact:{}},roadColors:{large:{},compact:{}} }
assert.equal(validDocument(document, metadata), true)
for (const flag of [true, false]) assert.equal(validDocument({...document,speedSources:flag},metadata),true)
for (const flag of [1, 0, null, "true", {}, []]) assert.equal(validDocument({...document,speedSources:flag},metadata),false)
for (const malformed of [null,undefined,[],1,"layout",{}]) assert.equal(validDocument(malformed,metadata),false)
assert.equal(validDocument({...document,unexpected:true},metadata),false)
assert.equal(validDocument({...document,layouts:null},metadata),false)
console.log("PASS speed source preference: absent/booleans/invalid flags/null/undefined/malformed documents")
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import { readdir } from 'node:fs/promises'
import { compile } from '../web/vendor/vue/vue.esm-browser.js'
// Compile every exported component, including lazily loaded pages and nested primitives.
globalThis.location = { hash: '#/' }
const directory = new URL('../web/js/', import.meta.url)
const visited = new Set()
let count = 0
function check(component, label) {
if (!component || typeof component !== 'object' || visited.has(component)) return
visited.add(component)
if (component.template) {
const errors = []
compile(component.template, { decodeEntities: value => value.replaceAll('&amp;', '&'), onError: error => errors.push(error.message) })
assert.deepEqual(errors, [], label)
count++
}
for (const [name, child] of Object.entries(component.components || {})) check(child, `${label}/${name}`)
}
for (const file of await readdir(directory)) {
if (!file.endsWith('.js') || ['app.js', 'boot.js'].includes(file)) continue
const module = await import(new URL(file, directory))
for (const [name, component] of Object.entries(module)) check(component, `${file}/${name}`)
}
assert.ok(count >= 40, `Expected the whole component inventory, got ${count}`)
console.log(`Compiled ${count} Galaxy page and shared-component templates.`)
@@ -0,0 +1,101 @@
import assert from "node:assert/strict"
import { UpdateRestart, UPDATE_RESTART_KEY } from "../web/js/update-restart.js"
import { SoftwareStatusFeed } from "../web/js/software-status.js"
const baseline = { commit: 'a'.repeat(40), branch: 'Dom', target: 'Dom', action: 'fast' }
const status = (commit = baseline.commit, request = null) => ({ schemaVersion: 1,
installed: { commit, branch: 'Dom' }, updater: { state: 'idle' }, operations: { request } })
const pending = { action: 'fast', state: 'pending' }
const flush = async () => { for (let i = 0; i < 12; i++) await Promise.resolve() }
function fixture(saved = null) {
const values = new Map(saved ? [[UPDATE_RESTART_KEY, JSON.stringify(saved)]] : [])
const storage = { getItem: key => values.get(key), setItem: (key, value) => values.set(key, value), removeItem: key => values.delete(key) }
const states = [], timers = new Map(), requests = []
let nextTimer = 0
let reloads = 0
const recovery = new UpdateRestart({ storage, publish: value => states.push(value),
request: () => new Promise((resolve, reject) => requests.push({ resolve, reject })),
later: (fn, ms) => { const id = ++nextTimer; timers.set(id, fn); return id },
cancel: id => timers.delete(id), reload: () => reloads++ })
return { recovery, states, requests, timers, values, tick() { const [id, fn] = timers.entries().next().value; timers.delete(id); fn() }, get reloads() { return reloads } }
}
const normal = fixture()
assert.deepEqual(normal.states, [false])
assert.equal(normal.requests.length, 0)
normal.recovery.start(baseline)
assert.deepEqual(normal.states, [false, true])
normal.recovery.start(baseline)
assert.equal(normal.requests.length, 1)
normal.requests[0].resolve(status(baseline.commit, pending))
await flush()
assert.equal(normal.reloads, 0)
normal.tick()
normal.requests[1].reject(new Error('Device offline'))
await flush()
assert.equal(normal.states.at(-1), true)
normal.tick()
normal.requests[2].resolve(status())
await flush()
normal.tick()
normal.requests[3].resolve(status('b'.repeat(40)))
await flush()
assert.equal(normal.reloads, 1)
assert.equal(normal.states.at(-1), false)
assert.equal(normal.values.has(UPDATE_RESTART_KEY), false)
// A changed on-disk build with an active restart request must not dismiss the waiting screen.
const installing = fixture()
installing.recovery.start(baseline)
installing.requests[0].resolve(status('b'.repeat(40), { state: 'complete', outcome: 'restarting' }))
await flush()
assert.equal(installing.reloads, 0)
// A same-revision return is ambiguous; keep waiting for explicit completion or a changed build.
const resumed = fixture(baseline)
assert.equal(resumed.states[0], true)
resumed.requests[0].resolve(status())
await flush()
assert.equal(resumed.reloads, 0)
for (const request of [null, { state: 'failed' }, { state: 'complete', outcome: 'up_to_date' }]) {
const recovery = fixture()
recovery.recovery.start(baseline)
recovery.requests[0].resolve(status(request ? baseline.commit : 'b'.repeat(40), request))
await flush()
assert.equal(recovery.reloads, 1)
}
const rebooted = fixture({ ...baseline, instance: 'one' })
rebooted.requests[0].resolve({ ...status(), instance: 'one' })
await flush()
assert.equal(rebooted.reloads, 0)
rebooted.tick()
rebooted.requests[1].resolve({ ...status(), instance: 'two' })
await flush()
assert.equal(rebooted.reloads, 1)
const busy = fixture({ ...baseline, instance: 'one' })
busy.requests[0].resolve({ ...status(baseline.commit, pending), instance: 'two' })
await flush()
assert.equal(busy.reloads, 0)
const kept = fixture()
kept.recovery.start({ ...baseline, instance: 'one' })
kept.recovery.start({ ...baseline, instance: 'two' })
assert.equal(JSON.parse(kept.values.get(UPDATE_RESTART_KEY)).instance, 'one')
// Only update-related restarts/disconnects trigger the full-screen handoff.
const restarts = []
const feed = new SoftwareStatusFeed({ publish() {}, restarting: value => restarts.push(value) })
feed.data = status(baseline.commit, pending)
feed.error = 'Connection lost'
feed.emit('ready')
assert.equal(restarts.length, 0)
feed.installBaseline = baseline
feed.error = ''
feed.emit('ready')
assert.equal(restarts.length, 0)
feed.error = 'Connection lost'
feed.emit('ready')
assert.equal(restarts.length, 1)
feed.error = ''
feed.data.operations.progress = { stage: 'rebooting' }
feed.emit('ready')
assert.equal(restarts.length, 2)
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict'
import {deflateSync} from 'node:zlib'
import {LayoutPreviewFeed,PreviewQueue} from '../web/js/layout-preview.js'
import {widgetArtMetadata} from '../web/js/widget-art.js'
import {LayoutWidgetPreview, OnroadLayoutPage} from '../web/js/onroad-layout.js'
function chunk(kind, bytes) {
const body=Buffer.concat([Buffer.from(kind),bytes]);let crc=0xffffffff
for(const byte of body){crc^=byte;for(let i=0;i<8;i++)crc=(crc>>>1)^(crc&1?0xedb88320:0)}
const size=Buffer.alloc(4),sum=Buffer.alloc(4);size.writeUInt32BE(bytes.length);sum.writeUInt32BE((crc^0xffffffff)>>>0)
return Buffer.concat([size,body,sum])
}
const meta={version:1,width:2,height:1,sprites:{conditional_mode:{x:0,y:0,width:2,height:1}}}
function image(metadata=meta){
const header=Buffer.alloc(13);header.writeUInt32BE(2);header.writeUInt32BE(1,4);header[8]=8;header[9]=6
return new Blob([Buffer.concat([Buffer.from([137,80,78,71,13,10,26,10]),chunk('IHDR',header),
chunk('tEXt',Buffer.from('starpilot-widget-art\0'+JSON.stringify(metadata))),
chunk('IDAT',deflateSync(Buffer.from([0,255,255,255,255,0,0,0,0]))),chunk('IEND',Buffer.alloc(0))])],{type:'image/png'})
}
assert.deepEqual(await widgetArtMetadata(image()),meta)
const withHeader={...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-32.5}}}
assert.deepEqual(await widgetArtMetadata(image(withHeader)),withHeader,'Native action sprites include the heading above the buttons')
for(const bad of [{...meta,width:3},{...meta,sprites:{conditional_mode:{x:1,y:0,width:2,height:1}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:NaN,height:1}}},
{...meta,sprites:{speed_limit_actions:{x:0,y:0,width:2,height:1,offsetY:-33}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetY:-32}}},
{...meta,sprites:{conditional_mode:{x:0,y:0,width:2,height:1,offsetX:1}}}])await assert.rejects(widgetArtMetadata(image(bad)))
await assert.rejects(widgetArtMetadata(new Blob(['not PNG'])))
const flush=async()=>{for(let i=0;i<20;i++)await Promise.resolve()}
function fixture() {
const updates=[],requests=[],timers=new Map(),revoked=[];let next=0,url=0,unauthorized=0
const options={art:true,publish:value=>updates.push(value),unauthorized:()=>unauthorized++,
fetcher:(address,options)=>new Promise(resolve=>requests.push({address,options,resolve})),
later:(fn,delay)=>{timers.set(++next,{fn,delay});return next},cancelTimer:id=>timers.delete(id),
urls:{createObjectURL:()=>`blob:${++url}`,revokeObjectURL:value=>revoked.push(value)}}
const feed=new LayoutPreviewFeed(options)
const tick=()=>{const [id,{fn}]=[...timers].at(-1);timers.delete(id);fn()}
const answer=async(index,status=200,blob=image())=>{requests[index].resolve({status,ok:status===200,
headers:{get:()=> 'image/png'},json:async()=>null,blob:async()=>blob});await flush()}
return {feed,options,updates,requests,timers,revoked,tick,answer,get unauthorized(){return unauthorized}}
}
const {feed,updates,requests,timers,revoked,tick,answer}=fixture()
const document={version:6,layouts:{compact:{}},palette:{text:'#FFFFFFFF'}}
feed.start();feed.update(document,'compact','engaged');tick()
assert.equal(requests[0].address,'./api/ui/layout/preview');assert.equal(requests[0].options.credentials,'same-origin')
assert.equal(JSON.parse(requests[0].options.body).art,true)
await answer(0);assert.deepEqual(updates.at(-1).sprites,meta)
feed.update(document,'compact','engaged',true);assert.equal(feed.currentUrl,'blob:1','Drag retains mounted native art')
assert.equal(feed.pending,null,'Drag queues no native render');assert.equal(feed.timer,null,'Drag schedules no request timer')
assert.equal(requests.length,1,'Entering drag sends no native request')
feed.update(document,'compact','cem_stop_light');assert.equal(feed.currentUrl,'blob:1','Scene change retains art until its replacement arrives')
tick();feed.update(document,'large','cem_curve');assert.equal(requests[1].options.signal.aborted,true)
tick();await answer(1);assert.equal(requests.length,3);await answer(2);assert.equal(updates.at(-1).url,'blob:2')
feed.update(document,'compact','engaged')
assert.equal(feed.currentUrl,'blob:1','Returning to a profile restores its artwork immediately')
assert.equal(updates.at(-1).status,'ready');assert.equal(timers.size,0,'Unchanged cached artwork needs no request')
assert.deepEqual(revoked,[],'Switching profiles keeps both mounted sheets alive')
feed.update(document,'large','braking');tick();await answer(3,503)
assert.equal(feed.currentUrl,'blob:2','A transient failure retains visible artwork')
assert.equal(updates.at(-1).status,'updating','One transient failure does not flash the unavailable message')
assert.equal([...timers.values()][0].delay,600,'A transient failure retries promptly')
tick();tick();await answer(4,503)
assert.equal(updates.at(-1).status,'unavailable','Persistent failures still report an outage')
tick()
assert.equal(updates.findLast(update=>update.status)?.status,'unavailable','Retry does not flicker the outage message')
tick();await answer(5)
assert.equal(updates.at(-1).status,'ready');assert.equal(feed.currentUrl,'blob:3')
assert.deepEqual(revoked,[],'Each scene keeps its own sheet')
feed.update(document,'large','cem_curve')
assert.equal(feed.currentUrl,'blob:2','Returning to a rendered scene shows its sheet immediately')
assert.equal(updates.at(-1).status,'ready');assert.equal(timers.size,0)
feed.update(document,'large','aol')
assert.equal(feed.currentUrl,'blob:2','An unrendered scene keeps the sheet on screen instead of outlines')
assert.equal(updates.findLast(update=>'url' in update).scene,'cem_curve')
assert.equal([...timers.values()][0].delay,0,'Scene changes skip the edit debounce')
feed.update(document,'large','braking');assert.equal(feed.currentUrl,'blob:3')
feed.pause();assert.equal(feed.active,false);assert.equal(feed.currentUrl,'blob:3')
feed.start();feed.update(document,'large','braking')
assert.equal(feed.currentUrl,'blob:3');assert.equal(timers.size,0,'Returning to a profile reuses matching artwork')
const edited={...document,palette:{text:'#FF0000FF'}}
feed.update(edited,'large','braking');tick()
assert.equal(feed.currentUrl,'blob:3','Draft edits keep artwork while rendering the new colors')
feed.pause();feed.start();feed.update(document,'compact','engaged');await answer(6)
assert.equal(feed.currentUrl,'blob:1','A late response after switching views cannot replace cached art')
assert.equal(updates.at(-1).status,'ready')
feed.stop();assert.deepEqual(revoked,['blob:1','blob:2','blob:3'])
assert.equal(feed.artCache.size,0);assert.equal(updates.at(-1).url,null)
for(const status of [429,503]){
const cold=fixture();cold.feed.start();cold.feed.update(document,'compact','engaged');cold.tick();await cold.answer(0,status)
assert.equal(cold.updates.at(-1).status,'updating','Cold-start transient failures also retry without flashing a warning')
cold.tick();cold.tick();await cold.answer(1)
assert.equal(cold.updates.some(update=>update.status==='unavailable'),false)
assert.equal(cold.updates.at(-1).status,'ready');cold.feed.stop()
}
for(const status of [401,403]){
const denied=fixture();denied.feed.start();denied.feed.update(document,'compact','engaged');denied.tick();await denied.answer(0)
denied.feed.update(edited,'compact','engaged');denied.tick();await denied.answer(1,status)
assert.equal(denied.feed.currentUrl,null,'Access denial releases retained artwork immediately')
assert.equal(denied.feed.artCache.size,0)
if(status===401){assert.equal(denied.unauthorized,1);assert.equal(denied.feed.active,false)}
else assert.equal(denied.updates.at(-1).status,'unavailable')
denied.feed.stop()
}
const stalled=fixture();stalled.feed.start();stalled.feed.update(document,'compact','engaged');stalled.tick();await stalled.answer(0)
stalled.feed.update(edited,'compact','engaged');stalled.tick();stalled.tick();await flush()
assert.equal(stalled.requests[1].options.signal.aborted,true)
assert.equal(stalled.feed.currentUrl,'blob:1','A timed-out refresh retains artwork')
assert.equal(stalled.updates.at(-1).status,'updating')
stalled.tick();stalled.tick();await stalled.answer(2)
assert.equal(stalled.updates.at(-1).status,'ready','A hung renderer can recover without discarding the last sheet')
stalled.feed.update(document,'compact','engaged');stalled.tick();await stalled.answer(3,200,new Blob(['invalid PNG']))
assert.equal(stalled.feed.currentUrl,'blob:2','Malformed replacement art cannot displace the last good sheet')
stalled.feed.stop()
// Art and backdrop share the device renderer: one request at a time, art first.
{
const queue=new PreviewQueue(),requests=[],timers=new Map();let next=0
const options=extra=>({...extra,queue,publish(){},fetcher:(address,options)=>new Promise(resolve=>requests.push({options,resolve})),
later:(fn,delay)=>{timers.set(++next,{fn,delay});return next},cancelTimer:id=>timers.delete(id),
urls:{createObjectURL:()=>'blob:q',revokeObjectURL(){}}})
const backdrop=new LayoutPreviewFeed(options({backdrop:true})),art=new LayoutPreviewFeed(options({art:true}))
const run=()=>{for(const [id,{fn,delay}] of [...timers])if(delay<=200){timers.delete(id);fn()}}
backdrop.start();art.start();backdrop.update(document,'compact','engaged');art.update(document,'compact','engaged')
run()
await flush();assert.equal(requests.length,1,'The second feed waits instead of being turned away as busy')
assert.equal(JSON.parse(requests[0].options.body).art,'backdrop')
backdrop.update(document,'compact','aol');run()
await flush();assert.equal(requests.length,1,'Cancelling a request does not release the renderer before it answers')
requests[0].resolve({status:200,ok:true,headers:{get:()=>'image/png'},blob:async()=>image()});await flush()
assert.equal(requests.length,2);assert.equal(JSON.parse(requests[1].options.body).art,true,'Widget art goes before the backdrop')
requests[1].resolve({status:200,ok:true,headers:{get:()=>'image/png'},blob:async()=>image()});await flush()
assert.equal(requests.length,3);assert.equal(JSON.parse(requests[2].options.body).scene,'aol')
requests[2].resolve({status:429,ok:false,headers:{get:()=>null},json:async()=>null});await flush()
assert.equal([...timers.values()].at(-1).delay,600,'A busy renderer is retried promptly by the backdrop too')
backdrop.stop();art.stop()
}
// Exercise the real page watchers when changing preview modes and parked authority.
const page=OnroadLayoutPage.setup({mode:'local',projection:false,unauthorized(){}})
Object.assign(page.backdropFeed,{start(){},update(){},stop(){}})
const pageFeed=fixture()
Object.assign(page.artFeed,pageFeed.options,{publish:update=>{Object.assign(page.state.art,update);pageFeed.updates.push(update)}})
page.state.data={editable:true,revision:1,metadata:{profiles:{large:{widgets:{conditional_mode:{}}},compact:{widgets:{conditional_mode:{}}}}}}
page.state.draft=document
pageFeed.tick();await flush();await pageFeed.answer(0) // The page feed takes its turn in the shared render queue first.
const artUrl=page.state.art.url
assert(artUrl)
page.state.data.editable=false
assert.equal(page.artFeed.active,false);assert.equal(page.state.art.url,null,'Parked authority changes clear artwork')
page.artFeed.stop()
assert.equal(OnroadLayoutPage.methods.runtimeWidget.call({profile:{runtimePreviewWidgets:['conditional_mode']}},'conditional_mode'),true)
assert.equal(OnroadLayoutPage.methods.runtimeWidget.call({profile:{}},'conditional_mode'),false)
assert.match(LayoutWidgetPreview.template,/data-preview-source="runtime"/)
assert.match(LayoutWidgetPreview.template,/data-preview-source="placeholder"/)
assert.doesNotMatch(LayoutWidgetPreview.template,/scene === 'experimental' \? 'E' : 'C'/)
assert.doesNotMatch(LayoutWidgetPreview.template,/M38 78 Q96 107 154 78/)
console.log('Canonical widget art: metadata, races, retained refreshes, profile/mode switches, retries, auth and cleanup passed')
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,705 @@
/* Settings controls adapted from the current StarPilot Vue Galaxy. */
.gx-settings__header .gx-note { margin: var(--sp-2) 0 0; }
.gx-settings__search { padding: var(--sp-3) var(--sp-4) 0; }
.gx-settings__search .gx-field { width: 100%; }
.gx-settings__section .gx-section__header .gx-icon-btn { flex: none; height: 40px; min-height: 40px; width: 40px; min-width: 40px; }
.gx-settings__section .gx-section__header .gx-icon-btn:hover { background: var(--glass-bg-hover); }
.gx-settings__body, .gx-settings-tabs { animation: gx-fade-in var(--motion-base) ease-out; }
.gx-panel-stage .gx-settings__body { animation: none; }
@media (prefers-reduced-motion: reduce) { .gx-settings__body, .gx-settings-tabs { animation: none; } }
.gx-settings__section { padding: 0; }
.gx-settings__section .gx-section__header { cursor: default; }
.gx-settings__developer-body { display: grid; gap: var(--sp-3); }
.gx-settings__developer-provider { display: grid; gap: var(--sp-2); font-weight: var(--fw-medium); }
.gx-settings__save-status { position: fixed; bottom: calc(var(--bottomnav-clearance) + 8px); left: 50%; transform: translateX(-50%); z-index: var(--z-snackbar); padding: var(--sp-2) var(--sp-4); border-radius: var(--radius-full); background: var(--surface-container-high); box-shadow: var(--elev-2); }
.gx-settings__save-status--animated { max-width: calc(100vw - 32px); margin: 0; text-align: center; opacity: 0; visibility: hidden; pointer-events: none; transition: opacity 220ms ease, visibility 0s 220ms; }
.gx-settings__save-status--animated.is-visible { opacity: 1; visibility: visible; transition-delay: 0s; }
@media (prefers-reduced-motion: reduce) { .gx-settings__save-status--animated { transition: none; } }
.gx-slider-header { display: flex; align-items: baseline; gap: var(--sp-2); min-height: 24px; }
.gx-slider-hint { color: var(--text-muted); font-size: var(--fs-xs); }
.gx-row__readout { max-width: 55%; text-align: right; white-space: normal !important; overflow-wrap: anywhere; }
.gx-switch input:focus-visible + .gx-switch__track { outline: 2px solid var(--secondary); outline-offset: 4px; }
input[type="range"].gx-slider:focus-visible { outline: 2px solid var(--secondary); outline-offset: 6px; }
input[type="range"].gx-slider:disabled { cursor: default; opacity: .4; }
.gx-row .gx-btn { flex-shrink: 0; }
.gx-settings-tabs { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.gx-layout__favorite-display { display: grid; gap: 8px; }
.gx-layout__favorite-display .gx-segment { display: flex; gap: 8px; }
.gx-layout__favorite-display .gx-btn { flex: 1; }
.gx-settings-tabs button.gx-chip[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); }
@media (max-width: 520px) { .gx-row__readout { max-width: 45%; } }
.gx-section__header {
align-items: center;
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
min-height: 56px;
padding: var(--sp-3) var(--sp-4);
background: rgba(255, 255, 255, 0.02);
}
.gx-section__header i {
color: var(--primary);
font-size: 1.35rem;
}
.gx-section__title {
flex: 1 1 8rem;
min-width: 0;
overflow-wrap: anywhere;
font-weight: var(--fw-bold);
letter-spacing: -0.01em;
}
.gx-section__count {
color: var(--text-muted);
font-size: var(--fs-xs);
background: rgba(255, 255, 255, 0.08);
padding: 2px 8px;
border-radius: var(--radius-full);
}
[data-theme="light"] .gx-section__count {
background: rgba(120, 73, 232, 0.12);
color: var(--primary);
font-weight: var(--fw-bold);
}
.gx-row {
align-items: center;
border-top: 1px solid var(--outline);
display: flex;
gap: var(--sp-3);
min-height: 64px;
padding: var(--sp-3) var(--sp-4);
transition: background-color var(--motion-fast);
}
.gx-row:hover {
background-color: rgba(255, 255, 255, 0.03);
}
.gx-row__info { flex: 1; min-width: 0; }
.gx-setting-default { margin-top: 0; min-height: 32px; padding: 4px 12px; font-size: var(--fs-sm); }
.gx-row__label {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-2);
font-weight: var(--fw-medium);
font-size: var(--fs-base);
line-height: 1.3;
}
.gx-row__desc {
display: block;
color: var(--text-muted);
font-size: var(--fs-sm);
margin-top: 6px;
line-height: 1.45;
}
.gx-row__value {
color: var(--primary);
font-weight: var(--fw-bold);
white-space: nowrap;
}
.gx-row.disabled .gx-row__label, .gx-row.disabled .gx-row__desc { opacity: 0.45; }
.gx-row--stack { align-items: stretch; flex-direction: column; }
.gx-row--stack .gx-row__info { flex: none; }
.gx-row--stack .gx-field,
.gx-row--stack .gx-slider-row { width: 100%; }
.gx-switch {
display: inline-flex;
position: relative;
cursor: pointer;
}
.gx-switch input { height: 1px; opacity: 0; position: absolute; width: 1px; }
.gx-switch__track {
background: rgba(255, 255, 255, 0.12);
border: 1px solid var(--glass-border);
border-radius: var(--radius-full);
height: 30px;
width: 52px;
transition: background-color var(--motion-base), border-color var(--motion-base);
}
[data-theme="light"] .gx-switch__track {
background: rgba(0, 0, 0, 0.08);
border: 1.5px solid var(--primary);
}
.gx-switch__thumb {
background: #ffffff;
border-radius: 50%;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.35);
height: 22px;
left: 4px;
position: absolute;
top: 4px;
transition: transform var(--motion-base), width var(--motion-fast);
width: 22px;
}
[data-theme="light"] .gx-switch__thumb {
border: 1px solid rgba(120, 73, 232, 0.3);
}
.gx-switch:active .gx-switch__thumb {
width: 26px;
}
.gx-switch input:checked + .gx-switch__track {
background: var(--primary);
border-color: var(--primary);
}
.gx-switch input:checked ~ .gx-switch__thumb {
transform: translateX(22px);
}
.gx-switch:active input:checked ~ .gx-switch__thumb {
transform: translateX(18px);
}
.gx-switch input:disabled + .gx-switch__track { opacity: 0.4; }
.gx-slider-row {
align-items: stretch;
display: flex;
flex-direction: column;
gap: var(--sp-2);
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
width: 100%;
}
.gx-slider-row .gx-row__value { text-align: left; min-width: 0; transition: color var(--motion-fast), text-shadow var(--motion-fast); }
.gx-slider-meta {
color: var(--text-muted);
display: flex;
font-size: var(--fs-xs);
justify-content: space-between;
}
input[type="range"].gx-slider {
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.12);
border-radius: var(--radius-full);
cursor: pointer;
margin: 0;
padding: 0;
border: none;
height: 8px;
outline: none;
-webkit-tap-highlight-color: transparent;
touch-action: none;
width: 100%;
}
[data-theme="light"] input[type="range"].gx-slider {
background: rgba(120, 73, 232, 0.18);
border: 1px solid rgba(120, 73, 232, 0.3);
}
input[type="range"].gx-slider::-webkit-slider-thumb {
-webkit-appearance: none;
background: #ffffff;
border: 2px solid var(--primary);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
height: 22px;
width: 22px;
transition: transform var(--motion-fast);
}
input[type="range"].gx-slider:active::-webkit-slider-thumb {
transform: scale(1.15);
}
input[type="range"].gx-slider::-moz-range-track {
background: transparent;
border: none;
height: 8px;
border-radius: var(--radius-full);
}
input[type="range"].gx-slider::-moz-range-thumb {
background: #ffffff;
border: 2px solid var(--primary);
border-radius: 50%;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
height: 22px;
width: 22px;
box-sizing: border-box;
transition: transform var(--motion-fast);
}
input[type="range"].gx-slider:active::-moz-range-thumb {
transform: scale(1.15);
}
.gx-select > select { display: none !important; }
.gx-select { display: inline-flex; position: relative; vertical-align: middle; min-width: 0; max-width: 100%; }
.gx-select.gx-field { padding: 0; background: var(--surface-container-high); }
.gx-select.gx-field--full { display: flex; width: 100%; }
.gx-select__button { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-width: 0; min-height: 44px; padding: 10px var(--sp-3); border: 0; border-radius: inherit; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; }
.gx-select__button > span { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.gx-select__button > i { flex-shrink: 0; }
.gx-select:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
.gx-select__button:disabled { opacity: .5; cursor: default; }
.gx-select-menu { position: fixed; inset: auto; margin: 0; padding: 6px; box-sizing: border-box; max-width: none; overflow-y: auto; overscroll-behavior: contain; border: 1px solid var(--outline-variant, var(--glass-border)); border-radius: var(--radius-md); color: var(--on-surface); background: var(--surface-container-high, #252033); box-shadow: 0 12px 36px #0006; font: var(--fs-base) var(--font-body); }
.gx-select-menu::backdrop { background: transparent; }
.gx-select-menu [role="option"] { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 44px; padding: 10px 12px; border: 0; border-radius: 10px; background: transparent; color: inherit; text-align: left; font: inherit; white-space: normal; overflow-wrap: anywhere; cursor: pointer; touch-action: manipulation; }
.gx-select-menu [role="option"][aria-selected="true"] { background: var(--primary-container); color: var(--on-primary-container); }
.gx-select-menu [role="option"]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); }
@media (hover: hover) {
.gx-select-menu [role="option"]:hover:not(:disabled) { outline: 2px solid var(--primary); outline-offset: -2px; background: var(--primary-container); color: var(--on-primary-container); }
}
.gx-select-menu [role="option"]:disabled { opacity: .45; cursor: default; }
.gx-select-menu__group { padding: 10px 12px 4px; color: var(--on-surface-variant); font-size: .85em; }
.gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #141020; }
[data-theme="light"] .gx-select-menu { background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), #fff; }
.gx-select-menu:not([open]) { display: none; }
.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider {
background: rgba(157, 114, 255, 0.28);
box-shadow: 0 0 8px var(--primary-glow);
}
.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider::-webkit-slider-thumb {
transform: scale(1.35);
background: var(--secondary);
border-color: #ffffff;
box-shadow: 0 0 10px var(--secondary-glow);
}
.gx-slider-row.is-fine-scrubbing input[type="range"].gx-slider::-moz-range-thumb {
transform: scale(1.35);
background: var(--secondary);
border-color: #ffffff;
box-shadow: 0 0 10px var(--secondary-glow);
}
.gx-slider-row.is-fine-scrubbing .gx-row__value {
color: var(--secondary);
text-shadow: 0 0 10px var(--secondary-glow);
}
.gx-model-control { flex-wrap: wrap; }
.gx-model-control > .gx-row__label { flex: 0 1 160px; min-width: 0; }
.gx-model-control > .gx-field { flex: 1 1 240px; min-width: 0; }
@media (max-width: 520px) {
.gx-model-control { align-items: stretch; flex-direction: column; }
.gx-model-control > .gx-row__label { flex: none; }
.gx-model-control > .gx-field { flex: none; width: 100%; }
}
.gx-model-manager .gx-card-grid { grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }
.gx-model-manager .gx-card { overflow: visible; }
.gx-model-manager .gx-card > div { min-width: 0; }
.gx-model-manager .gx-card strong, .gx-model-manager .gx-chip { overflow-wrap: anywhere; }
.gx-model-reason { margin: var(--sp-3) 0; color: var(--text-muted); font-size: var(--fs-sm); }
.gx-model-runtime { margin-top: var(--sp-3); }
.gx-model-runtime__body { padding: 0 var(--sp-3) var(--sp-3); overflow-wrap: anywhere; }
.gx-model-runtime__body p { margin: 0 0 var(--sp-2); }
.gx-favorites__intro { display: grid; gap: 6px; }
.gx-favorites__intro .gx-note { margin: 0; }
.gx-settings__hint { margin-right: auto; }
.gx-layout { width: 100%; min-width: 0; container-type: inline-size; }
.gx-favorites__slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 310px), 1fr)); gap: 18px; }
.gx-favorites__slot { display: grid; gap: 18px; padding: var(--panel-padding); min-width: 0; }
.gx-favorites__slot h3 { margin: 0; }
.gx-favorites__slot small { display: block; color: var(--on-surface-variant); font-size: var(--fs-sm); font-weight: normal; margin-top: 4px; }
.gx-favorites__slot > label { display: grid; gap: 8px; min-width: 0; }
.gx-favorites__slot input[type="text"] { min-width: 0; width: 100%; }
.gx-favorites__slot .gx-switch { flex-shrink: 0; }
.gx-controllers__row { flex-wrap: wrap; }
.gx-controllers__row .gx-row__info { flex: 1 1 180px; }
.gx-controllers__selector { display: grid; gap: 8px; }
.gx-controllers__selector .gx-field { width: 100%; min-width: 0; }
.gx-controllers__row .gx-btn { flex: 0 0 auto; }
@media (max-width: 440px) {
.gx-controllers__row .gx-row__info, .gx-controllers__row .gx-btn { flex-basis: 100%; }
}
.gx-layout h3, .gx-layout h4, .gx-layout p { margin: 0; }
.gx-layout__savebar, .gx-layout__subhead { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.gx-layout__savebar { padding: 14px 18px; border: 1px solid var(--glass-border); border-radius: var(--radius-md); background: linear-gradient(var(--surface-container), var(--surface-container)), var(--background); align-content: center; }
.gx-layout__savebar .gx-btn { flex: 0 0 auto; }
.gx-layout__savebar > span, .gx-layout__subhead > :first-child { margin-right: auto; }
.gx-layout__subhead > span, .gx-layout__savebar > span { color: var(--on-surface-variant); }
.gx-layout__zone-toggle { display: flex; align-items: center; gap: 8px; color: var(--on-surface-variant); font-size: var(--fs-sm); }
.gx-layout__zone-toggle input { accent-color: var(--primary); }
.gx-layout__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; align-items: start; min-width: 0; }
.gx-layout__preview-card, .gx-layout__inspector, .gx-layout__colors, .gx-layout__discard { padding: 20px; display: grid; gap: 16px; min-width: 0; }
.gx-layout__preview-card { padding: 20px 0; }
.gx-layout__preview-card > :not(svg) { margin-inline: 20px; }
.gx-layout__preview { display: block; width: 100%; height: auto; border-block: 1px solid var(--glass-border); touch-action: none; user-select: none; }
.gx-layout__widget { cursor: grab; outline: none; }
.gx-layout__widget:active { cursor: grabbing; }
.gx-layout-widget-art { pointer-events: none; font-family: var(--font-body); }
.gx-layout__remove { flex: none; color: var(--error); font-size: 1.6rem; touch-action: pan-y; }
.gx-layout__remove:disabled { opacity: .45; cursor: default; }
.gx-layout__visibility { flex: none; touch-action: pan-y; }
.gx-layout__required { flex: none; padding: 6px 12px; white-space: nowrap; }
.gx-layout__visibility[aria-pressed="false"] { color: var(--on-surface-variant); }
.gx-layout__clock-format { display: flex; align-items: center; gap: 10px; min-height: 44px; color: var(--on-surface); }
.gx-layout__clock-format input { width: 20px; height: 20px; accent-color: var(--primary); }
.gx-layout__selection { pointer-events: none; opacity: 0; }
.gx-layout__widget.is-selected .gx-layout__selection, .gx-layout__widget:focus-visible .gx-layout__selection { opacity: 1; }
.gx-layout__widget:focus-visible .gx-layout__selection { stroke: #fff; stroke-dasharray: none; }
/* The widget open in Edit; the glow size is in canvas units, so the stage sets it per layout size. */
.gx-layout__widget.is-editing .gx-layout__selection { opacity: 1; stroke-dasharray: none; filter: drop-shadow(0 0 var(--layout-glow) #b799ff); }
.gx-layout__resize { cursor: nwse-resize; touch-action: none; }
.gx-layout__resize:hover circle:last-of-type { fill: #d4c2ff; }
.gx-layout__widget-list { display: grid; gap: 8px; }
.gx-layout__position, .gx-layout__tray, .gx-layout__color { display: grid; gap: 10px; }
.gx-layout__position, .gx-layout__tray { padding-block: 16px; border-top: 1px solid var(--glass-border); }
.gx-layout__coordinates { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.gx-layout__coordinates label { display: grid; gap: 6px; }
.gx-layout__coordinates input { width: 100%; min-width: 0; }
.gx-layout__tray-item { display: flex; gap: 6px; align-items: center; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); padding: 6px; }
.gx-layout__drag-handle { flex: 0 0 44px; justify-content: center; min-width: 0; display: flex; align-items: center; gap: 4px; min-height: 44px; color: var(--on-surface); background: transparent; border: 0; font: inherit; text-align: left; cursor: grab; touch-action: none; }
.gx-layout__drag-handle:disabled { opacity: .5; cursor: default; }
.gx-layout__drag-handle i { color: var(--on-surface-variant); }
.gx-layout__palette { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; }
.gx-layout__inspector .gx-layout__colors { padding: 0; }
.gx-layout .gx-layout__reset { justify-self: start; flex: 0 0 auto; min-height: 36px; padding: 7px 12px; font-size: var(--fs-sm); }
.gx-layout__inspector .gx-layout__palette { grid-template-columns: 1fr; gap: 18px; }
.gx-layout__inspector .gx-layout__colors .gx-layout__subhead { flex-wrap: wrap; }
.gx-layout__display-group { margin: var(--sp-2) 0 calc(-1 * var(--sp-2)); font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; color: var(--on-surface-variant); }
.gx-layout__color > label:first-child { font-weight: var(--fw-bold); }
.gx-layout__color-values { display: flex; gap: 10px; align-items: center; }
.gx-layout__color-values input[type="color"] { width: 48px; height: 44px; flex: 0 0 48px; padding: 3px; background: var(--surface-container-high); border: 1px solid var(--glass-border); border-radius: 8px; cursor: pointer; }
.gx-layout__color-values input[type="text"] { min-width: 0; width: 100%; font-family: ui-monospace, monospace; }
.gx-layout__color input[type="range"] { width: 100%; accent-color: var(--primary); min-height: 36px; }
.gx-layout__color > label { color: var(--on-surface-variant); }
.gx-layout__savebar.gx-layout__savebar--leave, .gx-card.gx-layout__leave-dialog { --leave-glow: 0 0 0 1px var(--primary-glow), 0 0 24px var(--primary-glow); border: 1px solid var(--primary); box-shadow: var(--leave-glow); }
/* One workspace structure at every size; panels replace long stacked controls. */
.gx-layout { max-width: 1440px; margin-inline: auto; gap: 12px; }
.gx-layout__workspace { grid-template-columns: minmax(0, 1.4fr) minmax(320px, 1fr); gap: 16px; }
.gx-layout__preview-card { position: sticky; top: calc(var(--layout-sticky-top, 0px) + 88px); gap: 12px; }
.gx-layout__inspector { gap: 14px; }
.gx-layout__switcher { display: flex; gap: 6px; min-width: 0; }
.gx-layout__switcher .gx-btn { flex: 1; min-width: 0; padding-inline: 10px; }
.gx-layout__panel { display: grid; gap: 12px; min-width: 0; }
.gx-layout__position { border: 0; padding: 0; }
.gx-layout__widget-picker { display: grid; gap: 8px; }
.gx-layout__savebar { position: sticky; top: var(--layout-sticky-top, 0px); z-index: 10; padding: 10px 14px; gap: 8px; box-shadow: var(--elev-1); }
.gx-layout__savebar .gx-btn { min-height: 40px; padding: 8px 14px; }
.gx-layout__savebar > span { flex: 1 1 140px; font-size: var(--fs-sm); }
.gx-layout__help > div { display: grid; gap: 10px; padding-block: 8px; }
.gx-layout__inspector .gx-layout__palette { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; }
@container (max-width: 1040px) {
.gx-layout { max-width: 760px; }
.gx-layout__workspace { grid-template-columns: minmax(0, 1fr); gap: 12px; }
.gx-layout__preview-card { position: static; }
.gx-layout__inspector, .gx-layout__preview-card { padding: 14px; }
.gx-layout__preview-card > :not(svg) { margin-inline: 0; }
.gx-layout__preview-card .gx-layout__preview { border: 1px solid var(--glass-border); }
}
@media (max-width: 480px) {
.gx-layout__savebar { padding: 8px; gap: 6px; }
.gx-layout__savebar > span { flex-basis: 100%; }
.gx-layout__savebar > .gx-btn { flex: 1; padding-inline: 8px; }
.gx-layout__switcher .gx-btn { font-size: var(--fs-sm); padding-inline: 8px; }
.gx-layout__inspector .gx-layout__palette { grid-template-columns: minmax(0, 1fr); }
}
.gx-slider-header .gx-setting-default { order: 1; margin: 0 0 0 auto; }
/* Choice rows keep their label and controls beside each other. */
.gx-row:not(.gx-row--stack) > .gx-select { flex: 0 1 45%; min-width: 100px; width: auto; }
.gx-row:not(.gx-row--stack) .gx-setting-default { margin-top: 0; min-height: var(--touch-target); }
.gx-settings__header > div:first-child { flex: 1; min-width: 0; }
.gx-settings__header > .gx-actions, .gx-settings__header > .gx-icon-btn { flex: none; }
.gx-section__header > i { flex: none; }
.gx-section__header > .gx-chip, .gx-section__count { min-width: 0; max-width: 100%; overflow-wrap: anywhere; }
.gx-select__description { display: block; margin-top: var(--sp-1); white-space: normal; line-height: 1.4; }
.gx-layout__layer { display: flex; align-items: center; gap: 4px; width: 100%; padding: 4px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: var(--primary-container); touch-action: pan-y; }
.gx-layout__layer:has(.gx-layout__layer-name[aria-pressed="true"]) { border-color: var(--primary); }
.gx-layout__row-grip { color: var(--on-surface-variant); flex: none; cursor: grab; touch-action: none; }
.gx-layout__row-grip:active { cursor: grabbing; }
.gx-layout__row-grip:disabled { opacity: .5; cursor: default; }
.gx-layout__layer-name, .gx-layout__inactive-name { flex: 1; min-width: 0; text-align: left; white-space: normal; overflow-wrap: anywhere; touch-action: pan-y; }
.gx-layout__layer-name { align-self: stretch; min-height: 44px; padding: 6px; border: 0; border-radius: 8px; background: transparent; color: var(--on-surface); font: inherit; cursor: pointer; }
.gx-layout__layer { position: relative; }
.gx-layout__layer.is-drop-target::before { content: ""; position: absolute; left: 0; right: 0; top: -5px; height: 3px; border-radius: 3px; background: var(--primary); box-shadow: 0 0 8px var(--primary); }
.gx-layout__layer.is-drop-target.is-drop-after::before { top: auto; bottom: -5px; }
.gx-layout__drag-ghost { position: fixed; z-index: 1000; pointer-events: none; display: flex; align-items: center; gap: 12px; padding: 12px 16px; min-height: 44px; box-sizing: border-box; border: 2px solid var(--primary); border-radius: 16px; background: var(--background); color: var(--on-surface); box-shadow: 0 12px 32px #0008; transform: scale(1.03); }
.gx-layout__drag-ghost strong { flex: 1; }
.gx-layout__outline { pointer-events: none; }
.gx-layout__help > summary { cursor: pointer; min-height: 40px; align-content: center; }
.gx-layout__layer.is-dragging { opacity: .45; }
.gx-app .gx-layout { --layout-sticky-top: calc(72px + env(safe-area-inset-top, 0px)); }
.gx-layout__tray > summary { cursor: pointer; min-height: 40px; align-content: center; font-weight: var(--fw-bold); }
.gx-layout__tray[open] > :not(summary) { margin-top: 10px; }
@media (max-width: 480px) {
.gx-layout__savebar > .gx-btn:last-child { flex: 0 0 auto; min-width: 108px; white-space: nowrap; }
.gx-layout__savebar .gx-btn { font-size: var(--fs-sm); }
}
/* Toolbar follows available editor width, including when a desktop sidebar is open. */
.gx-layout { container-type: inline-size; }
.gx-layout__action-icon { display: none; width: 22px; height: 22px; flex: none; }
@container (max-width: 850px) {
.gx-layout__savebar .gx-layout__action-label { display: none; }
.gx-layout__savebar .gx-layout__action-icon { display: block; }
.gx-layout__savebar > .gx-btn, .gx-layout__savebar > .gx-btn:last-child { flex: 0 0 44px; width: 44px; min-width: 44px; height: 44px; min-height: 44px; padding: 10px; display: flex; align-items: center; justify-content: center; }
.gx-layout__savebar > span { flex: 1 1 100%; }
.gx-layout__savebar--leave .gx-layout__action-label { display: inline; }
.gx-layout__savebar--leave .gx-layout__action-icon { display: none; }
.gx-layout__savebar--leave > .gx-btn, .gx-layout__savebar--leave > .gx-btn:last-child { flex: 1 1 150px; width: auto; min-width: 0; padding-inline: 12px; }
}
/* Leave confirmations sit above the menu and keep all choices comfortably spaced. */
.gx-card.gx-layout__leave-dialog { width: min(calc(100% - 48px), 680px); padding: 32px; gap: 28px; }
.gx-layout__leave-copy { display: grid; gap: 12px; }
.gx-layout__leave-copy h3 { font-size: var(--fs-xl); line-height: 1.3; }
.gx-layout__leave-copy p { color: var(--on-surface-variant); line-height: 1.6; }
.gx-layout__leave-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.gx-layout__leave-actions .gx-btn { flex: 1 0 auto; min-height: 48px; padding: 12px 20px; }
@media (max-width: 600px) {
.gx-card.gx-layout__leave-dialog { width: calc(100% - 32px); padding: 24px; gap: 24px; }
.gx-layout__leave-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
}
/* Compact editor chrome: one target row, one-line save bar, overflow menu, accordion colors. */
.gx-layout { --layout-pin-max: 34vh; --layout-pin-max: 34svh; --layout-solid: linear-gradient(var(--surface-container), var(--surface-container)), var(--background); }
.gx-layout__targets { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.gx-layout__segment { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--glass-border); border-radius: var(--radius-full); background: var(--surface-container); min-width: 0; }
.gx-layout__segment > .gx-btn { min-height: 38px; padding: 6px 14px; border-radius: var(--radius-full); background: transparent; box-shadow: none; white-space: nowrap; }
.gx-layout__segment > .gx-btn[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); }
.gx-layout__active::before { content: ""; display: inline-block; width: 7px; height: 7px; margin-left: 7px; border-radius: 50%; background: currentColor; vertical-align: middle; }
.gx-layout__info { flex: none; color: var(--on-surface-variant); }
.gx-layout .gx-settings__header { align-items: center; }
.gx-layout .gx-settings__header h2 { margin: 0; }
.gx-layout__info[aria-expanded="true"], .gx-layout__bar-toggle[aria-expanded="true"], .gx-layout__bar-toggle[aria-pressed="true"] { color: var(--primary); background: var(--primary-container); }
.gx-layout__about { display: grid; gap: 6px; padding: 12px 14px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); }
.gx-layout__savebar > span.is-dirty::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: var(--warning); vertical-align: middle; }
.gx-layout__more { position: relative; flex: none; }
.gx-layout__menu-scrim { position: fixed; inset: 0; z-index: 19; padding: 0; border: 0; background: transparent; cursor: default; }
.gx-layout__menu { position: absolute; right: 0; top: calc(100% + 6px); z-index: 20; display: grid; gap: 2px; min-width: 240px; padding: 6px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), var(--background); box-shadow: var(--elev-2); }
.gx-layout__menu-item { display: flex; align-items: center; gap: 12px; min-height: 44px; padding: 8px 12px; border: 0; border-radius: 10px; background: transparent; color: var(--on-surface); font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer; }
.gx-layout__menu-item .gx-layout__action-icon { display: block; width: 20px; height: 20px; }
.gx-layout__menu-item:hover:not(:disabled), .gx-layout__menu-item:focus-visible { background: var(--primary-container); }
.gx-layout__menu-item:disabled { opacity: .45; cursor: default; }
.gx-layout__preview-bar { display: flex; align-items: center; gap: 6px; min-width: 0; }
.gx-layout__bar-toggle { flex: none; color: var(--on-surface-variant); }
.gx-layout__tips { display: grid; gap: 10px; max-height: 30vh; overflow-y: auto; overscroll-behavior: contain; }
.gx-layout__picker-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.gx-layout__coordinates { grid-template-columns: repeat(auto-fit, minmax(68px, 1fr)); gap: 8px; }
@media (hover: none) { .gx-layout__keys-note { display: none; } }
.gx-layout__tray { display: grid; gap: 10px; }
.gx-layout__chips { display: flex; flex-wrap: wrap; gap: 8px; }
.gx-layout__chips .gx-layout__tray-item { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--radius-full); }
.gx-layout__chips .gx-layout__drag-handle { flex: 0 0 34px; min-height: 40px; }
.gx-layout__chips .gx-layout__inactive-name { flex: none; white-space: nowrap; font-size: var(--fs-sm); }
.gx-layout__chip-add { flex: none; width: 40px; height: 40px; min-width: 40px; min-height: 40px; color: var(--primary); }
.gx-layout__inspector .gx-layout__palette { grid-template-columns: minmax(0, 1fr); gap: 0; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); overflow: hidden; }
.gx-layout__palette > .gx-layout__color { gap: 0; }
.gx-layout__palette > .gx-layout__color + .gx-layout__color { border-top: 1px solid var(--glass-border); }
.gx-layout__color.is-open { background: var(--primary-container); }
.gx-layout__color-row { display: flex; align-items: center; gap: 10px; width: 100%; min-height: 48px; padding: 6px 12px; border: 0; background: transparent; color: var(--on-surface); font: inherit; text-align: left; cursor: pointer; }
.gx-layout__swatch { flex: none; width: 28px; height: 28px; border-radius: 8px; border: 1px solid var(--glass-border);
background: linear-gradient(var(--swatch), var(--swatch)), repeating-conic-gradient(#8886 0 25%, transparent 0 50%) 0 0 / 10px 10px; }
.gx-layout__color-name { flex: 1; min-width: 0; font-weight: var(--fw-bold); overflow-wrap: anywhere; }
.gx-layout__color-value, .gx-layout__color-alpha { flex: none; color: var(--on-surface-variant); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.gx-layout__color-alpha { min-width: 3.2em; text-align: right; }
.gx-layout__color-row > .bi { flex: none; color: var(--on-surface-variant); transition: rotate var(--motion-fast); }
.gx-layout__color.is-open .gx-layout__color-row > .bi { rotate: 180deg; }
.gx-layout__color-editor { display: grid; gap: 8px; padding: 2px 12px 14px; }
.gx-layout__color-editor > label { color: var(--on-surface-variant); font-size: var(--fs-sm); }
@media (prefers-reduced-motion: reduce) { .gx-layout__color-row > .bi { transition: none; } }
/* Narrow editors pin the save bar, preview and editor tabs; controls scroll underneath. */
@container (max-width: 1040px) {
.gx-layout__preview-card { position: sticky; top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px)); z-index: 6; padding: 8px; gap: 6px; margin-bottom: 0; background: var(--layout-solid); }
.gx-layout__preview-card > .gx-layout__subhead { display: none; }
.gx-layout__preview-card .gx-layout__preview { width: 100%; max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
.gx-layout__sections { position: sticky; top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px) + var(--layout-pin-h, 0px)); z-index: 5;
margin: -14px -14px 0; padding: 10px 14px; background: var(--layout-solid); border-bottom: 1px solid var(--glass-border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.gx-layout__widget-list { gap: 6px; }
.gx-layout__layer { padding: 2px; }
}
@container (max-width: 850px) {
.gx-layout__savebar:not(.gx-layout__savebar--leave) > span { flex: 1 1 0; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gx-layout__savebar:not(.gx-layout__savebar--leave) { gap: 4px; }
.gx-layout__savebar > .gx-btn.gx-layout__save:last-child { flex: 0 0 auto; width: auto; padding-inline: 16px; }
.gx-layout__savebar .gx-layout__save .gx-layout__save-label { display: inline; }
.gx-layout__savebar .gx-layout__save .gx-layout__action-icon { display: none; }
}
@container (max-width: 560px) {
.gx-layout__long { display: none; }
.gx-layout__switcher .gx-btn { padding-inline: 8px; }
}
/* Landscape phones: preview and controls side by side, preview sized to the short screen. */
@media (orientation: landscape) and (max-height: 560px) {
.gx-layout { max-width: none; --layout-pin-max: calc(100vh - var(--layout-sticky-top, 0px) - var(--layout-bar-h, 60px) - 76px); --layout-pin-max: calc(100svh - var(--layout-sticky-top, 0px) - var(--layout-bar-h, 60px) - 76px); }
.gx-layout__workspace { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); }
.gx-layout__preview-card .gx-layout__preview { max-width: calc(var(--layout-pin-max) * var(--layout-ratio, 2)); margin-inline: auto; }
.gx-layout__sections { top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px)); }
}
/* Widget list help pops over the list; widget rows and Edit controls run tighter. */
.gx-layout__help-anchor { position: relative; flex-wrap: nowrap; gap: 8px; }
.gx-layout__help-anchor > .gx-layout__info { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
.gx-layout__popover { position: absolute; top: calc(100% + 6px); right: 0; z-index: 4; display: grid; gap: 6px; width: min(300px, 100%); padding: 12px 14px; border: 1px solid var(--glass-border); border-radius: var(--radius-sm); background: linear-gradient(var(--surface-container-high), var(--surface-container-high)), var(--background); box-shadow: var(--elev-2); color: var(--on-surface); font-size: var(--fs-sm); line-height: 1.45; cursor: pointer; }
.gx-layout__inspector .gx-layout__panel { gap: 10px; }
.gx-layout__widget-list { gap: 4px; }
.gx-layout__layer { gap: 0; padding: 0 2px; }
.gx-layout__layer-name { min-height: 40px; padding: 4px 6px; }
.gx-layout__layer .gx-icon-btn { width: 40px; height: 40px; min-width: 40px; min-height: 40px; }
.gx-layout__required { padding: 4px 10px; }
.gx-layout__position { gap: 8px; }
.gx-layout__widget-picker { gap: 4px; }
.gx-layout__coordinates { gap: 6px; }
.gx-layout__coordinates label { gap: 2px; font-size: var(--fs-sm); color: var(--on-surface-variant); }
.gx-layout__inspector .gx-field { min-height: 40px; padding-block: 7px; }
.gx-layout__inspector .gx-layout__colors { gap: 10px; }
.gx-layout__color-row { min-height: 44px; padding-block: 4px; }
.gx-layout__swatch { width: 24px; height: 24px; border-radius: 7px; }
.gx-layout__color-editor { gap: 6px; padding-bottom: 10px; }
.gx-layout__color-values input[type="color"] { height: 40px; }
.gx-layout__color input[type="range"] { min-height: 28px; }
.gx-layout__clock-format { min-height: 40px; }
.gx-layout .gx-layout__reset { min-height: 34px; padding: 6px 12px; }
.gx-layout__inspector .gx-layout__colors > .gx-layout__subhead { flex-wrap: nowrap; }
.gx-layout__inspector .gx-layout__colors > .gx-layout__subhead > .gx-btn { flex: none; min-height: 34px; padding: 6px 14px; }
/* Slimmer preview switcher and save bar on narrow editors. */
@container (max-width: 1040px) {
.gx-layout__preview-bar .gx-layout__switcher .gx-btn { min-height: 36px; padding-block: 6px; }
.gx-layout__preview-bar .gx-layout__bar-toggle { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
}
@container (max-width: 850px) {
.gx-layout__savebar:not(.gx-layout__savebar--leave) { padding: 6px 8px; }
.gx-layout__savebar:not(.gx-layout__savebar--leave) > .gx-btn, .gx-layout__savebar:not(.gx-layout__savebar--leave) > .gx-btn:last-child,
.gx-layout__savebar .gx-layout__more > .gx-btn { flex-basis: 40px; width: 40px; min-width: 40px; height: 40px; min-height: 40px; padding: 8px; }
.gx-layout__savebar:not(.gx-layout__savebar--leave) > .gx-btn.gx-layout__save:last-child { flex: 0 0 auto; width: auto; padding-inline: 14px; }
}
/* Pinned pieces keep the same 12px gap as the unpinned layout. While a piece is pinned, a page-colored strip fills
the gap below it and the slivers beside its rounded corners, so the controls never show through while scrolling.
Unpinned pieces get no fill, so nothing dark sits over the page backdrop. */
.gx-layout__savebar { --pin-r: var(--radius-md); }
.gx-layout__preview-card { --pin-r: var(--radius-lg); }
@container (max-width: 1040px) {
.gx-layout__preview-card { top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px) + 12px); overflow: visible; }
.gx-layout__sections { top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px) + 24px + var(--layout-pin-h, 0px)); }
.gx-layout__preview-card.is-stuck::before, .gx-layout__preview-card.is-stuck::after {
content: ""; position: absolute; left: -1px; right: -1px; pointer-events: none; --pin-bg: var(--background); }
.gx-layout__preview-card.is-stuck::after {
top: calc(100% + 1px - var(--pin-r)); height: calc(var(--pin-r) + 12px);
background:
radial-gradient(circle at 100% 0, transparent calc(var(--pin-r) - .5px), var(--pin-bg) var(--pin-r)) 0 0 / var(--pin-r) var(--pin-r) no-repeat,
radial-gradient(circle at 0 0, transparent calc(var(--pin-r) - .5px), var(--pin-bg) var(--pin-r)) 100% 0 / var(--pin-r) var(--pin-r) no-repeat,
linear-gradient(var(--pin-bg), var(--pin-bg)) 0 100% / 100% 12px no-repeat; }
.gx-layout__preview-card.is-stuck::before {
top: -1px; height: var(--pin-r);
background:
radial-gradient(circle at 100% 100%, transparent calc(var(--pin-r) - .5px), var(--pin-bg) var(--pin-r)) 0 0 / var(--pin-r) var(--pin-r) no-repeat,
radial-gradient(circle at 0 100%, transparent calc(var(--pin-r) - .5px), var(--pin-bg) var(--pin-r)) 100% 0 / var(--pin-r) var(--pin-r) no-repeat; }
}
@media (orientation: landscape) and (max-height: 560px) {
.gx-layout__sections { top: calc(var(--layout-sticky-top, 0px) + var(--layout-bar-h, 60px) + 12px); }
.gx-layout__preview-card.is-stuck::after { content: none; }
}
/* Editor tabs carry the panel's rounded top edge. Once pinned, page-colored tiles fill outside the top corners
and the bottom corners round too, so the pinned tabs read as one rounded bar over the scrolling controls. */
@container (max-width: 1040px) {
.gx-layout__inspector { overflow: visible; }
.gx-layout__sections { --pin-r: var(--radius-lg); margin: -15px -15px 0; padding: 10px 15px; border: 1px solid var(--glass-border); border-radius: var(--pin-r) var(--pin-r) 0 0; transition: border-radius var(--motion-fast); }
.gx-layout__sections.is-stuck { border-radius: var(--pin-r); }
.gx-layout__sections.is-stuck::before { content: ""; position: absolute; left: -1px; right: -1px; top: -1px; height: var(--pin-r); pointer-events: none;
background:
radial-gradient(circle at 100% 100%, transparent calc(var(--pin-r) - .5px), var(--background) var(--pin-r)) 0 0 / var(--pin-r) var(--pin-r) no-repeat,
radial-gradient(circle at 0 100%, transparent calc(var(--pin-r) - .5px), var(--background) var(--pin-r)) 100% 0 / var(--pin-r) var(--pin-r) no-repeat; }
}
/* Tap-area overlay label sits on the preview while the grid toggle is on. */
.gx-layout__preview-card > .gx-layout__stage { position: relative; margin-inline: 0; min-width: 0; }
.gx-layout__stage-chip { position: absolute; top: 6px; right: 6px; z-index: 1; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border: 1px solid #b799ff88; border-radius: var(--radius-full); background: rgba(22, 18, 31, .82); color: #d8c8ff; font-size: var(--fs-xs); font-weight: var(--fw-bold); white-space: nowrap; pointer-events: none; }
.gx-layout__stage-chip--below { position: static; justify-self: end; }
.gx-layout__stage-chip--start { right: auto; left: 6px; max-width: calc(100% - 12px); overflow: hidden; text-overflow: ellipsis; display: block; }
/* Covers the whole bar, buttons included, so its size never changes mid-drag. */
.gx-layout__savebar-snap { position: absolute; inset: 0; z-index: 1; display: flex; align-items: center; gap: 6px; padding: 0 14px; border-radius: inherit; background: inherit; color: var(--primary); font-weight: var(--fw-bold); pointer-events: none; }
.gx-layout__savebar-snap > .bi { flex: none; }
.gx-layout__stage-chip--with-tap-areas { max-width: calc(100% - 120px); }
.gx-layout__guide { stroke: #b799ff; stroke-width: 1.5; }
.gx-layout__guide--center { stroke-dasharray: 6 4; }
.gx-layout__guide--bounds, .gx-layout__guide--zone { stroke: #8495a8; }
/* On narrow editors a pinned save bar also covers the strip behind the app bar, so the editor never scrolls up
behind the search. The fill stays inside the editor column and follows the bar's rounded corners. Its outline is
drawn above that fill, so the top edge and corners stay visible. Wide editors skip the fill: only the save bar pins there. */
@container (max-width: 1040px) {
.gx-layout__savebar.is-stuck { --pin-line: var(--glass-border); --pin-shadow: var(--elev-1); border-color: transparent; box-shadow: none; }
.gx-layout__savebar.gx-layout__savebar--leave.is-stuck { --pin-line: var(--primary); --pin-shadow: var(--leave-glow); }
.gx-layout__savebar.is-stuck::before {
content: ""; position: absolute; top: calc(-1px - var(--layout-sticky-top, 0px)); bottom: -13px; left: -1px; right: -1px; pointer-events: none;
background:
radial-gradient(circle at 100% 100%, transparent calc(var(--pin-r) - .5px), var(--background) var(--pin-r)) left 0 top var(--layout-sticky-top, 0px) / var(--pin-r) var(--pin-r) no-repeat,
radial-gradient(circle at 0 100%, transparent calc(var(--pin-r) - .5px), var(--background) var(--pin-r)) right 0 top var(--layout-sticky-top, 0px) / var(--pin-r) var(--pin-r) no-repeat,
radial-gradient(circle at 100% 0, transparent calc(var(--pin-r) - .5px), var(--background) var(--pin-r)) left 0 bottom 12px / var(--pin-r) var(--pin-r) no-repeat,
radial-gradient(circle at 0 0, transparent calc(var(--pin-r) - .5px), var(--background) var(--pin-r)) right 0 bottom 12px / var(--pin-r) var(--pin-r) no-repeat,
linear-gradient(var(--background), var(--background)) 0 0 / 100% var(--layout-sticky-top, 0px) no-repeat,
linear-gradient(var(--background), var(--background)) 0 100% / 100% 12px no-repeat; }
.gx-layout__savebar.is-stuck::after {
content: ""; position: absolute; inset: -1px; pointer-events: none;
border: 1px solid var(--pin-line); border-radius: var(--pin-r); box-shadow: var(--pin-shadow); }
}
/* Selected switches and tabs stay lit on touch screens, where :hover sticks to the last tapped button. */
.gx-layout .gx-settings-tabs > .gx-btn[aria-pressed="true"], .gx-layout .gx-layout__segment > .gx-btn[aria-pressed="true"] { background: var(--primary); color: var(--on-primary); }
@media (hover: none) {
.gx-layout .gx-settings-tabs > .gx-btn--tonal:hover:not(:disabled):not([aria-pressed="true"]) { background: var(--primary-container); }
.gx-layout .gx-layout__segment > .gx-btn--tonal:hover:not(:disabled):not([aria-pressed="true"]) { background: transparent; }
}
/* Dragging rows, chips and preview widgets never starts a text selection or the long-press menu. */
.gx-layout__widget-list, .gx-layout__chips, .gx-layout__preview, .gx-layout__drag-ghost { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.gx-layout__row-grip { touch-action: none; }
/* iPhone zooms into any field under 16px and leaves the page zoomed; editor fields stay at 16px. */
.gx-layout input:is([type="text"], [type="number"], [type="search"], :not([type])), .gx-layout select, .gx-layout textarea { font-size: max(16px, 1em); }
/* No double-tap zoom while editing; touch screens size widgets from Edit, so the tiny corner handle is hidden there. */
.gx-layout { touch-action: manipulation; }
@media (pointer: coarse) { .gx-layout__resize { display: none; } }
.gx-layout__color { scroll-margin-bottom: 16px; }
html.gx-maker-open, html.gx-maker-open body { touch-action: manipulation; }
/* Display options: compact rows, small dropdowns and tight group headings. */
.gx-layout__inspector .gx-layout__display { gap: 0; }
.gx-layout__display > .gx-layout__subhead, .gx-layout__display > .gx-note { margin-bottom: 8px; }
.gx-layout__display .gx-row { min-height: 48px; padding: 4px 4px; gap: 10px; }
.gx-layout__display .gx-row .gx-field { min-height: 38px; padding-block: 5px; flex: 0 1 44%; }
.gx-layout__display .gx-row__desc { font-size: var(--fs-xs); }
.gx-layout__display .gx-layout__display-group { margin: 14px 0 4px; }
.gx-layout__display .gx-layout__display-group + .gx-row { border-top: 0; }
.gx-layout__inspector .gx-select__button { min-height: 40px; padding-block: 7px; }
.gx-layout__display .gx-row .gx-select__button { min-height: 36px; padding-block: 5px; }
.gx-layout__display .gx-row .gx-field { min-height: 0; padding-block: 0; }
@@ -0,0 +1,97 @@
{
"mode": "offline-preview",
"tools": [
{
"name": "Bluetooth",
"path": "/bluetooth",
"icon": "bi-bluetooth",
"description": "Adapter power, saved devices and connections",
"availability": "local-only"
},
{
"name": "Cameras & Monitoring",
"path": "/cameras",
"icon": "bi-camera-video",
"description": "Live cabin previews, Side Camera, Sentry motion, and V-ASM settings",
"availability": "local-only"
},
{
"name": "Force Drive State",
"path": "/device-preferences",
"icon": "bi-car-front",
"description": "Select a temporary drive state for this device",
"availability": "local-only"
},
{
"name": "Galaxy Access & Install",
"path": "/galaxy",
"icon": "bi-globe2",
"description": "Pair for remote access, install Galaxy, or set up a Wi-Fi hotspot",
"availability": "local-only"
},
{
"name": "Logs & Diagnostics",
"path": "/logs",
"icon": "bi-exclamation-triangle",
"description": "Live console, driving configuration, system activity and crash reports",
"availability": "partial-preview"
},
{
"name": "Model Manager",
"path": "/manage_models",
"icon": "bi-cpu",
"description": "Bundled model identity and runtime health",
"availability": "local-only"
},
{
"name": "Model Laboratory",
"path": "/model_laboratory",
"icon": "bi-bezier2",
"description": "Pair lateral and longitudinal eGPU variants",
"availability": "local-only"
},
{
"name": "Navigation & Maps",
"path": "/navigation",
"icon": "bi-map",
"description": "Destinations, directions, saved places and offline maps",
"availability": "local-only"
},
{
"name": "System Tools",
"path": "/system",
"icon": "bi-arrow-repeat",
"description": "Branch updates, automatic downloads and version history",
"availability": "local-only"
},
{
"name": "Colors & Layout",
"path": "/theme_maker",
"icon": "bi-palette-fill",
"description": "Widgets, colors, road and display options for Big and Small layouts",
"availability": "local-only"
},
{
"name": "Plots & Analysis",
"path": "/tuning",
"icon": "bi-sign-turn-right",
"description": "Live steering and acceleration plots, and recorded-drive analysis",
"availability": "local-only"
},
{
"name": "Vehicle Controls",
"path": "/vehicle",
"icon": "bi-car-front",
"description": "Parked Auto or manual next-start vehicle selection",
"availability": "local-only"
},
{
"name": "Android Auto",
"path": "/android-auto",
"icon": "bi-phone",
"description": "Show StarPilot on your car’s screen",
"availability": "local-only",
"visibility": "authenticated"
}
]
}
@@ -0,0 +1 @@
{"schemaVersion": 1, "monitor": "sample"}
@@ -0,0 +1,21 @@
{
"mode": "synthetic-preview",
"schemaVersion": 1,
"source": "Illustrative Galaxy System Monitor sample; no device was read",
"sampledAt": 1789992000,
"cpuPercent": 27.4,
"cores": [{"name":"cpu0","percent":32.1},{"name":"cpu1","percent":22.7},{"name":"cpu2","percent":null},{"name":"cpu3","percent":28.3}],
"memory": {"totalMiB":7872,"usedMiB":3182.4,"availableMiB":4689.6,"percent":40.4},
"storage": {"usedGiB":38.7,"totalGiB":118.1},
"uptimeSeconds": 98432,
"processCount": 6,
"processes": [
{"pid":101,"name":"selfdrive.controls.controlsd","user":"comma","kernel":false,"state":"R","cpu":8.3,"memoryMiB":222.6},
{"pid":102,"name":"selfdrive.modeld.modeld","user":"comma","kernel":false,"state":"S","cpu":12.7,"memoryMiB":410.2},
{"pid":103,"name":"system.hardware.hardwared","user":"comma","kernel":false,"state":"S","cpu":null,"memoryMiB":91.1},
{"pid":104,"name":"system.loggerd.uploader","user":"comma","kernel":false,"state":"S","cpu":1.2,"memoryMiB":73.4},
{"pid":105,"name":"system.manager.manager","user":"root","kernel":false,"state":"R","cpu":2.4,"memoryMiB":54.5},
{"pid":2,"name":"kthreadd","user":"root","kernel":true,"state":"S","cpu":null,"memoryMiB":0}
],
"vitals": {"cpuTempC":51.2,"gpuTempC":48.4,"hotspotTempC":null,"gpuEdgeTempC":null,"memoryUsedBytes":null,"memoryTotalBytes":null,"onboardMaxAgeMs":2500,"maxAgeMs":0,"memoryMaxAgeMs":0}
}
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#6f42a6">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Galaxy">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>Galaxy</title>
<link rel="icon" type="image/svg+xml" href="./assets/galaxy-icon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="./assets/galaxy-icon-180.png">
<link rel="manifest" href="./manifest.webmanifest" crossorigin="use-credentials">
<link rel="stylesheet" href="./vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="./css/galaxy.css">
<link rel="stylesheet" href="./css/settings.css">
<link rel="stylesheet" href="./css/navigation.css">
</head>
<body>
<div id="galaxy-bg" aria-hidden="true"></div>
<section id="galaxy-boot" class="gx-boot" role="status">
<img class="gx-boot__logo" src="./assets/galaxy-icon.svg" width="160" height="160" alt="" /><h1>Galaxy</h1><div class="gx-boot__orbit" aria-hidden="true"></div><p>Plotting the stars...</p><button type="button" class="gx-btn" hidden>Try again</button>
<noscript>Enable JavaScript to open Galaxy.</noscript>
</section>
<div id="galaxy-app" v-cloak></div>
<script type="module" src="./js/boot.js"></script>
</body>
</html>
@@ -0,0 +1,13 @@
export function spawnAmbientStars(background, random = Math.random) {
if (!background || background.querySelector(".galaxy-hero")) return 0
for (let index = 0; index < 14; index++) {
const star = background.ownerDocument.createElement("i")
star.className = "galaxy-hero"
star.style.left = `${(random() * 100).toFixed(2)}%`
star.style.top = `${(random() * 100).toFixed(2)}%`
star.style.animationDelay = `${(random() * 4).toFixed(2)}s`
star.style.width = star.style.height = random() > 0.6 ? "3px" : "2px"
background.appendChild(star)
}
return 14
}
File diff suppressed because it is too large Load Diff
+308
View File
@@ -0,0 +1,308 @@
import { rememberInstallPrompt } from "./install-app.js"
import { toggleHaptic } from "./haptics.js"
import { GxState } from "./state.js"
import { BottomNav, NAV, primaryTab } from "./bottom-nav.js"
import { UpdateRestart, UPDATE_RESTART_MESSAGE } from "./update-restart.js"
import { DeviceRestartWatch } from "./device-restart.js"
import { GalaxyLoading } from "./loading-screen.js"
import { GxNotice } from "./notice.js"
import { createApp, reactive, defineAsyncComponent } from "../vendor/vue/vue.esm-browser.js"
import { route, navigate, navigateBack, ensureBackEntry, setRouteLeaveGuard, startRouter } from "./router.js"
import { loadCatalog } from "./startup.js"
import { Tools } from "./tools.js"
window.addEventListener("beforeinstallprompt", rememberInstallPrompt)
const Logs = page(() => import("./logs.js").then((module) => module.Logs))
const SoftwarePage = page(() => import("./software-status.js").then((module) => module.SoftwarePage))
const NavigationPage = page(() => import("./navigation.js").then((module) => module.NavigationPage))
const ModelsPage = page(() => import("./models.js").then((module) => module.ModelsPage))
const LaboratoryPage = page(() => import("./model-laboratory.js").then((module) => module.LaboratoryPage))
import { SettingsPage } from "./settings.js"
import { SettingsRecoveryPage } from "./settings-recovery.js"
import { ToggleSearch } from "./toggle-search.js"
const PlotsPage = page(() => import("./plots.js").then((module) => module.PlotsPage))
const FlmPage = page(() => import("./flm.js").then((module) => module.FlmPage))
import { LocalAuth } from "./auth-client.js"
import { Home } from "./home.js"
import { MenuTile } from "./menu-tile.js"
const LocalRecordingsPage = page(() => import("./record-history.js").then((module) => module.LocalRecordingsPage))
const CamerasPage = page(() => import("./cameras.js").then((module) => module.CamerasPage))
const SentryEventsPage = page(() => import("./sentry.js").then((module) => module.SentryEventsPage))
const VasmPage = page(() => import("./vasm.js").then((module) => module.VasmPage))
const PipPage = page(() => import("./pip.js").then((module) => module.PipPage))
import { DrivingPage, drivingPage } from "./driving.js"
const LongitudinalCurvesPage = page(() => import("./longitudinal-curves.js").then((module) => module.LongitudinalCurvesPage))
import { DevicePreferencesPage, devicePage } from "./device-preferences.js"
const BluetoothPage = page(() => import("./bluetooth.js").then((module) => module.BluetoothPage))
const AndroidAutoPage = page(() => import("./android-auto.js").then((module) => module.AndroidAutoPage))
const VehicleControlsPage = page(() => import("./vehicle-controls.js").then((module) => module.VehicleControlsPage))
const OnroadLayoutPage = page(() => import("./onroad-layout.js").then((module) => module.OnroadLayoutPage))
const GalaxyPage = page(() => import("./galaxy.js").then((module) => module.GalaxyPage))
import { DevicePicker } from "./device-picker.js"
import { DeviceState } from "./device-state.js"
import { spawnAmbientStars } from "./ambient-stars.js"
function page(loader) {
return defineAsyncComponent({
loader, delay: 150, timeout: 10000,
loadingComponent: { components: { GxState }, template: '<GxState loading>Opening page…</GxState>' },
errorComponent: { components: { GxState }, template: '<GxState tone="danger">This page could not open.<template #actions><button class="gx-btn" @click="reload">Reload page</button></template></GxState>',
methods: { reload() { location.reload() } } },
onError(_error, retry, fail, attempts) { if (attempts < 2) setTimeout(retry, 500); else fail() },
})
}
const THEME_KEY = "galaxy-preview-theme"
const PIN_KEY = "galaxy-preview-nav-pinned"
const stored = (key) => { try { return localStorage.getItem(key) } catch { return null } }
const save = (key, value) => { try { localStorage.setItem(key, value) } catch {} }
const state = reactive({
tools: [],
loading: true,
startupPending: false,
updateRestarting: false,
error: "",
pageError: "",
monitorMode: "sample",
drawerOpen: false,
navPinned: stored(PIN_KEY) === "true",
theme: stored(THEME_KEY) === "light" ? "light" : "dark",
searchPage: "",
})
const updateRestart = new UpdateRestart({ publish: waiting => { state.updateRestarting = waiting } })
const deviceRestart = new DeviceRestartWatch({ paused: () => state.updateRestarting })
const authState = reactive({ status: "checking", error: "", localAccess: false, gatewayAccess: false })
const authForm = reactive({ password: "" })
const auth = new LocalAuth({ publish: (update) => {
Object.assign(authState, update)
if (update.status === "unavailable") scheduleReconnect()
} })
createApp({
components: { BottomNav, GalaxyLoading, Home, Tools, Logs, SoftwarePage, NavigationPage, ModelsPage, LaboratoryPage, SettingsPage, SettingsRecoveryPage, ToggleSearch, PlotsPage, FlmPage, LocalRecordingsPage, CamerasPage, SentryEventsPage, VasmPage, PipPage, DrivingPage, DevicePreferencesPage, BluetoothPage, AndroidAutoPage, LongitudinalCurvesPage, VehicleControlsPage, OnroadLayoutPage, GalaxyPage, DevicePicker, DeviceState, MenuTile },
data: () => ({ state, authState, authForm, route, NAV, routeDirection: 1, updateRestart, UPDATE_RESTART_MESSAGE, nextLayoutLeavePresentation: "inline" }),
computed: {
visibleTools() { return state.tools.filter(tool => tool.visibility !== "authenticated" ||
(state.monitorMode === "local" && authState.status === "authenticated")).sort((a, b) =>
Number(a.path === "/galaxy") - Number(b.path === "/galaxy")) },
currentTool() { return state.tools.find((tool) => route.path === tool.path || route.path.startsWith(tool.path + "/")) },
drivingSettingsPage() { return drivingPage(route.path) },
deviceSettingsPage() { return devicePage(route.path) },
pageName() {
if (this.currentTool) return this.currentTool.name
return NAV.find((item) => item.path === route.path)?.name || "This page"
},
isLight() { return state.theme === "light" },
connectionLabel() {
if (state.monitorMode !== "local") return "Preview"
if (authState.status === "authenticated") return authState.localAccess ? "Local connection" : "Remote connection"
return authState.status === "checking" ? "Connecting…" : "Not connected"
},
},
errorCaptured() { state.pageError = "This page could not finish loading."; return false },
watch: { 'route.path'(path, previous) {
state.pageError = ""
this.routeDirection = route.direction < 0 || primaryTab(path) < primaryTab(previous) ? -1 : 1
} },
methods: {
toggleHaptic,
setPageActive(element, active) { element.inert = !active; element.setAttribute("aria-hidden", String(!active)) },
reloadPage() { location.reload() },
go(path) { navigate(path, () => { state.drawerOpen = false; state.searchPage = "" }) },
goFromMenu(path) {
state.drawerOpen = false
if (path === route.path) return
this.nextLayoutLeavePresentation = "modal"
this.go(path)
},
openSearchHit(hit) { navigate("/settings", () => { state.drawerOpen = false; state.searchPage = hit.page }) },
returnFromSearch() { state.searchPage = "" },
returnToDriving() { this.go("/driving") },
returnToDevice() { this.go("/device-preferences") },
ensureBackEntry,
routeBack() { state.drawerOpen = false; navigateBack() },
toggleTheme() {
state.theme = this.isLight ? "dark" : "light"
document.documentElement.dataset.theme = state.theme
save(THEME_KEY, state.theme)
},
togglePin() {
state.navPinned = !state.navPinned
state.drawerOpen = state.navPinned
save(PIN_KEY, String(state.navPinned))
},
isActive(path) { return route.path === path || route.path.startsWith(path + "/") },
async signIn() {
const password = authForm.password
authForm.password = ""
await auth.login(password)
},
async signOut() { await auth.logout() },
sessionExpired() { if (initializing) return; auth.expired(); initialize() },
},
mounted() {
document.documentElement.dataset.theme = state.theme
this.visibility = () => {
if (!document.hidden) deviceRestart.wake()
clearTimeout(reconnectTimer)
reconnectTimer = null
if (!document.hidden && (state.error || authState.status === "unavailable") && !initializing) initialize()
}
document.addEventListener("visibilitychange", this.visibility)
window.addEventListener("pageshow", this.visibility)
setRouteLeaveGuard((proceed, { back } = {}) => {
if (back && this.$refs.activeSettings?.navigateBack()) return
if (back && state.searchPage) { this.returnFromSearch(); return }
const presentation = this.nextLayoutLeavePresentation
this.nextLayoutLeavePresentation = "inline"
if (this.$refs.activeLayout) this.$refs.activeLayout.requestLeave(proceed, presentation)
else if (this.$refs.activeSettings) this.$refs.activeSettings.requestRouteLeave(proceed)
else proceed()
})
},
beforeUnmount() {
reconnectEnabled = false
clearTimeout(reconnectTimer)
startupGeneration++
document.removeEventListener("visibilitychange", this.visibility)
window.removeEventListener("pageshow", this.visibility)
deviceRestart.stop()
},
template: `
<div class="gx-app" :class="{'gx-nav-pinned':state.navPinned}" @change.capture="toggleHaptic">
<GalaxyLoading v-if="state.updateRestarting" :message="UPDATE_RESTART_MESSAGE" refresh />
<template v-else>
<header class="gx-appbar">
<button type="button" class="gx-icon-btn gx-appbar__back gx-back-btn" aria-label="Back" @click="routeBack"><i class="bi bi-arrow-left"></i></button>
<div class="gx-appbar__pill">
<button type="button" class="gx-appbar__home" aria-label="Galaxy Home" @click="go('/')"><span class="gx-brand" aria-hidden="true"></span><span class="gx-appbar__title">Galaxy</span></button>
<ToggleSearch :enabled="state.monitorMode === 'local' && authState.status === 'authenticated'" :unauthorized="sessionExpired" :open-page="openSearchHit" />
<div class="gx-appbar__right">
<DeviceState v-if="state.monitorMode === 'local' && authState.status === 'authenticated'" :unauthorized="sessionExpired" :connection="connectionLabel" />
<span v-else class="gx-status-pill"><span class="gx-status-dot offline"></span>{{ connectionLabel }}</span>
<button v-if="state.monitorMode === 'local' && authState.status === 'authenticated' && !authState.localAccess && !authState.gatewayAccess" type="button" class="gx-btn gx-btn--tonal" @click="signOut">Sign out</button></div>
</div>
<button type="button" class="gx-icon-btn gx-theme-toggle" :aria-label="isLight ? 'Switch to dark mode' : 'Switch to light mode'" @click="toggleTheme"><i class="bi" :class="isLight ? 'bi-moon-stars-fill' : 'bi-sun-fill'"></i></button>
<button type="button" class="gx-icon-btn gx-appbar__menu" aria-label="Menu" @click="state.drawerOpen=true"><i class="bi bi-list"></i></button>
</header>
<div v-if="state.drawerOpen && !state.navPinned" class="gx-underlay" @click="state.drawerOpen=false"></div>
<aside class="gx-drawer" :class="{open:state.drawerOpen || state.navPinned}" aria-label="Galaxy navigation">
<div class="gx-drawer__header"><span class="gx-brand" aria-hidden="true"></span><span class="gx-drawer-title">Galaxy</span><button type="button" class="gx-icon-btn gx-drawer__pin" :aria-pressed="state.navPinned" :aria-label="state.navPinned ? 'Unpin navigation' : 'Pin navigation'" @click="togglePin"><i class="bi" :class="state.navPinned ? 'bi-pin-angle-fill' : 'bi-pin-angle'"></i></button></div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Main</div>
<button v-for="item in NAV.slice(0,3)" :key="item.path" type="button" class="gx-nav-item" :class="{active:isActive(item.path)}" @click="goFromMenu(item.path)"><i class="bi" :class="item.icon"></i><span>{{ item.name }}</span></button>
</div>
<div v-if="authState.localAccess" class="gx-nav-section"><div class="gx-nav-section__title">Routes</div><button type="button" class="gx-nav-item" :class="{active:isActive('/recordings')}" @click="goFromMenu('/recordings')"><i class="bi bi-camera-reels"></i><span>Routes</span></button></div>
<div class="gx-nav-section"><div class="gx-nav-section__title">Tools</div>
<button v-for="tool in visibleTools.filter(tool => tool.path !== '/galaxy')" :key="tool.path" type="button" class="gx-nav-item" :class="{active:isActive(tool.path)}" @click="goFromMenu(tool.path)"><i class="bi" :class="tool.icon"></i><span>{{ tool.name }}</span></button>
</div>
<div class="gx-nav-footer"><DevicePicker />
<button type="button" class="gx-nav-item" :class="{active:isActive('/galaxy')}" @click="goFromMenu('/galaxy')"><i class="bi bi-globe"></i><span>Galaxy Access & Install</span></button>
</div>
</aside>
<main class="gx-content" :style="{'--route-direction': routeDirection}"><div class="gx-route-stage">
<Transition name="gx-route" @before-enter="setPageActive($event, true)" @before-leave="setPageActive($event, false)"><div class="gx-route" :key="route.path">
<template v-if="state.loading && !state.error"><GalaxyLoading v-if="state.startupPending && !state.updateRestarting" message="Connecting to your device…" /></template>
<GxNotice v-else-if="state.error" tone="danger">{{ state.error }}</GxNotice>
<section v-else-if="state.monitorMode === 'local' && authState.status !== 'authenticated'" class="gx-card gx-auth" aria-label="Galaxy sign in">
<h2>Galaxy access</h2>
<p v-if="authState.status === 'checking'">Checking Galaxy access…</p>
<p v-else-if="authState.status === 'setup_required'">Remote Galaxy access is not configured.</p>
<p v-else-if="authState.status === 'unavailable'">Galaxy access is unavailable.</p>
<p v-else-if="authState.status === 'gateway_login'"><a class="gx-btn" href="/">Reconnect to Galaxy</a></p>
<form v-else @submit.prevent="signIn"><label for="galaxy-password">Password</label><input id="galaxy-password" class="gx-field" type="password" v-model="authForm.password" minlength="8" maxlength="255" autocomplete="current-password" required><button class="gx-btn" type="submit">Sign in</button></form>
<GxNotice v-if="authState.error" tone="danger">{{ authState.error }}</GxNotice>
</section>
<GxNotice v-else-if="state.pageError" tone="danger">{{ state.pageError }} <button class="gx-btn" @click="reloadPage">Reload page</button></GxNotice>
<Home v-else-if="route.path === '/'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<LocalRecordingsPage v-else-if="route.path === '/recordings' && authState.localAccess" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<GxState v-else-if="route.path === '/recordings'">Routes require a local Galaxy connection. Open a local address from Home while connected to the comma's hotspot or local network.</GxState>
<CamerasPage v-else-if="route.path === '/cameras'" :mode="state.monitorMode" :go="go" :unauthorized="sessionExpired" />
<SentryEventsPage v-else-if="['/cameras/events', '/cameras/sentry-settings'].includes(route.path)" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<PipPage v-else-if="route.path === '/cameras/pip'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<VasmPage v-else-if="route.path === '/cameras/vasm'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<Tools v-else-if="route.path === '/tools'" :tools="visibleTools" :mode="state.monitorMode" />
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="route.path === '/developer/connect'" :mode="state.monitorMode" :unauthorized="sessionExpired" initial-section="developer" />
<GalaxyPage v-else-if="route.path === '/galaxy'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<OnroadLayoutPage ref="activeLayout" v-else-if="['/theme_maker', '/theme_maker/android_auto', '/appearance'].includes(route.path)" :key="route.path" :projection="route.path === '/theme_maker/android_auto'" :initial-panel="route.path === '/appearance' ? 'display' : 'widgets'" :mode="state.monitorMode" :unauthorized="sessionExpired" @target="go($event === 'projection' ? '/theme_maker/android_auto' : '/theme_maker')" @close="routeBack" />
<SettingsRecoveryPage v-else-if="route.path === '/settings/recovery'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<Logs v-else-if="route.path === '/logs' || route.path.startsWith('/logs/') || ['/troubleshoot', '/manage_tmux'].includes(route.path)" :path="route.path === '/troubleshoot' ? '/logs/troubleshoot' : route.path === '/manage_tmux' ? '/logs/tmux' : route.path" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<SoftwarePage v-else-if="route.path === '/system'" :mode="state.monitorMode" :unauthorized="sessionExpired" :restarting="baseline => updateRestart.start({ ...baseline, instance: deviceRestart.instance })" />
<NavigationPage v-else-if="['/navigation', '/navigation/maps', '/navigation/setup'].includes(route.path)" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go"
:initial-tab="route.path === '/navigation/maps' ? 'maps' : route.path === '/navigation/setup' ? 'setup' : 'route'" />
<ModelsPage v-else-if="route.path === '/manage_models'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<LaboratoryPage v-else-if="route.path === '/model_laboratory'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<BluetoothPage v-else-if="route.path === '/bluetooth'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<AndroidAutoPage v-else-if="route.path === '/android-auto'" :mode="state.monitorMode" :local-access="authState.localAccess" :unauthorized="sessionExpired" />
<VehicleControlsPage v-else-if="route.path === '/vehicle'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<DevicePreferencesPage v-else-if="route.path === '/device-preferences'" :mode="state.monitorMode" :go="go" />
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="deviceSettingsPage" :key="deviceSettingsPage" :mode="state.monitorMode" :unauthorized="sessionExpired" :initial-page="deviceSettingsPage" :title="deviceSettingsPage === 'sounds' ? 'Sounds & Alerts' : 'Display'" :return-to="returnToDevice" />
<DrivingPage v-else-if="route.path === '/driving'" :mode="state.monitorMode" :go="go" />
<LongitudinalCurvesPage v-else-if="route.path === '/driving/longitudinal-curves'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="drivingSettingsPage" :key="drivingSettingsPage" :mode="state.monitorMode" :unauthorized="sessionExpired" :initial-page="drivingSettingsPage" :title="'Driving settings · ' + drivingSettingsPage.replaceAll('_', ' ')" :return-to="returnToDriving" />
<SettingsPage @nested="ensureBackEntry" ref="activeSettings" v-else-if="route.path === '/settings'" :key="state.searchPage || 'hub'" :mode="state.monitorMode" :unauthorized="sessionExpired"
:initial-page="state.searchPage || 'hub'" :return-to="state.searchPage ? returnFromSearch : null" />
<div v-else-if="route.path === '/tuning'" class="gx-view">
<h2>Plots &amp; Analysis</h2>
<p class="gx-note">Live control observations and recorded-drive analysis.</p>
<div class="gx-grid">
<MenuTile icon="bi-graph-up" title="Live plots" description="Compare steering and acceleration targets with measured response" @select="go('/tuning/plots')" />
<MenuTile icon="bi-file-earmark-bar-graph" title="Recorded-drive analysis" description="Review lateral tracking from saved drives" @select="go('/tuning/flm')" />
</div>
</div>
<PlotsPage v-else-if="route.path === '/tuning/plots'" :mode="state.monitorMode" :unauthorized="sessionExpired" />
<FlmPage v-else-if="route.path === '/tuning/flm'" :mode="state.monitorMode" :unauthorized="sessionExpired" :go="go" />
<GxState v-else :title="pageName">This capability is unavailable in this build. No operation was attempted.</GxState>
</div></Transition></div></main>
<BottomNav :path="route.path" :local-access="authState.localAccess" @navigate="go" />
</template>
</div>
`,
}).component("GxState", GxState).component("GxNotice", GxNotice).mount("#galaxy-app")
spawnAmbientStars(document.getElementById("galaxy-bg"))
startRouter()
let startupGeneration = 0
let reconnectTimer = null
let initializing = false
let reconnectEnabled = true
function scheduleReconnect() {
if (!reconnectEnabled || document.hidden || initializing || reconnectTimer !== null) return
reconnectTimer = setTimeout(() => {
reconnectTimer = null
if (reconnectEnabled && !document.hidden && !initializing && (state.error || authState.status === "unavailable")) initialize()
}, 3000)
}
async function initialize() {
const generation = ++startupGeneration
initializing = true
clearTimeout(reconnectTimer)
reconnectTimer = null
state.loading = !state.tools.length
const loadingDelay = setTimeout(() => { if (generation === startupGeneration) state.startupPending = true }, 150)
try {
const catalog = await loadCatalog()
if (generation !== startupGeneration) return
state.monitorMode = catalog.mode
deviceRestart.track(catalog.instance)
state.tools = catalog.tools
state.error = ""
if (state.monitorMode === "local") await auth.check()
else authState.status = "preview"
} catch {
if (generation === startupGeneration) state.error = "Galaxy could not connect. Reconnecting automatically…"
} finally {
clearTimeout(loadingDelay)
if (generation === startupGeneration) {
state.loading = state.startupPending = false
initializing = false
if (state.error || authState.status === "unavailable") scheduleReconnect()
}
}
}
initialize()
@@ -0,0 +1,97 @@
// Browser-only Galaxy session boundary; never persists passwords or tokens.
import { requestJson } from "./startup.js"
export class LocalAuth {
constructor({ publish, fetcher = (...args) => fetch(...args) }) {
this.publish = publish
this.fetcher = fetcher
this.generation = 0
this.gatewayAccess = false
this.localAccess = false
this.mutationTail = Promise.resolve()
}
update(state, localAccess = false) {
this.localAccess = localAccess
this.publish({ ...state, localAccess })
}
mutate(operation) {
const pending = this.mutationTail.then(operation)
this.mutationTail = pending.then(() => {}, () => {})
return pending
}
async check() {
const generation = ++this.generation
try {
const result = await requestJson("./api/auth/session", { fetcher: this.fetcher })
if (generation !== this.generation) return
this.gatewayAccess = result.gatewayAccess === true
this.publish({ gatewayAccess: this.gatewayAccess })
if (result.state === "unavailable") this.update({ status: "unavailable", error: "Galaxy access is unavailable." })
else if (result.state === "setup_required") this.update({ status: "setup_required", error: "Remote Galaxy access is not configured." })
else if (result.state === "configured") this.update({ status: result.authenticated === true ? "authenticated" : this.gatewayAccess ? "gateway_login" : "login", error: "",
...(this.gatewayAccess ? { gatewayAccess: true } : {}) },
result.authenticated === true && result.localAccess === true)
else throw new Error("Invalid session state")
} catch {
if (generation === this.generation) this.update({ status: "unavailable", error: "Galaxy access could not be checked." })
}
}
login(password) {
if (this.localAccess || this.gatewayAccess) return Promise.resolve(false)
const generation = ++this.generation
return this.mutate(async () => {
try {
const response = await this.fetcher("./api/auth/login", {
method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password }),
})
if (generation !== this.generation) return false
if (!response.ok) {
if (response.status === 429) this.update({ status: "login", error: "Try again shortly." })
else if (response.status === 503) this.update({ status: "unavailable", error: "Galaxy access is unavailable." })
else this.update({ status: "login", error: "Sign in failed." })
return false
}
this.update({ status: "authenticated", error: "" })
return true
} catch {
if (generation === this.generation) this.update({ status: "login", error: "Sign in could not be completed." })
return false
}
})
}
logout() {
if (this.localAccess || this.gatewayAccess) return Promise.resolve(false)
const generation = ++this.generation
return this.mutate(async () => {
try {
const response = await this.fetcher("./api/auth/logout", {
method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: "{}",
})
if (generation !== this.generation) return false
if (response.status === 401 || response.status === 503) {
this.update({ status: response.status === 503 ? "unavailable" : "login", error: "" })
return false
}
if (!response.ok) throw new Error("Logout failed")
this.update({ status: "login", error: "" })
return true
} catch {
if (generation === this.generation) this.update({ status: "authenticated", error: "Sign out could not be completed." })
return false
}
})
}
expired() {
this.generation++
if (this.localAccess || this.gatewayAccess) this.update({ status: "checking", error: "" })
else this.update({ status: "login", error: "Your Galaxy session expired. Sign in again." })
}
}
@@ -0,0 +1,21 @@
export function hasBluetoothName(device) {
if (typeof device?.name !== "string") return false
const name = device.name.trim()
const address = name.replace(/^[[(]|[\])]$/g, "").replace(/^dev_/i, "").replace(/[:.\s_-]/g, "")
return name.length > 0 && !/^[0-9a-f]{12}$/i.test(address)
}
// Keep discovery rows in their first-seen position while updating live state.
export class BluetoothDeviceList {
constructor() { this.reset() }
reset() { this.order = [] }
update(devices) {
const current = new Map()
for (const device of devices) {
if (!hasBluetoothName(device)) continue
current.set(device.address, { ...device, name: device.name.trim() })
if (!this.order.includes(device.address)) this.order.push(device.address)
}
return this.order.filter((address) => current.has(address)).map((address) => current.get(address))
}
}
@@ -0,0 +1,223 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { ControllersPage } from "./controllers.js"
import { BluetoothDeviceList } from "./bluetooth-devices.js"
const ADDRESS = /^(?:[0-9A-F]{2}:){5}[0-9A-F]{2}$/
const OPERATIONS = new Set(["power", "scan", "stop_scan", "connect", "disconnect", "forget", "pair", "pairing_response", "cancel_pair"])
const PAIR_STATES = new Set(["pairing", "paired", "failed", "canceled"])
const BLUETOOTH_ERRORS = {
radio_unavailable: "Bluetooth radio support is unavailable on this device.",
adapter_unavailable: "No Bluetooth adapter was detected. Turn Bluetooth on, then refresh.",
service_unavailable: "The device's Bluetooth service could not be reached. Refresh to reconnect.",
radio_preference_unavailable: "The Bluetooth power setting could not be read. Try again after restarting the device.",
busy: "Bluetooth is being used by another operation. Finish pairing or stop searching, then try again.",
park_required: "Park before changing Bluetooth connections.",
session_expired: "The pairing session expired. Refresh and start pairing again.",
changed: "The Bluetooth device or pairing request changed. Refresh and try again.",
}
export const bluetoothError = (code) => BLUETOOTH_ERRORS[code] || "Bluetooth could not complete the request. Refresh to try again."
const PROMPT_KINDS = new Set(["pin", "passkey", "confirmation", "authorization", "display_pin", "display_passkey"])
export function validPairInput(prompt, value) {
if (prompt?.kind === "pin") return /^[\x20-\x7E]{1,16}$/.test(value)
if (prompt?.kind === "passkey") return /^[0-9]{1,6}$/.test(value)
return ["confirmation", "authorization"].includes(prompt?.kind)
}
function validPairing(value) {
if (value === null) return true
const prompt = value?.prompt
return ADDRESS.test(value?.address) && PAIR_STATES.has(value?.state) &&
(prompt === null || (typeof prompt === "object" && /^[0-9a-f]{32}$/.test(prompt.id) &&
PROMPT_KINDS.has(prompt.kind) && typeof prompt.value === "string" && prompt.value.length <= 16 &&
typeof prompt.displayOnly === "boolean"))
}
export function validBluetoothStatus(value) {
return value?.version === 1 && typeof value.available === "boolean" && typeof value.parked === "boolean" &&
typeof value.powered === "boolean" && typeof value.discovering === "boolean" &&
(value.errorCode === null || ["radio_unavailable", "adapter_unavailable", "service_unavailable", "radio_preference_unavailable"].includes(value.errorCode)) &&
validPairing(value.pairing ?? null) && Array.isArray(value.devices) && value.devices.length <= 64 && value.devices.every((device) =>
ADDRESS.test(device?.address) && typeof device.name === "string" && device.name.length <= 80 &&
[device.paired, device.connected, device.trusted].every((flag) => typeof flag === "boolean"))
}
export class BluetoothFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.busy = false
this.status = null
this.deviceList = new BluetoothDeviceList()
this.error = ""
}
emit() { this.publish({ status: this.status, busy: this.busy, error: this.error }) }
stop(cancelPair = false) {
if (cancelPair && this.active && this.status?.pairing?.state === "pairing") {
this.fetcher("./api/bluetooth/action", { method: "POST", credentials: "same-origin", keepalive: true,
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel_pair" }) }).catch(() => {})
}
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.busy = false
this.status = null
this.deviceList.reset()
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
retire(generation, request) {
if (!this.active || this.generation !== generation || this.request !== request) return false
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.request = null
this.busy = false
if (this.pollTimer === null) this.pollTimer = this.later(() => this.refresh(), 2000)
this.emit()
return true
}
async run(url, options, timeout, background = false) {
if (!this.active || this.busy || background && this.request !== null) return null
if (!background) {
// The user action supersedes any in-flight poll, including its JSON body.
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
}
const generation = this.generation
const request = new AbortController()
this.request = request
if (!background) { this.busy = true; this.emit() }
this.timer = this.later(() => {
if (!this.retire(generation, request)) return
request.abort()
this.generation++
this.error = "Bluetooth took too long to respond. Refresh to check its current state."
this.emit()
}, timeout)
try {
const response = await this.fetcher(url, { ...options, signal: request.signal, credentials: "same-origin", cache: "no-store" })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
const data = await response.json().catch(() => { throw new Error("Galaxy could not reach Bluetooth. Refresh to reconnect.") })
if (!this.active || this.generation !== generation || this.request !== request || request.signal.aborted) return null
if (response.status === 503 && ["setup_required", "access_unavailable"].includes(data?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(bluetoothError(data?.code))
if (!validBluetoothStatus(data)) throw new Error("Galaxy received an invalid Bluetooth response. Refresh to reconnect.")
this.status = { ...data, devices: this.deviceList.update(data.devices) }
this.error = ""
return data
} catch (error) {
if (this.active && this.generation === generation && this.request === request) {
this.error = connectionError(error)
}
return null
} finally {
this.retire(generation, request)
}
}
async refresh() {
if (!this.active || this.busy || this.request !== null) return
if (this.pollTimer !== null) { this.cancelTimer(this.pollTimer); this.pollTimer = null }
await this.run("./api/bluetooth/status", {}, 12000, true)
}
async action(operation, fields = {}) {
if (!OPERATIONS.has(operation) || !this.active || this.busy || (operation === 'power' && !this.status?.parked)) return
const payload = { operation, ...fields }
const result = await this.run("./api/bluetooth/action", {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
}, operation === "power" ? 35000 : ["connect", "disconnect"].includes(operation) ? 30000 : 15000)
if (result) await this.refresh()
}
}
export const BluetoothPage = {
components: { GxState, GxNotice, ControllersPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: null, busy: false, error: "", pairValue: "" }),
mounted() {
this.feed = new BluetoothFeed({ publish: (value) => Object.assign(this, value), unauthorized: this.unauthorized })
this.visibility = () => document.hidden ? this.feed.stop(true) : this.feed.start()
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
watch: {
mode(value) { if (value === "local" && !document.hidden) this.feed?.start(); else this.feed?.stop(true) },
'status.pairing.prompt.id'() { this.pairValue = "" },
},
computed: {
saved() { return this.status?.devices?.filter((device) => device.paired || device.trusted || device.connected) || [] },
nearby() { return this.status?.devices?.filter((device) => !device.paired && !device.trusted && !device.connected) || [] },
canAllowPair() { return validPairInput(this.status?.pairing?.prompt, this.pairValue) },
},
methods: {
bluetoothError,
refresh() { return this.feed.refresh() },
action(operation, fields) { return this.feed.action(operation, fields) },
respond(accepted) {
const prompt = this.status?.pairing?.prompt
if (!prompt || prompt.displayOnly || accepted && !validPairInput(prompt, this.pairValue)) return
const value = accepted && ["pin", "passkey"].includes(prompt.kind) ? this.pairValue : ""
return this.action("pairing_response", { promptId: prompt.id, accepted, value })
},
},
template: `
<section class="gx-driving" aria-label="Bluetooth" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Bluetooth</h2>
<p>View, pair and manage nearby and saved devices.</p></div></header>
<GxState v-if="mode !== 'local'">Bluetooth requires the device. No radio was checked in preview.</GxState>
<template v-else>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxState v-if="!status && !error" loading>Checking Bluetooth…</GxState>
<template v-else-if="status">
<div class="gx-card gx-driving__intro"><h3>Adapter {{ status.powered ? 'On' : 'Off' }}</h3>
<p v-if="status.errorCode">{{ bluetoothError(status.errorCode) }}</p>
<p v-else-if="!status.parked">Park to restart the Bluetooth radio.</p>
<div class="gx-driving__actions gx-actions"><button class="gx-btn" :disabled="!status.parked || !status.available" @click="action('power', {enabled: !status.powered})">Turn {{ status.powered ? 'Off' : 'On' }}</button>
<button class="gx-btn gx-btn--tonal" :disabled="!status.powered" @click="action(status.discovering ? 'stop_scan' : 'scan')">{{ status.discovering ? 'Stop Search' : 'Search' }}</button></div></div>
<div v-if="status.pairing" class="gx-card gx-driving__intro" role="status"><h3>Pairing {{ status.pairing.address }}</h3>
<p v-if="status.pairing.state === 'pairing'">Waiting for the device and any confirmation below.</p>
<p v-else>{{ status.pairing.state === 'paired' ? 'Device paired.' : status.pairing.state === 'canceled' ? 'Pairing canceled.' : 'Pairing did not complete.' }}</p>
<div v-if="status.pairing.prompt" class="gx-driving__intro"><p>{{ status.pairing.prompt.kind === 'confirmation' ? 'Confirm this code matches the device:' :
status.pairing.prompt.kind === 'pin' ? 'Enter the PIN shown by the device.' :
status.pairing.prompt.kind === 'passkey' ? 'Enter the device passkey.' :
status.pairing.prompt.kind === 'authorization' ? 'Allow this device to pair?' : 'Use this code on the device:' }}</p>
<strong v-if="status.pairing.prompt.value">{{ status.pairing.prompt.value }}</strong>
<input v-if="['pin', 'passkey'].includes(status.pairing.prompt.kind)" v-model="pairValue" class="gx-field" type="text" maxlength="16" autocomplete="off" placeholder="PIN or passkey" />
<div v-if="!status.pairing.prompt.displayOnly" class="gx-driving__actions gx-actions"><button class="gx-btn gx-btn--tonal" @click="respond(false)">Reject</button>
<button class="gx-btn" :disabled="!canAllowPair" @click="respond(true)">{{ status.pairing.prompt.kind === 'pin' ? 'Submit PIN' : status.pairing.prompt.kind === 'passkey' ? 'Submit Passkey' : 'Allow' }}</button></div></div>
<button v-if="status.pairing.state === 'pairing'" class="gx-btn gx-btn--tonal" @click="action('cancel_pair')">Cancel Pairing</button></div>
<div class="gx-card gx-driving__intro"><h3>Saved Devices</h3><p v-if="!saved.length">No saved devices found.</p>
<div v-for="device in saved" :key="device.address" class="gx-row"><div class="gx-row__info"><strong>{{ device.name }}</strong><small>{{ device.connected ? 'Connected' : 'Disconnected' }}</small></div>
<div class="gx-driving__actions gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="!status.powered" @click="action(device.connected ? 'disconnect' : 'connect', {address:device.address})">{{ device.connected ? 'Disconnect' : 'Connect' }}</button>
<button class="gx-btn gx-btn--tonal" @click="action('forget', {address:device.address})">Forget</button></div></div></div>
<div class="gx-card gx-driving__intro"><h3>Nearby Devices</h3><p v-if="!nearby.length">{{ status.discovering ? 'Searching…' : 'No nearby devices found.' }}</p>
<div v-for="device in nearby" :key="device.address" class="gx-row"><strong>{{ device.name }}</strong>
<button class="gx-btn" :disabled="!status.powered || status.pairing?.state === 'pairing'" @click="action('pair', {address:device.address})">Pair</button></div></div>
</template>
</template>
<ControllersPage :mode="mode" :unauthorized="unauthorized" />
</section>`,
}
+42
View File
@@ -0,0 +1,42 @@
import { savedUpdateRestart, UPDATE_RESTART_MESSAGE } from "./update-restart.js"
import { requestJson } from "./startup.js"
const status = document.getElementById("galaxy-boot")
const message = status.querySelector("p")
const retry = status.querySelector("button")
if (savedUpdateRestart()) { message.textContent = UPDATE_RESTART_MESSAGE; retry.textContent = "↻ Refresh Galaxy"; retry.hidden = false }
retry.addEventListener("click", () => location.reload())
let probe = null, sawOffline = false, opened = false
function reloadWhenBack() {
if (probe !== null || opened) return
probe = setTimeout(() => requestJson("./data/runtime.json")
.then(() => true, () => false)
.then(online => {
probe = null
if (opened) return
if (online && sawOffline) location.reload()
else { sawOffline ||= !online; reloadWhenBack() }
}), 3000)
}
function failed() {
reloadWhenBack()
if (savedUpdateRestart()) return
message.textContent = "Galaxy could not finish opening. Check your connection and try again."
status.classList.add("gx-boot--failed")
retry.hidden = false
}
function dismiss() {
if (matchMedia("(prefers-reduced-motion: reduce)").matches) { status.remove(); return }
status.classList.add("gx-boot--done")
status.addEventListener("transitionend", () => status.remove(), { once: true })
setTimeout(() => status.remove(), 800)
}
const timer = setTimeout(failed, 10000)
// Local pages use their local origin; public pages use the authenticated tunnel.
// Do not intercept fetch or ask a public page to probe private network addresses.
import("./connection-migration.js").then(module => module.retireLegacyPhone()).catch(() => {})
import("./app.js").then(() => {
clearTimeout(timer)
if (document.querySelector("#galaxy-app .gx-app")) { opened = true; clearTimeout(probe); probe = null; dismiss() }
else failed()
}).catch(() => { clearTimeout(timer); failed() })
@@ -13,13 +13,14 @@ export function primaryTab(path) {
}
export const BottomNav = {
props: { path: { type: String, required: true } },
props: { path: { type: String, required: true }, localAccess: { type: Boolean, required: true } },
emits: ["navigate"],
computed: { activeTab() { return primaryTab(this.path) } },
data: () => ({ tabs: NAV }),
template: `<nav class="blur-nav" aria-label="Primary navigation" :style="{'--active-tab': activeTab}">
<span class="gx-nav-indicator" aria-hidden="true"></span>
<button v-for="(item, index) in tabs" :key="item.path" type="button" class="nav-item"
:disabled="item.path === '/recordings' && !localAccess" :title="item.path === '/recordings' && !localAccess ? 'Routes require a local Galaxy connection' : null"
:class="{active: index === activeTab}" :aria-current="index === activeTab ? 'page' : null" @click="$emit('navigate', item.path)">
<i class="bi" :class="item.icon" aria-hidden="true"></i><span>{{ item.name }}</span>
</button>
@@ -0,0 +1,193 @@
import { MenuTile } from "./menu-tile.js"
import { GxNotice } from "./notice.js"
import { GalaxySelect } from "./galaxy-select.js"
export class CameraSnapshotFeed {
constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args),
createURL = (blob) => URL.createObjectURL(blob), revokeURL = (url) => URL.revokeObjectURL(url),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = id => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, createURL, revokeURL, later, cancelTimer })
this.request = null
this.url = ""
this.timer = null
}
stop() {
this.rejectRequest?.(new Error("Snapshot canceled"))
this.rejectRequest = null
this.request?.abort()
this.request = null
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
if (this.url) this.revokeURL(this.url)
this.url = ""
this.publish({ image: "", capturing: false, error: "" })
}
async capture(camera) {
this.stop()
const request = new AbortController()
this.request = request
this.publish({ image: "", capturing: true, error: "" })
let expire
const deadline = new Promise((_, reject) => { this.rejectRequest = expire = reject })
this.timer = this.later(() => {
if (this.request !== request) return
expire(new Error("Snapshot timed out. Turn off the vehicle and try again."))
}, 13500)
try {
const response = await Promise.race([this.fetcher("./api/cameras/snapshot", { method: "POST", credentials: "same-origin",
cache: "no-store", signal: request.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ camera }) }), deadline])
if (this.request !== request || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (!response.ok) throw new Error(response.status === 409 ? "Turn the vehicle off before taking a snapshot." :
"Turn off the vehicle and open its camera preview, then try again.")
if (response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase() !== "image/jpeg") throw new Error("Camera image is unavailable.")
const blob = await Promise.race([response.blob(), deadline])
if (this.request !== request || request.signal.aborted) return
if (!blob.size || blob.size > 1000000) throw new Error("Camera image is unavailable.")
this.url = this.createURL(blob)
this.publish({ image: this.url, capturing: false, error: "" })
} catch (error) {
if (this.request === request && !request.signal.aborted)
this.publish({ image: "", capturing: false, error: error.message || "Camera image is unavailable." })
} finally {
if (this.request === request) {
request.abort()
this.request = null
this.rejectRequest = null
this.cancelTimer(this.timer)
this.timer = null
}
}
}
}
export const CamerasPage = {
name: "CamerasPage",
components: { GxNotice, GalaxySelect, MenuTile },
props: { mode: { type: String, required: true }, go: { type: Function, required: true },
unauthorized: { type: Function, required: true } },
data: () => ({ camera: "cabin", image: "", capturing: false, error: "" }),
created() { this.snapshots = new CameraSnapshotFeed({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) },
methods: {
chooseCamera(event) { this.camera = event.target.value; this.snapshots.stop() },
},
mounted() {
this.visibility = () => { if (document.hidden) this.snapshots.stop() }
document.addEventListener("visibilitychange", this.visibility)
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.snapshots.stop() },
watch: { mode() { this.snapshots.stop() } },
template: `
<div class="gx-view gx-settings" aria-label="Cameras and Monitoring">
<h2>Cameras &amp; Monitoring</h2>
<div class="gx-grid">
<MenuTile icon="bi-camera-video" title="Blind Spot Camera and Preview" description="Adjust the camera crop with a live cabin preview." :disabled="mode !== 'local'" @select="go('/cameras/pip')" />
<MenuTile icon="bi-shield" title="Sentry" description="View motion events, configure saved motion settings, and manage notifications." :disabled="mode !== 'local'" @select="go('/cameras/events')" />
<MenuTile icon="bi-eye" title="V-ASM" description="Preview the cabin camera, draw window regions, and adjust visual spot-monitoring choices." :disabled="mode !== 'local'" @select="go('/cameras/vasm')" />
</div>
<section class="gx-card gx-panel gx-stack"><h2>Camera Snapshot</h2>
<p>Turn off the vehicle, choose a camera, then take a snapshot.</p>
<template v-if="mode === 'local'">
<label>Camera
<GalaxySelect class="gx-field gx-field--full" aria-label="Camera" :value="camera" :disabled="capturing" @change="chooseCamera">
<option value="cabin">Cabin</option><option value="wide">Wide road</option><option value="narrow">Road</option>
</GalaxySelect></label>
<button class="gx-btn" type="button" :disabled="capturing" @click="snapshots.capture(camera)">{{ capturing ? 'Capturing…' : 'Take snapshot' }}</button>
<button v-if="image || capturing" class="gx-btn gx-btn--tonal" type="button" @click="snapshots.stop()">{{ capturing ? 'Cancel' : 'Clear' }}</button>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<img v-if="image" :src="image" :alt="camera + ' camera snapshot'" class="gx-media gx-media--spaced" />
</template>
<p v-else>Camera snapshots are available on the connected device.</p>
<small>Snapshots are not saved.</small></section>
</div>`,
}
// Shared owner for live editor frames, including bounded capture and image decode.
export class LiveCameraPreview {
constructor({ publish, unauthorized, redraw, enabled, imageFactory = () => new Image(), later = (fn, ms) => setTimeout(fn, ms), cancelTimer = id => clearTimeout(id) }) {
Object.assign(this, { publish, redraw, enabled, imageFactory, later, cancelTimer })
this.image = null
this.generation = 0
this.stopped = true
this.snapshots = new CameraSnapshotFeed({ unauthorized, publish: update => this.receive(update) })
this.visibility = () => {
this.cancelTimer(this.timer)
this.snapshots.stop()
if (!document.hidden && this.warming) this.poll()
}
}
clear() {
this.generation++
this.cancelTimer(this.decodeTimer)
this.snapshots.stop()
if (this.image) this.image.src = ""
this.image = null
this.publish({ imageName: "" })
this.redraw()
}
receive(update) {
if (update.error) {
this.lastError = update.error
return
}
if (!update.image) return
const generation = ++this.generation
const image = this.imageFactory()
const failed = () => {
if (this.stopped || generation !== this.generation) return
this.cancelTimer(this.decodeTimer)
this.generation++
this.lastError = "Camera frame could not be displayed."
if (!this.image && !this.warming) this.publish({ cameraError: this.lastError })
this.redraw()
}
image.onload = () => {
if (this.stopped || generation !== this.generation) return
this.cancelTimer(this.decodeTimer)
this.image = image
this.publish({ imageName: "Live cabin camera", cameraError: "" })
this.redraw()
}
image.onerror = failed
this.cancelTimer(this.decodeTimer)
this.decodeTimer = this.later(failed, 4000)
image.src = update.image
}
start() {
this.stopped = false
document.addEventListener("visibilitychange", this.visibility)
this.refresh()
}
refresh() {
this.cancelTimer(this.warmupTimer)
this.cancelTimer(this.timer)
this.warming = true
this.lastError = ""
this.publish({ cameraWarming: true, cameraError: "" })
this.warmupTimer = this.later(() => this.freeze(), 5000)
this.poll()
}
freeze() {
this.generation++
this.cancelTimer(this.decodeTimer)
this.warming = false
this.pollGeneration = (this.pollGeneration || 0) + 1
this.cancelTimer(this.timer)
this.snapshots.stop()
this.publish({ cameraWarming: false, cameraError: this.image ? "" : this.lastError || "No cabin frame was available. Turn off the vehicle and take a new snapshot." })
}
async poll() {
if (this.stopped || !this.warming) return
const generation = this.pollGeneration = (this.pollGeneration || 0) + 1
if (this.enabled() && !document.hidden) await this.snapshots.capture("cabin")
if (!this.stopped && this.warming && generation === this.pollGeneration) this.timer = this.later(() => this.poll(), 1000)
}
stop() {
this.stopped = true
this.warming = false
this.cancelTimer(this.warmupTimer)
this.cancelTimer(this.timer)
document.removeEventListener("visibilitychange", this.visibility)
this.clear()
}
}
@@ -0,0 +1,73 @@
import { GxState } from "./state.js"
import { PollTimer, connectionError } from "./polling.js"
import { requestJson } from "./startup.js"
import { GalaxySelect } from "./galaxy-select.js"
import { GxNotice } from "./notice.js"
export const validProviderStatus = value => value && ["comma", "konik"].includes(value.active) &&
["comma", "konik"].includes(value.selected) && /^[a-f0-9]{64}$/.test(value.revision) &&
typeof value.restartRequired === "boolean" && typeof value.canSelect === "boolean" &&
Array.isArray(value.providers) && value.providers.length === 2 &&
["comma", "konik"].every(name => value.providers.filter(p => p.id === name).length === 1) &&
value.providers.every(p => ["comma", "konik"].includes(p.id) && typeof p.label === "string" &&
p.url === (p.id === "comma" ? "https://connect.comma.ai" : "https://stable.konik.ai"))
export const CloudProviderPage = {
name: "CloudProviderPage",
components: { GxState, GalaxySelect, GxNotice },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: null, selection: null, busy: false, error: "", request: null }),
mounted() { this.activePage = true; this.poller = new PollTimer({ read: () => this.load() }); this.poller.start(); this.load() },
beforeUnmount() { this.activePage = false; this.poller.stop(); this.request?.abort() },
computed: {
active() { return this.status?.providers.find(p => p.id === this.status.active) },
selected() { return this.status?.providers.find(p => p.id === this.status.selected) },
},
methods: {
async call(body) {
this.request?.abort()
const controller = new AbortController()
this.request = controller
try {
const value = await requestJson("./api/connect/provider", { request: { signal: controller.signal,
...(body ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) } })
if (this.request !== controller || !this.activePage) return
if (!validProviderStatus(value)) throw new Error("Cloud provider status is unavailable")
this.error = ""
this.status = value
} finally {
if (this.request === controller) this.request = null
}
},
async load() {
if (this.mode !== "local" || this.busy || this.request) return
try { await this.call() } catch (error) { if (error.status === 401) this.unauthorized(); else this.error = connectionError(error) }
},
async select(name) {
if (!this.status?.canSelect || this.busy || !this.status.providers.some(p => p.id === name)) return
const label = this.status.providers.find(p => p.id === name).label
if (!window.confirm(`Use ${label} after the next device reboot? The current session stays unchanged. Returning to comma restores its saved cloud identity.`)) return
this.busy = true
this.selection = name
this.error = ""
try { await this.call({ provider: name, revision: this.status.revision, confirmed: true }) }
catch (error) { if (error.status === 401) this.unauthorized(); else this.error = connectionError(error) }
finally { this.selection = null; this.busy = false }
},
},
template: `<div class="gx-settings__developer-body">
<GxState v-if="mode !== 'local'">Open this Developer page on your device to choose a cloud provider.</GxState>
<template v-else>
<GxNotice v-if="status && !status.canSelect" tone="warn">Turn off the vehicle to change this Developer setting.</GxNotice>
<section class="gx-card gx-settings__section" :inert="busy" :aria-busy="busy || undefined"><div class="gx-section__header"><i class="bi bi-code-slash" aria-hidden="true"></i><span class="gx-section__title">Developer</span></div><div class="gx-row"><div class="gx-row__info">
<label class="gx-settings__developer-provider">Cloud provider
<GalaxySelect class="gx-field gx-field--full" aria-label="Cloud provider" :value="selection ?? status?.selected ?? ''"
:disabled="!status?.canSelect" @change="select($event.target.value)">
<option v-for="provider in status?.providers || []" :key="provider.id" :value="provider.id">{{ provider.label }}</option>
</GalaxySelect>
</label></div></div></section>
<GxNotice v-if="status?.restartRequired" tone="warn">{{ selected?.label }} applies after the next reboot. No automatic reboot will occur.</GxNotice>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
</template></div>`,
}
@@ -0,0 +1,214 @@
import { guardUnload } from "./unload-guard.js"
import { GxNotice } from "./notice.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const HEX = /^[0-9a-f]{64}$/
const string = (value, limit = 128) => typeof value === "string" && value.length <= limit && !/[\x00-\x1f\x7f]/.test(value)
const key = (value) => value === null || (string(value) && value.length > 0)
const integer = (value, max) => Number.isInteger(value) && value >= 0 && value <= max
const draftFrom = (status) => ({ revision: status.revision, enabled: status.enabled,
slots: status.slots.slice(3).map((slot) => slot.key) })
export function validControllersStatus(data) {
if (data?.version !== 1 || typeof data.available !== "boolean" || typeof data.editable !== "boolean" ||
typeof data.enabled !== "boolean" || typeof data.valid !== "boolean" || !HEX.test(data.revision) ||
!Array.isArray(data.devices) || data.devices.length > 32 || !Array.isArray(data.slots) || data.slots.length !== 13 ||
!Array.isArray(data.options) || data.options.length > 128 || !Array.isArray(data.bindings) || data.bindings.length > 128 ||
typeof data.testing !== "boolean") return false
if (!data.devices.every((device) => string(device?.id, 256) && device.id && string(device.name, 80) &&
[3, 5].includes(device.bus))) return false
if (!data.slots.every((slot, index) => slot?.index === index && string(slot.label, 80) &&
key(slot.key) && typeof slot.available === "boolean")) return false
if (!data.options.every((option) => key(option?.key) && option.key !== null &&
string(option.label, 80) && string(option.section, 80))) return false
if (new Set(data.options.map((option) => option.key)).size !== data.options.length) return false
if (!data.bindings.every((binding) => string(binding?.deviceId, 256) && binding.deviceId &&
string(binding.name, 80) && integer(binding.code, 65551) && integer(binding.slot, 12))) return false
if (data.learning !== null && (!integer(data.learning?.slot, 12) ||
!Number.isFinite(data.learning.expiresIn) || data.learning.expiresIn < 0 || data.learning.expiresIn > 20)) return false
if (data.lastPress !== null && (!string(data.lastPress?.deviceId, 256) ||
!integer(data.lastPress.code, 65551) || (data.lastPress.slot !== null && !integer(data.lastPress.slot, 12)) ||
typeof data.lastPress.executed !== "boolean" || !string(data.lastPress.message, 200))) return false
return true
}
export class ControllersFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = this.timeout = this.poll = null
this.status = null
this.busy = false
this.error = ""
}
emit(replaceDraft = false) {
this.publish({ status: this.status, busy: this.busy, error: this.error,
...(replaceDraft && this.status ? { draft: draftFrom(this.status) } : {}) })
}
stop() {
if (this.active && this.status?.editable && (this.status.learning || this.status.testing)) {
this.fetcher("./api/controllers/action", { method: "POST", credentials: "same-origin", keepalive: true,
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ operation: "cancel" }) }).catch(() => {})
}
this.active = false
this.generation++
this.request?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.poll !== null) this.cancelTimer(this.poll)
this.request = this.timeout = this.poll = null
this.status = null
this.busy = false
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
async run(payload = null, replaceDraft = false) {
if (!this.active) return null
if (this.request) {
if (payload === null || this.busy) return null
this.request.abort()
this.cancelTimer(this.timeout)
this.generation++
}
if (this.poll !== null) this.cancelTimer(this.poll)
this.poll = null
const generation = this.generation, request = new AbortController()
this.request = request
this.busy = payload !== null || this.status === null
this.emit()
this.timeout = this.later(() => request.abort(), 6000)
let accepted = false
let failure = "Controller Buttons are unavailable. Reconnecting automatically…"
try {
const response = await this.fetcher(payload === null ? "./api/controllers/status" : "./api/controllers/action", {
credentials: "same-origin", cache: "no-store", signal: request.signal,
...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }),
})
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (response.status === 403) { failure = "Turn off the vehicle to change controller buttons."; throw new Error() }
if (response.status === 409) { failure = "Controller settings changed. Reload them before saving."; throw new Error() }
if (response.status === 429) { failure = "Controller is busy. Try again shortly."; throw new Error() }
if (!response.ok) throw new Error()
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (!validControllersStatus(data)) { failure = "Controller Buttons returned an unsupported status. Try again."; throw new Error() }
this.status = data
accepted = true
this.error = ""
return data
} catch {
if (this.active && generation === this.generation) this.error = failure
return null
} finally {
if (this.request === request) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.request = this.timeout = null
this.busy = false
this.emit(accepted && (replaceDraft || payload?.operation === "save"))
if (this.active && this.poll === null) this.poll = this.later(() => { this.poll = null; this.refresh() }, 2000)
}
}
}
refresh(replaceDraft = false) { return this.run(null, replaceDraft) }
action(payload) { return this.run(payload) }
}
export const ControllersPage = {
components: { GxNotice },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: null, busy: false, error: "", draft: null })
const feed = new ControllersFeed({ unauthorized: props.unauthorized, publish(update) {
const prior = state.status
Object.assign(state, update)
if (update.status === null) state.draft = null
else if (update.status && (!state.draft || (prior && state.draft.revision === prior.revision &&
state.draft.enabled === prior.enabled && state.draft.slots.every((value, index) => value === prior.slots[index + 3].key)))) {
state.draft = draftFrom(update.status)
}
} })
return { state, feed }
},
computed: {
dirty() { return !!this.state.status && !!this.state.draft &&
(this.state.draft.enabled !== this.state.status.enabled ||
this.state.draft.slots.some((value, index) => value !== this.state.status.slots[index + 3].key)) },
changed() { return !!this.state.status && !!this.state.draft && this.state.draft.revision !== this.state.status.revision },
editable() { return !!this.state.status?.editable && !this.changed },
canEdit() { return this.editable && !this.state.busy },
favorites() { return this.state.status?.slots.slice(0, 3) || [] },
actions() { return this.state.status?.slots.slice(3) || [] },
},
mounted() {
this._stopUnloadGuard = guardUnload(() => this.dirty || this.state.busy)
this.visibility = () => { if (this.mode === "local" && !document.hidden) this.feed.start(); else this.feed.stop() }
document.addEventListener("visibilitychange", this.visibility)
this.visibility()
},
beforeUnmount() { this._stopUnloadGuard(); document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
watch: { mode() { this.visibility() } },
methods: {
reload() { return this.feed.refresh(true) },
async save() {
if (!this.canEdit || !this.dirty) return
return this.feed.action({ operation: "save", revision: this.state.draft.revision,
enabled: this.state.draft.enabled, slots: this.state.draft.slots.map((value) => value || null) })
},
learn(slot) {
if (this.canEdit && !this.dirty && integer(slot, 12)) return this.feed.action({ operation: "learn", revision: this.state.status.revision, slot })
},
cancel() { if (this.state.status?.learning) return this.feed.action({ operation: "cancel" }) },
test() { if (this.canEdit) return this.feed.action({ operation: "test", enabled: !this.state.status.testing }) },
remove(binding) {
if (this.canEdit && !this.dirty) return this.feed.action({ operation: "remove", revision: this.state.status.revision,
deviceId: binding.deviceId, code: binding.code })
},
},
template: `
<section class="gx-card gx-panel gx-stack gx-controllers" aria-label="Controller Buttons" :inert="state.busy" :aria-busy="state.busy || undefined">
<h3>Controller Buttons</h3>
<p>Assign physical USB or Bluetooth buttons to Quick Select and available driving screen actions. Turn off the vehicle to edit.</p>
<p v-if="mode !== 'local'" class="gx-note">Connect to local Galaxy to manage controller buttons.</p>
<template v-else>
<GxNotice :tone="state.error ? 'danger' : 'info'" v-if="state.error || !state.status || !state.status.available">
{{ state.error || (!state.status ? 'Checking attached controllers…' : 'Controller Buttons are unavailable. Reconnecting automatically…') }}
</GxNotice>
<template v-if="state.status?.available">
<GxNotice v-if="!state.status.editable" tone="warn">Turn off the vehicle to change controller buttons.</GxNotice>
<GxNotice v-if="changed" tone="warn">Controller settings changed. Reload before saving.</GxNotice>
<p v-else-if="dirty" class="gx-note">Save these changes before learning or removing buttons.</p>
<div class="gx-driving__actions gx-actions"><button class="gx-btn gx-btn--tonal" type="button" @click="reload">Reload</button>
<button class="gx-btn" type="button" :disabled="!editable || !dirty" @click="save">Save Controller Settings</button></div>
<div class="gx-controllers__switch-row"><span class="gx-row__label">Enable controller buttons</span>
<label class="gx-switch"><input type="checkbox" aria-label="Enable controller buttons" v-model="state.draft.enabled" :disabled="!editable"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<h4>Attached Controllers</h4>
<p v-if="!state.status.devices.length" class="gx-note">No controller is attached.</p>
<div v-for="device in state.status.devices" :key="device.id" class="gx-row gx-controllers__row"><div class="gx-row__info"><span class="gx-row__label">{{ device.name }}</span><span class="gx-row__desc">{{ device.bus === 3 ? 'USB' : 'Bluetooth' }}</span></div></div>
<h4>Favorite Buttons</h4>
<div v-for="slot in favorites" :key="slot.index" class="gx-row gx-controllers__row"><div class="gx-row__info"><span class="gx-row__label">{{ slot.label }}</span>
<span class="gx-row__desc">{{ slot.key ? 'Available when its control is ready' : 'Not assigned' }}</span></div>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || dirty || !!state.status.learning" @click="learn(slot.index)">Learn Button</button></div>
<h4>Action Buttons</h4>
<div v-for="slot in actions" :key="slot.index" class="gx-row gx-controllers__row"><label class="gx-row__info gx-controllers__selector"><span class="gx-row__label">{{ slot.label }}</span>
<select class="gx-field" :aria-label="slot.label + ' action'" v-model="state.draft.slots[slot.index - 3]" :disabled="!editable">
<option :value="null">No action</option><option v-for="option in state.status.options" :key="option.key" :value="option.key">{{ option.section }} · {{ option.label }}</option>
</select></label>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || dirty || !!state.status.learning" @click="learn(slot.index)">Learn Button</button></div>
<p v-if="state.status.learning" role="status">Press a button for {{ state.status.slots[state.status.learning.slot].label }} within {{ Math.ceil(state.status.learning.expiresIn) }} seconds.
<button class="gx-btn gx-btn--tonal" type="button" @click="cancel">Cancel Learning</button></p>
<div class="gx-driving__actions gx-actions"><button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable" @click="test">{{ state.status.testing ? 'Stop test' : 'Test buttons (20s)' }}</button></div>
<p v-if="state.status.testing" class="gx-note">Button test shows input without running its action.</p>
<GxNotice v-if="state.status.lastPress" tone="info">{{ state.status.lastPress.message }}</GxNotice>
<h4>Learned Buttons</h4><p v-if="!state.status.bindings.length" class="gx-note">No buttons learned yet.</p>
<div v-for="binding in state.status.bindings" :key="binding.deviceId + ':' + binding.code" class="gx-row gx-controllers__row">
<div class="gx-row__info"><span class="gx-row__label">{{ binding.name }}</span><span class="gx-row__desc">Button {{ binding.code }} · {{ state.status.slots[binding.slot].label }}</span></div>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!editable || dirty" @click="remove(binding)">Remove</button></div>
</template>
</template>
</section>`,
}
@@ -0,0 +1,105 @@
import { PollTimer, connectionError } from "./polling.js"
import { requestJson } from "./startup.js"
import { deleteRecords } from "./record-deletion.js"
export function validCrashReports(data) {
return data?.schemaVersion === 1 && Array.isArray(data.reports) && data.reports.length <= 200 &&
typeof data.scanIncomplete === "boolean" && typeof data.listLimited === "boolean" &&
data.reports.every(item => typeof item.id === "string" && item.id && typeof item.name === "string" && item.name &&
typeof item.size === "number" && Number.isFinite(item.size) && item.size >= 0 &&
typeof item.modifiedAt === "number" && Number.isFinite(item.modifiedAt))
}
// Local stored crash reports. State is cleared on navigation and sign-out.
export class CrashReportsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher })
Object.assign(this, { later, cancel })
this.poller = new PollTimer({ read: () => this.load(true), interval: 30000, later, cancel })
this.active = false
this.generation = 0
this.request = null
this.reports = []
}
clear() {
this.reports = []
this.deleting = false
this.publish({ deleting: false, storageBytes: 0, reportCount: 0, reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle" })
}
stop() {
this.poller.stop()
this.active = false
this.generation++
this.request?.abort()
this.request = null
this.clear()
}
start() {
this.stop()
this.active = true
this.poller.start()
return this.load()
}
async requestJson(url, generation, request) {
try {
const body = await requestJson(url, { fetcher: this.fetcher, later: this.later, cancel: this.cancel, request: { signal: request.signal } })
return this.active && generation === this.generation && !request.signal.aborted ? body : null
} catch (error) {
if (!this.active || generation !== this.generation) return null
if (error.status === 401 || ["access_unavailable", "setup_required"].includes(error.code)) { this.stop(); this.unauthorized(); return null }
throw error
}
}
async load(background = false) {
if (!this.active || background && this.request) return
const generation = ++this.generation
this.request?.abort()
const request = new AbortController()
this.request = request
if (!background) this.publish({ status: "loading" })
try {
const data = await this.requestJson("./api/crash-reports", generation, request)
if (!data) return
if (!validCrashReports(data)) throw new Error("Invalid crash report list")
this.reports = data.reports
this.publish({ reports: data.reports, scanIncomplete: data.scanIncomplete, listLimited: data.listLimited, reportCount: data.reportCount, storageBytes: data.storageBytes, storageIncomplete: data.storageIncomplete, status: "ready", error: "" })
} catch (error) {
if (this.active && generation === this.generation) this.publish({ reports: [], scanIncomplete: false, listLimited: false, status: "unavailable", error: connectionError(error), selected: null, preview: null, previewStatus: "idle" })
} finally {
if (generation === this.generation) this.request = null
}
}
remove(id, confirm) {
return deleteRecords(this, "./api/crash-reports/delete", id == null ? { all: true, confirmed: true } : { id }, {
confirm, subject: "crash reports", valid: data => validCrashReports(data) &&
Number.isSafeInteger(data.storageBytes) && data.storageBytes >= 0 && Number.isSafeInteger(data.reportCount) && data.reportCount >= 0 && typeof data.storageIncomplete === "boolean",
accept: data => { this.reports = data.reports; this.publish({ ...data, status: "ready", error: "" }) },
})
}
async open(report) {
if (!this.active || !this.reports.some((item) => item.id === report?.id)) return
const generation = ++this.generation
this.request?.abort()
const request = new AbortController()
this.request = request
this.publish({ selected: report.id, preview: null, previewStatus: "loading", error: "" })
try {
const data = await this.requestJson(`./api/crash-reports/${encodeURIComponent(report.id)}`, generation, request)
if (!data) return
if (data.schemaVersion !== 1 || data.name !== report.name || typeof data.text !== "string" || typeof data.truncated !== "boolean") {
throw new Error("Invalid crash report")
}
this.publish({ selected: report.id, preview: data, previewStatus: "ready", error: "" })
} catch (error) {
if (this.active && generation === this.generation) this.publish({ selected: null, preview: null, previewStatus: "unavailable", error: connectionError(error) })
} finally {
if (generation === this.generation) this.request = null
}
}
}
@@ -0,0 +1,92 @@
const SLUG = /^[A-Za-z0-9]{16}$/
const unsupported = (response) => response.status === 404 || response.status === 405
async function readName(device) {
try {
const response = await fetch(`/${device.slug}/api/galaxy/device-name`, {
cache: "no-store", credentials: "same-origin", signal: AbortSignal.timeout(5000),
})
if (!response.ok) return device
const data = await response.json()
if (typeof data.name === "string" && Array.from(data.name).length <= 40) return { ...device, name: data.name || device.name }
} catch {}
return device
}
async function namedDevices(devices) {
const result = [...devices]
let next = 0
const worker = async () => {
while (next < devices.length) {
const index = next++
result[index] = await readName(devices[index])
}
}
await Promise.all(Array.from({ length: Math.min(4, devices.length) }, worker))
return result
}
export const DevicePicker = {
name: "DevicePicker",
data: () => ({ devices: [], activeSlug: "", loading: true, editing: "", draft: "", error: "", saving: false }),
computed: { visible() { return location.hostname === "galaxy.firestar.link" && this.devices.length > 1 } },
mounted() { if (location.hostname === "galaxy.firestar.link") this.load() },
methods: {
async load() {
try {
const response = await fetch("/_gateway/devices", { cache: "no-store", credentials: "same-origin" })
if (!response.ok) return
const data = await response.json()
this.activeSlug = SLUG.test(data?.activeSlug || "") ? data.activeSlug : ""
this.devices = Array.isArray(data?.devices) ? data.devices.filter((device) => SLUG.test(device?.slug || "")).slice(0, 40) : []
this.devices = await namedDevices(this.devices)
} catch {} finally { this.loading = false }
},
select(device) {
if (SLUG.test(device.slug) && device.slug !== this.activeSlug) window.location.assign(`/${device.slug}/`)
},
edit(device) { this.editing = device.slug; this.draft = device.name || ""; this.error = "" },
async save() {
if (!SLUG.test(this.editing)) return
const slug = this.editing
const name = Array.from(this.draft.trim()).slice(0, 40).join("")
this.saving = true
this.error = ""
try {
let response = await fetch(`/${slug}/api/galaxy/device-name`, {
method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(5000), body: JSON.stringify({ name }),
})
if (unsupported(response)) {
response = await fetch(`/_gateway/devices/${slug}/name`, {
method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" },
signal: AbortSignal.timeout(7000), body: JSON.stringify({ name }),
})
}
const data = await response.json().catch(() => ({}))
if (!response.ok || typeof data.name !== "string") throw new Error(data.error || "Could not rename comma")
this.devices = this.devices.map((device) => device.slug === slug ? { ...device, name: data.name } : device)
if (this.editing === slug) this.editing = ""
} catch (error) { this.error = error.message }
finally { this.saving = false }
},
},
template: `
<div v-if="visible" class="gx-nav-section" :inert="saving" :aria-busy="saving || undefined">
<div class="gx-nav-section__title">Commas</div>
<div v-for="(device, index) in devices" :key="device.slug" class="gx-device-picker__row">
<button type="button" class="gx-nav-item gx-grow" :class="{active:device.slug === activeSlug}" @click="select(device)">
<i class="bi bi-cpu"></i><span class="gx-truncate">{{ device.name || 'Comma ' + (index + 1) }}</span>
<small v-if="device.slug === activeSlug">Current</small>
</button>
<button type="button" class="gx-icon-btn" :aria-label="'Rename ' + (device.name || 'comma')" @click="edit(device)"><i class="bi bi-pencil"></i></button>
</div>
<form v-if="editing" @submit.prevent="save" class="gx-device-picker__form">
<label for="gx-device-name">Rename comma</label>
<input id="gx-device-name" class="gx-field" v-model="draft" maxlength="40" autocomplete="off" />
<div class="gx-actions gx-tags"><button type="submit" class="gx-btn">Save</button><button type="button" class="gx-btn gx-btn--tonal" @click="editing=''">Cancel</button></div>
<p v-if="error" class="gx-note gx-note--danger">{{ error }}</p>
</form>
</div>
`,
}
@@ -0,0 +1,9 @@
import { GxState } from "./state.js"
import { DriveStatePanel } from "./drive-state.js"
export const DEVICE_PAGES = Object.freeze({})
export function devicePage(path) { return null }
export const DevicePreferencesPage = {
components: { GxState, DriveStatePanel },
props: { mode: { type: String, required: true }, go: { type: Function, required: true } },
template: `<section class="gx-driving" aria-label="Force Drive State"><DriveStatePanel v-if="mode === 'local'" /><GxState v-else>Force Drive State is available on your connected device.</GxState></section>`,
}
@@ -0,0 +1,58 @@
import { requestJson } from "./startup.js"
export class DeviceRestartWatch {
constructor({ request = requestJson, interval = 5000, later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id),
reload = () => location.reload(), hidden = () => typeof document !== "undefined" && document.hidden, paused = () => false }) {
Object.assign(this, { request, interval, later, cancel, reload, hidden, paused })
this.instance = null
this.timer = null
this.checking = false
this.reloading = false
}
track(instance) {
if (typeof instance !== "string" || !instance) return
if (this.instance === null) {
this.instance = instance
this.schedule()
} else if (instance !== this.instance) this.restarted()
}
restarted() {
if (this.reloading || this.paused()) return
this.reloading = true
this.stop()
this.reload()
}
schedule() {
if (this.instance === null || this.reloading || this.timer !== null) return
this.timer = this.later(() => { this.timer = null; this.check() }, this.interval)
}
wake() {
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
this.check()
}
async check() {
if (this.instance === null || this.checking || this.reloading) return
if (this.hidden()) { this.schedule(); return }
this.checking = true
try {
const runtime = await this.request("./data/runtime.json")
this.track(runtime?.instance)
} catch {
// Offline while rebooting; keep checking until Galaxy answers again.
} finally {
this.checking = false
this.schedule()
}
}
stop() {
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
}
}
@@ -0,0 +1,91 @@
import { SnapshotFeed } from "./snapshot-feed.js"
const STATES = Object.freeze({ parked: "Parked", driving: "Driving", standby: "Standby" })
export const validDeviceState = (value) => value &&
(value.state === null || Object.hasOwn(STATES, value.state)) &&
Number.isFinite(value.maxAgeMs) && value.maxAgeMs >= (value.state === null ? 0 : 1) && value.maxAgeMs <= 3000
// Reuse the authenticated request/deadline boundary; this lightweight endpoint
// does not scan processes or start any device services.
export class DeviceStateFeed extends SnapshotFeed {
static interval = 0
static endpoint = "./api/device/state"
static valid = validDeviceState
static subject = "device state"
static unavailable = "Device state is unavailable."
static invalid = "Device state is unavailable."
constructor(options) {
super(options)
this.clock = options.clock || (() => performance.now())
this.poll = this.expiry = null
this.lastState = null
this.validUntil = this.retainUntil = 0
this.presented = ""
}
stop() {
this.cancelTimer(this.poll)
this.cancelTimer(this.expiry)
this.poll = this.expiry = null
this.lastState = null
this.validUntil = this.retainUntil = 0
super.stop()
}
present() {
const now = this.clock()
const state = now < this.retainUntil ? this.lastState : null
const stale = state !== null && now >= this.validUntil
const key = `${state}:${stale}`
if (key !== this.presented) {
this.presented = key
this.publish({ state, stale })
}
this.cancelTimer(this.expiry)
this.expiry = null
const next = now < this.validUntil ? this.validUntil : this.retainUntil
if (state !== null) this.expiry = this.later(() => this.present(), Math.max(1, next - now))
}
emit() {
if (this.status === "loading") return
const now = this.clock()
const elapsed = now - this.startedAt
if (this.status === "ready" && this.data?.state !== null && elapsed >= 0 && this.data.maxAgeMs > elapsed) {
this.lastState = this.data.state
this.validUntil = this.startedAt + this.data.maxAgeMs
// Display continuity only. A missed poll never extends driving authority or this deadline.
this.retainUntil = this.validUntil + 5000
}
this.present()
if (this.active && this.poll === null) {
const delay = Math.max(500, Math.min(1500, (this.validUntil - now) / 2))
this.poll = this.later(() => { this.poll = null; this.load() }, delay)
}
}
load() {
if (!this.active || this.request !== null) return
this.startedAt = this.clock()
return super.load()
}
}
export const DeviceState = {
props: { unauthorized: { type: Function, required: true }, connection: { type: String, default: "" } },
data: () => ({ state: null, stale: false }),
computed: {
label() { return STATES[this.state] || "State unavailable" },
statusLabel() { return this.stale ? this.label + " · reconnecting" : this.label },
icon() { return this.stale ? "bi-wifi-off" : ({ parked: "bi-p-circle", driving: "bi-car-front", standby: "bi-moon" }[this.state] || "bi-question-circle") },
},
created() { this.feed = new DeviceStateFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => document.hidden ? this.feed.stop() : this.feed.start()
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
template: `<span class="gx-status-pill gx-device-state" role="status" :aria-label="statusLabel" :title="statusLabel + (connection ? ' · ' + connection : '')">
<i class="bi gx-device-state__icon" :class="[icon, { 'is-stale': stale || !state }]" aria-hidden="true"></i><span class="gx-device-state__label">{{ label }}</span>
</span>`,
}
@@ -0,0 +1,116 @@
import { GxState } from "./state.js"
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
import { connectionError } from "./polling.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const text = (value, max = 1024) => typeof value === "string" && value.length <= max
const rows = value => Array.isArray(value) && value.length <= 128 && value.every(row => row && text(row.label, 128) && text(row.value))
export function validTroubleshoot(value) {
return !!value && value.schemaVersion === 1 && [null, "parked", "driving", "standby"].includes(value.device?.state) &&
typeof value.vehicle?.available === "boolean" && typeof value.vehicle.longitudinal === "boolean" &&
["fingerprint", "brand", "steering"].every(key => text(value.vehicle[key])) && rows(value.snapshot) &&
Array.isArray(value.sections) && value.sections.length <= 32 &&
value.sections.every(section => text(section.title, 128) && rows(section.rows)) && text(value.note, 2048)
}
export function validTmux(value) {
return !!value && typeof value.available === "boolean" && text(value.reason) && text(value.pane, 128) &&
typeof value.truncated === "boolean" && typeof value.text === "string" &&
new TextEncoder().encode(value.text).length <= 65536 && value.text.split("\n").length <= 301
}
export function troubleshootReport(data) {
if (!data) return ""
return ["StarPilot Troubleshoot", `Device: ${data.device.state || "Unavailable"}`,
`Vehicle: ${data.vehicle.available ? data.vehicle.fingerprint : "Unavailable"}`,
`Brand: ${data.vehicle.brand}`, `Longitudinal: ${data.vehicle.longitudinal ? "Available" : "Unavailable"}`, `Steering: ${data.vehicle.steering}`,
...data.snapshot.map(row => `${row.label}: ${row.value}`),
...data.sections.flatMap(section => ["", section.title, ...section.rows.map(row => `${row.label}: ${row.value}`)]),
"", data.note].join("\n")
}
export class DiagnosticFeed {
constructor({ endpoint, validate, publish, unauthorized = () => {}, interval = 0,
fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { endpoint, validate, publish, unauthorized, interval, fetcher, later, cancel })
this.active = false; this.visible = true; this.live = true; this.data = null
this.request = this.timer = null; this.generation = 0
}
start(mode, visible = true) { this.stop(); this.mode = mode; this.visible = visible; this.active = true
if (mode !== "local") { this.data = null; this.publish({ data: null, error: "This tool is unavailable in sample mode.", loading: false }); return }
if (visible) return this.refresh()
}
cancelPending() { this.generation++; this.request?.abort(); this.request = null
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
}
stop() { this.active = false; this.cancelPending() }
setVisible(value) { this.visible = value; this.cancelPending(); if (value && this.active && this.live) return this.refresh() }
setLive(value) { this.live = value; this.cancelPending(); this.publish({ live: value, loading: false }); if (value && this.visible) return this.refresh() }
async refresh() {
if (!this.active || !this.visible || this.mode !== "local" || this.request) return
if (this.timer !== null) this.cancel(this.timer)
this.timer = null
const generation = ++this.generation, request = new AbortController()
this.request = request; this.publish({ loading: !this.data })
try {
const data = await requestJson(this.endpoint, { fetcher: this.fetcher, timeout: 4000, later: this.later, cancel: this.cancel, request: { signal: request.signal } })
if (!this.active || generation !== this.generation) return
if (!this.validate(data)) throw new Error("Unsupported diagnostic response")
this.data = data; this.publish({ data, error: "" })
} catch (error) {
if (this.active && generation === this.generation) {
if (error.status === 401 || ["setup_required", "access_unavailable"].includes(error.code)) { this.stop(); this.unauthorized(); return }
this.publish({ error: connectionError(error) })
}
} finally {
if (this.active && generation === this.generation) {
this.request = null; this.publish({ loading: false })
if (this.interval && this.live && this.visible) this.timer = this.later(() => { this.timer = null; this.refresh() }, this.interval)
}
}
}
}
const props = { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }
function setup(props, consoleTail) {
const state = reactive({ loading: false, live: true, data: null, error: "", notice: "" })
const feed = new DiagnosticFeed({ endpoint: consoleTail ? "./api/tmux/live" : "./api/troubleshoot",
validate: consoleTail ? validTmux : validTroubleshoot, interval: consoleTail ? 2000 : 10000,
publish: update => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed }
}
const lifecycle = {
mounted() { this._visibility = () => this.feed.setVisible(!document.hidden)
document.addEventListener("visibilitychange", this._visibility); this.feed.start(this.mode, !document.hidden) },
beforeUnmount() { document.removeEventListener("visibilitychange", this._visibility); this.feed.stop() },
watch: { mode(value) { this.feed.start(value, !document.hidden) } },
}
const methods = {
async copyText(value) { try { await navigator.clipboard.writeText(value); this.state.notice = "Visible text copied." }
catch { this.state.notice = "Copy is unavailable in this browser. Select the visible text to copy it." } },
saveText(value) { const url = URL.createObjectURL(new Blob([value], { type: "text/plain;charset=utf-8" }))
const link = document.createElement("a"); link.href = url; link.download = "starpilot-console.txt"; link.click(); URL.revokeObjectURL(url) },
}
export const TroubleshootPage = {
components: { GxState, GxIconButton, GxNotice }, props, ...lifecycle,
setup: props => setup(props, false), methods: { ...methods, report: troubleshootReport },
template: `<section class="gx-settings"><header class="gx-settings__header gx-page-header"><div><h2>Troubleshoot</h2>
<p>Read-only device, vehicle and settings snapshot. Copy it when reporting a problem.</p></div></header>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
<GxNotice v-if="state.notice && !state.error" tone="info">{{ state.notice }}</GxNotice>
<div class="gx-settings__controls gx-actions"><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local'" @click="$emit('navigate', '/settings/recovery')">Show Non-Default Values</button><button class="gx-btn gx-btn--tonal" :disabled="mode !== 'local'" @click="$emit('navigate', '/settings/recovery')">Reset to Default</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/monitor')">System Monitor</button><button class="gx-btn gx-btn--tonal" @click="$emit('navigate', '/logs/crashes')">Crash Reports</button><GxIconButton label="Copy report" icon="bi-clipboard" :disabled="!state.data" @click="copyText(report(state.data))" /></div>
<GxState v-if="state.loading && !state.error" loading>Loading report…</GxState>
<template v-if="state.data"><section class="gx-card gx-panel"><h3>Device Snapshot</h3><p>State: {{ state.data.device.state || 'Unavailable' }}</p><p>Vehicle: {{ state.data.vehicle.available ? state.data.vehicle.fingerprint : 'Unavailable' }}</p><p>Brand: {{ state.data.vehicle.brand || 'Unavailable' }} · Longitudinal: {{ state.data.vehicle.longitudinal ? 'Available' : 'Unavailable' }} · Steering: {{ state.data.vehicle.steering || 'Unavailable' }}</p><dl class="gx-details"><template v-for="row in state.data.snapshot" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section>
<section v-for="section in state.data.sections" :key="section.title" class="gx-card gx-panel"><h3>{{ section.title }}</h3><dl class="gx-details"><template v-for="row in section.rows" :key="row.label"><dt>{{ row.label }}</dt><dd>{{ row.value }}</dd></template></dl></section><p class="gx-note">{{ state.data.note }}</p></template></section>`,
}
export const TmuxPage = {
components: { GxState, GxIconButton, GxNotice }, props, ...lifecycle,
setup: props => setup(props, true), methods,
template: `<section class="gx-settings"><header class="gx-settings__header gx-page-header"><div><h2>tmux Live View</h2>
<p>Live view of the launcher console, refreshed every few seconds.</p></div></header>
<GxState v-if="state.loading && !state.error" loading>Loading console…</GxState><GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice><GxNotice v-if="state.notice && !state.error" tone="info">{{ state.notice }}</GxNotice>
<div class="gx-settings__controls gx-actions"><GxIconButton :label="state.live ? 'Pause console updates' : 'Resume console updates'" :icon="state.live ? 'bi-pause-fill' : 'bi-play-fill'" :disabled="mode !== 'local'" @click="feed.setLive(!state.live)" /><GxIconButton label="Copy console text" icon="bi-clipboard" :disabled="!state.data?.text" @click="copyText(state.data.text)" /><GxIconButton label="Download console text" icon="bi-download" :disabled="!state.data?.text" @click="saveText(state.data.text)" /></div>
<section v-if="state.data" class="gx-card gx-crash-preview gx-panel"><p v-if="!state.data.available">{{ state.data.reason }}</p><pre class="gx-pre">{{ state.data.text }}</pre></section>
<p class="gx-note gx-tool-footer" role="status">{{ state.live ? 'Live · every 2 seconds while visible' : 'Paused' }}<span v-if="state.data?.truncated"> · showing the latest console lines</span></p></section>`,
}
@@ -0,0 +1,68 @@
import { GxNotice } from "./notice.js"
export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad"].includes(value.mode) &&
typeof value.available === "boolean" && typeof value.overrideAllowed === "boolean" &&
(value.revision === null || typeof value.revision === "string" && /^[0-9a-f]{32}$/.test(value.revision)) &&
[null, "offroad", "onroad"].includes(value.effective)
export const DriveStatePanel = {
components: { GxNotice },
data: () => ({ state: null, error: "", busy: false }),
computed: {
pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode },
},
mounted() {
this.active = true
this.generation = 0
this.load()
this.timer = setInterval(() => { if (!document.hidden) this.load() }, 1000)
},
beforeUnmount() { this.active = false; this.generation++; clearInterval(this.timer); this.controller?.abort() },
methods: {
async load() {
if (this.busy || this.loading || !this.active) return
this.loading = true
const generation = ++this.generation
this.controller = new AbortController()
const controller = this.controller
const deadline = setTimeout(() => controller.abort(), 3000)
try {
const response = await fetch("./api/drive-state/status", { credentials: "same-origin", cache: "no-store", signal: this.controller.signal })
const value = await response.json()
if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable")
if (this.active && generation === this.generation) { this.state = value; this.error = "" }
} catch (error) { if (this.active && generation === this.generation) { this.state = null; this.error = "Drive state unavailable" } }
finally { clearTimeout(deadline); if (generation === this.generation) this.loading = false }
},
async change(mode) {
if (this.busy || !this.state?.available) return
if (!["onroad", "auto"].includes(mode)) return
const revision = this.state.revision
this.busy = true
this.loading = false
const generation = ++this.generation
this.controller?.abort()
this.controller = new AbortController()
const controller = this.controller
const deadline = setTimeout(() => controller.abort(), 3000)
try {
const response = await fetch("./api/drive-state/action", { method: "POST", credentials: "same-origin", cache: "no-store",
signal: this.controller.signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, revision }) })
const value = await response.json()
if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable")
if (this.active && generation === this.generation) { this.state = value; this.error = "" }
} catch (error) { if (this.active && generation === this.generation) this.error = error.message }
finally { clearTimeout(deadline); this.busy = false; if (this.active && generation === this.generation) this.load() }
},
},
template: `<section class="gx-card gx-force-drive" aria-label="Force Drive State" :inert="busy" :aria-busy="busy || undefined"><h3>Force Drive State</h3>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxNotice tone="danger" v-if="state && !state.available">The drive-state manager is unavailable. Reconnect to check again.</GxNotice>
<GxNotice v-if="pending" tone="info">Waiting for the device to switch {{ state.mode }}…</GxNotice>
<p>Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
<p>Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.</p>
<div class="gx-force-drive__actions gx-actions"><button v-for="mode in ['onroad', 'auto']" :key="mode" type="button"
class="gx-btn" :class="state?.mode === mode ? '' : 'gx-btn--tonal'"
:disabled="!state?.available"
:aria-pressed="state?.mode === mode" @click="change(mode)">{{ mode === 'auto' ? 'Return to Auto' : 'Onroad' }}</button></div>
</section>`,
}
@@ -0,0 +1,179 @@
const ROUTE = /^[a-zA-Z0-9_+|.-]{1,128}$/
const DATE = /^\d{4}-\d{2}-\d{2}$/
const number = (value) => typeof value === "number" && Number.isFinite(value) && value >= 0
const optionalNumber = (value) => value === null || number(value)
const integer = (value) => Number.isSafeInteger(value) && value >= 0
const text = (value, limit = 128) => typeof value === "string" && value.length <= limit &&
!/[\x00-\x1f\x7f]/.test(value)
const optionalText = (value, limit = 128) => value === null || text(value, limit)
const validDrive = (drive) => drive && ROUTE.test(drive.routeId) &&
[drive.startTime, drive.endTime].every((value) => value === null || number(value)) &&
[drive.distanceMeters, drive.durationSeconds, drive.engagedSeconds, drive.engagedPercent,
drive.distractedMoments, drive.unresponsiveMoments].every(optionalNumber) &&
optionalText(drive.model) && optionalText(drive.reason, 256) &&
typeof drive.complete === "boolean" && typeof drive.ignored === "boolean" &&
integer(drive.segmentCount) && drive.segmentCount <= 10000 &&
(drive.engagedPercent === null || drive.engagedPercent <= 100)
export function validDriveStats(value) {
if (value?.schemaVersion !== 1 || typeof value.isMetric !== "boolean" || !value.analysis || !value.totals || !value.week ||
!Array.isArray(value.recentDrives) || value.recentDrives.length > 100 ||
!Array.isArray(value.records) || value.records.length > 24 ||
!Array.isArray(value.models) || value.models.length > 100) return false
const { analysis, totals, week, lastDrive, recentDrives, records, models } = value
if (week.timezone !== undefined && !text(week.timezone)) return false
if (typeof analysis.running !== "boolean" || !integer(analysis.pending) || !integer(analysis.failed) ||
typeof analysis.scanIncomplete !== "boolean") return false
if (![totals.distanceMeters, totals.durationSeconds, totals.engagedSeconds].every(optionalNumber) ||
!integer(totals.drives) ||
![week.distanceMeters, week.durationSeconds, week.engagedPercent].every(optionalNumber) ||
(week.engagedPercent !== null && week.engagedPercent > 100) || !integer(week.drives) ||
!Array.isArray(week.days) || week.days.length !== 7 ||
!week.days.every((day) => day && DATE.test(day.date) && Number.isFinite(Date.parse(`${day.date}T00:00:00Z`)) &&
optionalNumber(day.distanceMeters))) return false
const routes = new Set()
if (lastDrive !== null && !validDrive(lastDrive)) return false
if (!recentDrives.every((drive) => {
if (!validDrive(drive) || routes.has(drive.routeId)) return false
routes.add(drive.routeId)
return true
})) return false
const recordIds = new Set()
if (!records.every((record) => {
if (!record || !text(record.id) || !text(record.label) ||
!(record.value === null || number(record.value) || text(record.value)) || !optionalText(record.unit, 32) ||
recordIds.has(record.id)) return false
recordIds.add(record.id)
return true
})) return false
const names = new Set()
return models.every((model) => {
if (!model || !text(model.name) || names.has(model.name) ||
![model.distanceMeters, model.durationSeconds].every(optionalNumber) || !integer(model.drives)) return false
names.add(model.name)
return true
})
}
export class DriveStatsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.controller = null
this.timeout = null
this.poll = null
this.data = null
this.status = "idle"
this.error = ""
this.busy = false
}
emit() { this.publish({ status: this.status, data: this.data, error: this.error, busy: this.busy }) }
stop() {
this.active = false
this.generation++
this.controller?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.poll !== null) this.cancelTimer(this.poll)
this.controller = this.timeout = this.poll = null
this.data = null
this.status = "idle"
this.error = ""
this.busy = false
this.emit()
}
start() { this.stop(); this.active = true; return this.load() }
schedule(delay = null) {
if (!this.active || this.poll !== null || this.controller !== null) return
const generation = this.generation
const analyzing = this.data?.analysis?.running
this.poll = this.later(() => {
this.poll = null
if (this.active && generation === this.generation) this.load()
}, delay ?? (analyzing ? 5000 : 60000))
}
async request(body = null) {
if (!this.active || this.controller !== null) return null
const generation = this.generation
const controller = new AbortController()
this.controller = controller
if (this.poll !== null) this.cancelTimer(this.poll)
this.poll = null
this.status = this.data ? "ready" : this.error ? "unavailable" : "loading"
this.busy = body !== null
if (body !== null) this.error = ""
this.emit()
this.timeout = this.later(() => {
if (!this.active || generation !== this.generation || this.controller !== controller) return
controller.abort()
this.generation++
this.controller = this.timeout = null
this.busy = false
this.status = this.data ? "ready" : "unavailable"
this.error = "Drive history timed out. Refresh to try again."
this.emit()
this.schedule(5000)
}, 5000)
try {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"
const statsUrl = "./api/drives/stats?timezone=" + encodeURIComponent(timezone)
let response = await this.fetcher(body === null ? statsUrl : "./api/drives/ignore", {
credentials: "same-origin", cache: "no-store", signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
})
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (response.status === 503) {
const detail = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (["setup_required", "access_unavailable"].includes(detail?.code)) {
this.stop(); this.unauthorized(); return null
}
}
if (!response.ok) throw new Error("Drive history could not be updated. Refresh to try again.")
if (body !== null) {
response = await this.fetcher(statsUrl, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (!response.ok) throw new Error("Drive history could not be refreshed.")
}
const data = await response.json()
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (!validDriveStats(data)) throw new Error("Drive history is unavailable. Refresh to try again.")
this.data = data
this.error = ""
this.status = "ready"
this.emit()
return data
} catch (error) {
if (this.active && generation === this.generation) {
this.status = this.data ? "ready" : "unavailable"
this.error = controller.signal.aborted ? "Drive history timed out. Refresh to try again." : error.message
this.emit()
}
return null
} finally {
if (this.controller === controller) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.controller = this.timeout = null
this.busy = false
this.emit()
this.schedule()
}
}
}
load() { return this.request() }
ignore(routeId, ignored) {
if (!this.data?.recentDrives?.some((drive) => drive.routeId === routeId && drive.ignored !== ignored) ||
typeof ignored !== "boolean") return Promise.resolve(null)
return this.request({ routeId, ignored })
}
}
@@ -0,0 +1,57 @@
import { GxState } from "./state.js"
import { MenuTile } from "./menu-tile.js"
export const DRIVING_PAGES = Object.freeze({
aol: "aol",
conditional: "conditional",
curve: "curve",
slc: "slc",
torque: "torque",
profiles: "profiles",
traffic: "traffic",
lane: "lane",
lane_change: "lane_change",
})
export const DRIVING_GROUPS = Object.freeze([
{ title: "Steering and buttons", items: [
{ page: "aol", title: "Always On Lateral", detail: "Steering assistance independent of cruise control" },
{ page: "lane", title: "Lane centering", detail: "Saved steering behavior" },
{ page: "lane_change", title: "Lane changes", detail: "Saved lane-change choices" },
{ page: "torque", title: "Torque tuning", detail: "Saved steering tune and vehicle-source selection" },
] },
{ title: "Speed and longitudinal", items: [
{ page: "conditional", title: "Conditional driving modes", detail: "Experimental and Chill conditions" },
{ page: "curve", title: "Curve Speed Controller", detail: "Saved curve-speed choices" },
{ page: "slc", title: "Speed Limit Controller", detail: "Dashboard and qualified vision source choices" },
{ page: "profiles", title: "Longitudinal profiles", detail: "Saved acceleration, braking, and following profiles" },
{ page: "traffic", title: "Traffic Mode", detail: "Saved follow, jerk, and profile curves" },
] },
])
export function drivingPage(path) {
const match = /^\/driving\/([a-z_]+)$/.exec(path)
return match && DRIVING_PAGES[match[1]] || null
}
export const DrivingPage = {
components: { GxState, MenuTile },
props: { mode: { type: String, required: true }, go: { type: Function, required: true } },
data: () => ({ groups: DRIVING_GROUPS }),
template: `
<section class="gx-driving" aria-label="Driving features">
<header class="gx-settings__header gx-page-header"><div><h2>Driving Settings</h2>
<p>Choose how StarPilot steers and controls speed.</p></div></header>
<GxState v-if="mode !== 'local'">Local driving settings are unavailable in preview.</GxState>
<template v-else>
<section v-for="group in groups" :key="group.title" class="gx-driving__group">
<h3>{{ group.title }}</h3>
<div class="gx-grid">
<MenuTile v-for="item in group.items" :key="item.page" icon="bi-sliders" :title="item.title" :description="item.detail" @select="go('/driving/' + item.page)" />
</div>
</section>
<div class="gx-driving__extras gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/longitudinal-curves')">Edit longitudinal curves</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning/plots')">Live plots</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/tuning/flm')">Offline FLM diagnostics</button></div>
</template>
</section>`,
}
@@ -0,0 +1,167 @@
import { GxState } from "./state.js"
import { PollTimer, connectionError } from "./polling.js"
import { GxNotice } from "./notice.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { GalaxySelect } from "./galaxy-select.js"
function validSpeedValue(value) {
return typeof value === "number" && Number.isFinite(value) && value >= 5 && value <= 145
}
export function validFavorites(data) {
return !!data && typeof data.revision === "string" && !!data.revision && typeof data.editable === "boolean" &&
typeof data.valid === "boolean" && Array.isArray(data.slots) && data.slots.length === 3 &&
data.slots.every((slot) => slot && typeof slot.enabled === "boolean" && typeof slot.show_onroad === "boolean" &&
(slot.key === null || typeof slot.key === "string") && typeof slot.label === "string" && slot.label.length <= 32) &&
Array.isArray(data.options) && data.options.length <= 128 && data.options.every((option) => option &&
typeof option.key === "string" && typeof option.label === "string" && ["toggle", "enum", "action"].includes(option.kind)) &&
new Set(data.options.map((option) => option.key)).size === data.options.length &&
Array.isArray(data.states) && data.states.length === 3 && data.states.every((state, index) => state &&
state.index === index && typeof state.available === "boolean" && typeof state.stateLabel === "string" && typeof state.reason === "string")
}
export class FavoritesFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.poller = new PollTimer({ read: () => this.load(), later, cancel: cancelTimer })
this.error = ""
this.saving = false
this.active = false; this.generation = 0; this.data = this.request = this.timer = null; this.needsReload = false
}
stop() {
this.poller.stop()
this.active = false; this.generation++; this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.saving = false
}
start() { this.stop(); this.active = true; this.poller.start(); return this.load() }
load() { return this.run() }
update(index, patch) {
if (!this.data?.editable || this.saving || this.needsReload || !Number.isInteger(index) || index < 0 || index > 2 ||
!patch || Object.keys(patch).some((key) => !["key", "label", "enabled", "show_onroad", "value"].includes(key))) return
const slots = this.data.slots.map((slot) => ({ ...slot }))
if (Object.hasOwn(patch, "key")) {
const option = this.data.options.find((entry) => entry.key === patch.key)
if (patch.key !== null && !option) return
delete slots[index].value
slots[index].label = option?.label.slice(0, 32) || ""
if (!option) Object.assign(slots[index], { enabled: false, show_onroad: false })
}
Object.assign(slots[index], patch)
if (Object.hasOwn(patch, "value") && (slots[index].key !== "__starpilot_controller_action__:set_speed" || !validSpeedValue(patch.value))) return
return this.run({ revision: this.data.revision, slots })
}
async run(body = null) {
if (!this.active) return
if (this.request) {
if (body === null || this.saving) return
this.request.abort()
this.cancelTimer(this.timer)
}
const request = new AbortController(), generation = ++this.generation, saving = body !== null
this.request = request
this.saving = saving
if (saving || !this.data && !this.error) this.publish({ status: saving ? "saving" : "loading", notice: "",
...(saving ? { data: { ...this.data, slots: body.slots } } : {}) })
const fail = (error) => {
this.error = error
this.needsReload ||= saving
this.publish({ status: this.data ? "ready" : "unavailable", data: this.data, needsReload: this.needsReload, error })
}
this.timer = this.later(() => {
if (!this.active || generation !== this.generation) return
request.abort(); this.generation++; this.request = this.timer = null
this.saving = false
fail(saving ? "Saving timed out. Reload to check which Quick Select were saved." : "Reading Quick Select timed out. Reload to try again.")
}, 5000)
try {
const response = await this.fetcher("./api/favorites/slots", { credentials: "same-origin", cache: "no-store", signal: request.signal,
...(saving ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } : {}) })
if (!this.active || generation !== this.generation) return
const data = await response.json().catch(() => null)
if (!this.active || generation !== this.generation) return
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) { this.stop(); this.unauthorized(); return }
if (response.status === 409) throw new Error("Quick Select changed on another screen. Reload before editing again.")
if (!response.ok || !validFavorites(data)) throw new Error(saving ? "Quick Select could not be confirmed. Reload before editing again." : "Saved Quick Select are unavailable.")
this.data = data; this.needsReload = false; this.error = ""
this.publish({ status: "ready", data, needsReload: false, error: "", notice: saving ? "Quick Select saved." : "" })
} catch (error) {
if (this.active && generation === this.generation) fail(connectionError(error))
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.saving = false
}
}
}
}
export const FavoritesPage = {
components: { GxState, GxNotice, GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
emits: ["close"],
setup(props) {
const state = reactive({ status: "idle", data: null, error: "", notice: "", needsReload: false })
const feed = new FavoritesFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed }
},
computed: {
busy() { return ["loading", "saving"].includes(this.state.status) },
disabled() { return !this.state.data?.editable || this.state.needsReload },
byKey() { return new Map((this.state.data?.options || []).map((option) => [option.key, option])) },
groups() { return [...new Set((this.state.data?.options || []).map((option) => option.section || "Controls"))] },
},
mounted() { if (this.mode === "local") this.feed.start() },
beforeUnmount() { this.feed.stop() },
methods: {
select(index, event) { return this.feed.update(index, { key: event.target.value || null }) },
toggle(index, field, event) {
const checked = event.target.checked
return this.feed.update(index, { [field]: checked })
},
speed(index, event) {
const value = Number(event.target.value)
if (!validSpeedValue(value)) return false
return this.feed.update(index, { value })
},
label(index, event) {
const value = event.target.value.trim().slice(0, 32)
if (value !== this.state.data.slots[index].label) return this.feed.update(index, { label: value })
},
},
template: `
<section class="gx-settings gx-favorites" aria-label="Quick Select" :inert="busy" :aria-busy="busy || undefined">
<div class="gx-settings__header gx-page-header"><div><h2>Quick Select</h2><p>Choose your three driving-screen shortcuts.</p></div>
</div>
<div class="gx-favorites__intro">
<p class="gx-note">Small UI: tap the invisible left, middle or right third. Big UI: tap or swipe the lower-left corner to open Quick Select.</p>
<p class="gx-note">Assigning a shortcut does not activate it. Each control keeps its usual availability; some settings can only change while parked.</p>
</div>
<GxState v-if="mode !== 'local'">Connect to local Galaxy to configure Quick Select.</GxState>
<template v-else>
<div class="gx-favorites__status"><span role="status">{{ state.status === 'saving' ? 'Saving Quick Select…' : state.status === 'loading' ? 'Loading Quick Select…' : state.notice }}</span>
</div>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
<GxNotice v-if="state.data && !state.data.valid" tone="warn">Saved Quick Select could not be read. Empty slots are shown. Choosing a control will replace the invalid saved configuration.</GxNotice>
<div v-if="state.data" class="gx-favorites__slots">
<section v-for="(slot, index) in state.data.slots" :key="index" class="gx-card gx-favorites__slot">
<div class="gx-favorites__slot-header"><h3>Favorite {{ index + 1 }} <small>{{ ['Left', 'Middle', 'Right'][index] }}</small></h3>
<label class="gx-switch"><input type="checkbox" :aria-label="'Enable favorite ' + (index + 1)" :checked="slot.enabled" :disabled="disabled || !slot.key" @change="toggle(index, 'enabled', $event)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<label>Control<GalaxySelect class="gx-field gx-field--full" :aria-label="'Quick Select ' + (index + 1) + ' control'" :value="slot.key || ''" :disabled="disabled" @change="select(index, $event)">
<option value="">Not assigned</option>
<option v-if="slot.key && !byKey.has(slot.key)" :value="slot.key">{{ slot.label || slot.key }} · Unavailable in this build</option>
<optgroup v-for="group in groups" :key="group" :label="group"><option v-for="option in state.data.options.filter(item => (item.section || 'Controls') === group)" :key="option.key" :value="option.key">{{ option.label }}</option></optgroup>
</GalaxySelect></label>
<label v-if="slot.key === '__starpilot_controller_action__:set_speed'">Set speed (device units)<input class="gx-field" type="number" min="5" max="145" step="1" :value="slot.value ?? 30" :disabled="disabled" @change="speed(index, $event)"></label>
<label>Label<input class="gx-field" type="text" maxlength="32" :aria-label="'Quick Select ' + (index + 1) + ' label'" :value="slot.label" :disabled="disabled || !slot.key" @change="label(index, $event)"></label>
<div class="gx-favorites__show"><span>Show onroad · Big and Small UI</span><label class="gx-switch"><input type="checkbox" :aria-label="'Show favorite ' + (index + 1) + ' onroad'" :checked="slot.show_onroad" :disabled="disabled || !slot.enabled || !slot.key" @change="toggle(index, 'show_onroad', $event)"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label></div>
<p v-if="slot.key" class="gx-note">{{ state.data.states[index].stateLabel }}<template v-if="state.data.states[index].reason"> · {{ state.data.states[index].reason }}</template></p>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="disabled || !slot.key" @click="feed.update(index, {key:null})">Clear slot</button>
</section>
</div>
</template>
</section>`,
}
+483
View File
@@ -0,0 +1,483 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { LocalHistoryFeed, validLocalHistory } from './record-history.js'
const SEGMENT = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}[|_](?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))--[0-9]{1,6}$/
const STATES = ['idle', 'running', 'completed', 'canceled', 'failed', 'unavailable']
const RESULTS = ['measured', 'insufficient_samples', 'missing_car_params', 'unsupported_car', 'unsupported_controller']
const FAILURE = {
not_parked: 'Park the vehicle before analyzing recordings.',
canceled: 'Analysis was canceled.',
source_changed: 'A selected recording changed. Refresh the inventory.',
invalid_segment: 'A selected full log is unavailable. Refresh the inventory.',
busy: 'Another analysis is already running.',
invalid_request: 'Select one to five closed full logs, then try again.',
operation_changed: 'The analysis session changed. Refresh status.',
process_failed: 'The analysis process stopped unexpectedly. Its error was saved to the device log.',
deadline: 'Analysis exceeded its time limit. Try one segment at a time.',
recording_unavailable: 'A selected recording is missing, still open, or changed. Refresh recordings.',
decode_failed: 'A selected full log could not be decoded. Try another segment.',
resource_limit: 'This recording exceeds the analysis memory limit. Try a smaller segment.',
}
const labelFailure = (code) => FAILURE[code] || 'Offline analysis is unavailable.'
const finite = (value) => typeof value === 'number' && Number.isFinite(value)
export function selectableSegments(inventory) {
if (!validLocalHistory(inventory)) return []
return inventory.routes.flatMap((route) => route.segments.filter((segment) => segment.files.rlog &&
typeof segment.segmentName === 'string' && SEGMENT.test(segment.segmentName))
.map((segment) => ({ routeId: route.routeId, number: segment.number, name: segment.segmentName })))
}
export function validFlmStatus(value) {
return value?.version === 1 && STATES.includes(value.state) &&
(value.operationId === null || typeof value.operationId === 'string' && /^[a-zA-Z0-9:_-]{1,100}$/.test(value.operationId)) &&
Number.isSafeInteger(value.selected) && value.selected >= 0 && value.selected <= 5 &&
Number.isSafeInteger(value.processed) && value.processed >= 0 && value.processed <= value.selected &&
(value.errorCode === null || typeof value.errorCode === 'string' && /^[a-z_]{1,60}$/.test(value.errorCode)) &&
(value.state === 'idle' ? value.operationId === null : value.operationId !== null)
}
function validSeries(points) {
return Array.isArray(points) && points.length <= 160 && points.every((point) =>
point && finite(point.mono_ns) && point.mono_ns > 0 && finite(point.desired_lat_accel) &&
finite(point.actual_lat_accel) && Number.isSafeInteger(point.continuity_id) && point.continuity_id >= 0)
}
function validAnalysis(value) {
return value && typeof value.route === 'string' && value.route.length <= 80 &&
Number.isSafeInteger(value.number) && value.number >= 0 && RESULTS.includes(value.status) &&
(value.car_params_sha256 === null || typeof value.car_params_sha256 === 'string' && /^[a-f0-9]{64}$/.test(value.car_params_sha256)) &&
['messages', 'torque_frames', 'eligible_samples'].every((key) => Number.isSafeInteger(value[key]) && value[key] >= 0) &&
[value.mean_abs_error, value.root_mean_square_error].every((number) => number === null || finite(number) && number >= 0) &&
Array.isArray(value.exclusions) && value.exclusions.length <= 32 && value.exclusions.every((item) =>
Array.isArray(item) && item.length === 2 && typeof item[0] === 'string' && /^[a-z_]{1,60}$/.test(item[0]) &&
Number.isSafeInteger(item[1]) && item[1] >= 0) && validSeries(value.series) &&
Array.isArray(value.windows) && value.windows.length <= 128 && typeof value.windows_truncated === 'boolean'
}
export function validFlmReport(value, operationId) {
return value?.schemaVersion === 1 && ['offline_tracking_diagnostics', 'gm_flm_evidence_profiles'].includes(value.purpose) &&
(value.purpose === 'offline_tracking_diagnostics' || validGmEvidence(value.gmEvidence)) &&
value.operationId === operationId && value.tuneRecommendation === null && value.vehicleQualification === false &&
Array.isArray(value.segments) && value.segments.length >= 1 && value.segments.length <= 5 && value.segments.every((entry) =>
entry?.source && typeof entry.source.segmentName === 'string' && SEGMENT.test(entry.source.segmentName) &&
typeof entry.source.sha256 === 'string' && /^[a-f0-9]{64}$/.test(entry.source.sha256) &&
Number.isSafeInteger(entry.source.compressedBytes) && entry.source.compressedBytes > 0 &&
['zst', 'bz2'].includes(entry.source.codec) && validAnalysis(entry.analysis))
}
// Each continuity ID is a distinct measured interval. Never draw through an excluded gap.
export function flmChart(series) {
if (!validSeries(series) || !series.length) return { valid: false, paths: [[], []] }
const first = series[0].mono_ns, last = series.at(-1).mono_ns
const values = series.flatMap((point) => [point.desired_lat_accel, point.actual_lat_accel])
const lo = Math.min(0, ...values), hi = Math.max(0, ...values), span = Math.max(.01, hi - lo)
const x = (stamp) => 48 + Math.max(0, Math.min(1, (stamp - first) / Math.max(1, last - first))) * 576
const y = (value) => 132 - (value - lo) / span * 114
const paths = [[], []]
for (const [keyIndex, key] of ['desired_lat_accel', 'actual_lat_accel'].entries()) {
let points = [], prior = null
const flush = () => { if (points.length >= 2) paths[keyIndex].push(points.join(' ')); points = [] }
for (const point of series) {
if (prior && (point.continuity_id !== prior.continuity_id || point.mono_ns <= prior.mono_ns)) flush()
points.push(`${x(point.mono_ns).toFixed(1)},${y(point[key]).toFixed(1)}`)
prior = point
}
flush()
}
return { valid: true, paths, top: hi.toFixed(2), bottom: lo.toFixed(2), zeroY: y(0),
duration: Math.max(0, (last - first) / 1e9).toFixed(1) }
}
export class FlmFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false; this.generation = 0; this.inFlight = null; this.timer = this.deadline = null; this.busy = false
this.status = null; this.report = null; this.error = ''
}
emit() { this.publish({ operation: this.status, report: this.report, operationError: this.error,
busy: this.busy, requesting: this.inFlight !== null }) }
stop() {
this.active = false; this.generation++
this.inFlight?.abort(); this.inFlight = null
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.deadline !== null) this.cancelTimer(this.deadline)
this.timer = this.deadline = null; this.busy = false; this.status = this.report = null; this.error = ''
this.emit()
}
start() { this.stop(); this.active = true; return this.refresh() }
async request(url, options = {}, timeoutMs = 10000) {
const generation = this.generation, controller = new AbortController()
this.inFlight = controller
this.emit()
let timer = null
const timeout = new Promise((_, reject) => { timer = this.later(() => { controller.abort(); reject(new Error('Offline analysis timed out. Refresh status before trying again.')) }, timeoutMs); this.deadline = timer })
try {
const response = await Promise.race([this.fetcher(url, { credentials: 'same-origin', cache: 'no-store', ...options, signal: controller.signal }), timeout])
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
const body = await Promise.race([response.json(), timeout])
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 503 && ['setup_required', 'access_unavailable'].includes(body?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(labelFailure(body?.code))
return body
} finally {
if (timer !== null) this.cancelTimer(timer)
if (this.deadline === timer) this.deadline = null
if (this.inFlight === controller) {
this.inFlight = null
if (this.active && generation === this.generation) this.emit()
}
}
}
schedule() {
if (!this.active || this.timer !== null) return
this.timer = this.later(() => { this.timer = null; this.refresh() }, this.status?.state === 'running' ? 1000 : 5000)
}
async refresh() {
if (!this.active || this.inFlight || this.busy) return
const generation = this.generation
try {
const status = await this.request('./api/flm/status')
if (!this.active || generation !== this.generation || status === null) return
if (!validFlmStatus(status)) throw new Error('Offline analysis status is unavailable.')
const previousId = this.status?.operationId
this.status = status
if (previousId !== status.operationId || status.state !== 'completed') this.report = null
this.error = status.state === 'failed' || status.state === 'unavailable' ? labelFailure(status.errorCode) : ''
this.emit()
if (status.state === 'completed' && this.report === null) await this.loadReport(status.operationId, generation)
} catch (error) {
if (this.active && generation === this.generation) { this.error = error.message; this.emit() }
} finally { if (generation === this.generation) this.schedule() }
}
async loadReport(id, generation = this.generation) {
try {
const report = await this.request(`./api/flm/report?operationId=${encodeURIComponent(id)}`)
if (!this.active || generation !== this.generation || report === null || this.status?.operationId !== id) return
if (!validFlmReport(report, id)) throw new Error('Offline analysis report is unavailable.')
this.report = report; this.error = ''; this.emit()
} catch (error) {
if (this.active && generation === this.generation) { this.error = error.message; this.emit() }
}
}
async mutate(url, body) {
if (!this.active || this.inFlight || this.busy) return
const generation = this.generation
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.busy = true; this.error = ''; this.emit()
try {
const status = await this.request(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
if (!this.active || generation !== this.generation || status === null) return
if (!validFlmStatus(status)) throw new Error('Offline analysis response is unavailable.')
this.status = status
this.report = null; this.emit()
} catch (error) {
if (this.active && generation === this.generation) { this.error = error.message; this.emit() }
} finally {
if (this.active && generation === this.generation) { this.busy = false; this.emit(); this.refresh() }
}
}
startAnalysis(segments) { return this.mutate('./api/flm/start', { segments }) }
startTraining(segments, token) { return this.mutate('./api/flm/train', { segments, token }) }
async feedback(feedback) {
if (!this.active || this.inFlight || this.busy || !this.report?.gmEvidence) return
const generation = this.generation, operationId = this.status?.operationId
this.busy = true; this.emit()
try {
const value = await this.request('./api/flm/recommend', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ operationId, feedback }) })
if (!this.active || generation !== this.generation || this.status?.operationId !== operationId || value === null) return
if (value.version !== 1 || value.operationId !== operationId || !validGmEvidence(value.recommendation)) throw new Error('Trial profiles are unavailable.')
this.report = { ...this.report, gmEvidence: value.recommendation }; this.emit()
} catch (error) { if (generation === this.generation) { this.error = error.message; this.emit() } }
finally { if (generation === this.generation) { this.busy = false; this.emit() } }
}
cancelAnalysis() {
if (this.status?.state !== 'running' || !this.status.operationId) return
return this.mutate('./api/flm/cancel', { operationId: this.status.operationId })
}
}
export const FlmChart = {
name: 'FlmChart',
props: { series: { type: Array, required: true } },
computed: { chart() { return flmChart(this.series) } },
template: `<svg viewBox="0 0 640 170" role="img" aria-label="Desired and actual lateral acceleration in recorded intervals">
<line class="gx-plot-axis" x1="48" y1="18" x2="48" y2="132"/><line class="gx-plot-axis" x1="48" y1="132" x2="624" y2="132"/>
<template v-if="chart.valid"><line class="gx-plot-zero" x1="48" :y1="chart.zeroY" x2="624" :y2="chart.zeroY"/>
<text class="gx-plot-label" x="42" y="21" text-anchor="end">{{ chart.top }}</text>
<text class="gx-plot-label" x="42" y="135" text-anchor="end">{{ chart.bottom }}</text>
<text class="gx-plot-label" x="52" y="160">0 s</text><text class="gx-plot-label" x="624" y="160" text-anchor="end">+{{ chart.duration }} s</text>
</template><g v-for="(paths, index) in chart.paths" :key="index"><polyline v-for="(points, part) in paths" :key="part" :points="points"
:class="index === 0 ? 'gx-plot-desired' : 'gx-plot-actual'"/></g>
</svg>`,
}
export function validGmEvidence(value) {
return value?.fit === false && value.vehicleQualification === false &&
/^[a-f0-9]{64}$/.test(value.context?.sourceToken) && value.context?.version === 1 &&
typeof value.context?.capability?.fingerprint === 'string' &&
['standard', 'starpilot'].includes(value.context?.capability?.controller) &&
Array.isArray(value.summaries) && value.summaries.length <= 128 &&
value.summaries.every((row) => typeof row.dimensionId === 'string' && finite(row.severity)) &&
Array.isArray(value.paths) && value.paths.length === 2 && value.paths.every((path) =>
['baseline_fix', 'cleanup_pass'].includes(path.key) && Array.isArray(path.suggestions) &&
Array.isArray(path.profiles) && path.profiles.length <= 3 && path.profiles.every((profile) =>
typeof profile.id === 'string' && profile.id.length <= 128 && typeof profile.label === 'string' &&
(profile.canonical === null ? typeof profile.unavailableReason === 'string' :
typeof profile.canonical?.manual === 'string' && profile.canonical.manual.length <= 4096 &&
['torque_universal', 'gm_bolt_2022_2023'].includes(profile.canonical?.surface?.profile))))
}
// Local authenticated profile editor; all mutation authority stays with the parked owner.
export function validLiveFlm(value) {
if (value?.version !== 1 || typeof value.available !== 'boolean' || typeof value.editable !== 'boolean') return false
if (!value.available && !value.token) return true
if (!value.available) {
// A stale rich profile cannot be edited under the current STANDARD law.
// Only its exact current owner token and reset action remain reachable.
return value.editable === false && typeof value.resettable === 'boolean' &&
/^[a-f0-9]{64}$/.test(value.token) && typeof value.vehicle === 'string' && value.vehicle.length > 0 &&
['standard', 'starpilot'].includes(value.controller) && ['torque_universal', 'gm_bolt_2022_2023'].includes(value.profile) &&
typeof value.reason === 'string' && value.reason.length > 0 && value.reason.length <= 256
}
const surface = (entry) => entry?.profile === value.profile && entry.knobs &&
Object.keys(entry.knobs).length === Object.keys(value.knobs || {}).length &&
Object.entries(entry.knobs).every(([key, number]) => value.knobs?.[key] && finite(number) &&
number >= value.knobs[key].min && number <= value.knobs[key].max) &&
(entry.baseValues === null || Array.isArray(entry.baseValues) && entry.baseValues.length === 5 &&
entry.baseValues.every((number) => finite(number) && number >= .05))
if (!/^[a-f0-9]{64}$/.test(value.token) || typeof value.vehicle !== 'string' ||
!['standard', 'starpilot'].includes(value.controller) || !['torque_universal', 'gm_bolt_2022_2023'].includes(value.profile) ||
!value.knobs || Object.keys(value.knobs).length > 32 ||
!Object.values(value.knobs).every((range) => finite(range.min) && finite(range.max) && range.min <= range.max) ||
!Array.isArray(value.curveDefaults) || value.curveDefaults.length !== 5 || !value.curveDefaults.every((n) => finite(n) && n >= .05) ||
!surface(value.defaults) || !value.state?.saved || Object.keys(value.state.saved).length > 4) return false
const state = value.state
if (typeof value.manual !== 'string' || value.manual.length > 4096 || typeof value.manualConflict !== 'boolean' ||
!value.preconditions || !value.inactiveKnobs || Object.entries(value.inactiveKnobs).some(([key, reason]) =>
!value.knobs[key] || typeof reason !== 'string' || reason.length > 256) || !state.manual || Object.keys(state.manual).some((id) => !state.saved[id]) ||
Object.values(state.manual).some((raw) => typeof raw !== 'string' || raw.length > 4096) ||
Object.entries(value.preconditions).some(([id, reasons]) => !state.saved[id] || !Array.isArray(reasons) ||
reasons.some((reason) => typeof reason !== 'string' || reason.length > 256))) return false
return Object.entries(state.saved).every(([id, item]) => /^[a-zA-Z0-9_-]{1,48}$/.test(id) &&
typeof item?.label === 'string' && item.label.trim().length > 0 && item.label.length <= 80 && surface(item.surface)) &&
typeof state.applied === 'boolean' && typeof state.baselineApplied === 'boolean' &&
(state.active === null ? !state.applied : Boolean(state.saved[state.active]) && state.applied) &&
(state.baselineActive === null ? !state.baselineApplied : Boolean(state.saved[state.baselineActive]) && state.baselineApplied) &&
(state.trial === null ? state.baselineActive === null : /^[a-f0-9]{32}$/.test(state.trial) && state.applied)
}
export const FlmLiveEditor = {
name: 'FlmLiveEditor',
components: { GxNotice },
emits: ['source'],
props: { unauthorized: { type: Function, required: true } },
data: () => ({ live: null, error: '', busy: false, id: 'profile1', label: 'My surface', draft: null, useCurve: false, curve: [] }),
mounted() { this.closed = false; this.load() },
beforeUnmount() { this.closed = true; this.abort?.abort() },
methods: {
async request(url, body = null) {
if (this.closed || this.busy || document.hidden) return null
this.busy = true; this.error = ''; this.abort = new AbortController()
const timer = setTimeout(() => this.abort.abort(), 10000)
try {
const response = await fetch(url, { credentials: 'same-origin', cache: 'no-store', signal: this.abort.signal,
...(body === null ? {} : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) })
if (response.status === 401) { this.unauthorized(); return null }
const value = await response.json()
if (!response.ok) throw new Error(value.error || 'The surface changed. Refresh before trying again.')
if (!validLiveFlm(value)) throw new Error('Surface profiles are unavailable.')
if (this.closed) return null
this.live = value
this.$emit?.('source', value)
return value
} catch (error) { if (!this.closed) this.error = error.name === 'AbortError' ? 'The request timed out. Refresh to check its result.' : error.message; return null }
finally { clearTimeout(timer); if (!this.closed) this.busy = false }
},
async load() { const value = await this.request('./api/flm/live'); if (value?.defaults) this.choose(this.id) },
choose(id) {
this.id = id
const saved = this.live?.state?.saved?.[id]
this.label = saved?.label || 'My surface'
this.draft = JSON.parse(JSON.stringify(saved?.surface || this.live?.defaults || null))
this.useCurve = Boolean(this.draft && this.draft.baseValues !== null)
this.curve = [...(this.useCurve ? this.draft.baseValues : this.live?.curveDefaults || [])]
},
create() {
const id = [1, 2, 3, 4].map((n) => `profile${n}`).find((key) => !this.live.state.saved[key])
if (id) this.choose(id)
},
async action(action, extra = {}) {
if (!this.live?.token || this.busy || !(action === 'reset' ? this.live.resettable : this.live.editable)) return
const value = await this.request('./api/flm/live-action', { action, token: this.live.token, ...extra })
if (value?.defaults) this.choose(this.id)
},
save() {
if (!this.draft || !this.label.trim() || Object.values(this.draft.knobs).some((v) => !finite(v)) ||
this.useCurve && this.curve.some((v) => !finite(v) || v < .05)) return
this.action('save', { id: this.id, label: this.label, surface: { ...this.draft, baseValues: this.useCurve ? this.curve : null } })
},
trial() { this.action('trial', { id: this.id }) },
restore() { this.action('restore', { trial: this.live.state.trial }) },
accept() { this.action('accept', { trial: this.live.state.trial }) },
},
template: `<section class="gx-card gx-flm__panel gx-stack" :inert="busy" :aria-busy="busy || undefined"><h3>GM Trial Tunes</h3>
<p class="gx-note">Save the current manual choices from Tuning together with this surface. Applying a trial switches both; Restore returns the original session baseline. Other vehicle preferences are retained.</p>
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="load">Refresh</button></div>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<p v-if="live && !live.available" class="gx-note">{{ live.reason || "Choose an eligible GM torque controller to edit its surface." }}</p>
<div v-if="live?.resettable && !live.available" class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="action('reset')">Reset this surface binding</button></div>
<template v-if="live?.available && draft">
<p class="gx-note">{{ live.vehicle.replaceAll('_', ' ') }} · {{ live.controller === 'standard' ? 'Standard' : 'StarPilot' }}</p>
<p v-if="!live.editable" class="gx-note">Park the vehicle to save, apply or restore a profile.</p>
<label class="gx-field-group"><span class="gx-row__label">Profile</span><select class="gx-field" :value="id" @change="choose($event.target.value)"><option v-for="(item, key) in live.state.saved" :value="key">{{ item.label }}</option><option v-if="!live.state.saved[id]" :value="id">New profile</option></select></label>
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="create" :disabled="Object.keys(live.state.saved).length >= 4">New profile</button></div>
<label class="gx-field-group"><span class="gx-row__label">Name</span><input class="gx-field" v-model="label" maxlength="80" :disabled="!live.editable"></label>
<label class="gx-field-group" v-for="(range, key) in live.knobs" :key="key"><span class="gx-row__label">{{ key.replaceAll('_', ' ') }}</span><input class="gx-field" type="number" v-model.number="draft.knobs[key]" :min="range.min" :max="range.max" step="0.001" :disabled="!live.editable || live.inactiveKnobs?.[key]"><small v-if="live.inactiveKnobs?.[key]" class="gx-note">{{ live.inactiveKnobs[key] }}</small></label>
<label class="gx-row"><input type="checkbox" v-model="useCurve" :disabled="!live.editable"><span>Use the selected controller base friction curve</span></label>
<template v-if="useCurve"><label class="gx-field-group" v-for="(speed, index) in [0,5,10,15,25]" :key="index"><span class="gx-row__label">At {{ speed }} m/s</span><input class="gx-field" type="number" min="0.05" step="0.001" v-model.number="curve[index]" :disabled="!live.editable"></label></template>
<p v-for="reason in live.preconditions[id] || []" :key="reason" class="gx-note">{{ reason }}</p>
<p v-if="live.manualConflict" class="gx-note">Manual choices changed after this trial. Keep as baseline to retain those edits, or review them before restoring; FLM will not overwrite them.</p>
<p v-if="live.state.applied" class="gx-note">Active: {{ live.state.saved[live.state.active]?.label }}</p>
<div class="gx-actions">
<button type="button" class="gx-btn" @click="save" :disabled="!live.editable">Save manual choices and surface</button>
<button type="button" class="gx-btn gx-btn--tonal" @click="trial" :disabled="!live.editable || !live.state.saved[id] || live.manualConflict || live.preconditions[id]?.length">Apply trial</button>
<button v-if="live.state.trial" type="button" class="gx-btn gx-btn--tonal" @click="restore" :disabled="!live.editable || live.manualConflict">Restore baseline</button>
<button v-if="live.state.trial" type="button" class="gx-btn gx-btn--tonal" @click="accept" :disabled="!live.editable">Keep as baseline</button>
<button v-if="live.state.applied" type="button" class="gx-btn gx-btn--tonal" @click="action('disable')" :disabled="!live.editable">Use controller surface</button>
<button type="button" class="gx-btn gx-btn--danger" @click="action('delete', { id })" :disabled="!live.editable || !live.state.saved[id] || id === live.state.active || id === live.state.baselineActive">Delete profile</button>
</div>
</template>
</section>`,
components: { GxNotice },
}
export function gmEventSeries(plot) {
if (!plot || !Array.isArray(plot.times) || plot.times.length < 2 || plot.times.length > 160 ||
!Array.isArray(plot.desired) || !Array.isArray(plot.actual) ||
plot.times.length !== plot.desired.length || plot.times.length !== plot.actual.length ||
!plot.times.every((time, index) => finite(time) && time >= 0 && (index === 0 || time > plot.times[index - 1])) ||
![...plot.desired, ...plot.actual].every(finite)) return []
// Chart-only relative coordinates; these are not producer clock evidence.
return plot.times.map((time, index) => ({ mono_ns: 1 + time*1e9, desired_lat_accel: plot.desired[index],
actual_lat_accel: plot.actual[index], continuity_id: 0 }))
}
export const FlmPage = {
name: 'FlmPage',
components: { GxState, GxNotice, FlmChart, FlmLiveEditor },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } },
data: () => ({ inventoryStatus: 'idle', inventory: null, inventoryError: '', operation: null, report: null,
operationError: '', busy: false, requesting: false, selected: [], gmSource: null, accepted: [], ignored: [] }),
created() {
this.inventoryFeed = new LocalHistoryFeed({ publish: ({ status, data, error }) => {
this.inventoryStatus = status; this.inventory = data; this.inventoryError = error
if (data) this.selected = this.selected.filter((name) => selectableSegments(data).some((segment) => segment.name === name))
}, unauthorized: this.unauthorized })
this.operationFeed = new FlmFeed({ publish: (state) => {
if (this.operation?.operationId !== state.operation?.operationId) { this.accepted = []; this.ignored = [] }
Object.assign(this.$data, state)
}, unauthorized: this.unauthorized })
},
mounted() {
this.visibility = () => {
if (document.hidden) { this.inventoryFeed.stop(); this.operationFeed.stop() }
else if (this.mode === 'local') { this.inventoryFeed.start(); this.operationFeed.start() }
}
document.addEventListener('visibilitychange', this.visibility)
if (this.mode === 'local' && !document.hidden) { this.inventoryFeed.start(); this.operationFeed.start() }
},
beforeUnmount() { document.removeEventListener('visibilitychange', this.visibility); this.inventoryFeed.stop(); this.operationFeed.stop() },
computed: {
available() { return selectableSegments(this.inventory) },
analysisAvailable() { return this.selected.length >= 1 && this.selected.length <= 5 && this.operation?.state !== 'running' },
canAnalyze() { return this.analysisAvailable && !this.busy && !this.requesting },
},
methods: {
toggle(name) { this.selected = this.selected.includes(name) ? this.selected.filter((item) => item !== name) :
this.selected.length < 5 ? [...this.selected, name] : this.selected },
analyze() { if (this.canAnalyze && this.selected.every((name) => this.available.some((item) => item.name === name))) this.operationFeed.startAnalysis(this.selected) },
train() { if (this.canAnalyze && this.gmSource?.editable) this.operationFeed.startTraining(this.selected, this.gmSource.token) },
feedback() { this.operationFeed.feedback({ acceptedDimensions: this.accepted, ignoredDimensions: this.ignored }) },
async saveReport(profile) {
if (!profile.canonical || this.busy || this.requesting || !this.gmSource?.editable) return
const editor = this.$refs.liveEditor
const id = [1, 2, 3, 4].map((n) => `profile${n}`).find((key) => !editor.live.state.saved[key])
if (!id) { this.operationError = 'Delete a saved profile before saving another trial.'; return }
const value = await editor.request('./api/flm/save-report', { operationId: this.operation.operationId,
token: this.gmSource.token, generatedId: profile.id, id, label: profile.label,
feedback: this.report.gmEvidence.feedback })
if (value?.available) editor.choose(id)
},
resultLabel(status) { return ({ measured: 'Measured', insufficient_samples: 'Insufficient samples', missing_car_params: 'Missing vehicle data',
unsupported_car: 'Unsupported vehicle', unsupported_controller: 'Unsupported controller' })[status] || 'Unavailable' },
eventSeries(plot) { return gmEventSeries(plot) },
metric(value) { return finite(value) ? `${value.toFixed(2)} m/s²` : 'Unavailable' },
exclusions(items) { return items.map(([reason, count]) => `${reason.replaceAll('_', ' ')}: ${count}`).join(' · ') || 'None recorded' },
},
template: `<div class="gx-view gx-flm" :inert="busy || requesting" :aria-busy="busy || requesting || undefined">
<div class="gx-settings__header gx-page-header"><div><h2>FLM</h2><p>Review recorded torque tracking or generate GM trial choices from observed symptoms. A trial is a user-selected tune, not a fitted learner or vehicle qualification.</p></div></div>
<GxState v-if="mode !== 'local'">Offline analysis requires authenticated local Galaxy access.</GxState>
<template v-else>
<FlmLiveEditor ref="liveEditor" :unauthorized="unauthorized" @source="gmSource = $event"/>
<GxState v-if="inventoryStatus === 'loading'" loading>Reading local recordings…</GxState><GxNotice tone="danger" v-if="inventoryStatus === 'unavailable'">{{ inventoryError }}</GxNotice>
<GxNotice v-if="inventory?.scanIncomplete" tone="warn">This recording scan was incomplete. More local segments may exist.</GxNotice>
<section class="gx-card gx-flm__panel"><h3>Choose Full Logs</h3><p class="gx-note">Select 1–5 closed local segments. Quick logs alone cannot provide this report.</p>
<div class="gx-actions">
<button type="button" class="gx-btn" :disabled="!analysisAvailable" @click="analyze">Analyze selected</button><button type="button" class="gx-btn gx-btn--tonal" :disabled="!analysisAvailable || !gmSource?.editable" @click="train">Generate GM trials</button><span class="gx-note">{{ selected.length }} of 5 selected</span>
</div>
<p v-if="inventoryStatus === 'ready' && !available.length">No closed full logs found in this scan.</p>
<div v-for="segment in available" :key="segment.name" class="gx-flm__choice"><label><input type="checkbox" :checked="selected.includes(segment.name)"
:disabled="!selected.includes(segment.name) && selected.length>= 5 || operation?.state === 'running'" @change="toggle(segment.name)">
<span>Route {{ segment.routeId }} · Segment {{ segment.number }}</span></label></div>
</section>
<section class="gx-card gx-flm__panel"><h3>Analysis</h3>
<p v-if="operation?.state === 'running'" role="status">Analyzing {{ operation.processed }} of {{ operation.selected }} selected segments…</p>
<p v-else-if="operation?.state === 'completed'" role="status">Analysis completed.</p>
<p v-else-if="operation?.state === 'canceled'" role="status">Analysis canceled.</p>
<GxNotice tone="danger" v-else-if="operation?.state === 'failed'">Analysis failed.</GxNotice>
<p v-else-if="operation?.state === 'unavailable'" role="status">Analysis owner unavailable.</p>
<p v-else role="status">No analysis running.</p>
<GxNotice tone="danger" v-if="operationError">{{ operationError }}</GxNotice>
<button v-if="operation?.state === 'running'" type="button" class="gx-btn gx-btn--tonal" @click="operationFeed.cancelAnalysis()">Cancel analysis</button>
</section>
<section v-if="report?.gmEvidence" class="gx-card gx-flm__panel"><h3>Recorded GM symptoms</h3>
<p>{{ report.gmEvidence.decision.reason }}</p>
<p>Only recordings matching the current final vehicle configuration are eligible. Gaps and driver overrides stay excluded.</p>
<label v-for="row in report.gmEvidence.summaries" :key="row.dimensionId">
{{ row.dimensionId.replaceAll('_', ' ') }}
<input type="checkbox" v-model="accepted" :value="row.dimensionId">Confirmed
<input type="checkbox" v-model="ignored" :value="row.dimensionId">Ignore
</label>
<div class="gx-actions"><button type="button" class="gx-btn" @click="feedback">Update trial choices</button></div>
<section v-for="path in report.gmEvidence.paths" :key="path.key"><h4>{{ path.title }}</h4><p>{{ path.description }}</p>
<article v-for="suggestion in path.suggestions" :key="suggestion.dimensionId"><p>{{ suggestion.observedBehavior }}</p><p>{{ suggestion.likelyInterpretation }}</p><p>{{ suggestion.primaryAdjustment }}</p><p>{{ suggestion.whatNotToTouchYet }}</p><p>{{ suggestion.ifThatWasWrong }}</p><flm-chart v-if="eventSeries(suggestion.plotData).length" :series="eventSeries(suggestion.plotData)"/></article>
<div v-for="profile in path.profiles" :key="profile.id"><p>{{ profile.label }} · {{ profile.description }}</p>
<p v-if="profile.unavailableReason">{{ profile.unavailableReason }}</p>
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="saveReport(profile)" :disabled="!gmSource?.editable || !profile.canonical">Save trial for review</button></div>
</div>
</section>
</section>
<template v-if="report"><p class="gx-note">Offline diagnostic only · {{ report.segments.length }} {{ report.segments.length === 1 ? 'segment' : 'segments' }} · {{ report.gmEvidence ? "Human-selected GM trials" : "No tune recommendation" }}</p>
<section v-for="entry in report.segments" :key="entry.source.segmentName" class="gx-card gx-flm__panel gx-plots__panel">
<h3>{{ entry.source.segmentName }}</h3><p class="gx-note">{{ resultLabel(entry.analysis.status) }} · Full log SHA {{ entry.source.sha256.slice(0,12) }}…</p>
<p>Eligible samples: {{ entry.analysis.eligible_samples }} · Mean absolute error: {{ metric(entry.analysis.mean_abs_error) }} · RMSE: {{ metric(entry.analysis.root_mean_square_error) }}</p>
<p class="gx-note">Excluded observations: {{ exclusions(entry.analysis.exclusions) }}</p>
<template v-if="entry.analysis.series.length"><p class="gx-note">Lateral acceleration · m/s² · gaps mark excluded or interrupted data</p>
<p class="gx-flm__legend"><span class="gx-flm__legend-desired">Desired</span><span class="gx-flm__legend-actual">Actual</span></p>
<flm-chart :series="entry.analysis.series" /></template>
<p v-else class="gx-note">No eligible tracking series for this segment.</p>
</section>
</template>
</template>
</div>`,
}
@@ -0,0 +1,146 @@
let nextId = 0
// Keep the native select as the value/event adapter; presentation belongs to Galaxy.
export const GalaxySelect = {
name: "GalaxySelect",
inheritAttrs: false,
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean },
emits: ["change", "update:modelValue"],
data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } },
computed: {
current() { return this.modelValue !== undefined ? this.modelValue : this.value },
buttonId() { return this.$attrs.id || this.uid },
nativeAttrs() {
const attrs = { ...this.$attrs }
for (const key of ["class", "style", "id", "aria-label", "aria-labeledby", "aria-describedby"]) delete attrs[key]
return attrs
},
},
methods: {
sync() {
const native = this.$refs.native
if (!native) return
if (this.current !== undefined) native.value = String(this.current ?? "")
const items = [...native.options].map((option, index) => ({ index, value: option.value, label: option.label, description: option.dataset?.description || "", disabled: option.disabled || (option.parentElement?.tagName === "OPTGROUP" && option.parentElement.disabled), group: option.parentElement?.tagName === "OPTGROUP" ? option.parentElement.label : "" }))
if (JSON.stringify(items) !== JSON.stringify(this.items)) this.items = items
this.selected = native.value
this.label = native.selectedOptions[0]?.dataset?.collapsedLabel || native.selectedOptions[0]?.label || "Choose an option"
const button = this.$refs.button
const labeled = this.$attrs["aria-labeledby"]?.split(/\s+/).map(id => document.getElementById(id)?.textContent || "").join(" ")
const labels = [...new Set([...(button?.labels || []), button?.closest("label")].filter(Boolean))].map(label => {
const copy = label.cloneNode(true)
copy.querySelectorAll(".gx-select, select, button").forEach(node => node.remove())
return copy.textContent.trim()
}).filter(Boolean).join(" ")
this.name = this.$attrs["aria-label"] || labeled || labels || button?.closest(".gx-row")?.querySelector(".gx-row__label")?.textContent || this.$attrs.title || "Choose an option"
if (this.disabled && this.open) this.close()
},
async show(event) {
if (this.disabled || this.open) return
this.sync()
this.open = true
await this.$nextTick()
const menu = this.$refs.menu
try {
menu.showModal()
} catch (error) {
this.open = false
return
}
this.position()
const options = [...menu.querySelectorAll('[role="option"]:not(:disabled)')]
const selected = options.find(option => option.dataset.value === this.selected)
const first = event?.key === "End" ? options.at(-1) : options[0]
;(selected || first)?.focus()
},
close() {
if (!this.open) return
this.open = false
this.$refs.menu?.close()
if (this.$refs.button?.isConnected) this.$refs.button.focus({ preventScroll: true })
},
position() {
if (!this.open) return
const rect = this.$refs.button.getBoundingClientRect()
const viewport = window.visualViewport
const width = viewport?.width || window.innerWidth
const height = viewport?.height || window.innerHeight
const menu = this.$refs.menu
let zoom = 1
for (let node = menu; node; node = node.parentElement) zoom *= Number.parseFloat(getComputedStyle(node).zoom) || 1
const menuWidth = Math.min(Math.max(rect.width, 240), width - 24)
menu.style.width = `${menuWidth / zoom}px`
menu.style.maxHeight = `${(height - 24) / zoom}px`
const menuHeight = Math.min(menu.scrollHeight * zoom + 2, height - 24)
const below = height - rect.bottom - 12
const top = below >= Math.min(menuHeight, 220) ? Math.min(rect.bottom + 6, height - menuHeight - 12) : Math.max(12, rect.top - menuHeight - 6)
menu.style.left = `${Math.max(12, Math.min(rect.left, width - menuWidth - 12)) / zoom}px`
menu.style.top = `${Math.max(12, top) / zoom}px`
},
select(item) {
if (this.disabled) return this.close()
const native = this.$refs.native
const option = native.options[item.index]
if (!option || option.value !== item.value || option.disabled || option.parentElement?.disabled) return this.close()
this.close()
if (native.value === option.value) return
native.value = option.value
native.dispatchEvent(new Event("change", { bubbles: true }))
this.sync()
},
change(event) {
this.$emit("update:modelValue", event.target.value)
this.$emit("change", event)
},
buttonKey(event) {
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { event.preventDefault(); this.show(event) }
},
menuKey(event) {
if (event.key === "Tab") { event.preventDefault(); this.close(); return }
const options = [...this.$refs.menu.querySelectorAll('[role="option"]:not(:disabled)')]
const index = options.indexOf(document.activeElement)
let target
if (event.key === "ArrowDown") target = options[(index + 1) % options.length]
if (event.key === "ArrowUp") target = options[(index - 1 + options.length) % options.length]
if (event.key === "Home") target = options[0]
if (event.key === "End") target = options.at(-1)
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
const now = performance.now()
this.typeahead = (now - this.typedAt < 700 ? this.typeahead : "") + event.key.toLocaleLowerCase()
this.typedAt = now
target = options.find(option => option.textContent.trim().toLocaleLowerCase().startsWith(this.typeahead))
}
if (target) { event.preventDefault(); target.focus() }
},
},
mounted() {
this.sync()
window.addEventListener("resize", this.position)
window.visualViewport?.addEventListener("resize", this.position)
},
updated() { this.sync() },
beforeUnmount() {
this.$refs.menu?.close()
window.removeEventListener("resize", this.position)
window.visualViewport?.removeEventListener("resize", this.position)
},
template: `
<span class="gx-select" :class="$attrs.class" :style="$attrs.style">
<select ref="native" v-bind="nativeAttrs" hidden tabindex="-1" aria-hidden="true" :disabled="disabled" @change="change"><slot /></select>
<button ref="button" :id="buttonId" class="gx-select__button" type="button" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="uid + '-list'"
:aria-label="name" :aria-describedby="$attrs['aria-describedby']" :disabled="disabled" @click="show" @keydown="buttonKey">
<span>{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
</button>
<dialog ref="menu" class="gx-select-menu" @cancel.prevent="close" @click.self="close" @keydown="menuKey">
<div :id="uid + '-list'" role="listbox" :aria-label="name">
<template v-for="(item, index) in items" :key="item.index">
<div v-if="item.group && item.group !== items[index - 1]?.group" class="gx-select-menu__group">{{ item.group }}</div>
<button type="button" role="option" :data-value="item.value" :aria-selected="item.value === selected" :disabled="item.disabled" @click="select(item)">
<span class="gx-grow gx-wrap"><span>{{ item.label }}</span><small v-if="item.description" class="gx-note gx-select__description">{{ item.description }}</small></span><i v-if="item.value === selected" class="bi bi-check-lg" aria-hidden="true"></i>
</button>
</template>
</div>
</dialog>
</span>
`,
}
@@ -0,0 +1,240 @@
import { GalaxySelect } from "./galaxy-select.js"
import { haptic } from "./haptics.js"
export const FINE_SCRUB_HOLD_MS = 300
export const FINE_SCRUB_FACTOR = 5
const FINE_SCRUB_JITTER_PX = 4
// Standalone preferences use the same pending-value lifecycle as settings pages.
export const switchRow = (label, enabled, reason = "") => ({
label, value: enabled ? "On" : "Off", reason, choices: ["Off", "On"], available: true, action: true,
})
export function numericBounds(row) {
const min = Number(row.minimum), max = Number(row.maximum), step = Number(row.step)
return Number.isFinite(min) && Number.isFinite(max) && Number.isFinite(step) && max > min && step > 0
? { min, max, step } : null
}
export function snapNumeric(value, bounds) {
if (!bounds || value === "" || value === null || value === undefined || !Number.isFinite(Number(value))) return null
const { min, max, step } = bounds
let lastStep = Math.floor((max - min) / step + 1e-9)
if (Number((min + lastStep * step).toFixed(8)) > max) lastStep--
const index = Math.max(0, Math.min(lastStep, Math.round((Number(value) - min) / step)))
return Number((min + index * step).toFixed(8))
}
export function settingControl(row) {
if (row.page) return "group"
if (row.confirm || row.repairValue) return "action"
if (row.choices?.length === 2 && row.choices.includes("Off") && row.choices.includes("On") && row.choices.includes(row.value)) return "switch"
if (numericBounds(row) && ((Number.isFinite(Number(row.value)) && row.value !== "") ||
(row.value === "Auto" && row.choices?.includes("Auto")))) return "slider"
if (row.choices?.length && row.choices.includes(row.value)) return "select"
return "readout"
}
export function laneStrengthDisplay(value, row) {
const raw = Number(value), min = Number(row.minimum), max = Number(row.maximum)
if (!Number.isFinite(raw) || min >= 100 || max <= 100) return value
return Number((raw <= 100 ? 1 + (raw - min) * 4 / (100 - min) : 5 + (raw - 100) * 5 / (max - 100)).toFixed(2))
}
export function laneStrengthSaved(value, row) {
const display = Number(value), min = Number(row.minimum), max = Number(row.maximum)
if (!Number.isFinite(display) || display < 1 || display > 10) return null
return Math.round(display <= 5 ? min + (display - 1) * (100 - min) / 4 : 100 + (display - 5) * (max - 100) / 5)
}
export function presentedSetting(row) {
if (row.displayScale !== "lane-strength") return row
return { ...row, value: laneStrengthDisplay(row.value, row),
defaultValue: row.defaultValue == null ? row.defaultValue : laneStrengthDisplay(row.defaultValue, row),
minimum: 1, maximum: 10, step: row.step > 0 ? 1 : 0, unit: "",
reason: row.available ? "1–10; 5 is normal. Lower values reduce centering and higher values strengthen it" : row.reason }
}
export const GalaxySettingRow = {
name: "GalaxySettingRow",
components: { GalaxySelect },
props: {
row: { type: Object, required: true },
index: { type: Number, required: true },
disabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
saveValue: { type: Function, required: true },
},
emits: ["open", "review", "reset-default"],
data() { return { preview: undefined, interacting: false, fineScrub: null, isFineScrubbing: false, updating: false } },
computed: {
presentedRow() { return presentedSetting(this.row) },
control() { return settingControl(this.row) },
showDefault() {
const value = this.presentedRow.value, initial = this.presentedRow.defaultValue
if (initial === null || initial === undefined || this.row.page) return false
return Number.isFinite(Number(value)) && Number.isFinite(Number(initial)) ? Number(value) !== Number(initial) : String(value) !== String(initial)
},
bounds() {
const bounds = numericBounds(this.presentedRow)
if (!bounds || !this.row.choices?.includes("Auto")) return bounds
const numericMax = snapNumeric(bounds.max, bounds)
return { ...bounds, numericMax, max: Number((numericMax + bounds.step).toFixed(8)), auto: true }
},
locked() { return this.busy || this.updating || this.dimmed },
dimmed() { return this.disabled || !this.row.available || (!this.row.action && !this.row.page) },
currentValue() { return this.preview !== undefined ? this.preview : this.presentedRow.value },
sliderValue() { return this.currentValue === "Auto" && this.bounds?.auto ? this.bounds.max : this.currentValue },
displayValue() {
return this.currentValue === "Auto" || (this.bounds?.auto && Number(this.currentValue) === this.bounds.max)
? "Auto" : `${this.currentValue}${this.presentedRow.unit ? " " + this.presentedRow.unit : ""}`
},
},
methods: {
async commit(value) {
if (this.locked) return
if (String(value) === String(this.presentedRow.value)) { this.preview = undefined; return }
const savedValue = this.row.displayScale === "lane-strength" ? laneStrengthSaved(value, this.row) : value
if (savedValue === null) return
this.preview = value
this.updating = true
try { await this.saveValue(this.index, savedValue) }
finally { this.preview = undefined; this.updating = false }
},
onSwitch(event) {
const next = event.target.checked ? "On" : "Off"
if (this.locked) { event.target.checked = this.currentValue === "On"; return }
return this.commit(next)
},
onSelect(event) { return this.commit(event.target.value) },
beginInteract() { if (!this.locked) this.interacting = true },
flushSlider(raw) {
if (this.locked) return
const numeric = snapNumeric(raw, this.bounds)
const next = numeric !== null && this.bounds?.auto && numeric === this.bounds.max ? "Auto" : numeric
this.preview = undefined
if (next !== null && String(next) !== String(this.presentedRow.value)) return this.commit(next)
},
clearHoldTimer() {
if (this._holdTimer !== undefined) clearTimeout(this._holdTimer)
this._holdTimer = undefined
},
startHoldTimer() {
this.clearHoldTimer()
if (!this.fineScrub || this.fineScrub.active) return
this._holdTimer = setTimeout(() => this.activateFineScrub(), FINE_SCRUB_HOLD_MS)
},
activateFineScrub() {
if (!this.fineScrub || this.fineScrub.active || this.locked) return
this.fineScrub.active = true
this.fineScrub.baseValue = snapNumeric(this.sliderValue, this.bounds) ?? this.bounds.min
this.fineScrub.baseX = this.fineScrub.lastX
this.isFineScrubbing = true
haptic()
},
onSliderInput(event) {
if (this.locked || this.fineScrub?.active) { event.target.value = this.sliderValue; return }
this.beginInteract()
this.preview = Number(event.target.value)
this.startHoldTimer()
},
onSliderCommit(event) {
if (this.fineScrub) return
this.interacting = false
return this.flushSlider(event.target.value)
},
onSliderBlur(event) { if (this.interacting && !this.fineScrub) return this.onSliderCommit(event) },
onSliderPointerDown(event) {
if (this.locked) return
this.beginInteract()
try { event.target.setPointerCapture?.(event.pointerId) } catch {}
const rect = event.target.getBoundingClientRect()
this.fineScrub = { active: false, baseValue: snapNumeric(this.sliderValue, this.bounds),
baseX: event.clientX, lastX: event.clientX, track: rect.width || 200, pointerId: event.pointerId }
this.startHoldTimer()
},
onSliderPointerMove(event) {
const scrub = this.fineScrub
if (!scrub || event.pointerId !== scrub.pointerId || this.locked) return
if (!scrub.active) {
if (Math.abs(event.clientX - scrub.lastX) > FINE_SCRUB_JITTER_PX) {
scrub.lastX = event.clientX
this.startHoldTimer()
}
return
}
event.preventDefault()
const raw = scrub.baseValue + (event.clientX - scrub.baseX) * (this.bounds.max - this.bounds.min) / scrub.track / FINE_SCRUB_FACTOR
const next = snapNumeric(raw, this.bounds)
if (next === null) return
this.preview = next
if (this.$refs.slider) this.$refs.slider.value = next
},
releasePointer(event) {
const pointerId = this.fineScrub?.pointerId
this.clearHoldTimer()
this.fineScrub = null
this.isFineScrubbing = this.interacting = false
try { (event?.target || this.$refs.slider)?.releasePointerCapture?.(pointerId) } catch {}
},
onSliderPointerEnd(event) {
if (!this.fineScrub || event.pointerId !== this.fineScrub.pointerId) return
const value = this.sliderValue
this.releasePointer(event)
return this.flushSlider(value)
},
onSliderCancel(event) {
if (!this.fineScrub || event.pointerId !== this.fineScrub.pointerId) return
this.releasePointer(event)
this.preview = undefined
if (this.$refs.slider) this.$refs.slider.value = this.sliderValue
},
},
watch: {
locked(value) {
if (value) {
this.releasePointer()
// A save locks input but owns its preview until the saved value is read.
if (!this.updating) this.preview = undefined
}
},
},
beforeUnmount() { this.clearHoldTimer() },
template: `
<div class="gx-row" :class="{ disabled: dimmed, 'gx-row--stack': control === 'slider' }" :inert="busy || updating" :aria-busy="busy || updating || undefined">
<div class="gx-row__info">
<span class="gx-row__label">{{ row.label }}</span>
<span v-if="presentedRow.reason" class="gx-row__desc">{{ presentedRow.reason.replace('https://firestar.link/discord', '') }}<a v-if="presentedRow.reason.includes('https://firestar.link/discord')" href="https://firestar.link/discord" target="_blank" rel="noopener">StarPilot Discord</a></span>
<span v-if="control === 'group' || control === 'action'" class="gx-row__desc">{{ presentedRow.value }}</span>
</div>
<button v-if="control !== 'slider' && showDefault" type="button"
class="gx-btn gx-btn--tonal gx-setting-default" :disabled="dimmed || !row.resetAvailable"
:aria-label="'Reset ' + row.label + ' to default'" :title="'Default: ' + presentedRow.defaultValue"
@click="$emit('reset-default', index)">Default</button>
<label v-if="control === 'switch'" class="gx-switch">
<input type="checkbox" role="switch" :aria-label="row.label" :checked="currentValue === 'On'" :disabled="dimmed" @change="onSwitch">
<span class="gx-switch__track"></span><span class="gx-switch__thumb"></span>
</label>
<div v-else-if="control === 'slider'" class="gx-slider-row" :class="{ 'is-fine-scrubbing': isFineScrubbing }">
<div class="gx-slider-header"><span class="gx-row__value">{{ displayValue }}</span>
<span v-if="interacting" class="gx-slider-hint">{{ isFineScrubbing ? 'Fine scrubbing' : 'Hold to fine scrub' }}</span>
<button v-if="showDefault" type="button"
class="gx-btn gx-btn--tonal gx-setting-default" :disabled="dimmed || !row.resetAvailable"
:aria-label="'Reset ' + row.label + ' to default'" :title="'Default: ' + presentedRow.defaultValue"
@click="$emit('reset-default', index)">Default</button></div>
<input ref="slider" type="range" class="gx-slider" :aria-label="row.label" :aria-valuetext="displayValue"
:min="bounds.min" :max="bounds.max" :step="bounds.step" :value="sliderValue" :disabled="dimmed"
@input="onSliderInput" @change="onSliderCommit" @blur="onSliderBlur"
@pointerdown="onSliderPointerDown" @pointermove="onSliderPointerMove" @pointerup="onSliderPointerEnd"
@pointercancel="onSliderCancel" @lostpointercapture="onSliderCancel" @keydown="beginInteract">
<div class="gx-slider-meta"><span>{{ bounds.min }} to {{ bounds.numericMax ?? bounds.max }} {{ presentedRow.unit }}<template v-if="bounds.auto"> · Auto at right</template></span><span>Step: {{ bounds.step }} {{ presentedRow.unit }}</span></div>
</div>
<GalaxySelect v-else-if="control === 'select'" class="gx-field" :aria-label="row.label" :value="currentValue" :disabled="dimmed" @change="onSelect">
<option v-for="choice in row.choices" :key="choice" :value="choice">{{ choice }}</option>
</GalaxySelect>
<button v-else-if="control === 'group'" type="button" class="gx-btn gx-btn--tonal" :disabled="dimmed" @click="$emit('open', row.page)">Manage</button>
<button v-else-if="control === 'action' && row.action" type="button" class="gx-btn gx-btn--tonal" :disabled="dimmed" @click="$emit('review', index, row.confirm ? 0 : 1)">{{ row.repairValue === 'Reset' || /reset|default/i.test(row.label) ? 'Reset' : row.repairValue ? 'Set ' + row.repairValue : row.label }}</button>
<span v-else class="gx-row__value gx-row__readout">{{ displayValue }}</span>
</div>`,
}
@@ -0,0 +1,93 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
import { InstallApp } from "./install-app.js"
import { GalaxyHotspot } from "./hotspot.js"
export const GalaxyPage = {
name: "GalaxyPage",
components: { GxState, GxNotice, InstallApp, GalaxyHotspot },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false,
legacyPairingAvailable: false, password: "", busy: false, error: "" }),
mounted() { this.load() },
methods: {
async request(path, body) {
try {
return await requestJson(path, { request: body === undefined ? {} : {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
} })
} catch (error) {
if (error.status === 401) this.unauthorized()
throw error
}
},
async load() {
if (this.mode !== "local") { this.loading = false; return }
try {
const data = await this.request("./api/galaxy/status")
this.paired = data.paired
this.url = data.url
this.tunnelClientAvailable = data.tunnelClientAvailable === true
this.legacyPassword = data.legacyPassword === true
this.legacyPairingAvailable = data.legacyPairingAvailable === true
} catch (error) { this.error = error.message }
finally { this.loading = false }
},
async pair() {
this.busy = true
this.error = ""
try {
const data = await this.request("./api/galaxy/pair", { password: this.password })
this.paired = data.paired
this.url = data.url
this.password = ""
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
async unpair() {
if (!window.confirm("Unpair from Galaxy? Remote access will stop until you pair again.")) return
this.busy = true
this.error = ""
try {
await this.request("./api/galaxy/unpair", {})
this.paired = false
this.url = ""
} catch (error) { this.error = error.message }
finally { this.busy = false }
},
},
template: `
<div class="gx-settings gx-tunnel" :inert="busy" :aria-busy="busy || undefined"><header class="gx-settings__header gx-page-header"><div><h2>Galaxy Access & Install</h2>
<p>Pair for remote access, install Galaxy, or set up a Wi-Fi hotspot.</p></div></header>
<GxState v-if="mode !== 'local'">Connect to your comma to set up remote access.</GxState>
<GxState v-else-if="loading" loading>Checking pairing status…</GxState>
<section v-else class="gx-card gx-panel">
<h3>Remote pairing</h3>
<p>Pair your comma to get a secure Galaxy link and QR code. Remote access requires Internet on your phone and comma, plus an active tunnel.</p>
<template v-if="paired">
<GxNotice tone="success" title="Paired">Scan this code or open the link when the Galaxy tunnel is connected.</GxNotice>
<p v-if="!tunnelClientAvailable" class="gx-note gx-note--danger">The remote tunnel client is not installed on this device yet. The link will work after Galaxy's tunnel client is available.</p>
<img src="./api/galaxy/qr.svg" alt="QR code for your Galaxy link" class="gx-tunnel__qr" />
<p><a :href="url" target="_blank" rel="noopener" class="gx-wrap">{{ url }}</a></p>
<button type="button" class="gx-btn gx-btn--danger" @click="unpair">{{ busy ? 'Unpairing…' : 'Unpair' }}</button>
</template>
<template v-else>
<GxNotice v-if="legacyPassword">Enter your existing Galaxy password to keep its saved link and QR code.</GxNotice>
<GxNotice v-else-if="legacyPairingAvailable">An earlier Galaxy pairing is available. Enter its password to keep the saved link, or choose a new password (at least 8 characters) for a new link.</GxNotice>
<GxNotice v-else>First choose a password, then pair your comma. Open Galaxy remotely using the link and QR code when the tunnel is connected.</GxNotice>
<div class="gx-actions"><input class="gx-field gx-filter-field" type="password" v-model="password" :minlength="legacyPassword || legacyPairingAvailable ? 6 : 8" maxlength="255" :autocomplete="legacyPassword ? 'current-password' : 'new-password'" :placeholder="legacyPassword ? 'Existing Galaxy password' : legacyPairingAvailable ? 'Existing or new Galaxy password' : 'New password (at least 8 characters)'" @keydown.enter="pair" />
<button type="button" class="gx-btn" :disabled="password.trim().length < (legacyPassword || legacyPairingAvailable ? 6 : 8)" @click="pair">{{ busy ? 'Pairing…' : 'Pair' }}</button></div>
</template>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
</section>
<section class="gx-card gx-info-card">
<h3>Install Galaxy</h3><InstallApp />
<p>Install Galaxy to launch it from your home screen or desktop. Use your remote Galaxy link for app installation; the button shows installation instructions when your browser needs them.</p>
<ul class="gx-install-benefits"><li><i class="bi bi-window" aria-hidden="true"></i> Your own full-screen window</li><li><i class="bi bi-lightning-charge" aria-hidden="true"></i> Launch from your home screen</li><li><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Automatic updates, no app store</li></ul>
<p class="gx-note">The public Galaxy link requires Internet and an active tunnel. Installing the app does not provide an offline connection to your comma. For local access without Internet, bookmark the hotspot address or a local address from Home.</p>
</section>
<GalaxyHotspot :mode="mode" :unauthorized="unauthorized" />
</div>
`,
}
+266
View File
@@ -0,0 +1,266 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { LocalAccess } from "./local-access.js"
import { MonitorFeed } from "./monitor-feed.js"
import { SoftwareStatusFeed } from "./software-status.js"
import { ModelStatusFeed } from "./models.js"
import { DriveStatsFeed } from "./drive-stats.js"
// The six lightweight motifs follow the original Galaxy Personal Records row.
const RECORD_ART = {
longestDrive: ["M4 12h16", "m14 6 6 6-6 6"],
mostEngagedDay: ["M20 11a8 8 0 1 1-5-7", "m8 11 4 4 8-9"],
bestWeek: ["M4 4v16h16", "m7 15 5-5 4 2 5-7", "M17 5h4v4"],
highestStreak: ["m13 2-9 12h7l-1 8 10-13h-7z"],
longestUndistractedDrive: ["m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6z", "m8 12 3 3 5-6"],
cleanDriveStreak: ["m12 2 2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z", "M20 2v4M18 4h4"],
}
export const recordArt = (id) => RECORD_ART[id] || ["m12 3 3 6 7 1-5 5 1 7-6-3-6 3 1-7-5-5 7-1z"]
const shown = (value) => typeof value === "string" && value.trim() ? value : "Unavailable"
const measure = (value, unit, digits = 0) =>
typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(digits)}${unit}` : "Unavailable"
const counted = (value, noun) => `${value} ${noun}${value === 1 ? "" : "s"}`
const distance = (meters, isMetric) => measure(meters === null ? null : meters / (isMetric ? 1000 : 1609.344),
isMetric ? " km" : " mi", 1)
const hours = (seconds) => measure(seconds === null ? null : seconds / 3600, " h", 1)
const percent = (value) => measure(value, "%")
const duration = (seconds) => {
if (seconds === null) return "Unavailable"
const minutes = Math.round(seconds / 60)
return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m` : `${minutes}m`
}
const driveDate = (start, end = null) => {
if (start === null) return "Date unavailable"
const date = new Date(start * 1000)
if (!Number.isFinite(date.getTime())) return "Date unavailable"
const calendar = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric", year: "numeric" })
const clock = (value) => new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(new Date(value * 1000))
const day = calendar.format(date)
const endDay = end !== null ? calendar.format(new Date(end * 1000)) : null
return `${day} · ${clock(start)}${end !== null ? `–${endDay === day ? "" : endDay + " · "}${clock(end)}` : ""}`
}
const dayLabel = (date) => new Intl.DateTimeFormat(undefined, { timeZone: "UTC", weekday: "short" }).format(new Date(`${date}T00:00:00Z`))
const recordDisplay = (record, isMetric) => record.value === null ? "Unavailable" :
record.unit === "m" ? distance(record.value, isMetric) : record.unit === "s" ? duration(record.value) :
record.unit === "days" || record.unit === "drives" ? counted(record.value, record.unit.slice(0, -1)) :
`${record.value}${record.unit === "%" ? "%" : record.unit ? ` ${record.unit}` : ""}`
export function driveSummary(feed) {
const data = feed.status === "ready" ? feed.data : null
if (!data) return null
const isMetric = data.isMetric
const last = data.lastDrive
const maximum = Math.max(1, ...data.week.days.map((day) => day.distanceMeters ?? 0))
const models = data.models.slice(0, 3)
const modelTotal = models.reduce((sum, model) => sum + (model.distanceMeters ?? 0), 0)
const colors = ["#5ec8c8", "#8b6cc5", "#d4a060"]
let offset = 0
const stops = models.map((model, index) => {
const next = offset + (modelTotal > 0 ? (model.distanceMeters ?? 0) / modelTotal * 100 : 0)
const stop = `${colors[index]} ${offset}% ${next}%`
offset = next
return stop
})
return {
analysis: data.analysis,
last: last && { routeId: last.routeId, date: driveDate(last.startTime, last.endTime), complete: last.complete,
distance: distance(last.distanceMeters, isMetric), duration: duration(last.durationSeconds), engaged: percent(last.engagedPercent),
average: measure(last.distanceMeters === null || !(last.durationSeconds > 0) ? null : last.distanceMeters / last.durationSeconds * (isMetric ? 3.6 : 2.236936), isMetric ? " km/h" : " mph"),
model: shown(last.model), distracted: last.distractedMoments === null ? "Unavailable" : String(last.distractedMoments),
unresponsive: last.unresponsiveMoments === null ? "Unavailable" : String(last.unresponsiveMoments) },
totals: { distance: distance(data.totals.distanceMeters, isMetric), duration: hours(data.totals.durationSeconds),
engaged: duration(data.totals.engagedSeconds), drives: data.totals.drives },
week: { timezone: data.week.timezone || "UTC", distance: distance(data.week.distanceMeters, isMetric), duration: hours(data.week.durationSeconds),
engaged: percent(data.week.engagedPercent), progress: data.week.engagedPercent ?? 0, drives: data.week.drives,
days: data.week.days.map((day) => ({ date: day.date, label: dayLabel(day.date),
distance: distance(day.distanceMeters, isMetric), height: day.distanceMeters === null ? 0 : day.distanceMeters / maximum * 100 })) },
recent: data.recentDrives.slice(0, 5).map((drive) => ({ routeId: drive.routeId, date: driveDate(drive.startTime, drive.endTime),
complete: drive.complete, ignored: drive.ignored, distance: distance(drive.distanceMeters, isMetric),
duration: duration(drive.durationSeconds), engaged: percent(drive.engagedPercent), model: shown(drive.model) })),
records: data.records.map((record) => ({ id: record.id, label: record.label, display: recordDisplay(record, isMetric) })),
models: models.map((model, index) => ({ name: model.name, distance: distance(model.distanceMeters, isMetric),
drives: model.drives, color: colors[index] })),
modelStyle: modelTotal > 0 ? `background:conic-gradient(${stops.join(",")})` : "",
}
}
// Each card retains its own source status. Missing/expired data is never a zero or a healthy state.
export function homeSummary(monitor, model, software) {
const live = monitor.status === "current" ? monitor.snapshot : null
const loaded = model.status === "ready" ? model.data : null
const installed = software.status === "ready" ? software.data : null
return {
system: {
available: !!live,
cpu: measure(live?.cpuPercent, "%"),
memory: measure(live?.memory?.percent, "%"),
storage: live?.storage?.usedGiB != null && live?.storage?.totalGiB != null
? `${measure(live.storage.usedGiB, " GiB", 1)} / ${measure(live.storage.totalGiB, " GiB", 1)}` : "Unavailable",
observed: live ? new Date(live.sampledAt * 1000).toLocaleString() : "Unavailable",
},
model: {
available: !!loaded,
health: loaded ? shown(loaded.health).replaceAll("-", " ") : "Unavailable",
variant: loaded?.loadedId ? loaded.variant === "chestnut" ? "Chestnut big" : "Small" : "Identity unavailable",
fallback: loaded?.fallbackReason === "chestnut-run-stalled" ? "Chestnut output stopped; Small restarted for this drive" :
loaded?.fallbackReason === "chestnut-load-failed" ? "Small model after Chestnut load failure" : null,
},
software: {
available: !!installed,
version: shown(installed?.installed?.displayVersion ?? installed?.installed?.version),
branch: shown(installed?.installed?.branch),
update: installed?.updater?.finalizedUpdateReady === true ? "Finalized update ready" :
installed?.updater?.finalizedUpdateReady === false ? "None reported" : "Unavailable",
},
}
}
export const Home = {
name: "Home",
components: { GxState, GxNotice, LocalAccess },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({
monitor: { snapshot: null, status: "idle", error: "" },
model: { data: null, status: "idle", error: "" },
software: { data: null, status: "idle", error: "" },
drives: { data: null, status: "idle", error: "", busy: false },
}),
computed: {
summary() { return homeSummary(this.monitor, this.model, this.software) },
driving() { return driveSummary(this.drives) },
},
created() {
const revoked = () => { this.stopFeeds(); this.unauthorized() }
this.monitorFeed = new MonitorFeed({ mode: this.mode, publish: (update) => Object.assign(this.monitor, update), unauthorized: revoked })
this.modelFeed = new ModelStatusFeed({ publish: (update) => Object.assign(this.model, update), unauthorized: revoked })
this.softwareFeed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => Object.assign(this.software, update), unauthorized: revoked })
this.driveFeed = new DriveStatsFeed({ publish: (update) => Object.assign(this.drives, update), unauthorized: revoked })
},
mounted() {
this.visibility = () => { if (document.hidden) this.stopFeeds(); else this.startFeeds() }
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.startFeeds()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.stopFeeds() },
methods: {
recordArt,
counted,
startFeeds() {
if (this.mode !== "local") return
this.monitorFeed.start()
this.modelFeed.start()
this.softwareFeed.start()
this.driveFeed.start()
},
stopFeeds() {
this.monitorFeed.stop()
this.modelFeed.stop()
this.softwareFeed.stop()
this.driveFeed.stop()
this.monitor.snapshot = null
this.monitor.status = "idle"
},
ignoreDrive(drive) {
if (this.mode !== "local" || document.hidden || this.drives.busy) return
return this.driveFeed.ignore(drive.routeId, !drive.ignored)
},
},
template: `
<div class="gx-view gx-home">
<div class="gx-home__hero">
<div><h1>Dashboard</h1><p class="gx-note">Your saved drives and current device</p></div>
</div>
<GxState v-if="mode !== 'local'">Dashboard information is available on your device.</GxState>
<template v-else>
<section class="gx-card gx-home__last">
<div class="gx-home__kicker"><span></span> Last drive</div>
<p v-if="drives.status !== 'ready'" role="status">{{ drives.status === 'loading' ? 'Loading your drives…' : drives.error || 'Drive history is unavailable.' }}</p>
<p v-else-if="!driving.last">No saved drives yet.</p>
<template v-else>
<p class="gx-home__date">{{ driving.last.date }}</p>
<p v-if="!driving.last.complete" class="gx-home__subtle">This drive is still being analyzed.</p>
<div class="gx-home__four"><div><strong>{{ driving.last.distance }}</strong><span>distance</span></div>
<div><strong>{{ driving.last.duration }}</strong><span>duration</span></div>
<div><strong>{{ driving.last.engaged }}</strong><span>engaged</span></div>
<div><strong>{{ driving.last.average }}</strong><span>average speed</span></div></div>
<div class="gx-home__attention"><span class="gx-home__model-tag"><i class="bi bi-cpu"></i> {{ driving.last.model }}</span><span><i class="bi bi-eye"></i> {{ driving.last.distracted === "Unavailable" ? "Attention data unavailable" : driving.last.distracted + " distracted " + (driving.last.distracted === "1" ? "moment" : "moments") }}</span>
<span><i class="bi bi-exclamation-triangle"></i> {{ driving.last.unresponsive }} unresponsive {{ driving.last.unresponsive === "1" ? "moment" : "moments" }}</span></div>
</template>
</section>
<template v-if="driving">
<p v-if="driving.analysis.pending" class="gx-home__analysis" role="status">
<i class="bi bi-hourglass-split"></i> {{ driving.analysis.running ? 'Updating drive history' : driving.analysis.state === 'waitingParked' ? 'Drive history will update when parked' : 'Drive history queued' }}{{ driving.analysis.pending ? ' · ' + driving.analysis.pending + ' remaining' : '' }}
</p>
<div class="gx-home__section-title"><span></span>Your driving</div>
<div class="gx-home__totals">
<section class="gx-card"><h2>Saved History</h2><strong>{{ driving.totals.distance }}</strong><span>{{ counted(driving.totals.drives, "drive") }}</span></section>
<section class="gx-card"><h2>Time on the Road</h2><strong>{{ driving.totals.duration }}</strong><span>{{ driving.totals.engaged }} engaged</span></section>
<section class="gx-card"><h2>This Week</h2><strong>{{ driving.week.distance }}</strong><span>{{ counted(driving.week.drives, "drive") }} · {{ driving.week.duration }}</span></section>
</div>
<div class="gx-home__pair">
<section class="gx-card gx-home__week"><h2>This Week</h2>
<div class="gx-home__week-top"><div class="gx-home__donut" :style="{ '--gx-home-progress': driving.week.progress + '%' }">
<strong>{{ driving.week.engaged }}</strong><span>engaged</span></div>
<div><strong>{{ driving.week.distance }}</strong><span>across {{ counted(driving.week.drives, "drive") }}</span></div></div>
<h3>Distance per day · {{ driving.week.timezone }}</h3>
<div class="gx-home__bars"><div v-for="day in driving.week.days" :key="day.date" :aria-label="day.date + ': ' + day.distance" :title="day.date + ': ' + day.distance">
<div class="gx-home__bar-track"><span :style="{ height: day.height + '%' }"></span></div><small>{{ day.label }}</small><small>{{ day.distance }}</small></div></div>
</section>
<section class="gx-card gx-home__records"><h2>Personal Records</h2>
<p v-if="!driving.records.length" class="gx-home__subtle">Records will appear as drives are saved.</p>
<div v-for="record in driving.records" :key="record.id" class="gx-home__record"><span class="gx-home__record-art" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path v-for="(path, index) in recordArt(record.id)" :key="index" :d="path" /></svg></span><span class="gx-home__record-label">{{ record.label }}</span><strong>{{ record.display }}</strong></div>
</section>
</div>
<section class="gx-card gx-home__recent"><h2>Recent Drives</h2>
<p v-if="!driving.recent.length" class="gx-home__subtle">No saved drives yet.</p>
<div v-for="drive in driving.recent" :key="drive.routeId" class="gx-home__drive" :class="{ 'is-ignored': drive.ignored }">
<div><strong>{{ drive.date }}</strong><small>{{ drive.model }}</small></div>
<div><strong>{{ drive.complete ? drive.distance : 'Analyzing' }}</strong><small>{{ drive.duration }} · {{ drive.engaged }} engaged</small></div>
<div><small>{{ drive.ignored ? 'Excluded from totals' : drive.complete ? 'Included in totals' : 'Waiting for analysis' }}</small>
<button type="button" class="gx-home__link" :inert="drives.busy" :aria-busy="drives.busy || undefined" @click="ignoreDrive(drive)">
{{ drive.ignored ? 'Include in stats' : 'Ignore drive stats' }}</button></div>
</div>
<GxNotice tone="danger" v-if="drives.error">{{ drives.error }}</GxNotice>
</section>
<section class="gx-card gx-home__models"><h2>Recorded Models</h2>
<p v-if="!driving.models.length" class="gx-home__subtle">Recorded models will appear as drives are saved.</p>
<div v-else class="gx-home__model-layout"><div class="gx-home__model-donut" :style="driving.modelStyle"></div>
<div><div v-for="model in driving.models" :key="model.name" class="gx-home__model-row">
<span class="gx-home__swatch" :style="{ background: model.color }"></span>
<div><strong>{{ model.name }}</strong><small>{{ counted(model.drives, "drive") }} · {{ model.distance }}</small></div></div></div></div>
</section>
</template>
<div class="gx-home__section-title"><span></span>Your device</div>
<LocalAccess ref="localAccess" :mode="mode" :on-unauthorized="unauthorized" />
<div class="gx-home__grid">
<section class="gx-card gx-home__card">
<h2><i class="bi bi-activity"></i> System</h2>
<p v-if="!summary.system.available" role="status">Live system status unavailable.</p>
<template v-else><div class="gx-home__metrics"><div><strong>{{ summary.system.cpu }}</strong><span>CPU</span></div>
<div><strong>{{ summary.system.memory }}</strong><span>Memory</span></div></div>
<p>Storage: {{ summary.system.storage }}</p><small>Sampled {{ summary.system.observed }}</small></template>
<button type="button" class="gx-home__link" @click="go('/logs/monitor')">Open System Monitor <i class="bi bi-arrow-right"></i></button>
</section>
<section class="gx-card gx-home__card">
<h2><i class="bi bi-cpu"></i> Driving model</h2>
<p v-if="!summary.model.available" role="status">Model identity unavailable.</p>
<template v-else><p>Reported health: <strong>{{ summary.model.health }}</strong></p>
<p>Loaded variant: <strong>{{ summary.model.variant }}</strong></p>
<small v-if="summary.model.fallback">{{ summary.model.fallback }}</small></template>
<button type="button" class="gx-home__link" @click="go('/manage_models')">Open Driving Model <i class="bi bi-arrow-right"></i></button>
</section>
<section class="gx-card gx-home__card">
<h2><i class="bi bi-arrow-repeat"></i> Software</h2>
<p v-if="!summary.software.available" role="status">Installed software report unavailable.</p>
<template v-else><p>Installed: <strong>{{ summary.software.version }}</strong></p>
<p>Branch: <strong>{{ summary.software.branch }}</strong></p>
<p>Last updater report: <strong>{{ summary.software.update }}</strong></p></template>
<button type="button" class="gx-home__link" @click="go('/system')">Open Software &amp; Updates <i class="bi bi-arrow-right"></i></button>
</section>
</div>
</template>
</div>`,
}
@@ -0,0 +1,104 @@
import { requestJson } from "./startup.js"
import { GxNotice } from "./notice.js"
import { GxState } from "./state.js"
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
export function validHotspot(value) {
return value?.version === 1 && /^[a-f0-9]{64}$/.test(value.revision) &&
typeof value.editable === 'boolean' && typeof value.active === 'boolean' &&
['active', 'starting', 'waiting', 'disabled', 'error', 'unavailable'].includes(value.state) &&
typeof value.reason === 'string' && value.reason.length <= 256 &&
value.url === 'http://172.31.254.1:8082/' && typeof value.config?.enabled === 'boolean' &&
/^TheGalaxy-[a-f0-9]{4}$/.test(value.config.ssid) &&
typeof value.config.password === 'string' && (value.config.password === '' || /^[\x21-\x7e]{12,63}$/.test(value.config.password))
}
export const GalaxyHotspot = {
components: { GxNotice, GxState, GalaxySettingRow },
props: { mode: String, unauthorized: Function },
data: () => ({ snapshot: null, draft: null, revision: '', busy: false, error: '', notice: '',
showPassword: false, timer: null, controller: null, generation: 0, active: false }),
computed: { dirty() { return JSON.stringify(this.draft) !== JSON.stringify(this.snapshot?.config) } },
mounted() { this.start() },
beforeUnmount() { this.stop() },
watch: { mode() { this.stop(); this.start() } },
methods: {
switchRow,
start() { if (this.mode === 'local') { this.active = true; this.refresh() } },
stop() {
this.active = false; this.generation++; clearTimeout(this.timer); this.controller?.abort()
this.snapshot = this.draft = null; this.revision = ''; this.showPassword = false; this.busy = false
},
adopt(value, reset = false) {
if (!validHotspot(value)) throw new Error('Invalid hotspot status')
const preserve = this.draft && this.dirty && !reset
this.snapshot = value
if (!preserve) { this.draft = { ...value.config }; this.revision = value.revision }
},
async refresh() {
if (!this.active || this.busy) return
clearTimeout(this.timer); this.controller?.abort()
const generation = ++this.generation
this.controller = new AbortController()
try {
const value = await requestJson('./api/galaxy/hotspot', { request: { signal: this.controller.signal } })
if (!this.active || generation !== this.generation) return
this.adopt(value); this.error = ''
} catch (error) {
if (!this.active || generation !== this.generation) return
if (error.status === 401) { this.stop(); this.unauthorized(); return }
this.error = error.message
} finally {
if (this.active && generation === this.generation) this.timer = setTimeout(() => this.refresh(), 5000)
}
},
async save() {
if (!this.active || this.busy || !this.snapshot?.editable) return
clearTimeout(this.timer); this.controller?.abort()
const generation = ++this.generation
this.controller = new AbortController(); this.busy = true; this.error = ''; this.notice = ''
try {
const value = await requestJson('./api/galaxy/hotspot', { request: { signal: this.controller.signal,
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revision: this.revision, config: this.draft }),
} })
if (!this.active || generation !== this.generation) return
this.adopt(value, true)
this.notice = value.config.enabled ? 'Settings saved. Join the network once its status is active.' : 'Hotspot disabled.'
} catch (error) {
if (!this.active || generation !== this.generation) return
if (error.status === 401) { this.stop(); this.unauthorized(); return }
this.error = `${error.message} If Wi-Fi disconnected, reconnect and reload to check whether settings saved.`
} finally {
if (this.active && generation === this.generation) { this.busy = false; this.timer = setTimeout(() => this.refresh(), 1000) }
}
},
},
template: `<section class="gx-card gx-panel gx-stack"><h3>Galaxy Wi-Fi Hotspot</h3>
<p>Local Galaxy access for iPhone and Android. No Android Auto, Bluetooth browser support, or cellular connection needed.</p>
<GxState v-if="mode !== 'local'">Connect to your comma to configure the hotspot.</GxState>
<GxState v-else-if="!snapshot && !error" loading>Loading hotspot settings…</GxState>
<template v-if="snapshot && draft">
<GxNotice :busy="snapshot.state === 'starting'" :tone="['error', 'unavailable'].includes(snapshot.state) ? 'warn' : 'info'">Status: {{ snapshot.state }} {{ snapshot.reason }}</GxNotice>
<GalaxySettingRow :row="switchRow('Broadcast hotspot automatically', draft.enabled)" :index="0"
:busy="busy" :disabled="!snapshot.editable" :save-value="(index, value) => { draft.enabled = value === 'On' }" />
<div class="gx-stack">
<p>Network name: <strong>{{ snapshot.config.ssid }}</strong></p>
<p class="gx-note">Fixed to TheGalaxy- plus the last four characters of your comma dongle ID. Once saved, the switch and password survive restarts.</p>
<label class="gx-field-group">Password <input class="gx-field" :type="showPassword ? 'text' : 'password'" v-model="draft.password" :disabled="busy || !snapshot.editable" maxlength="63" autocomplete="new-password" placeholder="Blank generates a password" /></label>
<label><input type="checkbox" v-model="showPassword" :disabled="busy || !snapshot.editable" /> Show password</label>
<div class="gx-settings__controls gx-actions"><button class="gx-btn" type="button" :disabled="!dirty || busy || !snapshot.editable" @click="save">{{ busy ? 'Saving…' : 'Save hotspot' }}</button></div>
</div>
<GxNotice v-if="!snapshot.editable">Park before changing hotspot settings.</GxNotice>
<p>Join the network, stay connected if warned of no Internet, then open and bookmark <a :href="snapshot.url">{{ snapshot.url }}</a>.</p>
</template>
<ul class="gx-install-benefits">
<li><i class="bi bi-power" aria-hidden="true"></i><span>Starts automatically when enabled. Needs the comma on and Wi-Fi available.</span></li>
<li><i class="bi bi-wifi" aria-hidden="true"></i><span>Works standalone, or shares the channel of connected Wi-Fi or Android Auto.</span></li>
<li><i class="bi bi-phone" aria-hidden="true"></i><span>Local access without Internet; no Internet sharing.</span></li>
<li><i class="bi bi-arrow-repeat" aria-hidden="true"></i><span>Network or channel changes may interrupt access. Changing the password or disabling the hotspot disconnects phones.</span></li>
<li><i class="bi bi-globe" aria-hidden="true"></i><span>The public galaxy.firestar.link address still needs Internet and its tunnel.</span></li>
</ul>
<GxNotice v-if="error" tone="danger">{{ error }}</GxNotice><GxNotice v-if="notice" tone="success">{{ notice }}</GxNotice>
</section>`,
}
@@ -0,0 +1,48 @@
let deferredPrompt = null
export function rememberInstallPrompt(event) {
event.preventDefault()
deferredPrompt = event
}
export function installHelp({ secure = window.isSecureContext, agent = navigator.userAgent } = {}) {
if (!secure) return 'Bookmark this local address for hotspot or Wi-Fi access without Internet. To install Galaxy as a web app (PWA), open your device-specific https://galaxy.firestar.link link from Galaxy Access & Install; this remote link needs Internet.'
if (/iPhone|iPad|iPod/.test(agent)) return 'Open Galaxy in Safari, tap Share, then Add to Home Screen. Turn on Open as Web App if shown.'
return 'This browser cannot install Galaxy automatically. Open the browser menu and choose Install app or Add to Home Screen. On a Mac in Safari, choose File → Add to Dock.'
}
export const InstallApp = {
name: 'InstallApp',
data: () => ({ prompt: null, installed: false, help: false, message: '' }),
mounted() {
this.prompt = deferredPrompt
this.displayMode = window.matchMedia('(display-mode: standalone)')
this.updateInstalled = () => { this.installed = this.displayMode.matches || navigator.standalone === true }
this.onPrompt = (event) => { event.preventDefault(); deferredPrompt = event; this.prompt = event; this.help = false }
this.onInstalled = () => { this.installed = true; this.prompt = deferredPrompt = null; this.help = false }
this.updateInstalled()
window.addEventListener('beforeinstallprompt', this.onPrompt)
window.addEventListener('appinstalled', this.onInstalled)
this.displayMode.addEventListener('change', this.updateInstalled)
},
beforeUnmount() {
window.removeEventListener('beforeinstallprompt', this.onPrompt)
window.removeEventListener('appinstalled', this.onInstalled)
this.displayMode?.removeEventListener('change', this.updateInstalled)
this.prompt = null
},
methods: {
async install() {
if (!this.prompt) { this.message = installHelp(); this.help = !this.help; return }
const prompt = this.prompt
this.prompt = deferredPrompt = null
try {
await prompt.prompt()
await prompt.userChoice
} catch { this.message = installHelp(); this.help = true }
},
},
template: `<div v-if="!installed" class="gx-install-app">
<button type="button" class="gx-btn gx-btn--tonal" @click="install"><i class="bi bi-download" aria-hidden="true"></i><span>Install Galaxy</span></button>
<p v-if="help" class="gx-note gx-install-help">{{ message }}</p>
</div>`,
}
@@ -0,0 +1,96 @@
// A portable copy of the single OnroadCustomizations document, not a Params backup.
const FORMAT = "starpilot-visual-layout"
const VERSION = 1
export const MAX_LAYOUT_BACKUP_BYTES = 32768
const K = new Uint32Array([
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
])
const INITIAL = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
const right = (value, count) => (value >>> count) | (value << (32 - count))
// Local Galaxy can be served over plain HTTP, where Web Crypto is unavailable.
function sha256Portable(bytes) {
const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64)
padded.set(bytes)
padded[bytes.length] = 0x80
const view = new DataView(padded.buffer)
const bits = bytes.length * 8
view.setUint32(padded.length - 8, Math.floor(bits / 0x100000000))
view.setUint32(padded.length - 4, bits >>> 0)
const state = INITIAL.slice()
const words = new Uint32Array(64)
for (let offset = 0; offset < padded.length; offset += 64) {
for (let i = 0; i < 16; i++) words[i] = view.getUint32(offset + i * 4)
for (let i = 16; i < 64; i++) {
const x = words[i - 15], y = words[i - 2]
const s0 = right(x, 7) ^ right(x, 18) ^ (x >>> 3)
const s1 = right(y, 17) ^ right(y, 19) ^ (y >>> 10)
words[i] = (words[i - 16] + s0 + words[i - 7] + s1) >>> 0
}
let [a, b, c, d, e, f, g, h] = state
for (let i = 0; i < 64; i++) {
const s1 = right(e, 6) ^ right(e, 11) ^ right(e, 25)
const choose = (e & f) ^ (~e & g)
const t1 = (h + s1 + choose + K[i] + words[i]) >>> 0
const s0 = right(a, 2) ^ right(a, 13) ^ right(a, 22)
const majority = (a & b) ^ (a & c) ^ (b & c)
const t2 = (s0 + majority) >>> 0
h = g; g = f; f = e; e = (d + t1) >>> 0
d = c; c = b; b = a; a = (t1 + t2) >>> 0
}
state[0] = (state[0] + a) >>> 0; state[1] = (state[1] + b) >>> 0
state[2] = (state[2] + c) >>> 0; state[3] = (state[3] + d) >>> 0
state[4] = (state[4] + e) >>> 0; state[5] = (state[5] + f) >>> 0
state[6] = (state[6] + g) >>> 0; state[7] = (state[7] + h) >>> 0
}
return state.map((word) => word.toString(16).padStart(8, "0")).join("")
}
export async function sha256(value, cryptoSource = globalThis.crypto) {
const bytes = new TextEncoder().encode(value)
if (!cryptoSource?.subtle) return sha256Portable(bytes)
const digest = await cryptoSource.subtle.digest("SHA-256", bytes)
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
}
function body(document) {
if (!document || typeof document !== "object" || Array.isArray(document)) throw new Error("Visual layout is unavailable.")
return { format: FORMAT, version: VERSION, document }
}
export async function encodeLayoutBackup(document, cryptoSource = globalThis.crypto) {
const content = body(document)
const sha256Value = await sha256(JSON.stringify(content), cryptoSource)
const encoded = JSON.stringify({ ...content, sha256: sha256Value }) + "\n"
if (new TextEncoder().encode(encoded).length > MAX_LAYOUT_BACKUP_BYTES) throw new Error("Visual layout backup is too large.")
return encoded
}
export async function decodeLayoutBackup(text, cryptoSource = globalThis.crypto) {
if (typeof text !== "string" || new TextEncoder().encode(text).length > MAX_LAYOUT_BACKUP_BYTES) {
throw new Error("Visual layout backup is too large or unreadable.")
}
let parsed
try { parsed = JSON.parse(text) } catch { throw new Error("Visual layout backup is not valid JSON.") }
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) ||
Object.keys(parsed).join(",") !== "format,version,document,sha256" ||
parsed.format !== FORMAT || parsed.version !== VERSION ||
!/^[0-9a-f]{64}$/.test(parsed.sha256) ||
text !== JSON.stringify(parsed) + "\n") {
throw new Error("Visual layout backup has an unsupported format.")
}
const content = body(parsed.document)
if (await sha256(JSON.stringify(content), cryptoSource) !== parsed.sha256) {
throw new Error("Visual layout backup checksum does not match.")
}
return parsed.document
}
@@ -0,0 +1,246 @@
import { widgetArtMetadata } from "./widget-art.js"
import { widgetSize } from "./onroad-layout-document.js"
// hiddenOn: screens where the scene looks exactly like another one. The Small screen has no text for these
// speed-limit states, so they match Engaged there.
export const PREVIEW_SCENES = [
{ id: "general", label: "General edit" },
{ id: "engaged", label: "Engaged" },
{ id: "aol", label: "Lateral only" },
{ id: "long_only", label: "Longitudinal only" },
{ id: "experimental", label: "Manual experimental" },
{ id: "braking", label: "Braking" },
{ id: "stopped", label: "Stopped timer" },
{ id: "cem_stop_light", label: "CEM · Traffic light" },
{ id: "cem_lead", label: "CEM · Stopped lead" },
{ id: "cem_curve", label: "CEM · Curve" },
{ id: "slc_pending", label: "Pending speed limit" },
{ id: "slc_max", label: "Using set speed" },
{ id: "slc_equal", label: "Equal speed limits" },
{ id: "slc_pedal", label: "Using accelerator", hiddenOn: ["compact"] },
{ id: "slc_retained", label: "Set-speed override", hiddenOn: ["compact"] },
{ id: "slc_disengaged", label: "Cruise off" },
{ id: "slc_unavailable", label: "Unavailable speed limit" },
{ id: "slc_display", label: "Display-only speed limit", hiddenOn: ["compact"] },
]
const SCENES = PREVIEW_SCENES.map(({ id }) => id)
// Keep the sketch and its eye controls aligned with sample_state and the native onroad view.
export function previewScene(scene) {
const off = scene === "slc_disengaged"
const lateral = !off && scene !== "long_only"
const longitudinal = !off && scene !== "aol"
const experimental = scene === "experimental" || scene.startsWith("cem_")
return { lateral, longitudinal, experimental,
border: off ? "#122839" : experimental ? "#da6f25" : !longitudinal ? "#0abab5" : !lateral ? "#ff69b4" : "#167f40",
mode: off ? "Disengaged" : !longitudinal ? "Always On Lateral" : experimental ? "ACC - Experimental" : "ACC - Chill" }
}
export function sceneHidesWidget(scene, profile, id, layout = {}, widgets = {}) {
if (scene === "general") return false
const { lateral, longitudinal } = previewScene(scene)
if (id === "stopped_timer") return scene !== "stopped"
if (id === "cem_gauge") return !scene.startsWith("cem_")
if (id === "torque_bar" || profile === "compact" && id === "steering_wheel") return !lateral
if (profile === "compact" && id === "max_speed") return !longitudinal
if (id === "speed_limit_actions") return !longitudinal || scene === "slc_unavailable" || scene === "slc_display" ||
profile === "compact" && scene !== "slc_pending"
const overlaps = (a, b) => a && b && a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
const box = key => {
if (!layout[key]?.enabled || !widgets[key]) return null
const [width, height] = widgetSize(widgets[key], layout[key])
return { ...widgets[key], ...layout[key], width, height }
}
if (profile === "compact" && id === "driver_monitor" && longitudinal) return !!overlaps(box(id), box("max_speed"))
if (profile === "compact" && id === "speed_limit") {
if (scene === "slc_unavailable") return true
const actions = box("speed_limit_actions")
if (scene !== "slc_pending" || !actions) return false
const inset = actions.visualInsetTop || 0
return !!overlaps(box(id), { ...actions, y: actions.y >= inset ? actions.y - inset : actions.y, height: actions.height + inset })
}
return false
}
// The device renders one preview at a time and answers a second request with 429.
// Feeds sharing a queue take turns instead, widget art before the backdrop.
export class PreviewQueue {
constructor() { this.running = false; this.waiting = [] }
acquire(priority, signal) {
return new Promise((resolve, reject) => {
const cancel = () => { this.waiting = this.waiting.filter(item => item !== entry); reject(new Error("Preview request cancelled")) }
const entry = { priority, start: () => { signal.removeEventListener("abort", cancel); resolve() } }
if (signal.aborted) { reject(new Error("Preview request cancelled")); return }
signal.addEventListener("abort", cancel, { once: true })
this.waiting.push(entry)
this.waiting.sort((a, b) => a.priority - b.priority)
this.next()
})
}
next() {
if (this.running || !this.waiting.length) return
this.running = true
this.waiting.shift().start()
}
release() { this.running = false; this.next() }
}
export class LayoutPreviewFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id),
urls = URL, art = false, backdrop = false, queue = null }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer, urls, art, backdrop, queue })
this.active = false
this.version = 0
this.timer = null
this.request = null
this.currentUrl = null
this.pending = null
// Keep one native sheet per profile and scene, so returning to either is instant.
this.artCache = new Map()
// The sheet last shown for a profile stands in for a scene that has not rendered yet.
this.latestArt = new Map()
this.failureKey = null
this.failures = 0
}
pause() {
this.active = false
this.version++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.pending = null
this.inputKey = null
this.request?.abort()
this.request = null
this.last = null
}
stop() {
this.pause()
this.clearImage()
this.failureKey = null
this.failures = 0
}
clearImage() {
const urls = new Set([...this.artCache.values()].map(item => item.url))
if (this.currentUrl) urls.add(this.currentUrl)
for (const url of urls) this.urls.revokeObjectURL(url)
this.artCache.clear()
this.latestArt.clear()
this.currentUrl = null
this.publish({ url: null, ...(this.art ? {sprites: null} : {}) })
}
start() { if (this.art) this.pause(); else this.stop(); this.active = true }
// `key` stands in for the document when only part of it changes the image (the backdrop ignores positions).
update(document, profile, scene, dragging = false, key = document) {
if (!this.active || !document || !["large", "compact", "projection"].includes(profile) || !SCENES.includes(scene)) return
const documentKey = JSON.stringify(key)
const inputKey = JSON.stringify([documentKey, profile, scene, dragging])
if (inputKey === this.inputKey) return
this.inputKey = inputKey
// Picking a scene or screen is one discrete change, so only document edits are debounced.
const debounce = documentKey === this.documentKey ? 0 : 200
this.documentKey = documentKey
this.version++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.pending = null
this.request?.abort()
if (dragging) { this.publish({ status: "editing", error: "" }); return }
const renderKey = JSON.stringify([documentKey, profile, scene])
if (renderKey !== this.failureKey) { this.failureKey = renderKey; this.failures = 0 }
if (this.art) {
const cacheKey = profile + "\n" + scene, cached = this.artCache.get(cacheKey)
if (cached) this.latestArt.set(profile, cacheKey)
const shown = cached || this.artCache.get(this.latestArt.get(profile))
this.currentUrl = shown?.url || null
this.publish({ url: this.currentUrl, sprites: shown?.sprites || null, scene: shown?.scene || null })
if (cached?.key === renderKey) {
this.publish({ status: "ready", error: "" })
return
}
}
this.pending = { document: JSON.parse(JSON.stringify(document)), key, profile, scene, version: this.version, renderKey }
// Once a real outage is reported, keep the message stable through retries.
if (!this.art || this.failures < 2) this.publish({ status: "updating", error: "" })
this.timer = this.later(() => { this.timer = null; this.dispatch() }, debounce)
}
retry() {
if (!this.active || !this.last) return
this.inputKey = null
this.update(this.last.document, this.last.profile, this.last.scene, false, this.last.key)
}
async dispatch() {
if (!this.active || this.request || !this.pending) return
const item = this.pending
this.pending = null
this.last = item
const request = new AbortController()
this.request = request
const timeout = this.later(() => request.abort(), 7000)
let rejectAborted
const aborted = new Promise((_, reject) => { rejectAborted = () => reject(new Error("Preview request cancelled")) })
request.signal.addEventListener("abort", rejectAborted, { once: true })
let failure = "Preview is unavailable. Reconnecting automatically…"
let denied = false, busy = false
const work = (async () => {
const held = !!this.queue && await this.queue.acquire(this.art ? 0 : 1, request.signal).then(() => true)
try {
const response = await this.fetcher("./api/ui/layout/preview", { method: "POST", credentials: "same-origin",
cache: "no-store", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ document: item.document, profile: item.profile, scene: item.scene, ...(this.art ? {art: true} : this.backdrop ? {art: "backdrop"} : {}) }), signal: request.signal })
if (!this.active || item.version !== this.version || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 403) { denied = true; failure = "Turn off the vehicle to preview this layout."; throw new Error() }
if (response.status === 429) { busy = true; failure = "Preview is busy. Retrying automatically…"; throw new Error() }
if (!response.ok) { const detail = await response.json().catch(() => null); failure = detail?.error || failure; throw new Error() }
if (!response.headers.get("Content-Type")?.toLowerCase().startsWith("image/png")) throw new Error("Preview is unavailable. Reconnecting automatically…")
const blob = await response.blob()
if (!this.active || item.version !== this.version || request.signal.aborted) return
if (!blob.size || blob.size > 4 * 1024 * 1024) throw new Error("Preview is unavailable. Reconnecting automatically…")
const sprites = this.art ? await widgetArtMetadata(blob) : null
if (!this.active || item.version !== this.version || request.signal.aborted) return
const cacheKey = item.profile + "\n" + item.scene
const previousUrl = this.art ? this.artCache.get(cacheKey)?.url : this.currentUrl
this.currentUrl = this.urls.createObjectURL(blob)
if (this.art) {
this.artCache.set(cacheKey, { key: item.renderKey, url: this.currentUrl, sprites, scene: item.scene })
this.latestArt.set(item.profile, cacheKey)
}
this.failures = 0
this.publish({ status: "ready", error: "", url: this.currentUrl, scene: item.scene, profile: item.profile, ...(this.art ? {sprites} : {}) })
if (previousUrl) this.urls.revokeObjectURL(previousUrl)
} finally {
// Hold the queue until the device answers, so the other feed is not turned away as busy.
if (held) this.queue.release()
}
})()
work.catch(() => {}) // A cancelled request settles after the race below has moved on.
try {
await Promise.race([aborted, work])
} catch {
if (this.active && item.version === this.version) {
this.inputKey = null
this.failures = denied ? 2 : this.failures + 1
if (this.art && denied) this.clearImage()
// Art retries promptly, as does any feed turned away by a busy renderer; real outages back off.
const recovering = (this.art || busy) && this.failures < 2
this.publish({ status: recovering ? "updating" : "unavailable", error: recovering ? "" : failure })
this.timer = this.later(() => { this.timer = null; this.retry() }, recovering ? 600 : 5000)
}
} finally {
this.cancelTimer(timeout)
request.signal.removeEventListener("abort", rejectAborted)
if (this.request === request) this.request = null
if (this.active && this.pending && this.timer === null) this.dispatch()
}
}
}
@@ -0,0 +1,75 @@
import { widgetSize } from "./onroad-layout-document.js"
export const SNAP_DISTANCE = { touch: 10, fine: 6 }
const NON_TARGET_KINDS = ["torque_bar"]
const PRIORITY = { center: 0, bounds: 1, widget: 2, zone: 3 }
const EPSILON = 1e-6
function lines(box, axis) {
const start = box[axis], size = box[axis === "x" ? "width" : "height"]
return { start, center: start + size / 2, end: start + size }
}
export function snapTargets(profile, layout, id, visible) {
if (!Object.hasOwn(profile.widgets, id)) return []
const targets = []
const add = (box, kind, label, names) => {
for (const axis of ["x", "y"]) {
const values = lines(box, axis)
// Keep the line role so widget centers cannot attract moving edges.
for (const line of names) targets.push({ axis, value: values[line], kind, label, line })
}
}
const bounds = profile.widgets[id].bounds || profile.bounds
add(bounds, "bounds", "the edge", ["start", "end"])
add(bounds, "center", "the center", ["center"])
for (const [key, widget] of Object.entries(profile.widgets)) {
const at = layout[key]
if (key === id || !at?.enabled || !Number.isFinite(at.x) || !Number.isFinite(at.y) ||
!visible(key) || NON_TARGET_KINDS.includes(widget.kind)) continue
const [width, height] = widgetSize(widget, at)
add({ x: at.x, y: at.y, width, height }, "widget", widget.label, ["start", "center", "end"])
}
for (const zone of profile.reservedZones || []) add(zone, "zone", "a reserved area", ["start", "end"])
return targets
}
function snapAxis(box, targets, threshold, axis, resize) {
if (!(threshold > 0)) return { delta: 0, guides: [] }
const moving = lines(box, axis), names = resize ? ["end"] : ["start", "center", "end"]
const matches = (name, target) => (name === "center") === (target.line === "center")
let best = null
for (const target of targets) {
if (target.axis !== axis) continue
for (const name of names) {
if (!matches(name, target)) continue
const delta = target.value - moving[name], distance = Math.abs(delta)
if (distance <= threshold && (!best || distance < best.distance ||
distance === best.distance && PRIORITY[target.kind] < PRIORITY[best.target.kind])) best = { delta, distance, target }
}
}
if (!best) return { delta: 0, guides: [] }
const guides = []
for (const target of targets) {
if (target.axis !== axis || !names.some(name => matches(name, target) && Math.abs(moving[name] + best.delta - target.value) <= EPSILON)) continue
const guide = { axis, value: target.value, kind: target.kind, label: target.label }
const index = guides.findIndex(item => Math.abs(item.value - guide.value) <= EPSILON)
if (index < 0) guides.push(guide)
else if (PRIORITY[guide.kind] < PRIORITY[guides[index].kind]) guides[index] = guide
}
return { delta: best.delta, guides }
}
export function snapMove(box, targets, threshold) {
const x = snapAxis(box, targets, threshold, "x", false), y = snapAxis(box, targets, threshold, "y", false)
return { x: box.x + x.delta, y: box.y + y.delta, guides: [...x.guides, ...y.guides] }
}
export function snapResize(box, targets, threshold) {
const x = snapAxis(box, targets, threshold, "x", true), y = snapAxis(box, targets, threshold, "y", true)
return { width: box.width + x.delta, height: box.height + y.delta, guides: [...x.guides, ...y.guides] }
}
export function snapThreshold(profile, rect, pointerType) {
return rect.width > 0 ? (pointerType === "touch" ? SNAP_DISTANCE.touch : SNAP_DISTANCE.fine) * profile.width / rect.width : 0
}
@@ -0,0 +1,71 @@
import { PollTimer, connectionError } from "./polling.js"
import { requestJson } from "./startup.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
export function validLocalAccess(value) {
if (!value || typeof value.available !== "boolean" || typeof value.reason !== "string" || value.reason.length > 1024 ||
!Array.isArray(value.addresses) || value.addresses.length > 32 || value.available !== (value.addresses.length > 0)) return false
return value.addresses.every(address => {
if (!address || !["interface", "label", "url"].every(key => typeof address[key] === "string" && address[key].length > 0 && address[key].length <= 512)) return false
try {
const url = new URL(address.url)
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !!url.hostname
} catch { return false }
})
}
export class LocalAccessFeed {
constructor({ publish, onUnauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { publish, onUnauthorized, fetcher, later, cancel })
this.active = false; this.request = null; this.generation = 0; this.data = null
this.poller = new PollTimer({ read: () => this.refresh(), later, cancel })
}
start(mode) {
this.stop(); this.active = true; this.mode = mode
if (mode !== "local") { this.publish({ loading: false, data: null, error: "Local comma addresses are unavailable in preview." }); return }
this.poller.start()
return this.refresh()
}
stop() { this.active = false; this.generation++; this.poller.stop(); this.request?.abort(); this.request = null }
async refresh() {
if (!this.active || this.mode !== "local" || this.request) return
const request = new AbortController(), generation = ++this.generation
this.request = request
if (!this.data) this.publish({ loading: true })
try {
const data = await requestJson("./api/local-access", { fetcher: this.fetcher, timeout: 4000, later: this.later, cancel: this.cancel, request: { signal: request.signal } })
if (!this.active || generation !== this.generation) return
if (!validLocalAccess(data)) throw new Error("Local comma addresses are unavailable.")
this.data = data
this.publish({ data, error: "" })
} catch (error) {
if (this.active && generation === this.generation) {
if (error.status === 401 || ["setup_required", "access_unavailable"].includes(error.code)) { this.stop(); this.onUnauthorized(); return }
this.publish({ error: connectionError(error) })
}
} finally { if (this.active && generation === this.generation) { this.request = null; this.publish({ loading: false }) } }
}
}
export const LocalAccess = {
props: { mode: { type: String, required: true }, onUnauthorized: { type: Function, default: () => {} } },
setup(props) {
const state = reactive({ loading: false, data: null, error: "" })
const feed = new LocalAccessFeed({ publish: update => Object.assign(state, update), onUnauthorized: props.onUnauthorized })
return { state, feed }
},
mounted() { this.feed.start(this.mode) },
watch: { mode(value) { this.feed.start(value) } },
beforeUnmount() { this.feed.stop() },
template: `<section class="gx-card gx-local-access gx-panel" aria-label="Local comma access">
<div class="gx-settings__subhead"><h3>Local access</h3></div>
<p v-if="state.error" class="gx-note" role="status">{{ state.error }}</p>
<template v-else-if="state.data?.available">
<p>Connect your phone to the Galaxy hotspot, or put your phone and comma on the same Wi-Fi or Ethernet network, then open a local address:</p>
<ul><li v-for="address in state.data.addresses" :key="address.interface + address.url"><a :href="address.url" target="_blank" rel="noopener" class="gx-wrap">{{ address.label }} · {{ address.url }}</a></li></ul>
<p class="gx-note">For local access, type or paste the full http:// address into a new browser tab and bookmark it. The public Galaxy link uses the Internet tunnel, independently of hotspot access. If your browser or phone asks for local-network permission, allow access to this comma.</p>
</template>
<p v-else-if="state.data" class="gx-note" role="status">{{ state.data.reason || 'The comma has no local network address available.' }}</p>
</section>`,
}
+115
View File
@@ -0,0 +1,115 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { TroubleshootPage, TmuxPage } from "./diagnostic-tools.js"
import { navigate } from "./router.js"
import { SystemMonitor } from "./system-monitor.js"
import { CrashReportsFeed } from "./crash-reports.js"
import { storedSize } from "./record-deletion.js"
import { AndroidAutoLogsFeed, BUNDLE_URL, fileUrl, outcomeLabel } from "./android-auto-logs.js"
import { MenuTile } from "./menu-tile.js"
export const Logs = {
name: "Logs",
components: { GxState, GxNotice, SystemMonitor, TroubleshootPage, TmuxPage, MenuTile },
props: { path: { type: String, required: true }, mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ crashes: { reports: [], scanIncomplete: false, listLimited: false, status: "idle", error: "", selected: null, preview: null, previewStatus: "idle", deleting: false, storageBytes: 0, reportCount: 0 }, search: "",
aaLogs: { sessions: [], others: [], status: "idle", error: "" } }),
computed: {
visibleReports() { return this.crashes.reports.filter((item) => item.name.toLowerCase().includes(this.search.toLowerCase())) },
},
created() {
this.crashFeed = new CrashReportsFeed({ publish: (update) => Object.assign(this.crashes, update), unauthorized: this.unauthorized })
this.aaFeed = new AndroidAutoLogsFeed({ publish: (update) => Object.assign(this.aaLogs, update), unauthorized: this.unauthorized })
},
mounted() {
if (this.path === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
if (this.path === "/logs/android-auto" && this.mode === "local") this.aaFeed.start()
},
beforeUnmount() { this.crashFeed.stop(); this.aaFeed.stop() },
watch: {
path(next, previous) {
if (next === "/logs/crashes" && this.mode === "local") this.crashFeed.start()
else if (previous === "/logs/crashes") { this.crashFeed.stop(); this.search = "" }
if (next === "/logs/android-auto" && this.mode === "local") this.aaFeed.start()
else if (previous === "/logs/android-auto") this.aaFeed.stop()
},
},
methods: {
storedSize,
go: navigate,
openTroubleshoot() { navigate("/logs/troubleshoot") },
openTmux() { navigate("/logs/tmux") },
openMonitor() { navigate("/logs/monitor") },
openCrashes() { if (this.mode === "local") navigate("/logs/crashes") },
openAndroidAuto() { if (this.mode === "local") navigate("/logs/android-auto") },
bundleUrl: () => BUNDLE_URL,
fileUrl,
outcomeLabel,
reportDate(seconds) { return new Date(seconds * 1000).toLocaleString() },
reportSize(bytes) { return `${(bytes / 1024).toFixed(1)} KiB` },
async copyPreview() {
if (!this.crashes.preview) return
try { await navigator.clipboard.writeText(this.crashes.preview.text) }
catch { this.crashes.error = "Copy is unavailable in this browser." }
},
},
template: `
<div class="gx-view">
<h2 v-if="path === '/logs'">Logs & Diagnostics</h2>
<p v-if="path === '/logs'" class="gx-note">Live console, driving configuration, system activity, crash reports and Android Auto logs.</p>
<div v-if="path === '/logs'" class="gx-grid">
<MenuTile icon="bi-cpu" title="System Monitor" :description="mode === 'sample' ? 'Inspect a synthetic activity sample' : 'Inspect local system activity'" @select="openMonitor" />
<MenuTile icon="bi-file-text" title="Crash Reports" :description="mode === 'local' ? 'Read local reports' : ''"
:availability="mode === 'local' ? '' : 'Unavailable in preview'" :disabled="mode !== 'local'" @select="openCrashes" />
<MenuTile icon="bi-phone" title="Android Auto" :description="mode === 'local' ? 'Session logs and a download for bug reports' : ''"
:availability="mode === 'local' ? '' : 'Unavailable in preview'" :disabled="mode !== 'local'" @select="openAndroidAuto" />
<MenuTile icon="bi-terminal" title="tmux Live View" description="Read the launcher console tail" @select="openTmux" />
<MenuTile icon="bi-wrench" title="Troubleshoot" description="Read device and vehicle diagnostics" @select="openTroubleshoot" />
</div>
<TroubleshootPage v-else-if="path === '/logs/troubleshoot'" :mode="mode" :unauthorized="unauthorized" @navigate="go" />
<TmuxPage v-else-if="path === '/logs/tmux'" :mode="mode" :unauthorized="unauthorized" @navigate="go" />
<template v-else-if="path === '/logs/monitor'"><SystemMonitor :mode="mode" :unauthorized="unauthorized" /></template>
<template v-else-if="path === '/logs/crashes' && mode === 'local'">
<h2>Crash Reports</h2>
<p class="gx-note">Local reports saved when a process stops unexpectedly.</p>
<GxNotice tone="danger" v-if="crashes.error">{{ crashes.error }}</GxNotice>
<div class="gx-crash-controls"><button type="button" class="gx-btn" :disabled="crashes.deleting" @click="crashFeed.remove(null)">Delete All</button><span>{{ crashes.scanIncomplete ? 'At least ' : '' }}{{ crashes.reportCount }} reports · {{ crashes.storageIncomplete ? 'At least ' : '' }}{{ storedSize(crashes.storageBytes) }} stored</span><input class="gx-field" type="search" v-model="search" placeholder="Search report names" aria-label="Search crash reports"></div>
<p v-if="crashes.scanIncomplete" class="gx-note">Directory scan is incomplete; newer reports may be omitted.</p>
<p v-else-if="crashes.listLimited" class="gx-note">Showing the 200 newest reports; older reports are omitted.</p>
<GxState v-if="crashes.status === 'loading'" loading>Loading crash reports…</GxState>
<GxNotice tone="danger" v-else-if="crashes.status === 'unavailable' && !crashes.error">Crash reports are unavailable.</GxNotice>
<GxState v-else-if="crashes.status === 'ready' && !visibleReports.length">{{ search ? 'No matching reports.' : 'No crash reports.' }}</GxState>
<div v-if="crashes.status === 'ready'" class="gx-crash-list">
<article v-for="report in visibleReports" :key="report.id" class="gx-card gx-crash-row gx-panel"><button type="button" class="gx-btn" @click="crashFeed.open(report)"><strong>{{ report.name }}</strong><span>{{ reportDate(report.modifiedAt) }} · {{ reportSize(report.size) }}</span></button><button type="button" class="gx-btn" :disabled="crashes.deleting" @click="crashFeed.remove(report.id)">Delete</button></article>
</div>
<GxState v-if="crashes.previewStatus === 'loading'" loading>Loading report…</GxState>
<section v-else-if="crashes.preview" class="gx-card gx-crash-preview gx-panel"><div class="gx-crash-preview__head"><strong>{{ crashes.preview.name }}</strong><button type="button" class="gx-btn gx-btn--tonal" @click="copyPreview">Copy visible text</button></div><p v-if="crashes.preview.truncated" class="gx-note">Preview truncated to the first 256 KiB.</p><pre>{{ crashes.preview.text }}</pre></section>
</template>
<section v-else-if="path === '/logs/android-auto' && mode === 'local'" class="gx-settings gx-aa-logs" aria-labelledby="gx-aa-logs-title">
<header class="gx-settings__header gx-page-header"><div><h2 id="gx-aa-logs-title">Android Auto Logs</h2>
<p>The last 20 connection sessions, newest first. The archive includes every session, a readable report for each, Android Auto settings, the car view's renderer logs, and a snapshot of the device (Wi-Fi, Bluetooth and kernel logs, build, CPU load). Hardware addresses are masked.</p></div></header>
<div class="gx-settings__controls gx-actions"><a class="gx-btn" :href="bundleUrl()" download><i class="bi bi-file-earmark-zip" aria-hidden="true"></i><span>Download all logs</span><span class="gx-aa-logs__format">ZIP</span></a><button type="button" class="gx-btn gx-btn--tonal" @click="aaFeed.load()"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i><span>Refresh</span></button></div>
<GxState v-if="aaLogs.status === 'loading'" loading>Loading Android Auto logs…</GxState>
<GxNotice v-else-if="aaLogs.status === 'unavailable'" tone="danger">{{ aaLogs.error || 'Android Auto logs are unavailable.' }}</GxNotice>
<GxState v-else-if="aaLogs.status === 'ready' && !aaLogs.sessions.length">No Android Auto sessions have been logged yet.</GxState>
<div v-if="aaLogs.status === 'ready'" class="gx-aa-log-list">
<article v-for="log in aaLogs.sessions" :key="log.name" class="gx-card gx-aa-log">
<div class="gx-aa-log__body"><strong>{{ outcomeLabel(log.outcome) }}</strong><span>{{ log.car || 'Car not identified' }}{{ log.transport ? ' · ' + log.transport : '' }}{{ log.trigger ? ' · ' + log.trigger : '' }}</span><span>{{ reportDate(log.modifiedAt) }} · {{ reportSize(log.size) }}</span></div>
<a class="gx-icon-btn gx-aa-log__download" :href="fileUrl(log.name)" :download="log.name" :aria-label="'Download ' + log.name" :title="'Download ' + log.name"><i class="bi bi-download" aria-hidden="true"></i></a>
</article>
</div>
<template v-if="aaLogs.status === 'ready' && aaLogs.others.length">
<h3 class="gx-aa-logs__section-title">Renderer logs</h3>
<div class="gx-aa-log-list">
<article v-for="log in aaLogs.others" :key="log.name" class="gx-card gx-aa-log">
<div class="gx-aa-log__body"><strong>{{ log.name }}</strong><span>{{ reportDate(log.modifiedAt) }} · {{ reportSize(log.size) }}</span></div>
<a class="gx-icon-btn gx-aa-log__download" :href="fileUrl(log.name)" :download="log.name" :aria-label="'Download ' + log.name" :title="'Download ' + log.name"><i class="bi bi-download" aria-hidden="true"></i></a>
</article>
</div>
</template>
</section>
<GxState v-else>This Logs & Diagnostics page is unavailable in the offline preview.</GxState>
</div>`,
}
@@ -0,0 +1,107 @@
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { SettingsFeed, settingsBusy } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
export const PROFILES = Object.freeze(["aggressive", "standard", "relaxed"])
export const CATEGORIES = Object.freeze(["acceleration", "braking", "following"])
export function savedCurve(page, rows) {
if (!PROFILES.some((profile) => CATEGORIES.some((category) => page === `${profile}/${category}`))) return null
if (!Array.isArray(rows) || rows[0]?.label !== "Preset" || rows[0]?.value !== "custom") return null
const points = rows.slice(1)
if (points.length !== 10) return null
const unit = page.endsWith("/following") ? "s" : "m/s²"
const values = points.map((row, index) => {
const value = Number(row.value)
if (row.label !== `${index * 10} mph point` || row.unit !== unit || typeof row.value !== "string" || !row.value.trim() ||
!Number.isFinite(value) || !Number.isFinite(row.minimum) || !Number.isFinite(row.maximum)) return null
return { index: index + 1, speed: index * 10, value, unit, minimum: row.minimum, maximum: row.maximum,
available: row.available === true }
})
return values.every(Boolean) ? values : null
}
export function curveGeometry(points) {
if (!Array.isArray(points) || points.length !== 10) return null
const values = points.map((point) => point.value)
if (values.some((value) => !Number.isFinite(value))) return null
const min = Math.min(...values)
const max = Math.max(...values)
const span = Math.max(max - min, 0.1)
const bottom = min - (span - (max - min)) / 2
const top = bottom + span
const coords = points.map((point, index) => ({ x: 44 + index * 32, y: 116 - (point.value - bottom) / span * 88 }))
return { coords, line: coords.map(({ x, y }) => `${x},${y}`).join(" "), bottom, top }
}
export const LongitudinalCurvesPage = {
components: { GxDialog, GxState, GxNotice, GalaxySettingRow },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, go: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", profile: "standard", category: "acceleration" })
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed, profiles: PROFILES, categories: CATEGORIES }
},
computed: {
busy() { return settingsBusy(this.state) },
page() { return `${this.state.profile}/${this.state.category}` },
points() { return savedCurve(this.state.data?.page, this.state.data?.rows) },
graph() { return curveGeometry(this.points) },
preset() { return this.state.data?.rows?.[0] },
},
mounted() { if (this.mode === "local") this.feed.start(this.page) },
beforeUnmount() { this.feed.stop() },
methods: {
selectProfile(profile) { if (PROFILES.includes(profile) && !this.busy && !this.feed.saving) { this.state.profile = profile; this.feed.load(this.page) } },
selectCategory(category) { if (CATEGORIES.includes(category) && !this.busy && !this.feed.saving) { this.state.category = category; this.feed.load(this.page) } },
},
template: `
<section class="gx-long-curves" aria-label="Longitudinal curves" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Longitudinal Curves</h2>
<p>Adjust acceleration, braking, and following curves for each driving personality.</p></div></header>
<GxState v-if="mode !== 'local'">Local saved curves are unavailable in preview.</GxState>
<template v-else>
<div class="gx-long-curves__tabs gx-tabs" role="group" aria-label="Personality profile">
<button v-for="profile in profiles" :key="profile" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="state.profile === profile"
@click="selectProfile(profile)">{{ profile }}</button></div>
<div class="gx-long-curves__tabs gx-tabs" role="group" aria-label="Curve category">
<button v-for="category in categories" :key="category" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="state.category === category"
@click="selectCategory(category)">{{ category }}</button></div>
<GxState v-if="state.status === 'loading'" loading>Loading saved curve…</GxState>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">Saved curve is unavailable.</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}
</GxNotice>
<article v-if="state.data" class="gx-card gx-long-curves__body">
<div class="gx-long-curves__heading"><div><h3>{{ state.data.title }}</h3><p>Enable saved profiles and this personality in All profile settings to use its curve.</p></div>
</div>
<p v-if="!state.data.parked" class="gx-note">Editing requires fresh parked vehicle evidence.</p>
<GalaxySettingRow v-if="preset" :key="state.data.page + ':preset'" :row="preset" :index="0"
:busy="state.status !== 'ready' || !!state.pending" :disabled="!state.data.parked || !!state.error"
:save-value="(index, value) => feed.previewValue(index, value)" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
<template v-if="points && graph">
<svg class="gx-long-curves__graph" viewBox="0 0 370 155" role="img" :aria-label="state.data.title + ' saved curve, 0 to 90 miles per hour'">
<line x1="44" y1="28" x2="44" y2="116" class="gx-long-curves__axis"/><line x1="44" y1="116" x2="332" y2="116" class="gx-long-curves__axis"/>
<text x="5" y="32">{{ graph.top.toFixed(2) }}</text><text x="5" y="118">{{ graph.bottom.toFixed(2) }}</text>
<text x="43" y="140">0</text><text x="307" y="140">90 mph</text>
<polyline :points="graph.line" class="gx-long-curves__line"/>
<circle v-for="(point, index) in graph.coords" :key="index" :cx="point.x" :cy="point.y" r="4" class="gx-long-curves__point"/>
</svg>
<p class="gx-note">Saved custom points in {{ points[0].unit }}. Use the controls below to change one point at a time.</p>
<div class="gx-long-curves__points">
<GalaxySettingRow v-for="point in points" :key="state.data.page + ':' + point.index" :row="state.data.rows[point.index]" :index="point.index"
:busy="state.status !== 'ready' || !!state.pending" :disabled="!state.data.parked || !!state.error"
:save-value="(index, value) => feed.previewValue(index, value)" />
</div>
</template>
<p v-else class="gx-note">{{ preset?.value === 'custom' ? 'Saved custom points are unavailable; review the saved profile.' : 'Choose the Custom preset to edit saved points. Preset curves remain managed by the vehicle profile.' }}</p>
<button type="button" class="gx-btn gx-btn--tonal" @click="go('/driving/profiles')">All profile settings</button>
</article>
<GxDialog v-if="state.pending" labelledby="gx-longitudinal-curves-confirm-title" @close="feed.cancel()"><h3 id="gx-longitudinal-curves-confirm-title">Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></GxDialog>
</template>
</section>`,
}
@@ -0,0 +1,356 @@
import { GxDialog } from "./dialog.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { requestJson } from "./startup.js"
// One parked map operation belongs to the local owner, not to this browser tab.
const STATES = new Set(["idle", "transferring", "validating", "selecting", "completed", "canceled", "failed", "interrupted", "unavailable"])
const GENERATION = /^(?:|[0-9a-f]{64})$/
const TOKEN = /^(?:nation|us_state)\.[A-Za-z0-9_-]+$/
const ERROR_LABELS = {
canceled: "Canceled", interrupted: "Interrupted", selection_changed: "The saved selection changed",
not_parked: "Parked availability was lost", package_unavailable: "The map package is unavailable",
process_failed: "Map preparation failed", unavailable: "Map manager unavailable",
}
const ACTION_ERRORS = {
busy: "Another map download is running. Check its progress below.",
selection_changed: "The selected map changed. Refresh and review the region again.",
not_parked: "Map downloads need a fresh parked status. Refresh and try again.",
operation_changed: "This map download changed. Refresh its status before trying again.",
}
// 503 codes that mean "not ready yet" rather than a hard failure.
const SERVICE_PENDING = new Set(["owner_unavailable", "unavailable"])
const integer = (value) => Number.isSafeInteger(value) && value >= 0
const nullableText = (value) => value === null || typeof value === "string" && value.length <= 128
const bounds = (value) => value === null || Array.isArray(value) && value.length === 4 &&
value.every((n) => typeof n === "number" && Number.isFinite(n)) &&
value[0] >= -90 && value[2] <= 90 && value[1] >= -180 && value[3] <= 180 &&
value[0] < value[2] && value[1] < value[3]
export function validCatalog(value) {
if (!Array.isArray(value?.regions) || value.regions.length > 500 ||
!integer(value.maxGroups) || value.maxGroups < 1 || !integer(value.maxTransferBytes) ||
!integer(value.maxNewDiskBytes) || !GENERATION.test(value.selectedGeneration ?? "!")) return false
const seen = new Set()
return value.regions.every((region) => {
if (!TOKEN.test(region?.token) || region.token.length > 73 || seen.has(region.token) || typeof region.name !== "string" ||
!region.name.trim() || region.name.length > 128 || (region.available && (!bounds(region.bounds) || region.bounds === null)) ||
!integer(region.groups) || typeof region.available !== "boolean" ||
!(region.unavailable == null || ["invalid_bounds", "date_line_bounds", "too_large"].includes(region.unavailable)) ||
(region.available && (region.unavailable != null || region.groups > value.maxGroups)) ||
(!region.available && !["invalid_bounds", "date_line_bounds", "too_large"].includes(region.unavailable))) return false
seen.add(region.token)
return true
})
}
export function validSetup(value) {
return value?.schemaVersion === 1 && typeof value.packageReady === "boolean" &&
["ready", "missing_binary", "missing_manifest", "invalid_package"].includes(value.packageState) &&
value.packageReady === (value.packageState === "ready") && typeof value.snapshotReady === "boolean" &&
GENERATION.test(value.selectedGeneration ?? "!") && value.snapshotReady === Boolean(value.selectedGeneration) &&
typeof value.parked === "boolean" && integer(value.freeDiskBytes) &&
integer(value.maxTransferBytes) && integer(value.maxNewDiskBytes)
}
const validSnapshot = (value, generation) => Boolean(value && value.generation === generation &&
integer(value.storedBytes) && value.storedBytes > 0 && integer(value.tileCount) && value.tileCount > 0 && value.bounds !== null && bounds(value.bounds))
export function validOperation(value) {
return value?.schemaVersion === 1 && STATES.has(value.state) &&
typeof value.ownerSession === "string" && value.ownerSession.length <= 128 &&
nullableText(value.operationId) && nullableText(value.regionToken) && bounds(value.bounds) &&
integer(value.completedGroups) && integer(value.totalGroups) && value.completedGroups <= value.totalGroups &&
integer(value.transferredBytes) && integer(value.transferBudgetBytes) &&
nullableText(value.preparedGeneration) && GENERATION.test(value.selectedGeneration ?? "!") &&
nullableText(value.errorCode) && typeof value.selectedForNextShadowStart === "boolean" &&
(value.selectedSnapshot == null || validSnapshot(value.selectedSnapshot, value.selectedGeneration)) &&
(!value.selectedForNextShadowStart || value.state === "completed" && validSnapshot(value.selectedSnapshot, value.selectedGeneration))
}
export const formatBytes = (value) => !integer(value) ? "Unavailable" : value < 1024 ? `${value} B` :
value < 1024 ** 2 ? `${(value / 1024).toFixed(1)} KiB` :
value < 1024 ** 3 ? `${(value / (1024 ** 2)).toFixed(1)} MiB` : `${(value / (1024 ** 3)).toFixed(1)} GiB`
export const formatBounds = (value) => bounds(value) && value !== null ?
`latitude ${value[0]}°–${value[2]}°, longitude ${value[1]}°–${value[3]}°` : "extent unavailable"
export class MapOperationsClient {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.requests = new Set()
this.timer = null
this.setup = null
this.catalog = null
this.operation = null
this.busy = false
this.error = ""
this.readErrors = {}
this.actionError = ""
}
emit(update = {}) {
this.publish({ setup: this.setup, catalog: this.catalog, operation: this.operation, busy: this.busy,
error: this.actionError || this.error, ...update })
}
stop() {
this.active = false
this.generation++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
for (const request of this.requests) request.abort()
this.requests.clear()
this.setup = this.catalog = this.operation = null
this.busy = false
this.error = this.actionError = ""
this.readErrors = {}
this.emit()
}
start() {
this.stop()
this.active = true
this.loadCatalog()
this.loadStatus()
this.loadSetup()
}
async request(url, body = null) {
const generation = this.generation
const controller = new AbortController()
this.requests.add(controller)
try {
const { response, data: payload } = await requestJson(url, { fetcher: this.fetcher, later: this.later, cancel: this.cancelTimer, timeout: 10000, withResponse: true, request: { credentials: "same-origin", cache: "no-store",
signal: controller.signal, ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) } })
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401) { this.stop(); this.unauthorized(); return null }
if (response.status === 409) {
const body = payload
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
const message = Object.hasOwn(ACTION_ERRORS, body?.code) ? ACTION_ERRORS[body.code] :
"Map request could not be completed. Refresh status and try again."
throw new Error(message)
}
if (response.status === 503) {
const body = payload
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (["setup_required", "access_unavailable"].includes(body?.code)) {
this.stop(); this.unauthorized(); return null
}
if (body?.code === "package_unavailable")
throw new Error("Offline map package is unavailable on this device.")
if (SERVICE_PENDING.has(body?.code))
throw new Error("Offline maps are starting up. Reconnecting automatically…")
throw new Error(typeof body?.error === "string" && body.error ? body.error : "Offline maps are unavailable. Try again shortly.")
}
if (!response.ok) throw new Error("Map request was rejected.")
const data = payload
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
return data
} finally {
this.requests.delete(controller)
}
}
readResult(source, error = null) {
this.readErrors[source] = error ? connectionError(error) : ""
this.error = Object.values(this.readErrors).find(Boolean) || ""
this.emit()
}
async loadSetup() {
if (!this.active) return
const generation = this.generation
try {
const data = await this.request("./api/maps/setup")
if (data === null) return
if (!validSetup(data)) throw new Error("Map setup status is unavailable.")
const wasReady = this.setup?.packageReady
this.setup = data
this.readResult("setup")
if (data.packageReady && wasReady === false) await this.loadCatalog()
} catch (error) { if (this.active && generation === this.generation) this.readResult("setup", error) }
}
async loadCatalog() {
if (!this.active) return
const generation = this.generation
try {
const data = await this.request("./api/maps/catalog")
if (data === null) return
if (!validCatalog(data)) throw new Error("Map catalog is unavailable.")
this.catalog = data
this.readResult("catalog")
} catch (error) { if (this.active && generation === this.generation) this.readResult("catalog", error) }
}
async loadStatus() {
if (!this.active || this.busy) return
const generation = this.generation
try {
const data = await this.request("./api/maps/operation")
if (data !== null) {
if (!validOperation(data)) throw new Error("Map operation status is unavailable.")
this.operation = data
this.readResult("operation")
}
} catch (error) {
if (this.active && generation === this.generation) {
this.operation = null; this.readResult("operation", error)
}
} finally {
if (this.active && generation === this.generation && !this.busy)
this.timer = this.later(() => {
this.timer = null
this.loadStatus()
if (this.readErrors.catalog) this.loadCatalog()
if (this.readErrors.setup) this.loadSetup()
}, 2500)
}
}
pausePolling() {
this.generation++
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
for (const request of this.requests) request.abort()
this.requests.clear()
}
async startRegion(region, expectedGeneration, expectedOwnerSession) {
if (!this.active || this.busy || !this.catalog || !this.operation || !region?.available ||
!this.catalog.regions.some((item) => item.token === region.token && item.available) ||
!["idle", "completed", "canceled", "failed", "interrupted"].includes(this.operation.state)) {
this.actionError = "Map operation changed. Refresh status and review again."
this.emit()
return
}
if (this.operation.selectedGeneration !== expectedGeneration || this.operation.ownerSession !== expectedOwnerSession) {
this.actionError = "Map selection changed. Review this region again."
this.emit()
return
}
const expected = this.operation.selectedGeneration
this.pausePolling()
const generation = this.generation
this.busy = true
this.actionError = ""
this.emit()
try {
const data = await this.request("./api/maps/start", { regionToken: region.token,
maxTransferBytes: this.catalog.maxTransferBytes, maxNewDiskBytes: this.catalog.maxNewDiskBytes,
expectedCurrentGeneration: expected })
if (data === null) return
if (!validOperation(data)) throw new Error("Map start status is unavailable. Refresh status.")
this.operation = data
this.emit()
} catch (error) { if (this.active && generation === this.generation) { this.actionError = error.message; this.emit() } }
finally { if (this.active && generation === this.generation) { this.busy = false; this.emit(); await this.loadStatus() } }
}
async cancel() {
if (!this.active || this.busy || !this.operation?.operationId ||
!["transferring", "validating", "selecting"].includes(this.operation.state)) return
const operationId = this.operation.operationId
this.pausePolling()
const generation = this.generation
this.busy = true
this.actionError = ""
this.emit()
try {
const data = await this.request("./api/maps/cancel", { operationId })
if (data === null) return
if (!validOperation(data)) throw new Error("Cancellation status is unavailable. Refresh status.")
this.operation = data
this.emit()
} catch (error) { if (this.active && generation === this.generation) { this.actionError = error.message; this.emit() } }
finally { if (this.active && generation === this.generation) { this.busy = false; this.emit(); await this.loadStatus() } }
}
}
export const MapOperationsPanel = {
components: { GxDialog, GxNotice },
name: "MapOperationsPanel",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ setup: null, catalog: null, operation: null, busy: false, error: "", search: "", review: null }),
created() { this.client = new MapOperationsClient({ publish: (update) => {
Object.assign(this.$data, update)
if (update.operation === null || update.error) this.review = null
}, unauthorized: () => { this.review = null; this.unauthorized() } }) },
mounted() {
this.visibility = () => { if (document.hidden) { this.review = null; this.client.stop() }
else if (this.mode === "local") this.client.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.client.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client.stop() },
computed: {
regions() {
const query = this.search.trim().toLocaleLowerCase()
return (this.catalog?.regions || []).filter((item) => !query ||
`${item.name} ${item.token}`.toLocaleLowerCase().includes(query))
},
running() { return ["transferring", "validating", "selecting"].includes(this.operation?.state) },
},
methods: {
formatBytes, formatBounds,
errorLabel(code) { return ERROR_LABELS[code] || "Review this operation in Galaxy" },
reviewRegion(region) {
if (!this.busy && region.available && this.operation)
this.review = { region, selectedGeneration: this.operation.selectedGeneration, ownerSession: this.operation.ownerSession }
},
async confirm() {
const review = this.review
this.review = null
if (review) await this.client.startRegion(review.region, review.selectedGeneration, review.ownerSession)
},
},
template: `
<section class="gx-card gx-map-manager" :inert="busy" :aria-busy="busy || undefined">
<div class="gx-section__header"><i class="bi bi-map"></i><span class="gx-section__title">Speed Limit Maps</span></div>
<div class="gx-map-manager__body">
<p class="gx-note">Speed-limit and curve data for a whole region, used by the map provider. Choose a region to download for offline use. Downloading another region replaces the selected map.</p>
<p v-if="mode !== 'local'">Parked map management is unavailable in the offline preview.</p>
<template v-else>
<template v-if="setup"><p role="status">{{ setup.packageReady ? 'Map service ready.' : 'Map service setup required.' }} {{ setup.snapshotReady ? 'An offline map database is saved and selected for the next Maps service start.' : setup.packageReady ? 'Choose a region to download its maps.' : 'Update device software to set up offline maps.' }}</p>
<p v-if="!setup.packageReady" class="gx-note">The offline map service is unavailable. Update device software, then retry maps. Your saved map selection is retained.</p>
<p class="gx-note">{{ formatBytes(setup.freeDiskBytes) }} available storage. {{ setup.parked ? 'Parked downloads available.' : 'Park to download maps.' }}</p></template>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<p v-if="!operation && !error" role="status">Loading map operation…</p>
<template v-if="operation">
<dl class="gx-details"><dt>Operation</dt><dd>{{ operation.state }}</dd>
<dt>Selected map</dt><dd>{{ operation.selectedGeneration ? 'Saved selection' : 'None' }}</dd>
<dt v-if="operation.regionToken">Region</dt><dd v-if="operation.regionToken">{{ operation.regionToken }}</dd>
<dt v-if="running">Progress</dt><dd v-if="running">{{ operation.completedGroups }} of {{ operation.totalGroups }} areas prepared</dd>
<dt v-if="running">Transferred</dt><dd v-if="running">{{ formatBytes(operation.transferredBytes) }} of {{ formatBytes(operation.transferBudgetBytes) }} cap</dd></dl>
<dl v-if="operation.selectedSnapshot" class="gx-details"><dt>Offline map database</dt><dd>{{ formatBytes(operation.selectedSnapshot.storedBytes) }} · {{ operation.selectedSnapshot.tileCount.toLocaleString() }} tiles</dd>
<dt>Package</dt><dd>{{ operation.selectedGeneration.slice(0, 12) }}</dd>
<dt>Coverage</dt><dd>{{ formatBounds(operation.selectedSnapshot.bounds) }}</dd></dl>
<p v-if="operation.selectedForNextShadowStart" class="gx-note">Download complete and saved on this comma. The map database is selected for the next Maps service start.</p>
<p v-if="operation.selectedGeneration && !operation.selectedForNextShadowStart" class="gx-note">An offline map database is saved and selected for the next Maps service start.</p>
<p v-if="operation.errorCode" class="gx-note">{{ errorLabel(operation.errorCode) }}.</p>
<button v-if="running" type="button" class="gx-btn gx-btn--tonal" @click="client.cancel()">Cancel operation</button>
</template>
<div class="gx-map-manager__picker"><label for="map-region-search">Search regions</label>
<input id="map-region-search" class="gx-field" type="search" v-model="search" placeholder="Country or US state"></div>
<p v-if="!catalog && setup?.packageReady && !error" role="status">Loading map catalog…</p>
<template v-if="catalog"><p class="gx-note">{{ regions.length }} regions shown · Up to {{ formatBytes(catalog.maxTransferBytes) }} transfer · {{ formatBytes(catalog.maxNewDiskBytes) }} new storage.</p>
<div class="gx-map-manager__regions">
<div v-for="region in regions" :key="region.token" class="gx-map-manager__region">
<div><strong>{{ region.name }}</strong><small>{{ region.token }} · {{ region.groups }} groups</small>
<small v-if="!region.available">Unavailable: {{ region.unavailable.replaceAll('_', ' ') }}</small></div>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="running || !region.available || !operation" @click="reviewRegion(region)">Review…</button>
</div>
</div></template>
</template>
</div>
<GxDialog v-if="review" :inert="busy" labelledby="gx-map-operations-confirm-title" @close="review=null"><h3 id="gx-map-operations-confirm-title">Replace Selected Map?</h3>
<p><strong>{{ review.region.name }}</strong> covers an approximate rectangle ({{ formatBounds(review.region.bounds) }}), {{ review.region.groups }} groups. This can transfer up to {{ formatBytes(catalog.maxTransferBytes) }} and use up to {{ formatBytes(catalog.maxNewDiskBytes) }} of new disk space. The result is selected for the next map service start.</p>
<div class="gx-settings__controls gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="review=null">Cancel</button>
<button type="button" class="gx-btn" :disabled="running || !operation" @click="confirm">Start download</button></div>
</GxDialog>
</section>`,
}
@@ -0,0 +1,193 @@
import { GxState } from "./state.js"
import { guardUnload } from "./unload-guard.js"
import { GxSummary } from "./summary.js"
import { GxNotice } from "./notice.js"
import { GalaxySelect } from "./galaxy-select.js"
import { ModelManagerFeed } from "./models.js"
export const laboratoryModels = data => data?.models || []
export const laboratoryReady = data => laboratoryModels(data).filter(m => m.small === true && m.modelLabEligible === true && m.modelLabArtifactAvailable === true && m.modelLabArtifactInstalled === true)
export const laboratoryStatusLabel = model => ({
unsupported: "Not supported", unpublished: "Not published", checking: "Checking download",
missing: "Not downloaded", "runtime-unavailable": "Downloaded", "gpu-unavailable": "Connect Chestnut",
installed: "Downloaded",
})[model.modelLabStatus] || (model.modelLabArtifactInstalled ? "Downloaded" : "Not downloaded")
export function laboratorySelectionError(data, configuration) {
if (!data) return "Waiting for parked device status"
if (data.isOnroad !== false) return "Turn off the vehicle before changing the model pair"
if (!data.runtimeSupported) return data.runtimeUnavailableReason || "Model Laboratory runtime is unavailable"
if (!data.chestnutReady) return "Connect a firmware-ready Chestnut to enable a pair"
const ready = laboratoryReady(data)
if (ready.length < 2) return "Download at least two eGPU variants to compose a pair"
if (!configuration.lateralModel || !configuration.longitudinalModel) return "Choose two downloaded eGPU variants"
if (configuration.lateralModel === configuration.longitudinalModel) return "Choose two different models"
if (![configuration.lateralModel, configuration.longitudinalModel].every(id => ready.some(m => m.value === id))) return "Both models must have downloaded eGPU variants"
return ""
}
export function laboratoryActionAllowed(data, action, model, configuration = data?.configuration) {
if (!data || data.capabilities?.[action === "enable" || action === "disable" ? "configure" : action] !== true) return false
if (action === "cancel") return data.download?.downloading === true && typeof data.download.jobId === "string"
if (data.isOnroad !== false || data.download?.downloading) return false
if (action === "enable") return !laboratorySelectionError(data, configuration)
if (action === "disable") return data.configuration.enabled === true
if (action === "download") return laboratoryModels(data).some(m => m.value === model?.value && m.small === true && m.modelLabEligible === true && m.modelLabArtifactAvailable === true && !m.modelLabArtifactInstalled)
if (action === "delete") return laboratoryReady(data).some(m => m.value === model?.value) &&
!(data.configuration.enabled && [data.configuration.lateralModel, data.configuration.longitudinalModel].includes(model.value))
return action === "refresh"
}
export function laboratoryDraft(data, draft, dirty) {
const configuration = { ...data.configuration, ...(dirty ? { lateralModel: draft.lateralModel, longitudinalModel: draft.longitudinalModel } : {}) }
if (!dirty) {
const ready = laboratoryReady(data)
if (!configuration.lateralModel) configuration.lateralModel = ready[0]?.value || ""
if (!configuration.longitudinalModel) configuration.longitudinalModel = ready.find(m => m.value !== configuration.lateralModel)?.value || ""
}
return configuration
}
function validPayload(data) {
return data?.schemaVersion === 1 && typeof data.isOnroad === "boolean" && typeof data.chestnutReady === "boolean" &&
typeof data.runtimeSupported === "boolean" && typeof data.configuration?.enabled === "boolean" &&
[data.configuration.lateralModel, data.configuration.longitudinalModel].every(id => typeof id === "string") &&
data.capabilities && typeof data.capabilities === "object" && typeof data.download?.downloading === "boolean" &&
typeof data.runtime?.active === "boolean" && typeof data.runtime?.requested === "boolean" && Array.isArray(data.models) &&
data.models.every(m => typeof m?.value === "string" && typeof m.label === "string") &&
new Set(data.models.map(m => m.value)).size === data.models.length
}
export class LaboratoryFeed extends ModelManagerFeed {
stop() {
if (this.expiry != null) this.cancel(this.expiry)
this.expiry = null
super.stop()
}
async load() {
if (!this.active || this.saving) return null
const data = await this.requestJson("laboratory")
if (!this.active) return null
if (data) {
this.data = validPayload(data) ? data : null
this.publish({ loading: false, data: this.data, error: this.data ? "" : "Invalid Model Laboratory response" })
if (this.expiry != null) this.cancel(this.expiry)
this.expiry = this.later(() => {
this.data = null
this.publish({ loading: false, data: null, error: "Laboratory status refresh delayed" })
}, 6000)
}
if (this.poll === null) this.poll = this.later(() => this.load(), 2000)
return this.data
}
async action(action, model = null, configuration) {
if (!this.active || this.saving || !laboratoryActionAllowed(this.data, action, model, configuration)) return null
const paths = { enable: "laboratory", disable: "laboratory", download: "laboratory/download", delete: "laboratory/delete", cancel: "cancel", refresh: "refresh_manifest" }
const payload = action === "enable" || action === "disable" ? { ...configuration, enabled: action === "enable" } :
action === "cancel" ? { jobId: this.data.download.jobId } : model ? { model: model.value } : {}
this.saving = true
const result = await this.requestJson(paths[action], payload)
if (!this.active) return null
this.saving = false
const error = this.lastError
await this.load()
if (error && this.active) this.publish({ loading: false, data: this.data, error })
return result
}
}
export const LaboratoryPage = {
components: { GxState, GxSummary, GxNotice, GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ loading: true, status: null, error: "", message: "", busy: false, dirty: false, trackingProgress: false,
configuration: { enabled: false, lateralModel: "", longitudinalModel: "" } }),
computed: {
availableModels() { return laboratoryModels(this.status) },
readyModels() { return laboratoryReady(this.status) },
candidates() { return this.readyModels.filter(m => m.value !== this.configuration.lateralModel) },
selectionError() { return laboratorySelectionError(this.status, this.configuration) },
runtimeState() { return !this.status ? "Status unavailable" : !this.status.runtimeSupported ? "Unavailable" : this.status.runtime.active ? "Pair active" : this.status.runtime.requested ? "Pair requested" : "Inactive" },
},
created() {
this.feed = new LaboratoryFeed({ unauthorized: this.unauthorized, publish: update => {
this.loading = update.loading
this.error = update.error
this.status = update.data
if (update.data) {
this.configuration = laboratoryDraft(update.data, this.configuration, this.dirty)
if (this.trackingProgress && update.data.download.progress) {
this.message = update.data.download.progress
if (!update.data.download.downloading) this.trackingProgress = false
}
}
} })
},
mounted() {
this._stopUnloadGuard = guardUnload(() => this.dirty || this.busy)
this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { this._stopUnloadGuard(); document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
methods: {
statusLabel: laboratoryStatusLabel,
actionAvailable(action, model = null) { return laboratoryActionAllowed(this.status, action, model, this.configuration) },
can(action, model = null) { return !this.busy && this.actionAvailable(action, model) },
modelLabel(id) { return this.status?.models.find(m => m.value === id)?.label || id || "Not selected" },
changeLateral(value) {
this.configuration.lateralModel = value
if (this.configuration.longitudinalModel === value) this.configuration.longitudinalModel = this.candidates[0]?.value || ""
this.dirty = true
},
async act(action, model = null) {
if (!this.can(action, model)) return
if (action === "delete" && !window.confirm(`Delete the eGPU variant for "${model.label}"? The normal on-device model will not be removed.`)) return
this.busy = true
this.message = ""
try {
const result = await this.feed.action(action, model, this.configuration)
if (result) {
this.message = result.message || "Updated"
this.trackingProgress = action === "download" || action === "cancel"
if (["enable", "disable", "delete"].includes(action)) {
this.dirty = false
if (this.status) this.configuration = laboratoryDraft(this.status, this.configuration, false)
}
}
} finally { this.busy = false }
},
},
template: `
<div class="gx-settings gx-models" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Model Laboratory</h2>
<p>Pair lateral judgment from one model with longitudinal judgment from another. Enabling applies when modeld next starts, when it loads and verifies the model weights, input tensors and GPU state.</p></div>
<div class="gx-actions"><span class="gx-chip">{{ status ? (status.chestnutReady ? 'Chestnut ready' : 'Chestnut required') : 'Waiting for device status' }}</span><span v-if="status" class="gx-chip">{{ status.isOnroad ? 'Onroad' : 'Parked' }}</span></div></header>
<GxState v-if="mode !== 'local'">Model Laboratory is available on the device. Preview does not configure a pair.</GxState>
<GxState v-else-if="loading" loading>Loading Model Laboratory…</GxState>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxNotice tone="danger" v-if="status?.configurationError">{{ status.configurationError }}</GxNotice>
<GxNotice v-if="message" class="gx-settings__save-status" tone="info">{{ message }}</GxNotice>
<template v-if="mode === 'local' && !loading">
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-download"></i><span class="gx-section__title">Available models</span></div>
<GxSummary :items="[{label: 'Catalog models', value: availableModels.length}, {label: 'Published versions', value: status?.summary?.published || 0}, {label: 'Verified downloads', value: readyModels.length}]" />
<p class="gx-note gx-inset">Browse Small and Big models here. Combining models requires compatible Chestnut downloads and support for running them together.</p>
<p v-if="status && !availableModels.length" class="gx-note gx-inset">The model catalog is unavailable. Refresh to retry.</p>
<article v-for="m in availableModels" :key="m.value" class="gx-row gx-row--wrap"><div class="gx-row__info gx-row__info--wide"><span class="gx-row__label">{{ m.label }}</span><span class="gx-row__desc">{{ m.value }} · {{ m.small ? 'Small' : 'Big' }} · {{ m.series || 'Unknown series' }} · {{ m.version }}</span><span class="gx-row__desc">{{ m.modelLabReason || 'Pair artifact status unavailable' }}</span></div><div class="gx-actions"><span class="gx-chip">{{ statusLabel(m) }}</span><button v-if="m.small && m.modelLabEligible && m.modelLabArtifactAvailable && !m.modelLabArtifactInstalled" class="gx-btn gx-btn--tonal" :disabled="!actionAvailable('download',m)" @click="act('download',m)">Download eGPU variant</button><button v-else-if="m.modelLabArtifactInstalled" class="gx-btn gx-btn--tonal" :disabled="!actionAvailable('delete',m)" @click="act('delete',m)">Delete eGPU variant</button></div></article>
<div v-if="status?.download.downloading" class="gx-row"><div class="gx-row__info"><span class="gx-row__desc">{{ status.download.progress || 'Downloading…' }}</span></div><button class="gx-btn gx-btn--tonal" :disabled="!actionAvailable('cancel')" @click="act('cancel')">Cancel download</button></div>
</section>
<section class="gx-card">
<div class="gx-section__header"><i class="bi bi-collection"></i><span class="gx-section__title">Compose a pair</span><span class="gx-chip">{{ configuration.enabled ? 'Enabled' : 'Disabled' }}</span></div>
<div class="gx-panel gx-stack">
<label class="gx-field-group"><strong>Lateral model</strong><small>Path shape, curvature, lane geometry, and driving desire</small><GalaxySelect class="gx-field" aria-label="Lateral model" :disabled="!status || status.isOnroad || status.download.downloading" :value="configuration.lateralModel" @change="changeLateral($event.target.value)"><option value="">Choose a model</option><option v-for="m in readyModels" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option></GalaxySelect></label>
<label class="gx-field-group"><strong>Longitudinal model</strong><small>Speed, acceleration, stopping, leads, and scene confidence</small><GalaxySelect class="gx-field" aria-label="Longitudinal model" :disabled="!status || status.isOnroad || status.download.downloading" :value="configuration.longitudinalModel" @change="configuration.longitudinalModel=$event.target.value;dirty=true"><option value="">Choose a model</option><option v-for="m in candidates" :key="m.value" :value="m.value">{{ m.label }} · {{ m.version }}</option></GalaxySelect></label>
<p class="gx-row__desc">{{ modelLabel(configuration.lateralModel) }} steers · {{ modelLabel(configuration.longitudinalModel) }} paces</p>
<p v-if="selectionError" class="gx-row__desc">{{ selectionError }}</p>
<div class="gx-actions"><button class="gx-btn" :disabled="!actionAvailable('enable')" @click="act('enable')">Enable for next drive</button><button class="gx-btn gx-btn--tonal" :disabled="!actionAvailable('disable')" @click="act('disable')">Disable</button><button class="gx-btn gx-btn--tonal" :disabled="!actionAvailable('refresh')" @click="act('refresh')">Check model catalog</button></div>
</div>
</section>
<section class="gx-card"><div class="gx-section__header"><i class="bi bi-activity"></i><span class="gx-section__title">Runtime</span><span class="gx-chip">{{ runtimeState }}</span></div><div class="gx-row"><div class="gx-row__info"><span class="gx-row__desc">Lateral: {{ modelLabel(status?.runtime.lateralModel) }}</span><span class="gx-row__desc">Longitudinal: {{ modelLabel(status?.runtime.longitudinalModel) }}</span><span v-if="status?.runtime.error" class="gx-note gx-note--danger">{{ status.runtime.error }}</span><span v-if="status && !status.runtimeSupported" class="gx-row__desc">{{ status.runtimeUnavailableReason || 'Pair runtime is unavailable.' }}</span></div></div></section>
</template>
</div>`,
}
+627
View File
@@ -0,0 +1,627 @@
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { GxSummary } from "./summary.js"
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { GalaxySelect } from "./galaxy-select.js"
// Read-only model identity; a saved label never proves what modeld loaded.
export class ModelStatusFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancel })
this.active = false
this.generation = 0
this.displayGeneration = 0
this.request = null
this.poll = null
this.expiry = null
}
stop() {
this.active = false
this.generation++
this.displayGeneration++
this.request?.abort()
this.request = null
if (this.poll !== null) this.cancel(this.poll)
if (this.expiry !== null) this.cancel(this.expiry)
this.poll = this.expiry = null
this.publish({ status: "idle", data: null, error: "" })
}
start() {
this.stop()
this.active = true
return this.load()
}
async load() {
if (!this.active) return
const generation = ++this.generation
this.request?.abort()
if (this.poll !== null) this.cancel(this.poll)
this.poll = null
const request = new AbortController()
this.request = request
const timeout = this.later(() => request.abort(), 2500)
try {
const response = await this.fetcher("./api/models/status", { signal: request.signal, cache: "no-store" })
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401) {
this.stop()
this.unauthorized()
return
}
if (!response.ok) throw new Error("Model status is unavailable")
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return
const states = ["unavailable", "loading", "identity-unavailable", "active", "stale", "failed"]
const variants = [null, "small", "chestnut"]
const digest = data?.artifactSha256
const ids = Array.isArray(data?.catalog) ? data.catalog.map(m => m?.id) : []
if (data?.schemaVersion !== 1 || !ids.length || new Set(ids).size !== ids.length ||
data.catalog.some(m => typeof m.id !== "string" || !m.id || typeof m.name !== "string" || typeof m.selectable !== "boolean") ||
!ids.includes(data.requestedId) || !(data.loadedId === null || ids.includes(data.loadedId)) || !variants.includes(data.variant) ||
!states.includes(data.health) || ![null, "chestnut-load-failed", "chestnut-run-stalled", "selected-load-failed"].includes(data.fallbackReason) ||
!(digest === null || typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest)) ||
typeof data.pendingNextStart !== "boolean" ||
(data.pendingNextStart && (data.loadedId === null || data.requestedId === data.loadedId)) ||
(data.loadedId === null ? data.variant !== null || digest !== null : data.variant === null || digest === null) ||
(data.health === "active" && data.loadedId === null)) {
throw new Error("Invalid model status")
}
this.publish({ status: "ready", data, error: "" })
if (this.expiry !== null) this.cancel(this.expiry)
const displayGeneration = ++this.displayGeneration
this.expiry = this.later(() => {
if (this.active && displayGeneration === this.displayGeneration) {
this.publish({ status: "stale", data: null, error: "Model status refresh delayed" })
}
}, 1500)
} catch (error) {
if (this.active && generation === this.generation) {
this.displayGeneration++
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
this.publish({ status: "unavailable", data: null, error: error.message })
}
} finally {
this.cancel(timeout)
if (generation === this.generation) this.request = null
if (this.active && generation === this.generation) this.poll = this.later(() => this.load(), 1000)
}
}
}
const FILTER_KEY = "galaxy.modelManager.hardwareFilter";
const FILTERS = new Set(["both", "gpu", "comma"]);
function readHardwareFilter() {
try {
const value = localStorage.getItem(FILTER_KEY);
return FILTERS.has(value) ? value : "both";
} catch {
return "both";
}
}
function saveHardwareFilter(value) {
const filter = FILTERS.has(value) ? value : "both";
try { localStorage.setItem(FILTER_KEY, filter); } catch {}
return filter;
}
function matchesHardware(model, filter) {
if (filter === "gpu") return model?.requiresGpu === true;
if (filter === "comma") return model?.requiresGpu === false;
return true;
}
function hardwareLabel(model) {
if (model?.requiresGpu === true) return "GPU · External";
if (model?.requiresGpu === false) return "Comma · On-device";
return "Hardware unknown";
}
function bytes(value) {
return Number.isSafeInteger(value) && value > 0 ? value : null;
}
function formatBytes(value) {
return value >= 1e9 ? `${(value / 1e9).toFixed(2)} GB` : `${(value / 1e6).toFixed(1)} MB`;
}
function fileSizeText(model) {
const installed = bytes(model?.fileSizeBytes);
const declared = bytes(model?.declaredSizeBytes || model?.artifactSize);
const downloaded = bytes(model?.downloadedBytes);
if (model?.partial || model?.sizeStatus === "partial") {
return `Partial: ${downloaded ? formatBytes(downloaded) : "unknown"} / ${declared ? formatBytes(declared) : "unknown"}`;
}
if (installed && declared && installed !== declared) return `${formatBytes(installed)} · size mismatch`;
if (installed) return formatBytes(installed);
if (declared) return `${formatBytes(declared)} · declared`;
return "Unavailable";
}
const actionPaths = { "select-small": "active", "select-big": "active", favorite: "preferences", unfavorite: "preferences",
"enable-randomizer": "preferences", "disable-randomizer": "preferences", exclude: "preferences", include: "preferences",
download: "download", downloadAll: "download_all", cancel: "cancel", delete: "delete", refresh: "refresh_manifest", jetlink: "jetlink" }
const actionCapabilities = { "select-small": "select", "select-big": "select", favorite: "favorites", unfavorite: "favorites",
"enable-randomizer": "randomizer", "disable-randomizer": "randomizer", exclude: "exclusions", include: "exclusions",
download: "download", downloadAll: "downloadAll", cancel: "cancel", delete: "delete", refresh: "refresh", jetlink: "jetlink" }
export function fitsModelProfile(model, profile) {
return Array.isArray(model?.profiles) ? model.profiles.includes(profile) : profile === "big" ? model?.requiresGpu === true : model?.requiresGpu === false
}
export function modelActionAllowed(data, action, model = null) {
if (!data || data.capabilities?.[actionCapabilities[action]] !== true) return false
if (data.isOnroad !== false && !["favorite", "unfavorite", "cancel"].includes(action)) return false
if (action === "jetlink") return data.jetlink !== undefined
if (["select-small", "select-big"].includes(action)) {
if (data.downloading || data.randomizer === true) return false
if (action === "select-big" && model === null) return true
return model?.installed === true && model.selectable === true && fitsModelProfile(model, action === "select-big" ? "big" : "small")
}
if (action === "download") return !data.downloading && !!model && !model.installed && model.downloadAvailable === true
if (action === "downloadAll") return !data.downloading && data.models.some(m => !m.installed && m.downloadAvailable === true)
if (action === "cancel") return data.downloading === true
if (action === "delete") return !data.downloading && !!model && !model.builtin && model.deletable !== false &&
(model.installed || model.partial) && ![data.currentModel, data.activeSmallModel, data.activeBigModel].includes(model.value)
if (["exclude", "include"].includes(action)) return !!model
if (["enable-randomizer", "disable-randomizer"].includes(action)) return !data.downloading
return ["favorite", "unfavorite"].includes(action) ? !!model : action === "refresh" && !data.downloading
}
export class ModelManagerFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancel })
this.active = false
this.generation = 0
this.request = this.poll = this.expiry = null
this.data = null
this.saving = false
this.lastError = ""
}
stop() {
this.active = false
this.generation++
this.request?.abort()
this.request = null
if (this.poll !== null) this.cancel(this.poll)
this.poll = null
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
this.data = null
this.saving = false
this.publish({ loading: false, data: null, error: "" })
}
start() {
this.stop()
this.active = true
return this.load()
}
async requestJson(path, payload) {
const generation = ++this.generation
if (payload) this.lastError = ""
this.request?.abort()
if (this.poll !== null) this.cancel(this.poll)
this.poll = null
const request = new AbortController()
this.request = request
const timeout = this.later(() => {
if (!this.active || generation !== this.generation) return
request.abort()
this.generation++
this.request = null
this.data = null
this.saving = false
this.lastError = payload ? "The result is unknown. Checking automatically…" : "Model Manager is unavailable. Reconnecting automatically…"
this.publish({ loading: false, data: null, error: this.lastError })
this.poll = this.later(() => this.load(), 2000)
}, 10000)
try {
const response = await this.fetcher(`./api/models/${path}`, { signal: request.signal, cache: "no-store", credentials: "same-origin",
...(payload ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) } : {}) })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(data?.code)) {
this.stop()
this.unauthorized()
return null
}
if (!response.ok) throw new Error(data?.message || data?.error || "Model request failed")
return data
} catch (error) {
if (this.active && generation === this.generation) {
this.data = null
this.lastError = connectionError(error)
this.publish({ loading: false, data: null, error: this.lastError })
}
return null
} finally {
this.cancel(timeout)
if (this.request === request) this.request = null
}
}
async load() {
if (!this.active || this.saving) return null
if (!this.data && !this.lastError) this.publish({ loading: true, data: null, error: "" })
const data = await this.requestJson("manager")
if (!this.active) return null
if (data) {
if (data.schemaVersion !== 1 || !Array.isArray(data.models) ||
data.models.some(m => !m || typeof m.value !== "string" || typeof m.label !== "string") ||
typeof data.isOnroad !== "boolean" || typeof data.capabilities !== "object" || data.capabilities === null) {
this.data = null
this.publish({ loading: false, data: null, error: "Invalid Model Manager response" })
} else {
this.data = data
this.lastError = ""
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
if (data.jetlink?.active === true) {
this.expiry = this.later(() => {
this.expiry = null
if (!this.active || this.data !== data) return
this.data = { ...data, jetlink: { ...data.jetlink, active: false, state: "checking execution", runtimeState: "unavailable" } }
this.publish({ loading: false, data: this.data, error: "" })
}, 1500)
}
this.publish({ loading: false, data, error: "" })
}
}
if (this.poll === null) this.poll = this.later(() => this.load(), this.data?.jetlink?.mode !== "off" && this.data?.jetlink ? 1000 : 2000)
return this.data
}
async action(action, model = null, extra = {}) {
if (!this.active || this.saving || !this.data) return null
if (model !== null) {
model = this.data.models.find(m => m.value === model.value)
if (!model) return null
}
if (!modelActionAllowed(this.data, action, model)) return null
const data = this.data
const key = model?.value || ""
let payload = action === "jetlink" ? extra : {}
if (action.startsWith("select-")) payload = { profile: action === "select-big" ? "big" : "small", model: key }
else if (["favorite", "unfavorite"].includes(action)) {
const userFavorites = data.models.filter(m => m.userFavorite && m.value !== key).map(m => m.value)
if (action === "favorite") userFavorites.push(key)
payload = { userFavorites }
} else if (["exclude", "include"].includes(action)) {
const blacklistedModels = (data.blacklistedModels || []).filter(value => value !== key)
if (action === "exclude") blacklistedModels.push(key)
payload = { blacklistedModels }
} else if (["enable-randomizer", "disable-randomizer"].includes(action)) payload = { randomizer: action === "enable-randomizer" }
else if (["download", "delete"].includes(action)) payload = { model: key }
if (["download", "downloadAll"].includes(action)) payload.allowGpuWithoutGpu = extra.allowGpuWithoutGpu === true
if (typeof data.view === "string") payload.view = data.view
this.saving = true
this.data = null
const result = await this.requestJson(actionPaths[action], payload)
if (!this.active) return null
this.saving = false
const error = this.lastError
await this.load()
if (error && this.active) this.publish({ loading: false, data: this.data, error })
return result
}
}
export const ModelsPage = {
name: "ModelsPage",
components: { GxDialog, GxState, GxSummary, GxIconButton, GalaxySelect, GxNotice },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data() {
return { loading: true, error: "", message: "", trackingProgress: false, busy: "", selectionUncertain: true, disposed: false,
sortMode: "release_date", userFilter: "all", communityFilter: "all", hardwareFilter: readHardwareFilter(),
models: [], currentModel: "", activeSmallModel: "", activeBigModel: "", capabilities: {},
summary: { installed: 0, missing: 0, total: 0 }, status: { isOnroad: true },
runtime: { status: "idle", data: null, error: "" }, dialog: null, pendingChoice: null }
},
computed: {
currentLabel() { return this.models.find(m => m.value === this.currentModel)?.label || this.currentModel || "none" },
installedSmallModels() { return this.models.filter(m => (m.value === this.activeSmallModel || m.installed && m.selectable) && fitsModelProfile(m, "small")) },
installedBigModels() { return this.models.filter(m => (m.value === this.activeBigModel || m.installed && m.selectable) && fitsModelProfile(m, "big")) },
sorted() {
return this.models.filter(m => matchesHardware(m, this.hardwareFilter))
.filter(m => this.userFilter === "all" || !!m.userFavorite === (this.userFilter === "yes"))
.filter(m => this.communityFilter === "all" || !!m.communityFavorite === (this.communityFilter === "yes"))
.sort((a, b) => (this.sortMode === "release_date" ? (Date.parse(b.released) || 0) - (Date.parse(a.released) || 0) : 0) ||
(a.label || a.value).localeCompare(b.label || b.value, undefined, { sensitivity: "base", numeric: true }))
},
downloadTargetLabel() { return this.status.downloadAll ? "all missing models" : this.models.find(m => m.value === this.status.modelToDownload)?.label || "a model" },
},
created() {
this.feed = new ModelStatusFeed({ publish: update => { this.runtime = update }, unauthorized: this.unauthorized })
this.manager = new ModelManagerFeed({ publish: update => {
this.loading = update.loading
this.error = update.error
this.selectionUncertain = !update.data
this.capabilities = update.data?.capabilities || {}
if (!update.data && this.status.jetlink) this.status = { ...this.status, jetlink: { ...this.status.jetlink, active: false, state: "unavailable" } }
if (update.data) {
const p = update.data
this.models = p.models
this.currentModel = p.currentModel || ""
this.activeSmallModel = p.activeSmallModel || ""
this.activeBigModel = p.activeBigModel || ""
this.summary = p.summary || { installed: 0, missing: 0, total: 0 }
this.status = p
if (this.trackingProgress && p.progress) {
this.message = p.progress
if (!p.downloading) this.trackingProgress = false
}
}
}, unauthorized: this.unauthorized })
},
mounted() {
this.visibility = () => {
if (document.hidden) { this.finishDialog(false); this.feed.stop(); this.manager.stop() }
else if (this.mode === "local") { this.feed.start(); this.manager.start() }
}
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) { this.feed.start(); this.manager.start() }
},
beforeUnmount() {
this.disposed = true
this.finishDialog(false)
document.removeEventListener("visibilitychange", this.visibility)
this.feed.stop()
this.manager.stop()
},
methods: {
hardwareLabel, fileSizeText,
setHardwareFilter(value) { this.hardwareFilter = saveHardwareFilter(value) },
actionAvailable(action, model = null) { return !this.selectionUncertain && modelActionAllowed(this.status, action, model) },
canAction(action, model = null) { return !this.busy && this.actionAvailable(action, model) },
rowState(model) {
if ([this.currentModel, this.activeSmallModel, this.activeBigModel].includes(model.value)) return "active"
if (this.status.downloading) return this.status.downloadAll || this.status.modelToDownload === model.value ? "cancellable" : "busy"
return model.installed ? "installed" : "available"
},
confirmAction(dialog) {
return new Promise(resolve => {
this.dialog = dialog
this.resolveDialog = resolve
this.$nextTick(() => this.$refs.confirmButton?.focus())
})
},
finishDialog(value) {
this.resolveDialog?.(value)
this.resolveDialog = null
this.dialog = null
},
async configureJetlink(mode, chargePhone = this.status.jetlink?.chargePhone === true) {
if (!this.canAction("jetlink")) return
this.pendingChoice = { action: "jetlink", value: mode }
this.busy = "jetlink:"
try {
const result = await this.manager.action("jetlink", null, { mode, chargePhone })
if (result) this.message = result.message
} finally { this.pendingChoice = null; this.busy = "" }
},
async runAction(action, model = null) {
if (this.disposed || !this.canAction(action, model)) return
if (action.startsWith("select-")) this.pendingChoice = { action, value: model?.value || "" }
this.busy = `${action}:${model?.value || ""}`
this.message = ""
this.trackingProgress = false
try {
let allowGpuWithoutGpu = false
if (action === "enable-randomizer" && !await this.confirmAction({ title: "Model Randomizer", message: "Choose a different installed, verified model each start. Excluded models are skipped. Favorites do not change the random pool.", confirmLabel: "Enable" })) return
if (action === "delete" && !await this.confirmAction({ title: "Delete Model", message: `Delete local files for "${model.label}"?`, confirmLabel: "Delete", danger: true })) return
const needsGpu = action === "download" ? model.requiresGpu && !model.gpuAvailable : action === "downloadAll" &&
this.models.some(m => !m.installed && m.requiresGpu && !m.gpuAvailable && m.downloadAvailable === true)
if (needsGpu) {
allowGpuWithoutGpu = await this.confirmAction({ title: "No External GPU Detected", confirmLabel: "Download Anyway",
message: "These model files require an external GPU. You can download them now, but they cannot run until a compatible GPU is connected and detected. GPU model files can be large. Downloading does not activate a model." })
if (!allowGpuWithoutGpu) return
}
if (this.disposed) return
const result = await this.manager.action(action, model, { allowGpuWithoutGpu })
if (!this.disposed && result) {
this.trackingProgress = ["download", "downloadAll", "refresh"].includes(action)
this.message = (this.trackingProgress && this.status.progress) || result.message ||
(action.startsWith("select-") ? "Selection saved for the next start." : "Saved.")
}
} finally { if (!this.disposed) { this.pendingChoice = null; this.busy = "" } }
},
},
template: `
<div class="gx-view gx-model-manager" :inert="!!busy" :aria-busy="!!busy || undefined">
<GxState v-if="mode !== 'local'">Model Manager is unavailable in the offline preview.</GxState>
<GxState v-else-if="loading" loading>Reading installed models and available downloads…</GxState>
<template v-else>
<header class="gx-settings__header gx-page-header"><div><h2>Model Manager</h2>
<p>Bundled model files, download state, and runtime health.</p></div></header>
<GxNotice v-if="message" class="gx-settings__save-status" tone="info">{{ message }}</GxNotice>
<GxNotice v-if="error" tone="danger" title="Model Manager">{{ error }}</GxNotice>
<GxNotice v-if="status.isOnroad" tone="warn">Park the vehicle before changing or downloading models.</GxNotice>
<GxNotice v-if="status.downloading" :title="'Downloading ' + downloadTargetLabel">{{ status.progress || 'Keep the device connected until the download finishes.' }}</GxNotice>
<section class="gx-card">
<div class="gx-section__header">
<i aria-hidden="true" class="bi bi-cpu"></i>
<span class="gx-section__title">Installed models</span>
</div>
<GxSummary :items="[{label: 'Installed', value: summary.installed}, {label: 'Missing', value: summary.missing}, {label: 'Total', value: summary.total}]" />
<p class="gx-model-selection">Selected: <strong>{{ currentLabel }}</strong></p>
</section>
<section v-if="status.jetlink" class="gx-card">
<div class="gx-section__header"><i aria-hidden="true" class="bi bi-usb-symbol"></i><span class="gx-section__title">Jetlink</span></div>
<div class="gx-info-card">
<div class="gx-field-group">
<label for="gx-jetlink-mode" class="gx-row__label">Remote model connection</label>
<GalaxySelect id="gx-jetlink-mode" class="gx-field" :value="pendingChoice?.action === 'jetlink' ? pendingChoice.value : status.jetlink.mode" :disabled="!actionAvailable('jetlink')" @change="configureJetlink($event.target.value)">
<option value="off">Off</option>
<option value="usb" :disabled="!status.jetlink.supported || status.jetlink.chestnut">USB computer</option>
<option value="ios" :disabled="!status.jetlink.supported || status.jetlink.chestnut">iPhone / iPad</option>
</GalaxySelect>
</div>
<p class="gx-note" role="status"><span class="gx-chip">{{ status.jetlink.state }}</span><span v-if="status.jetlink.model && !status.jetlink.active"> · selected: {{ status.jetlink.model }}</span></p>
<p v-if="status.jetlink.progress">{{ status.jetlink.progress }}</p>
<p v-if="status.jetlink.reason" class="gx-model-reason">{{ status.jetlink.reason }}</p>
<p v-if="status.jetlink.chestnut" class="gx-model-reason">Chestnut is connected. Jetlink stays off while Chestnut is fitted.</p>
<p class="gx-model-reason">Prepared means the remote model is built, not active. The local Small model remains available. Small and Chestnut catalog selections are unchanged.</p>
<div v-if="status.jetlink.mode === 'ios'" class="gx-row">
<span class="gx-row__label">Charge phone over USB</span>
<button class="gx-btn gx-btn--tonal" type="button" :aria-pressed="status.jetlink.chargePhone" :disabled="!actionAvailable('jetlink')" @click="configureJetlink(status.jetlink.mode, !status.jetlink.chargePhone)">{{ status.jetlink.chargePhone ? 'On' : 'Off' }}</button>
</div>
<p class="gx-model-reason">Set up parked with a USB 3 data cable and separate power. Keep the computer awake; keep the iPhone app open and unlocked.</p>
<a href="https://github.com/firestar5683/StarPilot/blob/StarPilot/openpilot/starpilot/models/JETLINK.md" target="_blank" rel="noopener noreferrer">Jetlink setup guides</a>
</div>
</section>
<section class="gx-card">
<div class="gx-section__header">
<i aria-hidden="true" class="bi bi-sliders"></i>
<span class="gx-section__title">Controls</span>
</div>
<div class="gx-panel gx-stack">
<p class="gx-note">Selections take effect the next time the driving model starts.</p>
<div class="gx-actions">
<button v-if="status.downloading" type="button" class="gx-btn gx-btn--danger" :disabled="!actionAvailable('cancel')" @click="runAction('cancel')"><i aria-hidden="true" class="bi bi-stop-circle"></i> Cancel Download</button>
<button v-else type="button" class="gx-btn" :disabled="!actionAvailable('downloadAll')" @click="runAction('downloadAll')"><i aria-hidden="true" class="bi bi-download"></i> Download Missing Models</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!actionAvailable('refresh')" @click="runAction('refresh')"><i aria-hidden="true" v-if="busy === 'refresh:'" class="bi bi-arrow-repeat gx-spin"></i><i aria-hidden="true" v-else class="bi bi-arrow-clockwise"></i> Check model catalog</button>
</div>
<div class="gx-row gx-row--borderless">
<span class="gx-row__label">Model Randomizer</span>
<button type="button" class="gx-btn gx-btn--tonal" :aria-pressed="status.randomizer === true" :disabled="!actionAvailable(status.randomizer ? 'disable-randomizer' : 'enable-randomizer')" @click="runAction(status.randomizer ? 'disable-randomizer' : 'enable-randomizer')">{{ status.randomizer ? 'On' : 'Off' }}</button>
</div>
<p v-if="status.randomizer" class="gx-model-reason">A different verified model is chosen each start. {{ status.gpuAvailable && activeBigModel ? 'Chestnut Big' : 'Small on-device' }} models are used. {{ (status.blacklistedModels || []).length }} excluded. Favorites only mark your preferred models.</p>
<p v-if="!status.gpuAvailable" class="gx-model-reason">Chestnut is not detected. Active Small is used; your Big selection is saved for later.</p>
<div class="gx-row gx-model-control gx-row--borderless">
<span class="gx-row__label">Active Small · On-device</span>
<GalaxySelect aria-label="Active Small" class="gx-field" :value="pendingChoice?.action === 'select-small' ? pendingChoice.value : activeSmallModel" :disabled="selectionUncertain || capabilities.select !== true || status.isOnroad || status.randomizer" @change="runAction('select-small', installedSmallModels.find(m => m.value === $event.target.value))">
<option v-for="m in installedSmallModels" :key="m.value" :value="m.value" :disabled="!m.installed || !m.selectable">{{ m.label || m.value }}</option>
</GalaxySelect>
</div>
<div class="gx-row gx-model-control gx-row--borderless">
<span class="gx-row__label">Active Big · Chestnut</span>
<GalaxySelect aria-label="Active Big" class="gx-field" :value="pendingChoice?.action === 'select-big' ? pendingChoice.value : activeBigModel" :disabled="selectionUncertain || capabilities.select !== true || status.isOnroad || status.randomizer" @change="$event.target.value ? runAction('select-big', installedBigModels.find(m => m.value === $event.target.value)) : runAction('select-big')">
<option value="">None — always use Active Small</option>
<option v-for="m in installedBigModels" :key="m.value" :value="m.value" :disabled="!m.installed || !m.selectable">{{ m.label || m.value }}</option>
</GalaxySelect>
</div>
<div class="gx-row gx-model-control gx-row--borderless">
<span class="gx-row__label">Sort</span>
<GalaxySelect aria-label="Sort models" class="gx-field" :value="sortMode" @change="sortMode = $event.target.value">
<option value="release_date">Release Date</option>
<option value="alphabetical">Alphabetical</option>
</GalaxySelect>
</div>
<div class="gx-row gx-model-control gx-row--borderless">
<label class="gx-row__label" for="gx-model-hardware">Model hardware</label>
<GalaxySelect id="gx-model-hardware" class="gx-field" :value="hardwareFilter" @change="setHardwareFilter($event.target.value)">
<option value="both">Both</option>
<option value="gpu">GPU models only</option>
<option value="comma">Comma models only</option>
</GalaxySelect>
</div>
<div class="gx-actions">
<GalaxySelect aria-label="Your Favorite filter" class="gx-field gx-filter-field" :value="userFilter" @change="userFilter = $event.target.value">
<option value="all">Your Favorite: All</option>
<option value="yes">Your Favorite: Yes</option>
<option value="no">Your Favorite: No</option>
</GalaxySelect>
<GalaxySelect aria-label="Community Favorite filter" class="gx-field gx-filter-field" :value="communityFilter" @change="communityFilter = $event.target.value">
<option value="all">Community: All</option>
<option value="yes">Community: Yes</option>
<option value="no">Community: No</option>
</GalaxySelect>
</div>
</div>
</section>
<template v-if="!sorted.length">
<div class="gx-card"><div class="gx-empty">No models match these filters. Try Both or clear the favorite filters.</div></div>
</template>
<template v-else>
<div class="gx-card-grid">
<section class="gx-card" v-for="m in sorted" :key="m.value">
<div class="gx-panel gx-cluster">
<div class="gx-grow">
<div class="gx-actions">
<strong>{{ m.label || m.value }}</strong>
<span v-if="m.userFavorite" class="gx-chip">Your Favorite</span>
<span v-if="m.communityFavorite" class="gx-chip">Community Favorite</span>
<span v-if="m.blacklisted" class="gx-chip">Excluded from randomizer</span>
</div>
<div class="gx-actions gx-tags">
<span class="gx-chip">{{ m.value }}</span>
<span v-if="m.builtin" class="gx-chip">Built-in</span>
<span class="gx-chip">{{ hardwareLabel(m) }}</span>
<span v-if="m.version" class="gx-chip">Version {{ m.version }}</span>
<span v-if="m.released" class="gx-chip">Released {{ m.released }}</span>
<span v-if="m.partial" class="gx-chip">Partial Files</span>
<span class="gx-chip">File size: {{ fileSizeText(m) }}</span>
</div>
</div>
<button type="button" class="gx-icon-btn" :disabled="!actionAvailable(m.userFavorite ? 'unfavorite' : 'favorite', m)" :aria-label="(m.userFavorite ? 'Remove from your favorites: ' : 'Add to your favorites: ') + m.label" :title="m.userFavorite ? 'Remove from your favorites' : 'Add to your favorites'" @click="runAction(m.userFavorite ? 'unfavorite' : 'favorite', m)">
<i aria-hidden="true" class="bi" :class="m.userFavorite ? 'bi-star-fill' : 'bi-star'"></i>
</button>
</div>
<p v-if="m.unavailableReason" class="gx-model-reason">{{ m.unavailableReason }}</p>
<div class="gx-panel-footer gx-actions">
<button v-if="status.randomizer || m.blacklisted" type="button" class="gx-btn gx-btn--tonal" :disabled="!actionAvailable(m.blacklisted ? 'include' : 'exclude', m)" @click="runAction(m.blacklisted ? 'include' : 'exclude', m)">{{ m.blacklisted ? 'Include in Randomizer' : 'Exclude from Randomizer' }}</button>
<template v-if="rowState(m) === 'active'">
<span class="gx-chip gx-chip--selected">Selected model</span>
</template>
<template v-else-if="rowState(m) === 'busy'">
<span class="gx-chip"><i aria-hidden="true" class="bi bi-hourglass-split"></i> Busy</span>
</template>
<template v-else-if="rowState(m) === 'cancellable'">
<button type="button" class="gx-btn gx-btn--danger" :disabled="!actionAvailable('cancel', m)" @click="runAction('cancel', m)"><i aria-hidden="true" class="bi bi-x-circle"></i> Cancel</button>
</template>
<template v-else-if="rowState(m) === 'installed'">
<button type="button" class="gx-btn" :disabled="!actionAvailable(m.requiresGpu ? 'select-big' : 'select-small', m)" @click="runAction(m.requiresGpu ? 'select-big' : 'select-small', m)"><i aria-hidden="true" class="bi bi-play-fill"></i> Set Active {{ m.requiresGpu ? 'Big' : 'Small' }}</button>
<button v-if="!m.builtin" type="button" class="gx-btn gx-btn--tonal gx-text-danger" :disabled="!actionAvailable('delete', m)" @click="runAction('delete', m)"><i aria-hidden="true" class="bi bi-trash"></i> Delete</button>
</template>
<template v-else>
<GxIconButton :label="'Download ' + m.label" icon="bi-download" :disabled="!actionAvailable('download', m)" @click="runAction('download', m)" />
</template>
</div>
</section>
</div>
</template>
<section class="gx-card gx-model-runtime">
<div class="gx-section__header"><i aria-hidden="true" class="bi bi-cpu"></i><span class="gx-section__title">Running model</span></div>
<div v-if="!selectionUncertain && status.jetlink?.active" class="gx-model-runtime__body">
<p>Active · Jetlink remote model</p>
<p v-if="status.jetlink.activeCheckpoint">{{ status.jetlink.activeCheckpoint }}</p>
<p v-if="status.jetlink.activeArtifactSha256">{{ status.jetlink.activeArtifactSha256.slice(0,16) }}…</p>
</div>
<div class="gx-model-runtime__body" v-else-if="runtime.status === 'ready' && runtime.data">
<p>{{ runtime.data.health.replaceAll('-', ' ') }} · {{ runtime.data.loadedId ? (runtime.data.variant === 'chestnut' ? 'Chestnut big' : 'Small') : 'No verified load receipt' }}</p>
<p v-if="runtime.data.loadedId">{{ runtime.data.loadedId }} · {{ runtime.data.artifactSha256.slice(0,16) }}…</p>
<p v-if="runtime.data.pendingNextStart">A saved selection is waiting for the next start.</p>
<p v-if="runtime.data.fallbackReason">Fallback: {{ runtime.data.fallbackReason.replaceAll('-', ' ') }}</p>
</div>
<p v-else class="gx-panel gx-note">{{ runtime.error || 'Checking the running model…' }}</p>
</section>
</template>
<GxDialog v-if="dialog" labelledby="gx-model-dialog-title" @close="finishDialog(false)">
<h3 id="gx-model-dialog-title">{{ dialog.title }}</h3><p>{{ dialog.message }}</p>
<div class="gx-settings__controls gx-actions"><button class="gx-btn gx-btn--tonal" type="button" @click="finishDialog(false)">Cancel</button><button ref="confirmButton" class="gx-btn" :class="{'gx-btn--danger': dialog.danger}" type="button" @click="finishDialog(true)">{{ dialog.confirmLabel }}</button></div>
</GxDialog>
</div>
`,
}
@@ -0,0 +1,88 @@
import { normalizeSnapshot } from "./system-monitor-data.js"
// One request at a time. The deadline starts before the request so slow replies
// never obtain a fresh lease merely by arriving late.
export class MonitorFeed {
constructor({ mode, publish, fetcher = (...args) => fetch(...args), clock = () => performance.now(),
schedule = (fn, delay) => setTimeout(fn, delay), cancel = (timer) => clearTimeout(timer), unauthorized = () => {} }) {
if (!["sample", "local"].includes(mode)) throw new Error("Unsupported monitor source")
Object.assign(this, { mode, publish, fetcher, clock, schedule, cancel, unauthorized })
this.generation = 0
this.stopped = true
this.timer = this.deadline = this.expiry = this.request = null
}
start() { this.stop(); this.stopped = false; this.refresh() }
stop() {
this.stopped = true
this.generation++
this.cancel(this.timer)
this.cancel(this.deadline)
this.cancel(this.expiry)
this.request?.abort()
this.request = null
}
async refresh() {
if (this.stopped || this.request) return
const generation = ++this.generation
const started = this.clock()
const request = new AbortController()
this.request = request
this.deadline = this.schedule(() => {
if (this.stopped || this.generation !== generation) return
this.publish({ snapshot: null, status: "unavailable", error: "System activity could not be refreshed." })
this.cancel(this.expiry)
// Retire it even if a stalled response body ignores cancellation.
this.generation++
this.request = null
request.abort()
if (this.mode === "local") this.timer = this.schedule(() => this.refresh(), 2000)
}, 4000)
try {
const url = this.mode === "local" ? "./api/system/monitor" : "./data/system-monitor.sample.json"
const response = await this.fetcher(url, { signal: request.signal, cache: "no-store" })
if (this.stopped || this.generation !== generation || request.signal.aborted) return
if (this.mode === "local" && response.status === 401) {
this.stop()
this.publish({ snapshot: null, status: "unavailable", error: "" })
this.unauthorized()
return
}
if (this.mode === "local" && response.status === 503) {
const body = await response.json().catch(() => null)
if (this.stopped || this.generation !== generation || request.signal.aborted) return
if (["access_unavailable", "setup_required"].includes(body?.code)) {
this.stop()
this.publish({ snapshot: null, status: "unavailable", error: "" })
this.unauthorized()
return
}
}
if (!response.ok) throw new Error("Monitor unavailable")
const snapshot = normalizeSnapshot(await response.json(), this.mode === "local" ? "local-runtime" : "synthetic-preview")
if (this.stopped || this.generation !== generation || request.signal.aborted) return
const elapsed = this.clock() - started
if (elapsed < 0 || elapsed >= 4000) throw new Error("Expired monitor response")
this.publish({ snapshot, status: this.mode === "local" ? "current" : "sample", error: "" })
this.cancel(this.deadline)
this.cancel(this.expiry)
if (this.mode === "local") {
this.expiry = this.schedule(() => {
if (!this.stopped) this.publish({ snapshot: null, status: "unavailable", error: "System activity is out of date." })
}, 4000 - elapsed)
}
} catch {
if (!this.stopped && this.generation === generation) {
this.cancel(this.expiry)
this.publish({ snapshot: null, status: "unavailable", error: "System activity is unavailable." })
}
} finally {
if (!this.stopped && this.generation === generation) {
this.cancel(this.deadline)
this.request = null
if (this.mode === "local") this.timer = this.schedule(() => this.refresh(), 2000)
}
}
}
}
@@ -0,0 +1,456 @@
import { GxNotice } from "./notice.js"
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
const SIZE = 512
const locationDeadlines = new WeakMap()
export function project(point, zoom) {
const latitude = Math.max(-85.05112878, Math.min(85.05112878, point.latitude))
const sine = Math.sin(latitude * Math.PI / 180), world = SIZE * 2 ** zoom
return [(point.longitude + 180) / 360 * world, (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * world]
}
export function unproject(x, y, zoom) {
const world = SIZE * 2 ** zoom
return {
longitude: ((x / world * 360) % 360 + 360) % 360 - 180, latitude: Math.atan(Math.sinh(Math.PI * (1 - 2 * y / world))) * 180 / Math.PI
}
}
// Screen pixels per meter at a latitude: the circles for offline areas are true ground distances.
export function metersPerPixelInverse(latitude, zoom) {
return SIZE * 2 ** zoom / (40075016.686 * Math.max(0.01, Math.cos(latitude * Math.PI / 180)))
}
export function relativeX(x, center, world) {
return center + ((x - center + world / 2) % world + world) % world - world / 2
}
// Match the tile style while imagery loads, including during live theme changes.
const LAND = { light: '#f5f5f3', dark: '#191a1a' }
const TILE_BITMAPS = 64 // decoded 512 px tiles are ~1 MB each; the browser's HTTP cache keeps the rest
const MAX_ZOOM = 18
const clampZoom = (zoom) => Math.max(0, Math.min(MAX_ZOOM, zoom))
export class RasterMap {
constructor(canvas, changed = () => {}) {
this.canvas = canvas;
this.changed = changed;
this.zoom = 2;
this.center = {
longitude: 0, latitude: 0
};
this.failed = new Set();
this.tiles = new Map();
this.pending = new Map();
this.closed = false;
this.theme = globalThis.document?.documentElement?.dataset.theme === 'light' ? 'light' : 'dark'
if (globalThis.MutationObserver && globalThis.document?.documentElement) {
this.themeObserver = new MutationObserver(() => this.setTheme(document.documentElement.dataset.theme))
this.themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
}
this.data = {};
this.initialized = false
// Following keeps the car centered; panning or a new route overview pauses it until Recenter.
this.follow = true
// Circles drawn over the map ({ latitude, longitude, radiusKm, stroke, fill }) and a tap callback (latitude, longitude).
this.overlays = [];
this.onTap = null
this.pointers = new Map()
this.observer = new ResizeObserver(() => this.draw());
this.observer.observe(canvas)
this.down = (event) => {
this.pointers.set(event.pointerId, [event.clientX, event.clientY])
canvas.setPointerCapture?.(event.pointerId)
if (this.pointers.size === 1) {
this.press = [event.clientX, event.clientY]
this.drag = [event.clientX, event.clientY, ...project(this.center, this.zoom)];
} else this.startPinch()
}
this.move = (event) => {
if (this.pointers.has(event.pointerId)) this.pointers.set(event.pointerId, [event.clientX, event.clientY])
if (this.pinch && this.pointers.size >= 2) {
const [[ax, ay], [bx, by]] = this.pointers.values(), distance = Math.max(1, Math.hypot(ax - bx, ay - by))
this.anchor(this.pinch.point, clampZoom(this.pinch.zoom + Math.log2(distance / this.pinch.distance)), (ax + bx) / 2, (ay + by) / 2)
return
}
if (!this.drag) return;
if (this.press && Math.hypot(event.clientX - this.press[0], event.clientY - this.press[1]) >= 6) this.pause()
this.center = unproject(this.drag[2] - event.clientX + this.drag[0], this.drag[3] - event.clientY + this.drag[1], this.zoom);
this.requestDraw()
}
this.up = (event) => {
this.pointers.delete(event?.pointerId)
if (this.pinch) {
if (this.pointers.size < 2) {
// Settle on a whole zoom level so tiles are drawn pixel for pixel.
const [x, y] = this.pinch.screen
this.pinch = this.press = null
this.drag = null
this.pointers.clear()
this.zoomAt(Math.round(this.zoom), x, y)
}
return
}
this.drag = null
const press = this.press
this.press = null
if (!press || event?.type !== 'pointerup' || Math.hypot(event.clientX - press[0], event.clientY - press[1]) >= 6) return
const now = performance.now(), last = this.lastTap
if (last && now - last[0] < 300 && Math.hypot(event.clientX - last[1], event.clientY - last[2]) < 24) {
this.lastTap = null
this.pause()
this.zoomAt(Math.round(this.zoom) + 1, ...this.local(event))
return
}
this.lastTap = [now, event.clientX, event.clientY]
if (this.onTap) this.onTap(this.pointAt(...this.local(event)))
}
this.key = (event) => {
if (this.onTap && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
this.onTap({ ...this.center })
return
}
const delta = {
ArrowLeft: [-80, 0], ArrowRight: [80, 0], ArrowUp: [0, -80], ArrowDown: [0, 80]
}
[event.key];
if (delta) {
event.preventDefault();
this.pause()
const [x,y] = project(this.center,this.zoom);
this.center = unproject(x+delta[0],y+delta[1],this.zoom);
this.draw()
} else if (event.key === '+' || event.key === '=' || event.key === '-') {
event.preventDefault()
this.changeZoom(event.key === '-' ? -1 : 1)
}
}
this.wheel = (event) => {
event.preventDefault();
// Trackpads send dozens of small wheel events per gesture; one zoom level per 100px, at most every 120 ms.
this.wheelDelta = (this.wheelDelta || 0) + (event.deltaMode === 1 ? event.deltaY * 33 : event.deltaY)
const now = performance.now()
if (Math.abs(this.wheelDelta) < 100 || now - (this.lastWheelZoom || 0) < 120) return
this.zoomAt(Math.round(this.zoom) + (this.wheelDelta < 0 ? 1 : -1), ...this.local(event))
this.wheelDelta = 0
this.lastWheelZoom = now
}
for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) canvas.addEventListener(name, listener, {
passive: false
})
}
setTheme(value) {
const theme = value === 'light' ? 'light' : 'dark'
if (this.closed || this.theme === theme) return
this.theme = theme
for (const controller of this.pending.values()) controller.abort()
this.pending.clear()
for (const image of this.tiles.values()) image.close()
this.tiles.clear()
this.failed.clear()
this.failures = 0
clearTimeout(this.retryTimer)
this.retryTimer = null
this.changed('')
this.draw()
}
pause() {
if (this.follow) { this.follow = false; this.changed() }
}
startPinch() {
const [[ax, ay], [bx, by]] = this.pointers.values(), x = (ax + bx) / 2, y = (ay + by) / 2
const [left, top] = this.offset()
this.drag = this.press = null
this.pause()
this.pinch = { zoom: this.zoom, distance: Math.max(1, Math.hypot(ax - bx, ay - by)), point: this.pointAt(x - left, y - top), screen: [x - left, y - top] }
}
offset() {
const rect = this.canvas.getBoundingClientRect?.()
return rect ? [rect.left, rect.top] : [0, 0]
}
local(event) {
const [left, top] = this.offset()
return [event.clientX - left, event.clientY - top]
}
// The map coordinate under a point on the canvas.
pointAt(x, y) {
const [cx, cy] = project(this.center, this.zoom)
return unproject(cx - this.canvas.clientWidth / 2 + x, cy - this.canvas.clientHeight / 2 + y, this.zoom)
}
// Show `point` under the canvas position (clientX, clientY) at `zoom` (pinch keeps the pinched place under the fingers).
anchor(point, zoom, clientX, clientY) {
const [left, top] = this.offset(), [px, py] = project(point, zoom)
this.zoom = zoom
this.pinch.screen = [clientX - left, clientY - top]
this.center = unproject(px - (clientX - left) + this.canvas.clientWidth / 2, py - (clientY - top) + this.canvas.clientHeight / 2, zoom)
this.requestDraw()
}
zoomAt(zoom, x, y) {
zoom = clampZoom(zoom)
const point = this.pointAt(x, y), [px, py] = project(point, zoom)
this.zoom = zoom
if (!this.follow || !this.lastLocation) this.center = unproject(px - x + this.canvas.clientWidth / 2, py - y + this.canvas.clientHeight / 2, zoom)
this.draw()
}
update(data, stale) {
clearTimeout(this.locationTimer)
const now = performance.now()
if (data && !locationDeadlines.has(data)) {
locationDeadlines.set(data, data.location?.expiresAt ?? now + (data.location?.validForMs ?? 0))
}
const remaining = Math.max(0, (data && locationDeadlines.get(data) || 0) - now)
this.locationFresh = !stale && remaining > 0
if (this.locationFresh) {
this.locationTimer = setTimeout(() => {
this.locationFresh = false;
this.changed();
this.draw()
}, remaining)
}
if (data?.location && (this.locationFresh || data.location.lastKnown === true && !this.lastLocation)) {
this.lastLocation = { ...data.location, bearing: Number.isFinite(data.location.bearing) ? data.location.bearing : this.lastLocation?.bearing }
}
this.data = data || {}
this.stale = stale
// A newly chosen route is shown whole once; Recenter returns to following the car.
const routeKey = this.data.destination && this.data.route?.length > 1 ? `${this.data.destination.id}|${this.data.routeKey ?? this.data.route.length}` : null
if (routeKey && routeKey !== this.overviewKey) {
this.overviewKey = routeKey
if (this.fit([...this.data.route, ...(this.lastLocation ? [this.lastLocation] : [])])) { this.follow = false; this.initialized = true }
} else if (!routeKey) this.overviewKey = null
if (this.follow && this.lastLocation && (this.locationFresh || !this.initialized)) {
this.center = { latitude: this.lastLocation.latitude, longitude: this.lastLocation.longitude }
if (!this.initialized) this.zoom = 15
this.initialized = true
} else if (!this.initialized && data?.destination) {
this.recenter()
this.initialized = true
}
this.changed()
this.draw()
}
// Frame these points with a margin, on a whole zoom level. False when there is nothing to frame.
fit(points) {
const width = this.canvas.clientWidth, height = this.canvas.clientHeight
if (!points.length || width < 2 || height < 2) return false
const xs = points.map((point) => project(point, 0)[0]), ys = points.map((point) => project(point, 0)[1])
const spanX = Math.max(1e-9, Math.max(...xs) - Math.min(...xs)), spanY = Math.max(1e-9, Math.max(...ys) - Math.min(...ys))
this.zoom = clampZoom(Math.floor(Math.min(16, Math.log2(width * 0.75 / spanX), Math.log2(height * 0.75 / spanY))))
this.center = unproject((Math.max(...xs) + Math.min(...xs)) / 2 * 2 ** this.zoom, (Math.max(...ys) + Math.min(...ys)) / 2 * 2 ** this.zoom, this.zoom)
return true
}
recenter() {
this.follow = true
const point = this.lastLocation || this.data.destination;
if (point) {
this.center = { latitude: point.latitude, longitude: point.longitude };
this.zoom = this.lastLocation ? 15 : 2
}
this.changed()
this.draw()
}
requestDraw() {
this.frameScheduled ??= requestAnimationFrame(() => { this.frameScheduled = null; this.draw() })
}
// The nearest loaded ancestor of a missing tile, and the part of it that covers the tile.
placeholder(zoom, x, y) {
for (let up = 1; up <= 5 && zoom - up >= 0; up++) {
const image = this.tiles.get(`${zoom - up}/${x >> up}/${y >> up}`)
if (image) {
const span = SIZE / 2 ** up
return [image, (x - ((x >> up) << up)) * span, (y - ((y >> up) << up)) * span, span]
}
}
return null
}
changeZoom(delta) {
this.zoom = clampZoom(Math.round(this.zoom) + delta);
this.draw()
}
draw() {
if (this.closed) return
const width = Math.max(1, Math.round(this.canvas.clientWidth)), height = Math.max(1, Math.round(this.canvas.clientHeight));
// Draw at the screen's pixel density so roads, labels and the route stay sharp on phones.
const ratio = Math.min(3, Math.max(1, globalThis.devicePixelRatio || 1))
if (this.canvas.width !== Math.round(width * ratio)) this.canvas.width = Math.round(width * ratio);
if (this.canvas.height !== Math.round(height * ratio)) this.canvas.height = Math.round(height * ratio)
const ctx = this.canvas.getContext('2d'), [cx, cy] = project(this.center, this.zoom), world = SIZE * 2 ** this.zoom, needed = []
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
ctx.imageSmoothingQuality = 'high'
ctx.fillStyle = LAND[this.theme];
ctx.fillRect(0, 0, width, height)
// Tiles come from the nearest whole zoom level, scaled while a pinch is between levels.
const tileZoom = clampZoom(Math.round(this.zoom)), n = 2 ** tileZoom, span = SIZE * 2 ** (this.zoom - tileZoom)
for (let ty = Math.floor((cy - height / 2) / span); ty <= Math.floor((cy + height / 2) / span); ty++) {
if (ty < 0 || ty >= n) continue
for (let tx = Math.floor((cx - width / 2) / span); tx <= Math.floor((cx + width / 2) / span); tx++) {
const x = ((tx % n) + n) % n, key = `${tileZoom}/${x}/${ty}`, left = tx * span - cx + width / 2, top = ty * span - cy + height / 2
needed.push([key, Math.hypot(left + span / 2 - width / 2, top + span / 2 - height / 2)])
const image = this.tiles.get(key)
if (image) ctx.drawImage(image, left, top, span, span)
else {
// A stretched coarser tile while this one loads, so zooming in never shows blank squares.
const cover = this.placeholder(tileZoom, x, ty)
if (cover) ctx.drawImage(cover[0], cover[1], cover[2], cover[3], cover[3], left, top, span, span)
}
}
}
const keys = new Set(needed.map(([key]) => key))
for (const [key, controller] of this.pending) if (!keys.has(key)) {
controller.abort();
this.pending.delete(key)
}
// The middle of the screen loads first.
needed.sort((a, b) => a[1] - b[1])
for (const [key] of needed) if (!this.tiles.has(key) && !this.pending.has(key) && !this.failed.has(key) && this.pending.size < 6) this.load(key)
const pixel = (point) => {
const [x, y] = project(point, this.zoom);
return [relativeX(x, cx, world) - cx + width / 2, y - cy + height / 2]
}
for (const circle of this.overlays) {
const [x, y] = pixel(circle), radius = metersPerPixelInverse(circle.latitude, this.zoom) * circle.radiusKm * 1000
ctx.beginPath(); ctx.arc(x, y, Math.max(2, radius), 0, Math.PI * 2)
ctx.fillStyle = circle.fill; ctx.fill()
ctx.setLineDash(circle.dash || []); ctx.strokeStyle = circle.stroke; ctx.lineWidth = circle.width || 2; ctx.stroke(); ctx.setLineDash([])
}
const line = (points, casing, color, size) => {
ctx.beginPath();
points.forEach((point, index) => { const xy = pixel(point); index ? ctx.lineTo(...xy) : ctx.moveTo(...xy) })
ctx.lineJoin = ctx.lineCap = 'round'
ctx.strokeStyle = casing; ctx.lineWidth = size + 3; ctx.stroke()
ctx.strokeStyle = color; ctx.lineWidth = size; ctx.stroke()
}
for (const choice of this.data.alternatives || []) {
if (choice.index === this.data.selectedRoute || !choice.geometry?.length) continue
line(choice.geometry, '#727e8d', '#a9b4c2', 5)
}
if (this.data.route?.length) line(this.data.route, '#2f7ac6', '#56a8fb', 6)
if (this.data.destination) {
// A pin whose tip marks the place.
const [x, y] = pixel(this.data.destination)
ctx.beginPath(); ctx.moveTo(x, y); ctx.bezierCurveTo(x - 4, y - 8, x - 11, y - 14, x - 11, y - 21)
ctx.arc(x, y - 21, 11, Math.PI, 0); ctx.bezierCurveTo(x + 11, y - 14, x + 4, y - 8, x, y); ctx.closePath()
ctx.fillStyle = '#e5484d'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke()
ctx.beginPath(); ctx.arc(x, y - 21, 4, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill()
}
if (this.lastLocation) {
const xy = pixel(this.lastLocation), color = this.locationFresh ? '#1a73e8' : '#8a94a6'
if (Number.isFinite(this.lastLocation.bearing)) {
ctx.save(); ctx.translate(...xy); ctx.rotate(this.lastLocation.bearing * Math.PI / 180);
ctx.beginPath(); ctx.moveTo(0, -22); ctx.lineTo(-8, -10); ctx.lineTo(8, -10); ctx.closePath();
ctx.fillStyle = color; ctx.fill(); ctx.restore()
}
ctx.beginPath(); ctx.arc(...xy, 8, 0, Math.PI * 2)
ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 3; ctx.stroke()
}
}
async load(key) {
const controller = new AbortController();
this.pending.set(key, controller);
let timedOut = false
const timer = setTimeout(() => {
timedOut = true;
controller.abort()
}, 8000)
try {
const response = await fetch(`./api/navigation/map/tiles/${key}.png?theme=${this.theme}`, {
credentials: 'same-origin', signal: controller.signal
});
if (!response.ok) throw new Error('Map tiles unavailable')
const image = await createImageBitmap(await response.blob());
if (this.closed || controller.signal.aborted) {
image.close();
return
}
this.tiles.set(key, image);
this.failures = 0
while (this.tiles.size > TILE_BITMAPS) {
const oldest = this.tiles.keys().next().value;
this.tiles.get(oldest).close();
this.tiles.delete(oldest)
}
if (!this.failed.size) this.changed('')
} catch (error) {
if (this.pending.get(key) === controller && (timedOut || !controller.signal.aborted)) {
this.failed.add(key);
if (this.failed.size > 64) this.failed.delete(this.failed.values().next().value);
// A quick refusal (a busy moment) is retried quickly and silently; a hung request or a run of failures is reported.
this.failures = (this.failures || 0) + 1
const persistent = timedOut || this.failures >= 6
if (persistent) this.changed('Map tiles unavailable. Check the saved key and connection. Reconnecting automatically…')
this.retryTimer ??= setTimeout(() => { this.retryTimer = null; this.retry() }, persistent ? 5000 : 800)
}
} finally {
clearTimeout(timer);
if (this.pending.get(key) === controller) this.pending.delete(key);
if (!this.closed) {
/* Paint completed tiles without continuously retrying failures. */ this.paintScheduled ??= requestAnimationFrame(() => {
this.paintScheduled = null;
this.draw()
})
}
}
}
retry() {
this.failed.clear();
this.draw()
}
close() {
this.closed = true;
clearTimeout(this.locationTimer);
clearTimeout(this.retryTimer);
this.observer.disconnect();
this.themeObserver?.disconnect();
cancelAnimationFrame(this.paintScheduled);
if (this.frameScheduled) cancelAnimationFrame(this.frameScheduled);
for (const controller of this.pending.values()) controller.abort();
for (const image of this.tiles.values()) image.close();
this.tiles.clear();
for (const [name, listener] of [['pointerdown', this.down], ['pointermove', this.move], ['pointerup', this.up], ['pointercancel', this.up], ['wheel', this.wheel], ['keydown', this.key]]) this.canvas.removeEventListener(name, listener)
}
}
export const NavigationMap = {
components: { GxNotice },
props: ['data', 'stale'], data: () => ({
error: '', map: null, locationFresh: false, lastLocation: null, following: true
}),
mounted() {
this.map = markRaw(new RasterMap(this.$refs.canvas, (error) => {
if (error !== undefined) this.error = error
this.locationFresh = this.map?.locationFresh || false
this.lastLocation = this.map?.lastLocation || null
this.following = this.map?.follow ?? true
}));
this.map.update(this.data, this.stale)
},
beforeUnmount() {
this.map.close()
},
methods: {
overview() {
if (this.map?.fit(this.data?.route || [])) {
this.map.pause()
this.map.draw()
}
},
},
watch: {
data(value) {
this.map?.update(value, this.stale)
}, stale(value) {
this.map?.update(this.data, value)
}
},
template: `<section class="gx-navigation-map" aria-label="Interactive navigation map">
<canvas tabindex="0" ref="canvas" aria-label="Map. Drag to pan, pinch or double-tap to zoom" />
<div v-if="!locationFresh && lastLocation" class="gx-navigation-map__last">{{ data?.route?.length ? 'Preview from last saved location · GPS unavailable' : 'Last saved location · GPS unavailable' }}</div>
<div class="gx-navigation-map__controls">
<button v-if="data?.route?.length" type="button" class="gx-navigation-map__control" @click="overview" aria-label="Show whole route" title="Show whole route"><i class="bi bi-bounding-box" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation-map__control" @click="map.changeZoom(1)" aria-label="Zoom in"><i class="bi bi-plus-lg" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation-map__control" @click="map.changeZoom(-1)" aria-label="Zoom out"><i class="bi bi-dash-lg" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation-map__control" :class="{'gx-navigation-map__control--active':!following}" @click="map.recenter()" :aria-pressed="following" aria-label="Recenter and follow"><i class="bi bi-crosshair" aria-hidden="true"></i></button>
</div>
<GxNotice v-if="error" tone="danger">{{error}}</GxNotice>
<a class="gx-navigation-map__logo" href="https://www.mapbox.com/" target="_blank" rel="noopener noreferrer" aria-label="Mapbox">
</a>
<div class="gx-navigation-map__credits">
<a href="https://www.mapbox.com/about/maps/" target="_blank" rel="noopener noreferrer">© Mapbox</a> · <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">© OpenStreetMap</a> · <a href="https://apps.mapbox.com/feedback/" target="_blank" rel="noopener noreferrer">Improve this map</a>
</div>
</section>`
}
@@ -0,0 +1,592 @@
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { NavigationMap } from "./navigation-map.js"
import { MapOperationsPanel } from "./map-operations.js"
import { OfflineRoadMapsPanel } from "./offline-road-maps.js"
export function searchUuid() {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID()
const bytes = crypto.getRandomValues(new Uint8Array(16))
bytes[6] = (bytes[6] & 15) | 64
bytes[8] = (bytes[8] & 63) | 128
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("")
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
export function remainingLocationLease(lease, requestStarted, now = performance.now()) {
return Math.max(0, Math.min(2500, lease) - Math.max(0, now - requestStarted))
}
const STATUS = new Set(["disabled", "needsKey", "noDestination", "waitingForLocation", "routing", "guiding", "arrived", "routeUnavailable", "stale"])
const ROUTE_AVOIDANCES = [{ key: "avoidTolls", label: "Avoid tolls" }, { key: "avoidHighways", label: "Avoid highways" },
{ key: "avoidFerries", label: "Avoid ferries" }]
const coordinate = (value) => !!value && Number.isFinite(value.latitude) && Math.abs(value.latitude) <= 90 &&
Number.isFinite(value.longitude) && Math.abs(value.longitude) <= 180
const text = (value, limit) => typeof value === "string" && value.trim().length > 0 && value.length <= limit
export const validDestination = (value) => coordinate(value) && text(value.id, 256) && text(value.name, 256) &&
(value.address === undefined || text(value.address, 512)) && (value.label === undefined || ["home", "work"].includes(value.label))
// Suggestions carry the search they came from; their temporary coordinates are retrieved only for routing.
export const validSuggestion = (value) => !!value && text(value.id, 256) && text(value.name, 256) &&
(value.description === undefined || typeof value.description === "string" && value.description.length <= 512) &&
typeof value.searchId === "string" && /^[0-9a-f-]{36}$/.test(value.searchId)
export const validSearchResult = (value) => validDestination(value) || validSuggestion(value)
export function validNavigation(value) {
const instruction = value?.instruction
if (value?.alternatives !== undefined && (!Array.isArray(value.alternatives) || value.alternatives.length > 3 ||
!value.alternatives.every((row,index) => row.index === index && Number.isFinite(row.durationSeconds) && row.durationSeconds >= 0 &&
Number.isFinite(row.distanceMeters) && row.distanceMeters >= 0 && Array.isArray(row.geometry) && row.geometry.length <= 512 && row.geometry.every(coordinate)) ||
!Number.isInteger(value.selectedRoute) || value.selectedRoute < 0 || value.selectedRoute > 2)) return false
return !!value && typeof value.enabled === "boolean" && typeof value.isMetric === "boolean" && typeof value.hasKey === "boolean" && STATUS.has(value.status) &&
ROUTE_AVOIDANCES.every(({ key }) => value[key] === undefined || typeof value[key] === "boolean") &&
(value.network === undefined || ["online", "offline", "unknown"].includes(value.network)) &&
typeof value.revision === "string" && value.revision.length > 0 && value.revision.length <= 128 &&
(value.destination === null || validDestination(value.destination)) && Array.isArray(value.favorites) && value.favorites.length <= 100 &&
(value.recents === undefined || Array.isArray(value.recents) && value.recents.length <= 10 && value.recents.every(validDestination)) &&
(value.routeKey === undefined || typeof value.routeKey === "string" && /^[0-9a-f]{16}$/.test(value.routeKey)) &&
(value.location == null || coordinate(value.location) && Number.isFinite(value.location.validForMs) && value.location.validForMs >= 0 && value.location.validForMs <= 2500) &&
value.favorites.every(validDestination) && Array.isArray(value.route) && value.route.length <= 512 && value.route.every(coordinate) &&
(instruction === null || !!instruction && ["text", "maneuverType", "maneuverModifier"].every((key) =>
typeof instruction[key] === "string" && instruction[key].length <= 1024) &&
["distanceMeters", "remainingDistanceMeters", "remainingDurationSeconds"].every((key) =>
Number.isFinite(instruction[key]) && instruction[key] >= 0))
}
// Equal route keys mean equal route lines, so only the small remainder is compared.
const withoutRoute = (value) => JSON.stringify({ ...value, route: undefined, alternatives: undefined })
export const sameSnapshot = (a, b) => !!a && !!b && (a.routeKey !== undefined && a.routeKey === b.routeKey ?
withoutRoute(a) === withoutRoute(b) : JSON.stringify(a) === JSON.stringify(b))
// The place fields the comma stores; ids are recomputed from the coordinates.
export const placeOf = (place) => Object.fromEntries(["name", "latitude", "longitude", "address"].filter((key) => place[key] !== undefined).map((key) => [key, place[key]]))
export function routePath(points) {
if (!Array.isArray(points) || points.length < 2 || points.length > 512 || !points.every(coordinate)) return ""
const latitude = points.reduce((sum, point) => sum + point.latitude, 0) / points.length
const scale = Math.max(0.05, Math.cos(latitude * Math.PI / 180))
const firstLongitude = points[0].longitude
const projected = points.map((point) => [(((point.longitude - firstLongitude + 540) % 360) - 180) * scale, -point.latitude])
const xs = projected.map((point) => point[0]), ys = projected.map((point) => point[1])
const left = Math.min(...xs), top = Math.min(...ys)
const width = Math.max(...xs) - left, height = Math.max(...ys) - top
const zoom = Math.min(360 / Math.max(width, 1e-8), 180 / Math.max(height, 1e-8))
return projected.map(([x, y], index) => `${index ? "L" : "M"}${(200 + (x - left - width / 2) * zoom).toFixed(2)},${(110 + (y - top - height / 2) * zoom).toFixed(2)}`).join(" ")
}
export class NavigationClient {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (timer) => clearTimeout(timer) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.clientId = searchUuid()
this.active = false
this.generation = 0
this.controller = this.timer = null
this.data = null
this.results = []
this.searched = false
this.busy = false
this.error = ""
this.stale = false
}
emit() { this.publish({ data: this.data, results: this.results, searched: this.searched, busy: this.busy, error: this.error, stale: this.stale,
suggestions: this.suggestions }) }
stop() {
if (this.searchId && this.data) {
this.fetcher("./api/navigation/action", { method: "POST", credentials: "same-origin", cache: "no-store",
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "cancelSearch",
revision: this.data.revision, searchId: this.searchId }) }).catch(() => {})
}
this.searchId = null
this.active = false
this.generation++
this.controller?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.controller = this.timer = null
this.data = null
this.results = []
this.searched = false
this.error = ""
this.busy = this.stale = false
this.endSuggestions(false)
this.emit()
}
start() { this.stop(); this.active = true; return this.load() }
load() { return this.run("status") }
async search(query) {
const text = query.trim()
if (this.pendingSuggestion && this.suggestionQuery === text) await this.pendingSuggestion
if (!this.active || this.busy) return null
if (this.suggestionResultsQuery === text && this.suggestions?.length && this.autocompleteId && !this.suggestions.some(place => place.temporary)) {
this.searchId = this.autocompleteId
this.results = this.suggestions.slice()
this.searched = true
this.error = ""
this.endSuggestions(false)
this.emit()
return { results: this.results }
}
this.endSuggestions()
this.searchId = searchUuid()
return this.run("search", { query: text, searchId: this.searchId, clientId: this.clientId })
}
choose(place) {
const pending = place.searchId ? this.action("selectPlace", { id: place.id, searchId: place.searchId }) :
this.action("select", { destination: placeOf(place) })
this.endSuggestions()
return pending
}
save(place, label) {
if (place.temporary) return Promise.resolve(null)
const category = label === undefined ? {} : { label }
return place.searchId ? this.action("favoritePlace", { id: place.id, searchId: place.searchId, ...category }) :
this.action("favorite", { destination: placeOf(place), ...category })
}
// As-you-type suggestions run beside status polling and never cancel it. One typing session
// shares a Mapbox search session on the comma until a place is chosen or a full search runs.
suggest(query) {
this.suggestionQuery = query.trim()
return this.pendingSuggestion = this.runSuggestion(query)
}
async runSuggestion(query) {
const text = query.trim()
this.suggestController?.abort()
if (!this.active || text.length < 3 || !this.data?.hasKey || !this.data?.enabled) {
if (this.suggestions?.length) { this.suggestions = []; this.emit() }
return []
}
this.autocompleteId ??= searchUuid()
const controller = new AbortController(), sequence = this.suggestSequence = (this.suggestSequence || 0) + 1
this.suggestController = controller
const timer = this.later(() => controller.abort(), 8000)
try {
const response = await this.fetcher("./api/navigation/search", { method: "POST", credentials: "same-origin", cache: "no-store",
signal: controller.signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: text, searchId: this.autocompleteId, clientId: this.clientId, autocomplete: true }) })
const payload = await response.json().catch(() => null)
if (sequence !== this.suggestSequence || !this.active) return []
if (response.status === 401) { this.stop(); this.unauthorized(); return [] }
if (!response.ok || !Array.isArray(payload?.results) || payload.results.length > 20 || !payload.results.every(validSearchResult)) {
this.autocompleteId = null // the comma ended this session (expired, or settings changed); the next keystroke starts another
this.suggestions = []
} else {
this.suggestions = payload.results
this.suggestionResultsQuery = text
}
this.emit()
return this.suggestions
} catch {
if (sequence === this.suggestSequence && !controller.signal.aborted) { this.suggestions = []; this.emit() }
return []
} finally { this.cancelTimer(timer) }
}
endSuggestions(emit = true) {
this.suggestController?.abort()
this.suggestSequence = (this.suggestSequence || 0) + 1
this.autocompleteId = null
this.suggestionQuery = null
this.suggestionResultsQuery = null
this.pendingSuggestion = null
const had = this.suggestions?.length
this.suggestions = []
if (emit && had) this.emit()
}
action(action, value = {}) {
if (!this.data || this.busy || this.stale) return Promise.resolve(null)
return this.run("action", { action, revision: this.data.revision, ...value })
}
async run(operation, body = null) {
if (!this.active || this.busy) return null
this.controller?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
const requestStarted = performance.now()
const generation = ++this.generation
const controller = new AbortController()
this.controller = controller
this.busy = operation !== "status"
if (operation !== "status") this.error = ""
this.emit()
const deadline = this.later(() => controller.abort(), operation === "search" ? 24000 : operation === "action" && body?.action === "selectPlace" ? 12000 : 4000)
try {
// Status polls name the route lines they already hold; the comma then leaves them out.
const query = operation === "status" && this.data?.routeKey ? `?routeKey=${this.data.routeKey}` : ""
const response = await this.fetcher(`./api/navigation/${operation}${query}`, {
credentials: "same-origin", cache: "no-store", signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
})
if (!this.active || generation !== this.generation) return null
const payload = await response.json()
if (!this.active || generation !== this.generation) return null
if (controller.signal.aborted) throw new Error("Navigation did not respond.")
if (response.status === 401 || response.status === 503 && ["access_unavailable", "setup_required"].includes(payload?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(payload?.error || "Navigation could not complete this request.")
this.error = ""
if (operation === "search") {
if (!Array.isArray(payload.results) || payload.results.length > 20 || !payload.results.every(validSearchResult))
throw new Error("Search results could not be read.")
this.results = payload.results
this.searched = true
} else {
if (payload?.routeUnchanged === true) {
delete payload.routeUnchanged
if (this.data?.routeKey === payload.routeKey) {
payload.route = this.data.route
if (this.data.alternatives) payload.alternatives = this.data.alternatives
} else {
payload.route = []
delete payload.routeKey // the next poll asks for the lines again
}
}
if (!validNavigation(payload)) throw new Error("Navigation status could not be read.")
// Route lines are drawn, never edited: keep them out of Vue's deep reactivity.
Object.freeze(payload.route)
if (payload.alternatives) Object.freeze(payload.alternatives)
if (payload.location) {
payload.location.validForMs = remainingLocationLease(payload.location.validForMs, requestStarted)
payload.location.expiresAt = performance.now() + payload.location.validForMs
}
if (!sameSnapshot(payload, this.data)) this.data = payload
this.stale = false
if (operation === "action" && ["select", "selectPlace", "clear"].includes(body.action)) { this.results = []; this.searched = false }
}
return payload
} catch (error) {
if (this.active && generation === this.generation) {
this.error = controller.signal.aborted ? "Navigation did not respond. Reconnecting…" : connectionError(error)
if (operation !== "search") this.stale = true
}
return null
} finally {
this.cancelTimer(deadline)
if (this.active && generation === this.generation) {
this.controller = null
this.busy = false
this.emit()
this.timer = this.later(() => { this.timer = null; this.load() }, this.data?.enabled && this.data?.hasKey ? 1000 : this.data?.status === "guiding" ? 2000 : 5000)
}
}
}
}
const LABELS = { home: { name: "Home", icon: "bi-house-fill" }, work: { name: "Work", icon: "bi-briefcase-fill" } }
const matches = (place, text) => place.name.toLocaleLowerCase().includes(text) || !!place.address?.toLocaleLowerCase().includes(text)
export const FavoriteChoices = {
props: ["available"],
emits: ["choose"],
template: `<div class="gx-navigation__favorite-choices" role="group" aria-label="Save place as">
<button v-for="choice in [{label:'home',name:'Home',icon:'bi-house'}, {label:'work',name:'Work',icon:'bi-briefcase'}, {label:null,name:'Other',icon:'bi-star'}]"
:key="choice.name" type="button" class="gx-btn gx-btn--tonal" :disabled="!available" @click="$emit('choose', choice.label)"><i class="bi" :class="choice.icon" aria-hidden="true"></i>{{ choice.name }}</button>
</div>`,
}
export const NavigationPage = {
name: "NavigationPage",
components: { GxNotice, MapOperationsPanel, NavigationMap, OfflineRoadMapsPanel, FavoriteChoices },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, default: null }, initialTab: { type: String, default: "route" } },
data() { return { data: null, results: [], busy: false, error: "", stale: false, tab: this.initialTab, query: "", token: "", searched: false,
suggestions: [], suggestOpen: false, savedSuggestionPlaces: {}, favoritePicker: null, searchPending: false } },
watch: { initialTab(value) { this.tab = value } },
computed: {
routeAvoidances() { return ROUTE_AVOIDANCES },
controlsAvailable() { return this.mode === "local" && !!this.data && !this.stale },
available() { return this.controlsAvailable && !this.busy && !this.searchPending },
ready() { return !!this.data?.enabled && !!this.data?.hasKey },
offline() { return this.mode === "local" && this.ready && !this.stale && this.data?.network === "offline" },
usingSavedLocation() { return this.mode === "local" && this.ready && !this.stale && this.data?.network !== "offline" &&
this.data?.location?.lastKnown === true },
favorites() { return this.data?.favorites || [] },
savedIds() { return new Set(this.favorites.map((place) => place.id)) },
// Recent places that are not already saved, newest first.
recents() { return (this.data?.recents || []).filter((place) => !this.savedIds.has(place.id)) },
// Saved places as one-tap buttons: Home and Work first (the comma sends them in that order).
shortcuts() { return this.favorites.slice(0, 8) },
showShortcuts() { return this.ready && !this.data.destination && !this.suggestOpen && !this.results.length && this.shortcuts.length > 0 },
localMatches() {
const text = this.query.trim().toLocaleLowerCase()
if (!text) return []
return [...this.favorites, ...this.recents].filter((place) => matches(place, text)).slice(0, 5)
},
showQuick() { return this.suggestOpen && !this.query.trim() && (this.favorites.length > 0 || this.recents.length > 0) },
showSuggestions() { return this.suggestOpen && !!this.query.trim() && (this.localMatches.length > 0 || this.suggestions.length > 0) },
destinationSaved() { return !!this.data?.destination && this.favorites.find((place) => place.id === this.data.destination.id) || null },
usageRows() {
const usage = this.data?.mapboxUsage
if (!usage) return []
return [["searchSessions", "Searches"], ["geocoding", "Address lookups"], ["directions", "Routes"], ["staticTiles", "Map views"]]
.filter(([key]) => usage[key]).map(([key, label]) => ({ key, label, ...usage[key] }))
},
path() { return routePath(this.data?.route) },
summary() {
const instruction = this.data?.instruction, route = this.data?.alternatives?.[this.data?.selectedRoute || 0]
return instruction ? {distance:instruction.remainingDistanceMeters,duration:instruction.remainingDurationSeconds} :
route ? {distance:route.distanceMeters,duration:route.durationSeconds} : null
},
statusLabel() {
if (this.data?.status === 'waitingForLocation' && this.data.route?.length)
return 'Route preview from last saved location'
if (this.data?.status === 'routing' && this.data.location?.lastKnown)
return 'Finding a route from last saved location…'
return ({ disabled: "Navigation is off", needsKey: "Add your Mapbox key to get started", noDestination: "Where would you like to go?",
waitingForLocation: "Waiting for GPS", routing: "Finding your route…", guiding: "Route guidance", arrived: "You have arrived",
routeUnavailable: "A route could not be found", stale: "Waiting for navigation" })[this.data?.status] || "Connecting to navigation…"
},
},
created() { this.client = new NavigationClient({ publish: (state) => Object.assign(this.$data, state), unauthorized: this.unauthorized }) },
mounted() {
if (this.mode !== "local") return
this.visibilityHandler = () => document.visibilityState === "hidden" ? this.client.stop() : this.client.start()
document.addEventListener("visibilitychange", this.visibilityHandler)
if (document.visibilityState !== "hidden") this.client.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibilityHandler); clearTimeout(this.suggestTimer); this.client.stop(); this.token = "" },
methods: {
selectTab(tab) {
this.tab = tab
this.go?.(tab === 'route' ? '/navigation' : `/navigation/${tab}`)
},
async search() {
if (this.searchPending) return
clearTimeout(this.suggestTimer)
this.suggestOpen = false
this.savedSuggestionPlaces = {}
this.favoritePicker = null
this.searchPending = true
try { await this.client.search(this.query) }
finally { this.searchPending = false }
},
typed() {
clearTimeout(this.suggestTimer)
this.suggestOpen = true
if (!this.query.trim()) { this.client.endSuggestions(); return }
this.favoritePicker = null
this.suggestTimer = setTimeout(() => this.client.suggest(this.query), 150)
},
onSearchFocusOut(event) {
if (!event.relatedTarget?.closest('.gx-navigation__suggestions, .gx-navigation__search')) this.closeSuggestions()
},
closeSuggestions() { this.suggestOpen = false; clearTimeout(this.suggestTimer) },
clearQuery() {
this.query = ""
this.savedSuggestionPlaces = {}
this.favoritePicker = null
this.results = []
this.searched = false
this.client.endSuggestions()
this.$refs.search?.focus()
},
async pick(place) {
this.favoritePicker = null
this.closeSuggestions()
this.query = ""
this.$refs.search?.blur()
await this.client.choose(place)
},
// Search suggestions get their saved id from the comma, so the ones saved here are remembered by suggestion id.
isFavorite(place) { return this.savedIds.has(this.savedSuggestionPlaces[place.id] || place.id) },
favoriteKey(place) { return `${place.searchId || ''}:${place.id}` },
openFavorite(place) {
const key = this.favoriteKey(place)
this.favoritePicker = this.favoritePicker === key ? null : key
},
async saveAs(place, label) {
const saved = this.favorites.find(row => row.id === (this.savedSuggestionPlaces[place.id] || place.id))
const result = saved ? await this.client.action("labelFavorite", { id: saved.id, label }) : await this.client.save(place, label)
if (!result) return
if (place.searchId) {
const row = result.favorites.find(row => row.name === place.name && (!place.description || row.address === place.description))
|| result.favorites.find(row => row.name === place.name)
if (row) this.savedSuggestionPlaces[place.id] = row.id
}
this.favoritePicker = null
},
async removeFavorite(place) {
if (!this.isFavorite(place)) return
const id = this.savedSuggestionPlaces[place.id] || place.id
this.favoritePicker = null
const result = await this.client.action("removeFavorite", { id })
if (!result) return
for (const [suggestionId, savedId] of Object.entries(this.savedSuggestionPlaces)) {
if (savedId === id) {
delete this.savedSuggestionPlaces[suggestionId]
}
}
return result
},
setLabel(place, label) { return this.client.action("labelFavorite", { id: place.id, label: place.label === label ? null : label }) },
placeIcon(place) { return LABELS[place.label]?.icon || (this.isFavorite(place) ? "bi-star-fill" : place.searchId ? "bi-geo-alt-fill" : "bi-clock-history") },
placeName(place) { return LABELS[place.label]?.name || place.name },
placeDetail(place) { return place.label ? place.name : place.address || place.description || "" },
async saveKey(enable = false) { const token = this.token.trim(); this.token = ""; await this.client.action("configure", { patch: enable ? { token, enabled: true } : { token } }) },
distance(value) {
const metric = this.data?.isMetric !== false
return metric ? value < 1000 ? `${Math.round(value)} m` : `${(value / 1000).toFixed(1)} km` :
value < 160.9344 ? `${Math.round(value / 0.3048 / 10) * 10} ft` : `${(value / 1609.344).toFixed(1)} mi`
},
duration(value) { const minutes = Math.max(1, Math.round(value / 60)); return minutes >= 60 ? `${Math.floor(minutes / 60)} hr ${minutes % 60} min` : `${minutes} min` },
},
template: `
<div class="gx-view gx-navigation" :inert="busy || searchPending" :aria-busy="busy || searchPending || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Navigation</h2>
<p>Set a destination, prepare offline maps, and configure navigation and Mapbox.</p></div></header>
<div class="gx-tabs gx-actions" role="group" aria-label="Navigation tools">
<button v-for="item in [{id:'route',label:'Destination'},{id:'maps',label:'Offline Maps'},{id:'setup',label:'Setup'}]" :key="item.id"
type="button" class="gx-btn" :class="tab === item.id ? '' : 'gx-btn--tonal'" :aria-pressed="tab === item.id" @click="selectTab(item.id)">{{ item.label }}</button>
</div>
<div v-if="tab === 'maps'" class="gx-navigation__offline">
<OfflineRoadMapsPanel :mode="mode" :unauthorized="unauthorized" :has-key="!!data?.hasKey" :metric="data?.isMetric !== false" />
<MapOperationsPanel :mode="mode" :unauthorized="unauthorized" />
</div>
<template v-else-if="tab === 'setup'">
<p v-if="mode !== 'local'" class="gx-note">Connect to your comma to set up navigation and choose a destination.</p>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<section class="gx-card gx-navigation__section">
<h3>Navigation</h3>
<p>Show directions on your comma. Navigation works with every driving model. Route guidance helps prepare for turns; steering and speed control follow your normal engagement settings.</p>
<button v-if="data" type="button" class="gx-btn" :class="{'gx-btn--tonal':data.enabled}" :disabled="!controlsAvailable"
@click="client.action('configure',{patch:{enabled:!data.enabled}})">{{ data.enabled ? 'Turn off navigation' : 'Turn on navigation' }}</button>
</section>
<section class="gx-card gx-navigation__section">
<h3>Route preferences</h3>
<p>Apply to new routes and automatic reroutes. Changing a preference recalculates your current route.</p>
<div v-for="option in routeAvoidances" :key="option.key" class="gx-row">
<span class="gx-row__info gx-row__label">{{ option.label }}</span>
<label class="gx-switch"><input type="checkbox" role="switch" :aria-label="option.label" :checked="!!data?.[option.key]"
:disabled="!available" @change="client.action('configure', {patch: {[option.key]: $event.target.checked}})"><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></label>
</div>
</section>
<section class="gx-card gx-navigation__section">
<h3>Mapbox</h3>
<p>One public Mapbox access token (starting with pk.) handles the map, place and address search, and routes. A separate secret token is not needed.</p>
<p>Save your token while parked. Galaxy keeps it on your comma and never displays it after saving. Permanent address results can be saved. Search suggestions are temporary and used only for the current route. Favorites and recent destinations are also kept on your comma; going to one skips search, so it uses none of your search allowance. URL-restricted keys may reject map requests.</p>
<p class="gx-note">Typing suggestions and offline road maps stop just short of Mapbox's free monthly allowance and resume on the 1st. Permanent address lookups are billed by Mapbox. Searches, address lookups, routes and map views are counted here but not limited.</p>
<dl v-if="usageRows.length" class="gx-navigation__usage" aria-label="Mapbox use this month">
<template v-for="row in usageRows" :key="row.key"><dt>{{ row.label }}</dt><dd>{{ row.used.toLocaleString() }}<template v-if="row.key !== 'geocoding'"> of {{ row.limit.toLocaleString() }} free</template> this month</dd></template>
</dl>
<a class="gx-navigation__token-link" href="https://account.mapbox.com/access-tokens/" target="_blank" rel="noopener noreferrer">Get a Mapbox access token</a>
<p v-if="data?.hasKey" class="gx-note">A Mapbox key is saved.</p>
<div class="gx-field-group"><label for="navigation-token">Public Mapbox access token</label>
<form @submit.prevent="saveKey(false)" class="gx-navigation__search">
<input id="navigation-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!controlsAvailable">
<button class="gx-btn" type="submit" :disabled="!controlsAvailable || !token.trim()">{{ data?.hasKey ? 'Replace key' : 'Save key' }}</button>
</form></div>
</section>
</template>
<template v-else>
<div class="gx-navigation__workspace">
<div class="gx-navigation__panel">
<p v-if="mode === 'local' && !data" role="status" class="gx-note">{{ error ? 'Navigation could not be loaded.' : 'Connecting to your comma…' }}</p>
<button v-if="mode === 'local' && !data && error" type="button" class="gx-btn" @click="client.load()">Try again</button>
<p v-if="mode !== 'local'" class="gx-card gx-navigation__section gx-note">Connect to your comma to set up navigation and choose a destination.</p>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<p v-if="stale && data" class="gx-card gx-navigation__section gx-note">Showing the last received route. Reconnecting before accepting changes.</p>
<GxNotice v-if="offline" tone="warn">Your comma has no network connection. You can
<a class="gx-navigation__link" href="#/navigation/maps" @click.prevent="selectTab('maps')">download maps for offline navigation views</a>
when you're back online. New routes need internet.</GxNotice>
<GxNotice v-else-if="usingSavedLocation">Using your last saved location to plan routes. Route planning requires Internet; offline map downloads aren't required.
Live guidance starts when GPS is available.</GxNotice>
<form v-if="ready" @submit.prevent="search" class="gx-navigation__search" role="search" @focusout="onSearchFocusOut">
<div class="gx-navigation__field">
<i class="bi bi-search" aria-hidden="true"></i>
<label for="navigation-search" class="gx-sr-only">Search destinations</label>
<input id="navigation-search" ref="search" v-model="query" placeholder="Address or place name" minlength="2" maxlength="200" required
autocomplete="off" enterkeyhint="search" :disabled="!controlsAvailable" role="searchbox"
@input="typed" @focus="suggestOpen = true" @keydown.escape="closeSuggestions">
<button v-if="query || results.length" type="button" class="gx-navigation__icon" aria-label="Clear search" @mousedown.prevent @click="clearQuery"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
</div>
</form>
<div v-if="showQuick || showSuggestions" id="navigation-suggestions" class="gx-navigation__suggestions" @mousedown.prevent @focusout="onSearchFocusOut">
<template v-if="showQuick">
<div v-if="favorites.length" class="gx-navigation__group"><span>Saved</span></div>
<div v-for="place in favorites" :key="'saved-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small v-if="placeDetail(place)">{{ placeDetail(place) }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from saved places'" :disabled="!controlsAvailable" @click="removeFavorite(place)"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name + ' as Home, Work, or Other'" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi bi-star-fill" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</div>
<div v-if="recents.length" class="gx-navigation__group"><span>Recent</span>
<button type="button" class="gx-navigation__link" :disabled="!controlsAvailable" @click="client.action('clearRecents')">Clear</button></div>
<div v-for="place in recents" :key="'recent-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi bi-clock-history" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address">{{ place.address }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi bi-star" aria-hidden="true"></i></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Remove ' + place.name + ' from recent places'" :disabled="!controlsAvailable" @click="client.action('removeRecent',{id:place.id})"><i class="bi bi-x-lg" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</div>
</template>
<template v-else>
<div v-for="place in localMatches" :key="'local-' + place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i><span><strong>{{ placeName(place) }}</strong><small>{{ placeDetail(place) || (isFavorite(place) ? 'Saved place' : 'Recent') }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</div>
<div v-for="place in suggestions" :key="place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi bi-geo-alt-fill" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.description">{{ place.description }}</small></span></button>
<button type="button" class="gx-navigation__icon" :aria-label="'Save ' + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</div>
</template>
</div>
<div v-if="showShortcuts" class="gx-navigation__shortcuts" aria-label="Saved places">
<button v-for="place in shortcuts" :key="place.id" type="button" class="gx-navigation__chip" :disabled="!controlsAvailable" @click="pick(place)">
<i class="bi" :class="placeIcon(place)" aria-hidden="true"></i>{{ placeName(place) }}</button>
</div>
<section v-if="data?.destination" class="gx-card gx-navigation__section gx-navigation__destination" :class="{'gx-navigation--stale':stale}">
<div class="gx-navigation__heading">
<div><h3 class="gx-navigation__summary-title">{{ data.destination.name }}</h3><p v-if="data.destination.address" class="gx-note">{{ data.destination.address }}</p></div>
<button type="button" class="gx-navigation__icon gx-navigation__save" :class="{'gx-navigation__save--on':destinationSaved}" :aria-pressed="!!destinationSaved"
:aria-label="destinationSaved ? 'Change saved place category' : 'Save this place'" :aria-expanded="favoritePicker === favoriteKey(data.destination)" :disabled="!controlsAvailable || data.destination.temporary" :title="data.destination.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(data.destination)">
<i class="bi" :class="destinationSaved ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
</div>
<FavoriteChoices v-if="favoritePicker === favoriteKey(data.destination)" :available="controlsAvailable" @choose="saveAs(data.destination, $event)" />
<div v-if="summary" class="gx-navigation__summary">
<div><strong>{{duration(summary.duration)}}</strong><span>{{distance(summary.distance)}}</span><span>Arrive {{new Date(Date.now()+summary.duration*1000).toLocaleTimeString([], {hour:'numeric',minute:'2-digit'})}}</span></div>
</div>
<p v-else class="gx-note">{{statusLabel}}</p>
<template v-if="data.instruction && !stale">
<p class="gx-navigation__instruction">{{ data.instruction.text }}</p>
<p class="gx-note">In {{ distance(data.instruction.distanceMeters) }}</p>
</template>
<div v-if="data.alternatives?.length > 1" class="gx-navigation__alternatives gx-actions" aria-label="Alternative routes">
<button v-for="choice in data.alternatives" :key="choice.index" type="button" class="gx-btn" :class="choice.index===data.selectedRoute ? '' : 'gx-btn--tonal'" :aria-pressed="choice.index===data.selectedRoute" :disabled="!controlsAvailable" @click="client.action('selectRoute',{index:choice.index})">{{duration(choice.durationSeconds)}} · {{distance(choice.distanceMeters)}}</button>
</div>
<div class="gx-navigation__route-actions gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!controlsAvailable" @click="$refs.search?.focus()"><i class="bi bi-search" aria-hidden="true"></i> Change destination</button>
<button type="button" class="gx-btn gx-btn--danger" :disabled="!controlsAvailable" @click="client.action('clear')">End navigation</button>
</div>
</section>
<section v-if="data && !data.hasKey" class="gx-card gx-navigation__section"><h3>Connect Mapbox</h3><p>Add your public Mapbox key to search for places and plan a route. Your key stays on your comma.</p>
<form @submit.prevent="saveKey(true)" class="gx-navigation__search"><label for="navigation-inline-token" class="gx-sr-only">Public Mapbox access token</label><input id="navigation-inline-token" class="gx-field" type="password" autocomplete="off" v-model="token" placeholder="pk.…" maxlength="2048" required :disabled="!controlsAvailable"><button type="submit" class="gx-btn" :disabled="!controlsAvailable || !token.trim()">Save and enable</button></form><button type="button" class="gx-btn gx-btn--tonal" @click="selectTab('setup')">Key details</button>
</section>
<section v-else-if="data && !data.enabled" class="gx-card gx-navigation__section"><h3>Navigation is off</h3><button type="button" class="gx-btn" :disabled="!controlsAvailable" @click="client.action('configure',{patch:{enabled:true}})">Turn on navigation</button></section>
<template v-else>
<p v-if="busy || searchPending" role="status" class="gx-navigation__busy">{{ searchPending ? 'Searching…' : 'Working…' }}</p>
<p v-if="searched && !busy && !error && results.length === 0" class="gx-card gx-navigation__section gx-note">No places found. Try a nearby town or a more specific address.</p>
<ul v-if="results.length" class="gx-navigation__places" aria-label="Search results">
<li v-for="place in results" :key="place.id" class="gx-navigation__row">
<button type="button" class="gx-navigation__suggestion" :disabled="!controlsAvailable" @click="pick(place)" :aria-label="'Start navigation to ' + place.name">
<i class="bi bi-arrow-up-right" aria-hidden="true"></i><span><strong>{{ place.name }}</strong><small v-if="place.address || place.description">{{ place.address || place.description }}</small><small class="gx-navigation__start">Start navigation</small></span>
</button>
<button type="button" class="gx-navigation__icon" :aria-label="(isFavorite(place) ? 'Saved: ' : 'Save ') + place.name" :aria-pressed="isFavorite(place)" :aria-expanded="favoritePicker === favoriteKey(place)" :disabled="!controlsAvailable || place.temporary" :title="place.temporary ? 'Search for an address to save this place' : undefined" @click="openFavorite(place)"><i class="bi" :class="isFavorite(place) ? 'bi-star-fill' : 'bi-star'" aria-hidden="true"></i></button>
<FavoriteChoices v-if="favoritePicker === favoriteKey(place)" :available="controlsAvailable" @choose="saveAs(place, $event)" />
</li>
</ul>
</template>
</div>
<section class="gx-card gx-navigation__preview" aria-label="Route preview">
<div class="gx-navigation__preview-head"><div class="gx-navigation__step"><div><h3>Your route</h3><p role="status">{{ stale ? 'Reconnecting · last received route' : statusLabel }}</p></div></div>
<span v-if="summary" class="gx-navigation__trip">{{ duration(summary.duration) }} · {{ distance(summary.distance) }}</span></div>
<NavigationMap v-if="ready" :data="data" :stale="stale" />
<div v-else class="gx-navigation__empty"><i class="bi bi-signpost-split" aria-hidden="true"></i><p>{{ mode !== 'local' ? 'Connect to your comma to use navigation.' : 'Set up navigation to show the map.' }}</p><button v-if="mode === 'local' && data" type="button" class="gx-btn gx-btn--tonal" @click="selectTab('setup')">Open navigation setup</button></div>
</section>
</div>
</template>
</div>`,
}
@@ -0,0 +1,282 @@
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
import { GxNotice } from "./notice.js"
import { PollTimer, connectionError } from "./polling.js"
import { RasterMap } from "./navigation-map.js"
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
// Road-line maps for the Android Auto overlay: areas saved on the comma, save-as-you-drive, and the downloader's progress.
const object = (value) => !!value && typeof value === "object" && !Array.isArray(value)
const finite = (value) => typeof value === "number" && Number.isFinite(value)
const STATES = new Set(["queued", "downloading", "waiting_wifi", "complete", "incomplete", "no_space"])
const KM_PER_MILE = 1.609344
const START_ZOOM = 12 // a few miles across: streets readable, a typical area still fits after zooming out
// Areas are stored in km; imperial devices pick and show whole miles.
export function radiusLabel(radiusKm, metric) {
return metric ? `${Math.round(radiusKm)} km` : `${Math.round(radiusKm / KM_PER_MILE)} mi`
}
export function radiusRange(constants, metric) {
if (metric) return { min: constants.minRadiusKm, max: constants.maxRadiusKm, initial: constants.defaultRadiusKm }
return { min: Math.ceil(constants.minRadiusKm / KM_PER_MILE), max: Math.floor(constants.maxRadiusKm / KM_PER_MILE), initial: 15 }
}
export const toKm = (value, metric) => metric ? value : Math.round(value * KM_PER_MILE * 10) / 10
export function formatBytes(bytes) {
const value = Math.max(0, Number(bytes) || 0)
if (value < 1024 * 1024) return `${Math.max(value ? 1 : 0, Math.round(value / 1024))} KB`
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(value < 10 * 1024 ** 2 ? 1 : 0)} MB`
return `${(value / 1024 ** 3).toFixed(2)} GB`
}
// Mirrors offline_roads.estimate: a circle of z14 tiles.
export function estimateArea(latitude, radiusKm, constants) {
const sideKm = 40075.016686 * Math.cos(latitude * Math.PI / 180) / 16384
const tiles = Math.max(1, Math.round(Math.PI * (radiusKm / sideKm + 0.5) ** 2))
return { tiles, downloadBytes: tiles * constants.averageDownloadBytes, storedBytes: tiles * constants.averageStoredBytes }
}
export function validOffline(data) {
const point = (value) => object(value) && finite(value.latitude) && Math.abs(value.latitude) <= 90 && finite(value.longitude) && Math.abs(value.longitude) <= 180
const constants = data?.constants
return object(data) && Array.isArray(data.areas) && data.areas.length <= 64 && object(data.settings) && typeof data.settings.saveDriven === "boolean" &&
(data.position === null || point(data.position)) && object(data.service) && typeof data.service.running === "boolean" &&
object(constants) && ["minRadiusKm", "maxRadiusKm", "defaultRadiusKm", "averageDownloadBytes", "averageStoredBytes", "maxAreas"].every((key) => finite(constants[key])) &&
data.areas.every((area) => point(area) && typeof area.id === "string" && /^[0-9a-f]{32}$/.test(area.id) &&
typeof area.name === "string" && area.name.length <= 80 && finite(area.radiusKm) &&
(area.progress === null || object(area.progress) && STATES.has(area.progress.state) && finite(area.progress.total) && finite(area.progress.done)))
}
export function areaLabel(area) {
const progress = area.progress
if (!progress) return "Waiting for the downloader"
const percent = progress.total ? Math.floor(progress.done / progress.total * 100) : 0
return ({ queued: "Queued", downloading: `Downloading · ${percent}%`, waiting_wifi: `Waiting for Wi-Fi · ${percent}%`,
complete: "Saved for offline", incomplete: `${progress.failed} tiles missing · retries later`, no_space: "Storage full" })[progress.state]
}
export class OfflineRoadsClient {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancel })
this.data = null
this.busy = false
this.error = ""
this.active = false
this.generation = 0
this.poller = new PollTimer({ read: () => this.load(), interval: 3000, later, cancel })
}
emit() { this.publish({ data: this.data, busy: this.busy, error: this.error }) }
start() { this.stop(); this.active = true; this.poller.start(); return this.load() }
stop() { this.active = false; this.generation++; this.controller?.abort(); this.busy = false; this.poller.stop() }
async request(body = null) {
this.controller?.abort()
const generation = ++this.generation, controller = this.controller = new AbortController()
const deadline = this.later(() => controller.abort(), 10000)
try {
const response = await this.fetcher("./api/navigation/offline", { credentials: "same-origin", cache: "no-store",
signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) })
const payload = await response.json().catch(() => null)
if (!this.active || generation !== this.generation) return null
if (controller.signal.aborted) throw new Error("Offline maps did not respond. Try again shortly.")
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(payload?.code)) {
this.stop(); this.unauthorized(); return null
}
if (!response.ok) throw new Error(payload?.error || "Offline maps are unavailable. Try again shortly.")
if (!validOffline(payload)) throw new Error("Offline maps returned an unexpected answer. Reload to try again.")
return payload
} catch (error) {
if (this.active && generation === this.generation) throw error
return null
} finally { this.cancel(deadline) }
}
async load() {
if (!this.active || this.busy) return
try {
const data = await this.request()
if (data && this.active && !this.busy) { this.data = data; this.error = ""; this.emit() }
} catch (error) { if (this.active) { this.error = connectionError(error); this.emit() } }
}
async action(body) {
if (!this.active || this.busy) return null
this.busy = true
this.error = ""
this.emit()
const generation = this.generation + 1
try {
const data = await this.request(body)
if (data) this.data = data
return data
} catch (error) { this.error = connectionError(error); return null }
finally { if (generation === this.generation) { this.busy = false; if (this.active) this.emit() } }
}
}
export const OfflineRoadMapsPanel = {
name: "OfflineRoadMapsPanel",
components: { GxNotice, GalaxySettingRow },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
hasKey: { type: Boolean, default: false }, metric: { type: Boolean, default: true } },
data: () => ({ data: null, busy: false, error: "", point: null, name: "", radius: null, confirmDelete: null, notice: "" }),
created() { this.client = new OfflineRoadsClient({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
if (this.mode !== "local") return
this.visibility = () => document.hidden ? this.client.stop() : this.client.start()
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.client.start()
this.ensureMap()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client.stop(); this.map?.close() },
watch: {
hasKey() { this.$nextTick(() => this.ensureMap()) },
data(value) {
if (value && this.radius === null) this.radius = radiusRange(value.constants, this.metric).initial
if (value?.position && this.map && !this.map.initialized) {
this.map.center = value.position; this.map.zoom = START_ZOOM; this.map.initialized = true
}
this.drawOverlays()
},
metric() { if (this.constants) this.radius = radiusRange(this.constants, this.metric).initial },
point() { this.drawOverlays() },
radius() { this.drawOverlays() },
},
computed: {
constants() { return this.data?.constants },
range() { return this.constants ? radiusRange(this.constants, this.metric) : null },
radiusKm() { return this.radius === null ? null : toKm(this.radius, this.metric) },
estimate() { return this.point && this.constants ? estimateArea(this.point.latitude, this.radiusKm, this.constants) : null },
service() { return this.data?.service || {} },
serviceLabel() {
const service = this.service, areas = this.data?.areas || []
if (!this.data) return "Checking…"
if (!service.running) return "Not running"
if (service.failure === "key") return "Mapbox key rejected"
if (service.failure === "budget") return "Paused until the 1st"
if (service.noSpace) return "Storage full"
if (areas.some((area) => area.progress?.state === "downloading")) return "Downloading"
if (areas.some((area) => area.progress?.state === "waiting_wifi")) return "Waiting for Wi-Fi"
return service.network === "offline" ? "Offline" : "Ready"
},
storage() {
const bytes = this.data?.bytes, limits = this.data?.limits
if (!bytes) return null
const used = (bytes.saved || 0) + (bytes.driven || 0) + (bytes.cache || 0)
const cap = limits ? (limits.saved || 0) + (limits.driven || 0) + (limits.cache || 0) : 0
return { used, cap, parts: [["Areas", bytes.saved || 0, "#9d72ff"], ["Driven", bytes.driven || 0, "#34c778"], ["Recent", bytes.cache || 0, "#4096ff"]] }
},
usage() { const usage = this.data?.usage || {}; return { tiles: usage.tiles || 0, free: usage.freeTiles || 200000 } },
// This counter covers this comma, not other uses of the same key.
freeTilesLeft() { return Math.max(0, Math.floor(this.usage.free * 0.99) - this.usage.tiles) },
saveAvailable() { return this.mode === "local" && !!this.point && !!this.data && this.data.areas.length < this.constants.maxAreas },
canSave() { return this.saveAvailable && !this.busy },
},
methods: {
formatBytes, areaLabel, radiusLabel, switchRow,
ensureMap() {
if (this.map || !this.hasKey || !this.$refs.canvas) return
this.map = markRaw(new RasterMap(this.$refs.canvas, () => {}))
this.map.onTap = (point) => this.pick({ ...point, name: "" })
this.map.update({}, false)
if (this.data?.position) { this.map.center = this.data.position; this.map.zoom = START_ZOOM; this.map.initialized = true }
this.overlaySignature = null
this.drawOverlays()
},
zoom(delta) { this.map?.changeZoom(delta) },
pick(point) {
this.point = { latitude: point.latitude, longitude: point.longitude }
this.name = point.name || ""
this.notice = ""
},
useLocation() { if (this.data?.position) { this.pick({ ...this.data.position, name: "Around my car" }); this.focus(this.point, this.radiusKm) } },
focus(point, radiusKm) {
if (!this.map) return
this.map.center = { latitude: point.latitude, longitude: point.longitude }
const pixels = Math.min(this.$refs.canvas.clientWidth, this.$refs.canvas.clientHeight) * 0.42
const metersPerPixel = radiusKm * 1000 / Math.max(40, pixels)
const zoom = Math.log2(40075016.686 * Math.cos(point.latitude * Math.PI / 180) / (512 * metersPerPixel))
this.map.zoom = Math.max(3, Math.min(14, Math.floor(zoom)))
this.map.requestDraw()
},
drawOverlays() {
if (!this.map) return
const saved = (this.data?.areas || []).map((area) => ({ ...area, stroke: area.progress?.state === "complete" ? "#34c778" : "#9d72ff",
fill: area.progress?.state === "complete" ? "#34c77822" : "#9d72ff1f" }))
const draft = this.point ? [{ ...this.point, radiusKm: this.radiusKm, stroke: "#f5b642", fill: "#f5b64226", dash: [8, 6], width: 3 }] : []
const overlays = [...saved, ...draft], signature = JSON.stringify(overlays)
if (signature === this.overlaySignature) return // polls usually change nothing on the map
this.overlaySignature = signature
this.map.overlays = overlays
this.map.requestDraw()
},
async save() {
if (!this.canSave) return
const data = await this.client.action({ action: "addArea", area: { ...this.point, radiusKm: Number(this.radiusKm), name: this.name.trim() } })
if (data) { this.point = null; this.name = ""; this.notice = "Area saved. It downloads on Wi-Fi; you can leave this page." }
},
async remove(area) {
this.confirmDelete = null
await this.client.action({ action: "deleteArea", id: area.id })
},
setSaveDriven(value) { return this.client.action({ action: "settings", patch: { saveDriven: value } }) },
},
template: `
<section class="gx-card gx-offline-roads" aria-label="Offline road maps" :inert="busy" :aria-busy="busy || undefined">
<div class="gx-section__header"><i class="bi bi-signpost-split"></i><span class="gx-section__title">Map Overlay Roads</span></div>
<p class="gx-note">Road lines for the Android Auto map overlay. Saved areas stay on your comma, so the overlay keeps drawing roads with no signal. Downloads happen on Wi-Fi.</p>
<p v-if="mode !== 'local'" class="gx-note">Connect to your comma to manage offline road maps.</p>
<template v-else>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxNotice tone="warn" v-if="!hasKey">Add your Mapbox key in Setup. Road maps download with the same key.</GxNotice>
<GxNotice tone="warn" v-else-if="service.failure === 'key'">Mapbox rejected the saved key for map tiles. Check that it is a public (pk.) key without URL restrictions.</GxNotice>
<GxNotice tone="warn" v-if="service.failure === 'budget'">This comma's Mapbox road tile allowance is used up, so downloads pause until the 1st.</GxNotice>
<GxNotice tone="warn" v-if="service.noSpace">The comma's storage is nearly full, so new map areas are paused. Delete an area or free space.</GxNotice>
<div class="gx-offline-roads__stats">
<div class="gx-offline-roads__stat"><span>Downloader</span><strong>{{ serviceLabel }}</strong></div>
<div class="gx-offline-roads__stat"><span>Mapbox this month</span><strong>{{ usage.tiles.toLocaleString() }}</strong><small>of {{ Math.floor(usage.free * 0.99).toLocaleString() }} free tiles used</small></div>
<div v-if="storage" class="gx-offline-roads__stat gx-offline-roads__stat--wide"><span>Offline overlay road lines</span><strong>{{ formatBytes(storage.used) }}</strong>
<div class="gx-offline-roads__bar" role="img" :aria-label="formatBytes(storage.used) + ' used for road maps'">
<i v-for="[label, value, color] in storage.parts" :key="label" :style="{ width: (storage.cap ? Math.max(value ? 1.5 : 0, value / storage.cap * 100) : 0) + '%', background: color }"></i></div>
<small><span v-for="[label, value, color] in storage.parts" :key="label" class="gx-offline-roads__legend"><b :style="{ background: color }"></b>{{ label }} {{ formatBytes(value) }}</span></small></div>
</div>
<GalaxySettingRow :row="switchRow('Save roads as you drive', data?.settings.saveDriven, 'Downloads road lines around and ahead of your car while the map overlay is on and internet is available. Saved lines keep the overlay visible offline; older driven areas are removed when its 600 MB cache fills.')"
:index="0" :busy="busy" :disabled="!data" :save-value="(index, value) => setSaveDriven(value === 'On')" />
<div class="gx-offline-roads__picker">
<div v-if="hasKey" class="gx-offline-roads__map"><canvas ref="canvas" tabindex="0" aria-label="Map. Tap or press Enter to center a new offline area; drag or use arrow keys to pan."></canvas>
<div class="gx-navigation-map__controls"><button class="gx-btn" type="button" @click="zoom(1)" aria-label="Zoom in">+</button>
<button class="gx-btn" type="button" @click="zoom(-1)" aria-label="Zoom out">−</button></div>
<span class="gx-offline-roads__hint">{{ point ? 'Adjust the radius, name it, then save.' : 'Tap the map to center a new area.' }}</span></div>
<div class="gx-offline-roads__form">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!data?.position" @click="useLocation"><i class="bi bi-crosshair"></i> Around my car</button>
<template v-if="point && constants">
<label for="offline-area-name">Name</label>
<input id="offline-area-name" class="gx-field" v-model="name" maxlength="80" placeholder="Home, Tahoe trip…">
<label for="offline-area-radius">Radius · {{ radiusLabel(radiusKm, metric) }}</label>
<input id="offline-area-radius" class="gx-slider" type="range" :min="range.min" :max="range.max" step="1" v-model.number="radius" @change="focus(point, radiusKm)">
<p class="gx-note" role="status">≈ {{ estimate.tiles.toLocaleString() }} tiles · {{ formatBytes(estimate.downloadBytes) }} to download · {{ formatBytes(estimate.storedBytes) }} on the comma</p>
<p v-if="estimate.tiles > freeTilesLeft" class="gx-note">Only {{ freeTilesLeft.toLocaleString() }} tiles are left in this comma's allowance this month. The area resumes after the 1st.</p>
<div class="gx-settings__controls"><button type="button" class="gx-btn gx-btn--tonal" @click="point = null">Cancel</button>
<button type="button" class="gx-btn" :disabled="!saveAvailable" @click="save">Save area</button></div>
</template>
<p v-if="notice" class="gx-note" role="status">{{ notice }}</p>
</div>
</div>
<ul v-if="data?.areas.length" class="gx-offline-roads__areas" aria-label="Saved offline areas">
<li v-for="area in data.areas" :key="area.id">
<div class="gx-offline-roads__area"><strong>{{ area.name }}</strong><small>{{ radiusLabel(area.radiusKm, metric) }} radius · {{ areaLabel(area) }}</small>
<div v-if="area.progress && area.progress.state !== 'complete'" class="gx-offline-roads__bar"><i :style="{ width: (area.progress.total ? area.progress.done / area.progress.total * 100 : 0) + '%', background: '#9d72ff' }"></i></div></div>
<div class="gx-navigation__actions">
<button type="button" class="gx-btn gx-btn--tonal" @click="focus(area, area.radiusKm)">Show</button>
<button v-if="confirmDelete !== area.id" type="button" class="gx-btn gx-btn--tonal" @click="confirmDelete = area.id">Delete</button>
<button v-else type="button" class="gx-btn" @click="remove(area)">Delete {{ area.name }}?</button>
</div>
</li>
</ul>
<p v-else-if="data" class="gx-note">No offline areas yet.{{ data.settings.saveDriven ? ' Road lines are saved while the map overlay is on.' : ' Turn on Save roads as you drive to retain road lines along your drives.' }}</p>
<p v-if="data?.route" class="gx-note">Active route: {{ data.route.done }} of {{ data.route.total }} corridor tiles checked.</p>
</template>
</section>`,
}
@@ -1,3 +1,5 @@
import { LEAD_INFO } from "./projection-layout.js"
export const PROFILES = ["large", "compact"]
const COLORS = ["cardFill", "cardBorder", "text"]
export const ROAD_COLORS = ["path", "pathEdge", "laneLines"]
@@ -17,6 +19,7 @@ const frontmostOrder = (order, profile) => {
// Display preferences ride along in the draft but are not part of the rendered layout.
export const layoutDocument = ({ display, ...document }) => document
export const previewDocument = document => ({ ...layoutDocument(document), developerSidebar: document.display?.DeveloperSidebar === "On" })
// The device sends them beside the layout; the editor keeps them in its draft so Save, undo and reload cover them.
export const withDisplay = (data) => !object(data?.display) || !Array.isArray(data.display.rows) ? data : {
...data, display: data.display.rows,
@@ -103,18 +106,23 @@ export function previewPoint(event, rect, profile) {
}
// Android Auto display switches, independent of the comma screen's preferences.
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback"]
// Text alignment inside the cruise and speed-limit card, matching onroad_customization.SPEED_ALIGNMENTS.
export const SPEED_ALIGNMENTS = ["left", "center", "right"]
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback", "leadIndicator"]
export function validDocument(document, metadata) {
if (!object(document)) return false
const profiles = metadata?.projection === true ? ["large"] : PROFILES
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : [])]) || document.version !== 6 ||
if (!keysEqual(document, ["version", "palette", "layouts", "widgetColors", "roadColors", ...(Object.hasOwn(document, "widgetOrder") ? ["widgetOrder"] : []), ...(Object.hasOwn(document, "speedSources") ? ["speedSources"] : []), ...(Object.hasOwn(document, "speedSourcesCard") ? ["speedSourcesCard"] : []), ...(Object.hasOwn(document, "speedAlign") ? ["speedAlign"] : []), ...(Object.hasOwn(document, "clock24Hour") ? ["clock24Hour"] : []), ...(Object.hasOwn(document, "showMaxSpeed") ? ["showMaxSpeed"] : []), ...(metadata?.projection !== true && Object.hasOwn(document, "display") ? ["display"] : []), ...(metadata?.projection === true && Object.hasOwn(document, "largeUiGammaTrial") ? ["largeUiGammaTrial"] : []), ...(metadata?.projection === true ? PROJECTION_DISPLAY_SWITCHES.filter(key => Object.hasOwn(document, key)) : []), ...(metadata?.projection === true && Object.hasOwn(document, "leadInfo") ? ["leadInfo"] : [])]) || document.version !== 7 ||
(Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
(Object.hasOwn(document, "speedSourcesCard") && typeof document.speedSourcesCard !== "boolean") ||
(Object.hasOwn(document, "speedAlign") && !SPEED_ALIGNMENTS.includes(document.speedAlign)) ||
(Object.hasOwn(document, "clock24Hour") && typeof document.clock24Hour !== "boolean") ||
(Object.hasOwn(document, "showMaxSpeed") && typeof document.showMaxSpeed !== "boolean") ||
(Object.hasOwn(document, "display") && (!object(document.display) || !Object.values(document.display).every((value) => typeof value === "string"))) ||
(Object.hasOwn(document, "largeUiGammaTrial") && typeof document.largeUiGammaTrial !== "boolean") ||
PROJECTION_DISPLAY_SWITCHES.some(key => Object.hasOwn(document, key) && typeof document[key] !== "boolean") ||
(Object.hasOwn(document, "leadInfo") && !LEAD_INFO.includes(document.leadInfo)) ||
!keysEqual(document.palette, COLORS) || !COLORS.every((key) => HEX.test(document.palette[key])) ||
!keysEqual(document.layouts, profiles) || !keysEqual(document.widgetColors, profiles) || !keysEqual(document.roadColors, profiles)) return false
if (Object.hasOwn(document, "widgetOrder") && (!object(document.widgetOrder) ||
@@ -204,13 +212,13 @@ export function validSnapshot(data) {
![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.opacity && (!projection || !["nav_map", "nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) ||
if (widget.opacity && (!projection || !["nav_map", "nav_home", "nav_work", "bookmark", "car_exit", "cruise_limits"].includes(widget.kind) ||
!object(widget.opacity) || ![widget.opacity.min, widget.opacity.max, widget.opacity.default].every(Number.isInteger) ||
widget.opacity.min < 0 || widget.opacity.max > 100 || widget.opacity.min > widget.opacity.default ||
widget.opacity.default > widget.opacity.max)) return false
if (widget.backgroundToggle != null && (!projection || !["nav_home", "nav_work", "bookmark", "car_exit"].includes(widget.kind) ||
widget.backgroundToggle !== true || !widget.opacity)) return false
if (widget.resizable && (widget.kind !== "steering_wheel" || !Number.isInteger(widget.resizable.min) ||
if (widget.resizable && (!["steering_wheel", "driver_monitor"].includes(widget.kind) || !Number.isInteger(widget.resizable.min) ||
!Number.isInteger(widget.resizable.default) || !Number.isInteger(widget.resizable.max) ||
widget.resizable.default !== widget.width || widget.width !== widget.height ||
widget.resizable.min < 24 || widget.resizable.max > area.width || widget.resizable.max > area.height ||
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
// Saved centers are pixels in the unmirrored cabin frame. Invert changes only
// the displayed crop pixels; vehicle-side selection always uses source sides.
export const FORMATS = [[1928, 1208], [1344, 760]]
export function displayPoint(center, width, invert) {
return center === null ? null : [invert ? width - center[0] : center[0], center[1]]
}
export function sourcePoint(clientX, clientY, rect, width, height, invert) {
if (!(rect?.width > 0 && rect.height > 0) || typeof invert !== "boolean" ||
!FORMATS.some(([w, h]) => w === width && h === height)) return null
const x = (clientX - rect.left) / rect.width
const y = (clientY - rect.top) / rect.height
if (!Number.isFinite(x) || !Number.isFinite(y) || x < 0 || x > 1 || y < 0 || y > 1) return null
return [Math.round((invert ? 1 - x : x) * width), Math.round(y * height)]
}
const centerValid = (center, width, height, size) => center === null ||
(Array.isArray(center) && center.length === 2 && center.every(Number.isInteger) &&
center[0] - size / 2 >= 0 && center[0] + size / 2 <= width &&
center[1] - size / 2 >= 0 && center[1] + size / 2 <= height)
export function maskDraft(width, height, cropSize, centerLeft, centerRight) {
if (!FORMATS.some(([w, h]) => w === width && h === height) ||
!Number.isInteger(cropSize) || cropSize < 20 || cropSize > Math.min(width, height) ||
(centerLeft === null && centerRight === null) ||
!centerValid(centerLeft, width, height, cropSize) ||
!centerValid(centerRight, width, height, cropSize)) return null
return { width, height, crop_size: cropSize, center_left: centerLeft, center_right: centerRight }
}
+212
View File
@@ -0,0 +1,212 @@
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { LiveCameraPreview } from "./cameras.js"
import { SettingsFeed, isPipCropRow, settingsBusy } from "./settings.js"
import { GalaxySettingRow } from "./galaxy-setting-row.js"
import { displayPoint, FORMATS, maskDraft, sourcePoint } from "./pip-geometry.js"
const SIDES = [
{ key: "centerRight", label: "Left camera", color: "#5ee5ee" },
{ key: "centerLeft", label: "Right camera", color: "#ffb865" },
]
export const PipPage = {
name: "PipPage",
components: { GxDialog, GxState, GxNotice, GalaxySettingRow },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", width: 1928, height: 1208,
cropSize: 580, centerLeft: null, centerRight: null, invert: null, activeSide: "centerRight",
localNote: "", cameraWarming: false, cameraError: "", imageName: "", reviewing: false })
let lastEditor = null
const feed = new SettingsFeed({ unauthorized: props.unauthorized, publish: (update) => {
Object.assign(state, update)
if (update.status === "ready" && update.data?.view && JSON.stringify(update.data.editor) !== lastEditor) {
lastEditor = JSON.stringify(update.data.editor)
const editor = update.data.editor
if (editor) {
state.width = editor.width
state.height = editor.height
state.cropSize = editor.cropSize
state.centerLeft = editor.centerLeft === null ? null : [...editor.centerLeft]
state.centerRight = editor.centerRight === null ? null : [...editor.centerRight]
state.invert = editor.invert
state.localNote = ""
} else {
state.invert = null
state.localNote = "Restore the invalid saved crop or mirror choice before editing."
}
}
} })
return { state, feed, FORMATS, SIDES }
},
mounted() {
this._dragIndex = -1
this._dragSide = null
this.liveCamera = new LiveCameraPreview({
unauthorized: this.unauthorized, enabled: () => this.mode === "local",
publish: update => Object.assign(this.state, update), redraw: () => this.redraw(),
})
if (this.mode === "local") this.feed.start("pip")
this.liveCamera.start()
this.$nextTick(() => this.redraw())
},
updated() { this.$nextTick(() => this.redraw()) },
beforeUnmount() { this.liveCamera.stop(); this.feed.stop() },
computed: {
controls() { return (this.state.data?.rows || []).map((row, index) => ({ row, index }))
.filter(({ row }) => !isPipCropRow(row) && !/camera availability/i.test(row.label)) },
busy() { return settingsBusy(this.state) },
editable() { return !!this.state.imageName && this.mode === "local" && !!this.state.data?.parked && this.state.data?.editorRow >= 0 &&
!!this.state.data.rows[this.state.data.editorRow]?.available && !this.state.error && typeof this.state.invert === "boolean" &&
FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height) },
canEdit() { return this.editable && !this.busy && this.state.status === "ready" },
cropLimit() {
const frame = Math.min(this.state.width, this.state.height)
const centers = [this.state.centerLeft, this.state.centerRight].filter(Boolean)
const fromCenters = centers.map(([x, y]) => 2 * Math.min(x, this.state.width - x, y, this.state.height - y))
return Math.max(20, Math.floor(Math.min(frame, ...fromCenters)))
},
},
methods: {
pointerDown(event) {
if (!this.canEdit) return
const canvas = this.$refs.canvas
const point = sourcePoint(event.clientX, event.clientY, canvas.getBoundingClientRect(), this.state.width, this.state.height, this.state.invert)
if (!point) return
const tolerance = 16 * this.state.width / canvas.getBoundingClientRect().width
const side = SIDES.find(side => this.state[side.key] && Math.hypot(point[0] - this.state[side.key][0], point[1] - this.state[side.key][1]) <= this.state.cropSize / 2 + tolerance)
if (side) this.state.activeSide = side.key
const center = this.state[this.state.activeSide]
this._gesture = { pointerId: event.pointerId, resize: !!center && Math.abs(Math.hypot(point[0] - center[0], point[1] - center[1]) - this.state.cropSize / 2) <= tolerance }
canvas.setPointerCapture(event.pointerId)
this.pointerMove(event)
},
pointerMove(event) {
if (!this._gesture || this._gesture.pointerId !== event.pointerId || !this.canEdit) return
if (!this._gesture.resize) { this.place(event); return }
const point = sourcePoint(event.clientX, event.clientY, this.$refs.canvas.getBoundingClientRect(), this.state.width, this.state.height, this.state.invert)
const center = this.state[this.state.activeSide]
if (point && center) this.resizeCrop(Math.round(2 * Math.hypot(point[0] - center[0], point[1] - center[1])))
},
pointerUp() { this._gesture = null },
choose(side) { if (this.canEdit) this.state.activeSide = side },
place(event) {
if (!this.canEdit) return
const canvas = this.$refs.canvas
const point = sourcePoint(event.clientX, event.clientY, canvas?.getBoundingClientRect(),
this.state.width, this.state.height, this.state.invert)
if (!point) return
const nextLeft = this.state.activeSide === "centerLeft" ? point : this.state.centerLeft
const nextRight = this.state.activeSide === "centerRight" ? point : this.state.centerRight
if (!maskDraft(this.state.width, this.state.height, this.state.cropSize, nextLeft, nextRight)) {
this.state.localNote = "Place the center far enough from the frame edge for the whole crop."
return
}
this.state.centerLeft = nextLeft
this.state.centerRight = nextRight
this.state.localNote = "Draft only; review both centers and crop size before saving."
this.redraw()
},
clear(side) {
if (!this.canEdit) return
this.state[side] = null
this.redraw()
},
resizeCrop(value) {
if (!this.canEdit || !Number.isFinite(value)) return
value = Math.round(value)
if (this.state.centerLeft || this.state.centerRight) {
if (!maskDraft(this.state.width, this.state.height, value, this.state.centerLeft, this.state.centerRight)) {
this.state.localNote = "That crop size would cross the image edge or leave no configured side."
return
}
} else if (value < 20 || value > Math.min(this.state.width, this.state.height)) return
this.state.cropSize = value
this.state.localNote = "Draft only; review both centers and crop size before saving."
this.redraw()
},
async saveCrop() {
if (!this.canEdit) return
const draft = maskDraft(this.state.width, this.state.height, this.state.cropSize,
this.state.centerLeft, this.state.centerRight)
if (!draft) { this.state.localNote = "Keep at least one complete crop inside the image."; return }
this.state.reviewing = true
try { await this.feed.preview(this.state.data.editorRow, 0, draft) }
finally { this.state.reviewing = false }
},
redraw() {
const canvas = this.$refs.canvas
if (!canvas || !FORMATS.some(([w, h]) => w === this.state.width && h === this.state.height)) return
if (canvas.width !== this.state.width) canvas.width = this.state.width
if (canvas.height !== this.state.height) canvas.height = this.state.height
const ctx = canvas.getContext("2d")
if (!ctx) return
const w = canvas.width, h = canvas.height
ctx.fillStyle = "#151821"
ctx.fillRect(0, 0, w, h)
if (this.liveCamera?.image) {
if (this.state.invert) { ctx.save(); ctx.translate(w, 0); ctx.scale(-1, 1) }
ctx.drawImage(this.liveCamera.image, 0, 0, w, h)
if (this.state.invert) ctx.restore()
} else {
ctx.strokeStyle = "#38404e"
ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke()
}
for (const side of SIDES) {
const center = this.state[side.key]
if (!center) continue
const [x, y] = displayPoint(center, w, this.state.invert)
const half = this.state.cropSize / 2
ctx.strokeStyle = side.color
ctx.lineWidth = Math.max(2, w / 640)
ctx.beginPath(); ctx.arc(x, y, half, 0, Math.PI * 2); ctx.stroke()
ctx.beginPath(); ctx.arc(x + half, y, Math.max(6, w / 160), 0, Math.PI * 2); ctx.fillStyle = side.color; ctx.fill()
ctx.beginPath(); ctx.arc(x, y, Math.max(4, w / 320), 0, Math.PI * 2); ctx.fillStyle = side.color; ctx.fill()
}
},
},
template: `
<section class="gx-settings gx-pip" aria-label="Blind Spot Camera and Preview saved settings" :inert="busy" :aria-busy="busy || undefined">
<header class="gx-settings__header gx-page-header"><div><h2>Blind Spot Camera and Preview</h2>
<p>Shows a close-up of the cabin window as you drive. Whether it appears on your turn signal or when a car is in your blind spot depends on your settings. Choose which window each side covers and how much to zoom in.</p></div>
<div class="gx-actions"><button type="button" class="gx-icon-btn" :disabled="state.cameraWarming" aria-label="Take a new cabin snapshot" title="New snapshot" @click="liveCamera.refresh()"><i class="bi bi-camera"></i></button><button type="button" class="gx-icon-btn" aria-label="Position side cameras" title="Position side cameras" @click="go('/theme_maker')"><i class="bi bi-layout-wtf" aria-hidden="true"></i></button></div></header>
<GxState v-if="mode !== 'local'">Local saved settings are unavailable in preview.</GxState>
<template v-else>
<GxNotice v-if="state.data && !state.data.parked" tone="warn">Turn the vehicle off to change these settings.</GxNotice>
<GxState v-if="state.status === 'loading'" loading>Reading blind-spot camera preferences and crop positions…</GxState>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read blind-spot camera preferences or crop positions. Reconnecting automatically…</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
<GxNotice tone="danger" v-if="state.cameraError">{{ state.cameraError }}</GxNotice>
<div v-if="state.data" class="gx-settings__body">
<section class="gx-card gx-vasm__editor" aria-label="Blind Spot Camera crop editor">
<h3>Camera Crop</h3>
<p class="gx-note">Drag a circle onto the cabin window you want to watch. Both crops share one size.</p>
<p class="gx-note gx-camera-status" role="status">{{ state.cameraWarming ? "Warming up the camera · 5 seconds…" : state.imageName ? "Snapshot ready" : "Turn off the vehicle to take a snapshot." }}</p>
<div class="gx-actions">
<button v-for="side in SIDES" :key="side.key" type="button" class="gx-btn gx-btn--tonal" :disabled="!editable" :aria-pressed="state.activeSide === side.key" :aria-label="side.label" @click="choose(side.key)">{{ side.key === "centerRight" ? "Left" : "Right" }}</button>
<button type="button" class="gx-icon-btn" :disabled="!editable || !state[state.activeSide]" aria-label="Clear selected camera crop" title="Clear selected crop" @click="clear(state.activeSide)"><i class="bi bi-eraser"></i></button>
<button type="button" class="gx-btn" :disabled="!editable" aria-label="Save Crop" @click="saveCrop">Save</button>
</div>
<canvas ref="canvas" class="gx-vasm__canvas" :aria-label="'Cabin source crop canvas, editing ' + (state.activeSide === 'centerRight' ? 'vehicle left' : 'vehicle right')" @pointerdown="pointerDown" @pointermove="pointerMove" @pointerup="pointerUp" @pointercancel="pointerUp" @lostpointercapture="pointerUp"></canvas>
<label class="gx-slider-row"><span class="gx-row__label">Crop size <span class="gx-row__value">{{ state.cropSize }} px</span></span>
<input type="range" class="gx-slider" min="20" :max="cropLimit" step="1" :value="state.cropSize" :disabled="!editable" aria-label="Camera crop size" @input="resizeCrop(Number($event.target.value))"></label>
<p v-if="state.localNote" class="gx-note" role="status">{{ state.localNote }}</p>
</section>
<section class="gx-card gx-settings__section" aria-label="Saved settings">
<GalaxySettingRow v-for="{ row, index } in controls" :key="state.data.page + ':' + index" :row="row" :index="index"
:busy="state.status !== 'ready' || state.reviewing || !!state.pending" :disabled="!state.data.parked || !!state.error"
:save-value="(index, value) => feed.previewValue(index, value)"
@review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
</section>
</div>
<GxDialog v-if="state.pending" labelledby="gx-pip-confirm-title" @close="feed.cancel()"><h3 id="gx-pip-confirm-title">Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></GxDialog>
</template>
</section>`,
}
+251
View File
@@ -0,0 +1,251 @@
import { GxState } from "./state.js"
import { GxIconButton } from "./icon-button.js"
// Bounded, read-only local control history. No layout or telemetry is persisted.
export const MAX_POINTS = 240
export const POLL_MS = 750
const SIGNALS = ['desiredLateralAccel', 'actualLateralAccel', 'desiredLongitudinalAccel', 'actualLongitudinalAccel',
'lateralP', 'lateralI', 'lateralD', 'lateralF', 'longitudinalP', 'longitudinalI', 'longitudinalF', 'speedMps']
const STATES = ['current', 'stale', 'unavailable']
const SOURCES = {
lateralSource: ['unavailable', 'torqueState', 'curvature'],
longitudinalSource: ['unavailable', 'aTarget', 'pidSum'],
lateralTermsSource: ['unavailable', 'torqueState', 'pidState'],
longitudinalTermsSource: ['unavailable', 'controlsState'],
speedSource: ['unavailable', 'deviceMotion', 'carState'],
}
export function validatePlotPayload(data) {
if (data?.schemaVersion !== 1 || !/^[0-9a-f]{16}$/.test(data.sessionId) || !STATES.includes(data.state) ||
!Number.isSafeInteger(data.sampleIndex) || data.sampleIndex < 0 ||
!(data.sampleAgeSeconds === null || Number.isFinite(data.sampleAgeSeconds) && data.sampleAgeSeconds >= 0) ||
typeof data.error !== 'string' || data.error.length > 80 || typeof data.bootStabilizing !== 'boolean' ||
!(data.values === null || typeof data.values === 'object')) throw new Error('Invalid plot sample')
if (data.state !== 'current') {
if (data.values !== null) throw new Error('Stale plot values')
return data
}
if (!data.values || data.sampleAgeSeconds === null || data.sampleAgeSeconds > 1.5 ||
data.values.controlsFresh !== true || typeof data.values.poseFresh !== 'boolean' ||
typeof data.values.controlsActive !== 'boolean' || typeof data.values.lateralControlActive !== 'boolean' ||
typeof data.values.longitudinalControlActive !== 'boolean') throw new Error('Invalid plot source')
for (const key of SIGNALS) if (!(data.values[key] === null || typeof data.values[key] === 'number' && Number.isFinite(data.values[key]))) throw new Error('Invalid plot value')
for (const [key, allowed] of Object.entries(SOURCES)) if (!allowed.includes(data.values[key])) throw new Error('Invalid plot source')
if (data.values.lateralSource === 'unavailable' && (data.values.desiredLateralAccel !== null || data.values.actualLateralAccel !== null)) throw new Error('Invalid lateral source')
if (data.values.longitudinalSource === 'unavailable' && data.values.desiredLongitudinalAccel !== null) throw new Error('Invalid longitudinal source')
if (!data.values.poseFresh && data.values.actualLongitudinalAccel !== null) throw new Error('Invalid pose source')
if (data.values.speedSource === 'unavailable' && data.values.speedMps !== null) throw new Error('Invalid speed source')
return data
}
export function appendPlotSample(history, payload, observedAt) {
if (payload.state !== 'current' || !payload.values) return history
if (history.at(-1)?.sessionId !== undefined && history.at(-1).sessionId !== payload.sessionId) history = []
if (payload.sampleIndex === (history.at(-1)?.index ?? 0)) return history
if (payload.sampleIndex < (history.at(-1)?.index ?? 0)) history = []
const sample = { ...payload.values, sessionId: payload.sessionId, index: payload.sampleIndex,
at: observedAt - payload.sampleAgeSeconds * 1000 }
return [...history, sample].slice(-MAX_POINTS)
}
export function matchQuality(history, desired, actual, { minSpeed = 0, minDemand = 0, great, good, fair, activeKey = 'controlsActive',
warnError = null, severeError = null }) {
const latest = history.at(-1)?.at
if (!Number.isFinite(latest)) return { label: 'N/A', detail: 'Waiting for data' }
const errors = history.filter((sample) => sample.at >= latest - 30000 && sample[activeKey] &&
Number.isFinite(sample[desired]) && Number.isFinite(sample[actual]) &&
(sample.speedMps ?? 0) >= minSpeed && Math.max(Math.abs(sample[desired]), Math.abs(sample[actual])) >= minDemand)
.map((sample) => Math.abs(sample[desired] - sample[actual])).sort((a, b) => a - b)
if (errors.length < 8) return { label: 'N/A', detail: `${errors.length} of 8 valid driving samples` }
const percentile = (p) => { const i = (errors.length - 1) * p; const lo = Math.floor(i); return errors[lo] + (errors[Math.ceil(i)] - errors[lo]) * (i - lo) }
const value = 0.7 * percentile(0.5) + 0.3 * percentile(0.9)
const warnFraction = warnError === null ? 0 : errors.filter((error) => error > warnError).length / errors.length
const severeFraction = severeError === null ? 0 : errors.filter((error) => error > severeError).length / errors.length
const pass = (limit, maxWarn, maxSevere) => value <= limit && warnFraction <= maxWarn && severeFraction <= maxSevere
return { label: pass(great, .18, .05) ? 'Great' : pass(good, .34, .12) ? 'Good' : pass(fair, .55, .24) ? 'Fair' : 'Poor',
detail: `${errors.length} samples / 30 s · robust error ${value.toFixed(2)} m/s²` }
}
export function chartGeometry(history, keys) {
const valid = history.filter((sample) => keys.some((key) => Number.isFinite(sample[key])))
const lo = Math.min(0, ...valid.flatMap((sample) => keys.map((key) => sample[key]).filter(Number.isFinite)))
const hi = Math.max(0, ...valid.flatMap((sample) => keys.map((key) => sample[key]).filter(Number.isFinite)))
const span = Math.max(0.01, hi - lo)
const first = history[0]?.at ?? 0, elapsed = Math.max(1, (history.at(-1)?.at ?? first) - first)
const y = (value) => 132 - (value - lo) / span * 114
const duration = valid.length > 1 ? Math.max(0, (history.at(-1).at - first) / 1000) : 0
const paths = keys.map((key) => {
let points = [], paths = [], previousAt = null
for (const sample of history) {
if (previousAt !== null && sample.at - previousAt > 1500) {
if (points.length > 1) paths.push(points.join(' ')); points = []
}
previousAt = sample.at
if (!Number.isFinite(sample[key])) { if (points.length > 1) paths.push(points.join(' ')); points = []; continue }
const x = Math.max(48, Math.min(624, 48 + (sample.at - first) / elapsed * 576))
points.push(`${x.toFixed(1)},${y(sample[key]).toFixed(1)}`)
}
if (points.length > 1) paths.push(points.join(' '))
return paths
})
return { paths, valid: valid.length > 0, top: hi.toFixed(2), bottom: lo.toFixed(2), zeroY: y(0),
from: duration > 0 ? `−${duration < 10 ? duration.toFixed(1) : Math.round(duration)} s` : '0 s' }
}
export function seriesPoints(history, keys) {
return chartGeometry(history, keys).paths
}
export class PlotsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args),
now = () => performance.now(), later = (fn, ms) => setTimeout(fn, ms), cancel = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, now, later, cancel })
this.active = false; this.paused = false; this.generation = 0; this.displayGeneration = 0
this.timer = this.deadline = this.expiry = this.request = null
this.history = []; this.lastData = null
}
clear() {
for (const key of ['timer', 'deadline', 'expiry']) { if (this[key] !== null) this.cancel(this[key]); this[key] = null }
this.request?.abort(); this.request = null
}
stop() {
this.active = false; this.generation++; this.displayGeneration++; this.clear(); this.history = []; this.lastData = null
this.publish({ status: 'idle', data: null, history: [], error: '' })
}
start() { this.stop(); this.active = true; if (!this.paused) return this.load() }
setPaused(paused) {
this.paused = paused
if (paused) { this.generation++; this.displayGeneration++; this.clear(); this.publish({ status: 'paused', data: this.lastData, history: this.history, error: '' }) }
else if (this.active) this.load()
}
async load() {
if (!this.active || this.paused || this.request) return
const generation = ++this.generation, start = this.now(), request = new AbortController()
this.request = request
this.deadline = this.later(() => {
if (!this.active || generation !== this.generation) return
this.generation++; this.displayGeneration++; request.abort(); this.request = null; this.clear()
this.publish({ status: this.lastData ? 'stale' : 'unavailable', data: this.lastData, history: this.history, error: 'Plot refresh timed out' })
this.timer = this.later(() => { this.timer = null; this.load() }, POLL_MS)
}, 1500)
try {
const response = await this.fetcher('./api/plots/live', { signal: request.signal, cache: 'no-store' })
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401) { this.stop(); this.unauthorized(); return }
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (['access_unavailable', 'setup_required'].includes(body?.code)) { this.stop(); this.unauthorized(); return }
}
if (!response.ok) throw new Error('Live plots unavailable')
const data = validatePlotPayload(await response.json())
if (!this.active || generation !== this.generation || request.signal.aborted) return
const elapsed = this.now() - start
if (!Number.isFinite(elapsed) || elapsed < 0 || elapsed >= 1500) throw new Error('Plot sample delayed')
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
const displayGeneration = ++this.displayGeneration
const remaining = data.sampleAgeSeconds === null ? 0 : 1500 - data.sampleAgeSeconds * 1000 - elapsed
if (data.state === 'current' && remaining > 0) {
this.history = appendPlotSample(this.history, data, this.now() - elapsed)
this.lastData = data
this.publish({ status: 'current', data, history: this.history, error: '' })
this.expiry = this.later(() => {
if (this.active && displayGeneration === this.displayGeneration) this.publish({ status: 'stale', data: this.lastData, history: this.history, error: 'Showing the last received sample' })
}, remaining)
} else this.publish({ status: this.lastData ? 'stale' : data.state === 'unavailable' ? 'unavailable' : 'stale', data: this.lastData, history: this.history, error: data.error || 'Waiting for fresh control data' })
} catch (error) {
if (this.active && generation === this.generation) {
this.displayGeneration++
if (this.expiry !== null) this.cancel(this.expiry)
this.expiry = null
this.publish({ status: this.lastData ? 'stale' : 'unavailable', data: this.lastData, history: this.history, error: error.message })
}
} finally {
if (generation === this.generation) {
if (this.deadline !== null) this.cancel(this.deadline); this.deadline = null
this.request = null
if (this.active && !this.paused) this.timer = this.later(() => { this.timer = null; this.load() }, POLL_MS)
}
}
}
}
export const PlotGraph = {
name: 'PlotGraph',
props: { chart: { type: Object, required: true }, title: { type: String, required: true }, terms: { type: Boolean, default: false } },
template: `<svg viewBox="0 0 640 170" role="img" :aria-label="title">
<line class="gx-plot-axis" x1="48" y1="18" x2="48" y2="132" />
<line class="gx-plot-axis" x1="48" y1="132" x2="624" y2="132" />
<template v-if="chart.valid">
<line class="gx-plot-zero" x1="48" :y1="chart.zeroY" x2="624" :y2="chart.zeroY" />
<text class="gx-plot-label" x="42" y="21" text-anchor="end">{{ chart.top }}</text>
<text class="gx-plot-label" x="42" y="135" text-anchor="end">{{ chart.bottom }}</text>
<text class="gx-plot-label" x="52" y="160">{{ chart.from }}</text>
<text class="gx-plot-label" x="624" y="160" text-anchor="end">Latest</text>
</template>
<g v-for="(paths, series) in chart.paths" :key="series"><polyline v-for="(points, i) in paths" :key="i" :points="points"
:class="terms ? 'gx-plot-term-' + series : series === 0 ? 'gx-plot-desired' : 'gx-plot-actual'" /></g>
</svg>`,
}
export const PlotsPage = {
name: 'PlotsPage',
components: { GxState, PlotGraph, GxIconButton },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ status: 'idle', data: null, history: [], error: '', paused: false, advanced: false }),
created() { this.feed = new PlotsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => {
if (document.hidden) this.feed.setPaused(true)
else if (!this.paused && this.mode === 'local') {
if (!this.feed.active) this.feed.start()
else this.feed.setPaused(false)
}
}
document.addEventListener('visibilitychange', this.visibility)
if (this.mode === 'local' && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener('visibilitychange', this.visibility); this.feed.stop() },
computed: {
lateralQuality() { return matchQuality(this.history, 'desiredLateralAccel', 'actualLateralAccel', { minSpeed: 0.5, minDemand: 0.008, great: 0.15, good: 0.30, fair: 0.50, activeKey: 'lateralControlActive' }) },
longQuality() { return matchQuality(this.history, 'desiredLongitudinalAccel', 'actualLongitudinalAccel', { minDemand: 0.05, great: 0.32, good: 0.52, fair: 0.78, activeKey: 'longitudinalControlActive', warnError: .52, severeError: .90 }) },
lateralChart() { return chartGeometry(this.history, ['desiredLateralAccel', 'actualLateralAccel']) },
longChart() { return chartGeometry(this.history, ['desiredLongitudinalAccel', 'actualLongitudinalAccel']) },
lateralTermsChart() { return chartGeometry(this.history, ['lateralP', 'lateralI', 'lateralD', 'lateralF']) },
longTermsChart() { return chartGeometry(this.history, ['longitudinalP', 'longitudinalI', 'longitudinalF']) },
},
methods: {
togglePause() { this.paused = !this.paused; this.feed.setPaused(this.paused) },
label(value, unit = 'm/s²') { return value === null || value === undefined ? 'Unavailable' : `${value.toFixed(2)} ${unit}` },
source(kind) { return ({ torqueState: 'Steering torque controller', curvature: 'Curvature × speed² estimate', aTarget: 'Planner target acceleration',
pidSum: 'PID term sum', pidState: 'Steering PID', controlsState: 'Longitudinal controller',
deviceMotion: 'Device motion', carState: 'Vehicle speed', unavailable: 'Unavailable' })[kind] || 'Unavailable' },
},
template: `
<div class="gx-view gx-plots">
<header class="gx-settings__header gx-page-header"><div><h2>Plots</h2><p>Live local control observations. Graphs and match scores are diagnostic, not driving-control checks.</p></div>
<div class="gx-actions"><a class="gx-btn gx-btn--tonal" href="#/tuning/flm">Offline tracking analysis</a></div></header>
<GxState v-if="mode !== 'local'">Live plots require the authenticated local Galaxy service.</GxState>
<template v-else>
<div class="gx-plots__toolbar"><GxIconButton :label="paused ? 'Resume live plots' : 'Pause live plots'" :icon="paused ? 'bi-play-fill' : 'bi-pause-fill'" @click="togglePause" />
<button type="button" class="gx-btn gx-btn--tonal" :aria-pressed="advanced" @click="advanced=!advanced">{{ advanced ? 'Hide' : 'Show' }} controller terms</button>
<span role="status">{{ paused ? 'Paused' : data?.bootStabilizing ? 'Starting vehicle systems' : status === 'current' ? 'Live' : status === 'stale' ? 'Reconnecting · last received values' : status === 'unavailable' ? 'Unavailable' : 'Connecting' }}</span></div>
<p class="gx-note gx-plots__hint" role="status">{{ !paused && error ? error : 'Missing samples leave a gap in the graph.' }}</p>
<div class="gx-plots__grid">
<section class="gx-card gx-plots__panel"><h3>Lateral Acceleration</h3><p class="gx-note">Desired / actual · m/s² · {{ source(data?.values?.lateralSource) }}</p>
<plot-graph :chart="lateralChart" title="Lateral acceleration history" />
<p>Desired: {{ label(data?.values?.desiredLateralAccel) }} · Actual: {{ label(data?.values?.actualLateralAccel) }}</p>
<p class="gx-note">Speed source: {{ source(data?.values?.speedSource) }}</p>
<p>30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : lateralQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : lateralQuality.detail }}</p></section>
<section class="gx-card gx-plots__panel"><h3>Longitudinal Acceleration</h3><p class="gx-note">Target / measured · m/s² · {{ source(data?.values?.longitudinalSource) }}; measured from {{ data?.values?.poseFresh ? 'device motion' : 'unavailable motion source' }}</p>
<plot-graph :chart="longChart" title="Longitudinal acceleration history" />
<p>Target: {{ label(data?.values?.desiredLongitudinalAccel) }} · Measured: {{ label(data?.values?.actualLongitudinalAccel) }}</p>
<p>30 s match: <strong>{{ !data || data?.bootStabilizing ? 'N/A' : longQuality.label }}</strong> · {{ data?.bootStabilizing ? 'Waiting for startup to settle' : status !== 'current' ? 'Last received window' : longQuality.detail }}</p></section>
<section v-if="advanced" class="gx-card gx-plots__panel"><h3>Lateral Controller Terms</h3><p class="gx-note">P / I / D / F · controller output units · {{ source(data?.values?.lateralTermsSource) }}</p>
<plot-graph :chart="lateralTermsChart" title="Lateral controller terms" :terms="true" /></section>
<section v-if="advanced" class="gx-card gx-plots__panel"><h3>Longitudinal Controller Terms</h3><p class="gx-note">P / I / F · m/s² · {{ source(data?.values?.longitudinalTermsSource) }}</p>
<plot-graph :chart="longTermsChart" title="Longitudinal controller terms" :terms="true" /></section>
</div><p class="gx-note">Blue is target, orange is observed. Missing or stale sources are never plotted as zero.</p>
</template>
</div>`,
}
@@ -0,0 +1,57 @@
// Translate the independent AA placement document into the existing editor model.
const clone = (value) => JSON.parse(JSON.stringify(value))
// Text above the Android Auto lead marker, matching projection_layout.LEAD_INFO_CHOICES.
export const LEAD_INFO = ["Off", "Distance", "Speed"]
// projection_onroad draws the fixed brand at (width - 210, height - 90),
// using Sora-800.fnt at 30 * Profile.LARGE.font_scale. Reuse that atlas and
// its glyph metrics so the browser needs no substitute font or native request.
const brandGlyphs = {
S: [331, 6, 131, 155, 43], t: [6, 175, 87, 142, 54], a: [474, 6, 120, 117, 80],
r: [913, 6, 85, 112, 82], P: [184, 6, 135, 148, 46], i: [606, 6, 69, 157, 38],
l: [687, 6, 65, 147, 47], o: [764, 6, 137, 119, 79],
}
export const PROJECTION_WORDMARK = {
right: 210, bottom: 90, scale: 30 * 1.242 / 200,
glyphs: [..."StarPilot"].map((letter, index, letters) => {
const [x, y, width, height, offsetY] = brandGlyphs[letter]
const offsetX = letters.slice(0, index).reduce((sum, char) => sum + brandGlyphs[char][2], 0)
return { x, y, width, height, offsetX, offsetY }
}),
}
export function editorSnapshot(raw) {
if (!raw || raw.version !== 1 || !raw.screen || !raw.metadata || !raw.document || !raw.defaults)
throw new Error(raw?.reason || "Connect Android Auto once to obtain the actual screen.")
const colors = raw.colors
const document = (value) => {
if (value.version !== 3 || value.canvas?.width !== raw.metadata.width || value.canvas?.height !== raw.metadata.height)
throw new Error("The saved Android Auto layout does not match its screen. Reload before editing.")
return { version: 7, clock24Hour: value.clock24Hour === true, largeUiGammaTrial: value.largeUiGammaTrial === true, blindSpotBorder: value.blindSpotBorder === true, signalBorder: value.signalBorder === true, blindSpotCamera: value.blindSpotCamera === true, hidePersonalityAlerts: value.hidePersonalityAlerts === true, wheelPedalFeedback: value.wheelPedalFeedback === true, leadIndicator: value.leadIndicator === true, leadInfo: LEAD_INFO.includes(value.leadInfo) ? value.leadInfo : "Off", speedSources: value.speedSources === true, speedSourcesCard: value.speedSourcesCard === true, speedAlign: ["center", "right"].includes(value.speedAlign) ? value.speedAlign : "left", ...(value.widgetOrder ? { widgetOrder: { large: clone(value.widgetOrder) } } : {}), palette: clone(colors.palette), layouts: { large: clone(value.widgets) },
widgetColors: { large: clone(colors.widgetColors) }, roadColors: { large: clone(colors.roadColors) } }
}
return { ...raw, projection: true, activeProfile: "large", document: document(raw.document), defaults: document(raw.defaults),
metadata: { projection: true, profiles: { large: clone(raw.metadata) },
paletteFields: Object.entries(colors.palette).map(([id, value]) => ({ id, label: id, default: value })),
roadColorFields: [{ id: "path", label: "Path", default: "#30FF9CFF" },
{ id: "pathEdge", label: "Path edges", default: "#00FF40FF" }, { id: "laneLines", label: "Lane lines", default: "#FFFFFFFF" }] } }
}
export function projectionPayload(body, metadata) {
return { revision: body.revision, document: { version: 3,
canvas: { width: metadata.profiles.large.width, height: metadata.profiles.large.height },
clock24Hour: body.document.clock24Hour === true,
largeUiGammaTrial: body.document.largeUiGammaTrial === true,
blindSpotBorder: body.document.blindSpotBorder === true,
signalBorder: body.document.signalBorder === true,
blindSpotCamera: body.document.blindSpotCamera === true,
hidePersonalityAlerts: body.document.hidePersonalityAlerts === true,
wheelPedalFeedback: body.document.wheelPedalFeedback === true,
leadIndicator: body.document.leadIndicator === true,
leadInfo: LEAD_INFO.includes(body.document.leadInfo) ? body.document.leadInfo : "Off",
speedSources: body.document.speedSources === true,
speedSourcesCard: body.document.speedSourcesCard === true,
speedAlign: ["center", "right"].includes(body.document.speedAlign) ? body.document.speedAlign : "left",
...(body.document.widgetOrder?.large ? { widgetOrder: clone(body.document.widgetOrder.large) } : {}),
widgets: clone(body.document.layouts.large) } }
}
@@ -0,0 +1,43 @@
import { requestJson } from "./startup.js"
export const storedSize = bytes => Number.isFinite(bytes) && bytes >= 0
? bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB`
: bytes < 1073741824 ? `${(bytes / 1048576).toFixed(1)} MB` : `${(bytes / 1073741824).toFixed(2)} GB` : "Unavailable"
export async function deleteRecords(feed, url, selection, { valid, accept, confirm = message => globalThis.confirm(message), subject }) {
if (!feed.active || feed.deleting) return false
if (selection.all && !confirm(`Delete all ${subject} and their saved artifacts? This cannot be undone.`)) return false
feed.deleting = true
feed.poller.stop()
const generation = ++feed.generation
feed.request?.abort()
if (feed.timer != null) (feed.cancelTimer || feed.cancel)(feed.timer)
feed.timer = null
const request = new AbortController()
feed.request = request
feed.publish({ deleting: true, error: "", selected: null, preview: null, previewStatus: "idle" })
try {
for (let batch = 0; batch < 32; batch++) {
const data = await requestJson(url, { fetcher: feed.fetcher, later: feed.later, cancel: feed.cancelTimer || feed.cancel,
request: { method: "POST", headers: { "Content-Type": "application/json" }, signal: request.signal, body: JSON.stringify(selection) } })
if (!feed.active || generation !== feed.generation || request.signal.aborted) return false
if (!valid(data) || typeof data.more !== "boolean") throw new Error("Invalid deletion response")
accept(data)
if (!data.more) return true
if (!selection.all) throw new Error("Stored artifacts remain; refresh and retry")
}
throw new Error("More stored artifacts remain. Delete All again to finish cleanup.")
} catch (error) {
if (!feed.active || generation !== feed.generation) return false
if (error.status === 401 || ["access_unavailable", "setup_required"].includes(error.code)) { feed.stop(); feed.unauthorized(); return false }
feed.publish({ error: error.message || "Stored records could not be deleted" })
return false
} finally {
feed.deleting = false
if (feed.active && generation === feed.generation) {
feed.request = null
feed.publish({ deleting: false })
feed.poller.start()
}
}
}
@@ -0,0 +1,407 @@
import { GxState } from "./state.js"
import { GxDialog } from "./dialog.js"
import { requestJson } from "./startup.js"
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { SnapshotFeed } from "./snapshot-feed.js"
import { RecordingActions } from "./recording-actions.js"
import { GalaxySelect } from "./galaxy-select.js"
import { ReplayViewer, replayableSegment } from "./replay.js"
// Local recording inventory and closed camera playback.
const LOCAL = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}\|(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))$/
const SEGMENT = /^(?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}|[a-f0-9]{16}[|_](?:[a-f0-9]{8}--[a-f0-9]{10}|[0-9]{4}-[0-9]{2}-[0-9]{2}--[0-9]{2}-[0-9]{2}-[0-9]{2}))--[0-9]{1,6}$/
const FILES = ["rlog", "qlog", "fcamera", "dcamera", "ecamera", "qcamera"]
const LABELS = { rlog: "Full log", qlog: "Quick log", fcamera: "Road video", dcamera: "Driver video",
ecamera: "Wide video", qcamera: "Quick video" }
export function validLocalHistory(value) {
if (value?.schemaVersion !== 1 || value.source !== "local" || value.partialHistory !== true ||
typeof value.scanIncomplete !== "boolean" || !Array.isArray(value.routes) || value.routes.length > 100) return false
let count = 0
const routes = new Set()
return value.routes.every((route) => {
if (typeof route?.routeId !== "string" || !LOCAL.test(route.routeId) || routes.has(route.routeId) ||
!Array.isArray(route.segments) || route.segments.length > 64 ||
!Number.isSafeInteger(route.segmentCount) || route.segmentCount !== route.segments.length) return false
routes.add(route.routeId)
const numbers = new Set()
count += route.segmentCount
return count <= 512 && route.segments.every((segment) => {
if (!Number.isSafeInteger(segment?.number) || segment.number < 0 || segment.number > 999999 ||
typeof segment.segmentName !== "string" || !SEGMENT.test(segment.segmentName) ||
(!segment.segmentName.startsWith(route.routeId.replace('|', '_') + '--') &&
!segment.segmentName.startsWith(route.routeId + '--')) ||
Number(segment.segmentName.slice(segment.segmentName.lastIndexOf('--') + 2)) !== segment.number ||
numbers.has(segment.number) || segment.files === null || typeof segment.files !== "object" ||
Object.keys(segment.files).length !== FILES.length ||
!FILES.every((key) => typeof segment.files[key] === "boolean") ||
!FILES.some((key) => segment.files[key])) return false
numbers.add(segment.number)
return true
})
})
}
const VIDEOS = ["fcamera", "dcamera", "ecamera", "qcamera"]
export const hasVideo = (files) => VIDEOS.some((key) => files?.[key])
export const availableFiles = (files) => FILES.filter((key) => files?.[key]).map((key) => LABELS[key])
export const routeFiles = (route) => availableFiles(Object.fromEntries(FILES.map((key) =>
[key, route.segments.some((segment) => segment.files[key])])))
export const firstQuickVideo = (route) => route.segments.find((segment) => segment.files.qcamera) || null
export function routeDate(route, locale = undefined, timeZone = undefined) {
const validTime = (value) => typeof value === "number" && Number.isFinite(value) && value > 0 && Number.isFinite(new Date(value * 1000).getTime())
const captured = validTime(route.startTime)
const value = captured ? route.startTime : validTime(route.fileTime) ? route.fileTime : null
if (value === null) return { label: "Date unavailable", source: "", datetime: null }
const date = new Date(value * 1000)
return { label: new Intl.DateTimeFormat(locale, { year: "numeric", month: "short", day: "numeric",
hour: "numeric", minute: "2-digit", ...(timeZone ? { timeZone } : {}) }).format(date),
source: captured ? "" : "File date", datetime: date.toISOString() }
}
export function connectRouteUrl(route) {
if (typeof route?.routeId !== "string" || !LOCAL.test(route.routeId) || typeof route.connectUrl !== "string") return null
const host = route.provider === "konik" ? "stable.konik.ai" : route.provider === undefined || route.provider === "comma" ? "connect.comma.ai" : null
if (host === null) return null
const match = /^https:\/\/(connect\.comma\.ai|stable\.konik\.ai)\/([a-f0-9]{16})\/([^/?#]+)$/.exec(route.connectUrl)
const [device, identifier] = route.routeId.includes('|') ? route.routeId.split('|') : [null, route.routeId]
return match && match[1] === host && match[3] === identifier && (device === null || match[2] === device) ? route.connectUrl : null
}
export const recordingUrl = (segmentName, camera = "qcamera") => SEGMENT.test(segmentName) &&
["qcamera", "fcamera", "dcamera", "ecamera"].includes(camera) ?
`./api/recordings/media/${encodeURIComponent(segmentName)}${camera === "qcamera" ? "" : "/" + camera}` : null
export const quickRoadUrl = (segmentName) => recordingUrl(segmentName)
export const segmentSummaryUrl = (segmentName) => SEGMENT.test(segmentName) ?
`./api/recordings/segment-summary?segmentName=${encodeURIComponent(segmentName)}` : null
export function validSegmentSummary(value, segmentName) {
const metric = (v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0)
return value?.schemaVersion === 1 && value.source === "closed_local_rlog" &&
value.segmentName === segmentName && typeof value.sourceSha256 === "string" &&
/^[a-f0-9]{64}$/.test(value.sourceSha256) &&
[value.observedCarSpanSeconds, value.estimatedDistanceMeters,
value.observedLatActiveSeconds, value.observedLongActiveSeconds].every(metric) &&
value.gaps && Number.isSafeInteger(value.gaps.carState) && value.gaps.carState >= 0 &&
Number.isSafeInteger(value.gaps.carControl) && value.gaps.carControl >= 0 &&
typeof value.sampleCoverageComplete === "boolean"
}
export const detailMetric = (seconds, unit = "s") => seconds === null ? "Unavailable" :
`${seconds.toFixed(unit === "m" ? 1 : 2)} ${unit}`
const searchText = value => String(value || "").normalize("NFKD").replace(/\p{M}/gu, "")
.toLocaleLowerCase().replace(/(\d+)(?:st|nd|rd|th)\b/g, "$1").replace(/[^\p{L}\p{N}]+/gu, " ").trim()
export function routeMatchesSearch(route, query) {
const date = routeDate(route)
const index = searchText(`${route.displayName || ""} ${route.routeId} ${date.label} ${date.datetime || ""}`)
return searchText(query).split(" ").every(word => index.includes(word))
}
export class LocalHistoryFeed extends SnapshotFeed {
static endpoint = "./api/recordings/local"
static valid = validLocalHistory
static subject = "local recordings"
static unavailable = "Local recordings are unavailable."
static invalid = "Local recording inventory is unavailable."
}
export const LocalRecordingsPage = {
name: "LocalRecordingsPage",
components: { GxState, GxIconButton, GxNotice, RecordingActions, GalaxySelect, GxDialog, ReplayViewer },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ sortOrder: "newest", replayRoute: null, logsRoute: null, query: "", preservedOnly: false, status: "idle", data: null, error: "", playing: null, playerError: "", playerGeneration: 0,
details: null, detailsName: null, detailsStatus: "idle", detailsError: "", detailsGeneration: 0,
deleting: null, deleteSelection: null, deleteError: "" }),
computed: {
stats() { const routes = this.data?.routes || []; return { count: routes.length, minutes: routes.reduce((sum, route) => sum + route.segmentCount, 0), preserved: routes.filter(route => route.preserved).length } },
playerCameras() { return ["fcamera", "ecamera", "dcamera", "qcamera"].filter(camera =>
this.playing?.route.segments.some(segment => segment.files[camera])) },
routeCards() {
const ordered = (this.data?.routes || []).slice().sort((a, b) => {
if (["longest", "shortest"].includes(this.sortOrder)) return (b.segmentCount - a.segmentCount) * (this.sortOrder === "shortest" ? -1 : 1)
return ((b.startTime || b.fileTime || 0) - (a.startTime || a.fileTime || 0)) * (this.sortOrder === "oldest" ? -1 : 1)
})
return ordered.filter(route => (!this.preservedOnly || route.preserved) &&
routeMatchesSearch(route, this.query)).map((route) => ({ ...route, date: routeDate(route),
connect: connectRouteUrl(route), fileLabels: routeFiles(route), firstQuick: firstQuickVideo(route), firstFull: route.segments.find(segment => segment.files.fcamera) }))
},
},
created() { this.feed = new LocalHistoryFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) { this.replayRoute = null; this.closePlayer(); this.closeDetails(); this.logsRoute = null; this.feed.stop() } else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { this.deleteRequest?.abort(); document.removeEventListener("visibilitychange", this.visibility); this.closePlayer(); this.closeDetails(); this.logsRoute = null; this.feed.stop() },
watch: { mode(value) { if (value !== "local") { this.replayRoute = null; this.closePlayer(); this.closeDetails() } } },
methods: {
availableFiles,
replayableSegment,
openReplay(route, segment = null) { this.closePlayer(); this.replayRoute = { routeId: route.routeId, segment } },
hasVideo,
quickRoadUrl,
recordingUrl,
driveVideoUrl: (routeId, camera) => LOCAL.test(routeId) && ["qcamera", "fcamera", "dcamera", "ecamera"].includes(camera) ?
`./api/recordings/route/${encodeURIComponent(routeId)}/${camera}` : null,
cameraLabel: camera => LABELS[camera]?.replace(" video", ""),
logUrl: (segmentName, filename) => SEGMENT.test(segmentName) && /^(rlog|qlog)\.(zst|bz2)$/.test(filename) ?
`./api/recordings/files/${encodeURIComponent(segmentName)}/${filename}` : null,
detailMetric,
refresh() { this.closePlayer(); this.closeDetails(); this.logsRoute = null; return this.feed.load() },
watchRoute(route, segment = null) {
const segments = segment ? [segment] : route.segments
const camera = ["fcamera", "ecamera", "dcamera", "qcamera"].find(camera => segments.some(segment => segment.files[camera]))
if (camera) this.openPlayer(route, segments.find(segment => segment.files[camera]), camera)
},
openLogs(route) { this.logsRoute = route; this.$nextTick?.(() => this.$refs.logsPanel?.scrollIntoView?.({ behavior: "smooth", block: "nearest" })) },
logArchiveUrl(routeId) { return LOCAL.test(routeId) ? `./api/recordings/logs/${encodeURIComponent(routeId)}` : null },
formatDuration(minutes) { return minutes >= 60 ? `${Math.floor(minutes / 60)}h ${minutes % 60}m` : `${minutes} min` },
formatBytes(bytes) { return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(2)} GB` : `${((bytes || 0) / 1e6).toFixed(1)} MB` },
confirmDeleteVideos(segment) {
if (this.mode !== "local" || this.status !== "ready" || this.deleting !== null || !hasVideo(segment?.files)) return
this.deleteSelection = segment
this.deleteError = ""
},
async deleteVideos() {
const segment = this.deleteSelection
if (!segment || this.mode !== "local" || this.status !== "ready" || this.deleting !== null) return
if (this.playing?.segments.some((item) => item.segmentName === segment.segmentName)) this.closePlayer()
this.deleting = segment.segmentName
this.deleteError = ""
const controller = this.deleteRequest = new AbortController()
try {
await requestJson("./api/recordings/delete-videos", { timeout: 10000,
request: { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ segmentName: segment.segmentName }), signal: controller.signal } })
} catch (error) {
if (controller.signal.aborted) return
if (error.status === 401) { this.feed.stop(); this.unauthorized(); return }
if (error.status !== 404) {
this.deleteError = error.name === "TimeoutError" ? "Deleting videos timed out. Refresh to check what remains." : error.message
return
}
} finally {
this.deleteRequest = null
this.deleting = null
}
this.deleteSelection = null
await this.feed.load()
if (this.logsRoute) this.logsRoute = this.routeCards.find(route => route.routeId === this.logsRoute.routeId) || null
},
closeDetails() {
this.detailsRequest?.abort()
if (this.detailsTimer !== undefined) clearTimeout(this.detailsTimer)
this.detailsRequest = null
this.detailsTimer = undefined
this.detailsGeneration++
this.details = null
this.detailsName = null
this.detailsStatus = "idle"
this.detailsError = ""
},
async openDetails(segment) {
if (this.mode !== "local" || this.status !== "ready" || !segment?.files?.rlog || document.hidden) return
const url = segmentSummaryUrl(segment.segmentName)
if (!url) return
this.closeDetails()
const generation = this.detailsGeneration
const controller = new AbortController()
this.detailsRequest = controller
this.detailsName = segment.segmentName
this.detailsStatus = "loading"
this.detailsTimer = setTimeout(() => {
if (generation !== this.detailsGeneration || this.detailsRequest !== controller) return
controller.abort()
this.detailsRequest = null
this.detailsGeneration++
this.detailsTimer = undefined
this.detailsStatus = "unavailable"
this.detailsError = "Reading this segment timed out. Select Details to try again."
}, 10000)
try {
const response = await fetch(url, { credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (generation !== this.detailsGeneration || controller.signal.aborted) return
if (response.status === 401) { this.closeDetails(); this.unauthorized(); return }
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (generation !== this.detailsGeneration || controller.signal.aborted) return
if (["setup_required", "access_unavailable"].includes(body?.code)) { this.closeDetails(); this.unauthorized(); return }
}
if (!response.ok) throw new Error("Recorded segment details are unavailable.")
const result = await response.json()
if (generation !== this.detailsGeneration || controller.signal.aborted) return
if (!validSegmentSummary(result, segment.segmentName)) throw new Error("Recorded segment details are unavailable.")
this.details = result
this.detailsStatus = "ready"
this.$nextTick?.(() => this.$refs.detailsPanel?.scrollIntoView?.({ block: "nearest", behavior: "smooth" }))
} catch (error) {
if (generation === this.detailsGeneration) {
this.detailsStatus = "unavailable"
this.detailsError = error instanceof Error ? error.message : "Recorded segment details are unavailable."
}
} finally {
if (generation === this.detailsGeneration) {
if (this.detailsTimer !== undefined) clearTimeout(this.detailsTimer)
this.detailsTimer = undefined
this.detailsRequest = null
}
}
},
openPlayer(route, segment, camera = "qcamera") {
if (this.mode !== "local" || this.status !== "ready" || !segment.files[camera]) return
const segments = route.segments.filter((item) => item.files[camera] && recordingUrl(item.segmentName, camera))
const index = segments.findIndex((item) => item.segmentName === segment.segmentName)
if (index < 0) return
this.closePlayer()
this.playing = { route, routeId: route.routeId, segments, index, camera, url: recordingUrl(segment.segmentName, camera) }
this.playerGeneration++
this.$nextTick?.(() => { if (this.$refs.playerPanel && !this.$refs.playerPanel.open) this.$refs.playerPanel.showModal() })
},
selectCamera(camera) {
if (!this.playing) return
const { route, segments, index } = this.playing
const segment = route.segments.find(item => item.number === segments[index].number && item.files[camera]) ||
route.segments.find(item => item.files[camera])
if (segment) this.openPlayer(route, segment, camera)
},
chooseSegment(offset) {
if (!this.playing) return
const index = this.playing.index + offset
if (index < 0 || index >= this.playing.segments.length) return
this.sessionProbe?.abort()
this.sessionProbe = null
this.$refs.quickVideo?.pause()
this.$refs.quickVideo?.removeAttribute("src")
this.playing = { ...this.playing, index, url: recordingUrl(this.playing.segments[index].segmentName, this.playing.camera) }
this.playerError = ""
this.playerGeneration++
},
closePlayer() {
this.$refs.playerPanel?.close?.()
this.sessionProbe?.abort()
this.sessionProbe = null
this.$refs.quickVideo?.pause()
this.$refs.quickVideo?.removeAttribute("src")
this.$refs.quickVideo?.load()
this.playing = null
this.playerError = ""
this.playerGeneration++
},
async videoError(event) {
if (!this.playing || event.currentTarget !== this.$refs.quickVideo ||
Number(event.currentTarget.dataset.playerGeneration) !== this.playerGeneration) return
const generation = this.playerGeneration
this.playerError = "This camera recording could not play in this browser. Download the video to watch it in a compatible player."
const controller = new AbortController()
this.sessionProbe?.abort()
this.sessionProbe = controller
const deadline = setTimeout(() => controller.abort(), 4000)
let revoke = false
try {
const response = await fetch("./api/auth/session", { credentials: "same-origin", cache: "no-store", signal: controller.signal })
if (!this.playing || generation !== this.playerGeneration || controller.signal.aborted) return
const state = await response.json()
if (!this.playing || generation !== this.playerGeneration || controller.signal.aborted) return
revoke = response.status === 401 || state.authenticated === false
} catch { /* A media or session request may fail; keep the explicit playback error. */ }
finally { clearTimeout(deadline); if (this.sessionProbe === controller) this.sessionProbe = null }
if (revoke && this.playing && generation === this.playerGeneration) { this.closePlayer(); this.unauthorized() }
},
},
template: `
<div class="gx-view gx-recordings">
<h2>Routes</h2>
<GxState v-if="mode !== 'local'">Local recordings are unavailable in the offline preview.</GxState>
<template v-else>
<section class="gx-card gx-recordings__library">
<header class="gx-section__header"><i class="bi bi-camera-reels" aria-hidden="true"></i><span class="gx-section__title">Dashcam drives</span>
<span class="gx-section__count">{{ stats.count }} drives · ~{{ formatDuration(stats.minutes) }}</span>
</header>
<div class="gx-recordings__filters">
<input class="gx-field" type="search" aria-label="Search routes" placeholder="Search drives, dates or IDs…" v-model="query" />
<GalaxySelect class="gx-field" aria-label="Sort routes" :value="sortOrder" @change="sortOrder=$event.target.value">
<option value="newest">Newest first</option><option value="oldest">Oldest first</option><option value="longest">Longest duration</option><option value="shortest">Shortest duration</option>
</GalaxySelect>
<div class="gx-recordings__tabs gx-tabs" aria-label="Filter routes"><button class="gx-btn gx-btn--tonal" :aria-pressed="!preservedOnly" @click="preservedOnly=false">All</button><button class="gx-btn gx-btn--tonal" :aria-pressed="preservedOnly" @click="preservedOnly=true">Preserved · {{ stats.preserved }}</button></div>
</div>
</section>
<GxState v-if="status === 'loading'" loading>Finding local drives…</GxState><GxNotice tone="danger" v-if="status === 'unavailable'">{{ error }}</GxNotice>
<template v-if="status === 'ready' && data">
<GxNotice v-if="data.scanIncomplete" tone="warn">This scan was incomplete. More local segments may exist.</GxNotice>
<section class="gx-card gx-recordings__list">
<p v-if="!routeCards.length" class="gx-recordings__empty">{{ data.routes.length ? 'No drives match your filters.' : 'No saved drives found on this device.' }}</p>
<article v-for="route in routeCards" :key="route.routeId" class="gx-recordings__row" :class="{'gx-recordings__row--preserved': route.preserved}">
<button class="gx-recordings__drive" :disabled="!route.segments.some(segment => segment.files.fcamera || segment.files.ecamera || segment.files.dcamera || segment.files.qcamera)" :aria-label="'Watch ' + (route.displayName !== route.routeId ? route.displayName : route.date.label)" @click="watchRoute(route)">
<span class="gx-row__label">{{ route.displayName !== route.routeId ? route.displayName : route.date.label }}</span>
<span class="gx-row__desc"><template v-if="route.displayName !== route.routeId">{{ route.date.label }} · </template>~{{ formatDuration(route.segmentCount) }}</span>
<span v-if="route.preserved" class="gx-chip">Preserved</span>
</button>
<button type="button" class="gx-btn gx-btn--tonal" :aria-label="'Choose a segment for ' + (route.displayName || route.routeId)" @click="openLogs(route)">{{ route.segmentCount }} {{ route.segmentCount === 1 ? "segment" : "segments" }}</button>
<button class="gx-btn gx-btn--tonal" :disabled="!route.segments.some(replayableSegment)" @click="openReplay(route)"><i class="bi bi-play-circle" aria-hidden="true"></i>Replay with UI</button>
<RecordingActions :recording="route" :unauthorized="unauthorized" @changed="refresh" />
</article>
</section>
<section v-if="logsRoute" ref="logsPanel" class="gx-card gx-recordings__logs">
<header class="gx-section__header"><i class="bi bi-file-earmark-arrow-down"></i><span class="gx-section__title">{{ logsRoute.displayName !== logsRoute.routeId ? logsRoute.displayName : logsRoute.date.label }} · Segments and files</span><button class="gx-icon-btn" aria-label="Close segments and files" @click="logsRoute=null"><i class="bi bi-x-lg"></i></button></header>
<div class="gx-recordings__actions gx-recordings__log-toolbar gx-actions"><a v-if="logsRoute.segments.some(segment => segment.logFiles?.length)" class="gx-btn gx-btn--tonal" :href="logArchiveUrl(logsRoute.routeId)" :download="logsRoute.routeId + '-logs.tar'"><i class="bi bi-download"></i>Download all logs (.tar)</a><a class="gx-btn gx-btn--tonal" href="#/tuning/flm">Analyze driving logs</a><a v-if="logsRoute.connect" class="gx-btn gx-btn--tonal" :href="logsRoute.connect" target="_blank" rel="noopener noreferrer">Open in comma connect</a></div>
<ul class="gx-recordings__segments"><li v-for="segment in logsRoute.segments" :key="segment.number">
<div class="gx-recordings__segment-info"><strong>Segment {{ segment.number }}</strong><ul class="gx-recordings__files"><li v-for="file in availableFiles(segment.files)" :key="file">{{ file }}</li></ul></div>
<div class="gx-recordings__actions gx-actions"><a v-for="filename in segment.logFiles || []" :key="filename" class="gx-btn gx-btn--tonal" :href="logUrl(segment.segmentName, filename)" :download="segment.segmentName + '-' + filename">{{ filename }}<span v-if="segment.logBytes?.[filename]"> · {{ formatBytes(segment.logBytes[filename]) }}</span><i class="bi bi-download"></i></a>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!hasVideo(segment.files)" @click="watchRoute(logsRoute, segment)">Play video</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!replayableSegment(segment)" @click="openReplay(logsRoute, segment.number)">Replay with UI</button>
<button v-if="segment.files.rlog" class="gx-btn gx-btn--tonal" @click="openDetails(segment)">Details</button>
<button v-if="hasVideo(segment.files)" type="button" class="gx-btn gx-btn--tonal gx-recordings__danger" :disabled="deleting !== null" @click="confirmDeleteVideos(segment)"><i aria-hidden="true" class="bi bi-trash"></i> {{ deleting === segment.segmentName ? 'Deleting…' : 'Delete videos' }}</button>
</div>
</li></ul>
</section>
<section v-if="detailsName" ref="detailsPanel" class="gx-card gx-recordings__details" aria-label="Recorded segment details">
<div class="gx-recordings__player-head"><h3>Recorded Segment Details</h3><GxIconButton label="Close segment details" icon="bi-x-lg" @click="closeDetails" /></div>
<p class="gx-note">{{ detailsName }} · For this saved segment only. Distance is estimated from recorded speed.</p>
<GxState v-if="detailsStatus === 'loading'" loading>Reading closed full log…</GxState>
<GxNotice tone="danger" v-if="detailsStatus === 'unavailable'">{{ detailsError }}</GxNotice>
<div v-if="detailsStatus === 'ready' && details" class="gx-recordings__metrics">
<div><strong>{{ detailMetric(details.observedCarSpanSeconds) }}</strong><span>Recorded time span</span></div>
<div><strong>{{ detailMetric(details.estimatedDistanceMeters, 'm') }}</strong><span>Estimated distance</span></div>
<div><strong>{{ detailMetric(details.observedLatActiveSeconds) }}</strong><span>Steering active</span></div>
<div><strong>{{ detailMetric(details.observedLongActiveSeconds) }}</strong><span>Longitudinal active</span></div>
</div>
<p v-if="detailsStatus === 'ready' && details && !details.sampleCoverageComplete" class="gx-note">Samples have gaps or a missing source; unavailable values were not inferred.</p>
</section>
<section v-if="data.routes.length" class="gx-card gx-recordings__delete">
<header class="gx-section__header"><i class="bi bi-exclamation-triangle"></i><span class="gx-section__title">Delete local drives</span></header>
<RecordingActions :recording="{displayName: 'local driving recordings'}" all :unauthorized="unauthorized" @changed="refresh" />
</section>
<p class="gx-note">Duration is approximate from saved one-minute segments. Older footage may have been removed to free space.</p>
<Teleport to="body"><dialog v-if="playing" ref="playerPanel" class="gx-card gx-recordings__player" aria-label="Camera recording player" @cancel.prevent="closePlayer">
<div class="gx-recordings__player-head"><div><h3>{{ cameraLabel(playing.camera) }}</h3><p class="gx-note">{{ playing.camera === "qcamera" ? "Quick preview" : "Full-resolution recording" }} · {{ playing.routeId }} · segment {{ playing.segments[playing.index].number }}</p></div>
<GxIconButton label="Close recording player" icon="bi-x-lg" @click="closePlayer" /></div>
<div class="gx-recordings__actions gx-actions"><button v-for="camera in playerCameras" :key="camera" class="gx-btn gx-btn--tonal"
:aria-pressed="playing.camera === camera" @click="selectCamera(camera)">{{ cameraLabel(camera) }}</button></div>
<video :key="playerGeneration" ref="quickVideo" :data-player-generation="playerGeneration" controls playsinline autoplay preload="metadata" :src="playing.url" @error="videoError($event)" @ended="chooseSegment(1)"></video>
<GxNotice tone="danger" v-if="playerError">{{ playerError }}</GxNotice>
<div class="gx-recordings__actions gx-actions"><a class="gx-btn gx-btn--tonal" :href="playing.url" :download="playing.routeId + '-' + playing.camera + '-' + playing.segments[playing.index].number + '.mp4'">Download segment</a>
<a class="gx-btn gx-btn--tonal" :href="driveVideoUrl(playing.routeId, playing.camera)" :download="playing.routeId + '-' + playing.camera + '.mp4'">Download drive</a></div>
<div class="gx-recordings__player-controls"><GxIconButton label="Previous segment" icon="bi-skip-backward-fill" :disabled="playing.index === 0" @click="chooseSegment(-1)" />
<GalaxySelect class="gx-field" aria-label="Video segment" :value="String(playing.index)" @change="chooseSegment(Number($event.target.value) - playing.index)"><option v-for="(segment, index) in playing.segments" :key="segment.number" :value="String(index)" :data-collapsed-label="'Segment ' + segment.number">Segment {{ segment.number }} · {{ index + 1 }} of {{ playing.segments.length }}</option></GalaxySelect>
<GxIconButton label="Next segment" icon="bi-skip-forward-fill" :disabled="playing.index === playing.segments.length - 1" @click="chooseSegment(1)" /></div>
</dialog></Teleport>
</template>
</template>
<ReplayViewer v-if="replayRoute" :route-id="replayRoute.routeId" :start-segment="replayRoute.segment" :unauthorized="unauthorized" @close="replayRoute=null" />
<GxDialog v-if="deleteSelection" :inert="deleting !== null" labelledby="gx-delete-videos-title" describedby="gx-delete-videos-body" alert
@close="deleting === null && (deleteSelection = null)">
<h3 id="gx-delete-videos-title">Delete segment videos?</h3>
<p id="gx-delete-videos-body">Delete the videos for segment {{ deleteSelection.number }}? Its logs stay on the device. This cannot be undone.</p>
<GxNotice v-if="deleteError" tone="danger">{{ deleteError }}</GxNotice>
<div class="gx-settings__controls gx-actions">
<button class="gx-btn gx-btn--tonal" @click="deleteSelection = null">Cancel</button>
<button class="gx-btn gx-btn--danger" @click="deleteVideos">{{ deleting ? 'Deleting…' : 'Delete videos' }}</button>
</div>
</GxDialog>
</div>`,
}
@@ -0,0 +1,61 @@
import { GxDialog } from "./dialog.js"
import { GxNotice } from "./notice.js"
import { requestJson } from "./startup.js"
export const RecordingActions = {
components: { GxDialog, GxNotice },
props: { recording: { type: Object, required: true }, all: Boolean,
unauthorized: { type: Function, required: true } },
emits: ["changed"],
data: () => ({ action: "", name: "", busy: false, error: "", includePreserved: false }),
methods: {
edit(action) {
this.action = action
this.name = this.recording.displayName || this.recording.routeId
this.error = ""
},
async save(action = this.action) {
if (this.busy) return
this.busy = true
this.error = ""
const payload = this.all ? { action: "delete-all", confirmed: true, includePreserved: this.includePreserved } : { action, routeId: this.recording.routeId,
...(action === "rename" ? { name: this.name } : action === "preserve" ? { preserved: !this.recording.preserved } : { confirmed: true }) }
try {
await requestJson("./api/recordings/action", {
request: { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) },
})
this.action = ""
this.$emit("changed")
} catch (error) {
if (error.status === 401) this.unauthorized()
else this.error = error.status === 409 ? "Turn off the vehicle before managing recordings." :
error.message || "Recording action failed. Refresh the library before trying again."
} finally { this.busy = false }
},
},
template: `
<div class="gx-recordings__actions gx-actions" :inert="busy" :aria-busy="busy || undefined">
<template v-if="!all">
<button class="gx-icon-btn" :aria-label="recording.preserved ? 'Unpreserve drive' : 'Preserve drive'" :title="recording.preserved ? 'Unpreserve' : 'Preserve'" @click="save('preserve')"><i class="bi" :class="recording.preserved ? 'bi-heart-fill' : 'bi-heart'"></i></button>
<slot></slot>
<button class="gx-icon-btn" aria-label="Rename drive" title="Rename" @click="edit('rename')"><i class="bi bi-pencil"></i></button>
<button class="gx-icon-btn gx-recordings__danger" aria-label="Delete drive" title="Delete" @click="edit('delete')"><i class="bi bi-trash"></i></button>
</template>
<template v-else>
<button class="gx-btn gx-btn--tonal" @click="includePreserved=false; edit('delete')">Delete non-preserved</button>
<button class="gx-btn gx-btn--tonal gx-recordings__danger" @click="includePreserved=true; edit('delete')">Delete all including preserved</button>
</template>
<GxNotice tone="danger" v-if="error && !action">{{ error }}</GxNotice>
<GxDialog v-if="action" :inert="busy" labelledby="gx-recording-action-title" :alert="action === 'delete'" @close="!busy && (action='')">
<form class="gx-stack" @submit.prevent="save()">
<h3 id="gx-recording-action-title">{{ action === 'rename' ? 'Rename recording' : 'Delete recording?' }}</h3>
<label v-if="action === 'rename'">Name<input class="gx-field" v-model="name" maxlength="128" required /></label>
<p v-else>Delete {{ recording.displayName || recording.routeId }}? This cannot be undone.</p>
<p v-if="all">{{ includePreserved ? "Preserved drives will also be deleted." : "Preserved drives will be kept." }}</p>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<div class="gx-settings__controls gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="action=''">Cancel</button>
<button class="gx-btn">{{ action === 'rename' ? 'Save name' : 'Delete recording' }}</button></div>
</form>
</GxDialog>
</div>`,
}
+419
View File
@@ -0,0 +1,419 @@
import { requestJson } from "./startup.js"
import { GxDialog } from "./dialog.js"
import { GxNotice } from "./notice.js"
import { GxState } from "./state.js"
import { GxIconButton } from "./icon-button.js"
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
const ID = /^[a-f0-9]{32}$/
const STATES = new Set(["idle", "preparing", "buffering", "playing", "paused", "seeking", "stopping", "ended", "failed"])
const TERMINAL = new Set(["idle", "failed"])
const number = value => typeof value === "number" && Number.isFinite(value)
const integer = value => Number.isSafeInteger(value) && value >= 0
const token = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), value => value.toString(16).padStart(2, "0")).join("")
const mediaSources = () => [globalThis.MediaSource, globalThis.ManagedMediaSource].filter(Source => typeof Source === "function")
const STREAMING_UNAVAILABLE = "Replay video streaming is unavailable in this browser. Update your browser or try another browser."
const PLAY_BUFFER = .5, MAX_BUFFER_AHEAD = 1, MEDIA_INTERVAL = 50
export const replayableSegment = segment => !!((segment.files.rlog || segment.files.qlog) && (segment.files.fcamera || segment.files.qcamera))
export function validReplayStatus(value) {
const ranges = value?.seekRanges
return value?.schemaVersion === 1 && ID.test(value.ownerId) && STATES.has(value.state) &&
(value.sessionId === null || ID.test(value.sessionId)) && integer(value.revision) && integer(value.epoch) &&
[value.position, value.start, value.end].every(number) && value.start <= value.end &&
[.5, 1, 2].includes(value.rate) && typeof value.paused === "boolean" && typeof value.offroad === "boolean" && typeof value.owned === "boolean" &&
(value.codec == null || typeof value.codec === "string" && value.codec.length <= 128) &&
(ranges === undefined || Array.isArray(ranges) && ranges.length <= 512 && ranges.every((range, i) =>
Array.isArray(range) && range.length === 2 && range.every(number) && value.start <= range[0] && range[0] <= range[1] &&
range[1] <= value.end && (i === 0 || ranges[i-1][1] <= range[0])))
}
export function replaySeekPosition(status, position) {
const bounded = Math.min(status.end, Math.max(status.start, position)), ranges = status.seekRanges || []
if (!ranges.length || ranges.some(([start, end]) => start <= bounded && bounded <= end)) return bounded
let nearest = ranges[0][0]
for (const range of ranges) for (const boundary of range) if (Math.abs(boundary - bounded) < Math.abs(nearest - bounded)) nearest = boundary
return nearest
}
export function replaySeekGaps(status) {
const gaps = [], ranges = status?.seekRanges || []
let after = status?.start
for (const [start, end] of ranges) { if (start > after) gaps.push([after, start]); after = end }
if (ranges.length && after < status.end) gaps.push([after, status.end])
return gaps
}
export function replayBufferTime(buffered, paused = false) {
const last = buffered.length - 1, start = buffered.start(last), end = buffered.end(last)
return Math.max(start, end - (paused ? .001 : Math.min(.2, end - start)))
}
export const replayTime = seconds => `${Math.floor(Math.max(0, seconds) / 60)}:${String(Math.floor(Math.max(0, seconds) % 60)).padStart(2, "0")}`
// The browser owns viewing; every route action still goes through the parked replay owner.
export class ReplayClient {
constructor({ publish, unauthorized, video, fetcher = (...args) => fetch(...args),
mediaSource = Source => new Source(), createURL = value => URL.createObjectURL(value), revokeURL = value => URL.revokeObjectURL(value),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = id => clearTimeout(id), clientId = token() }) {
Object.assign(this, { publish, unauthorized, video, fetcher, mediaSource, createURL, revokeURL, later, cancelTimer, clientId })
this.active = false
this.generation = this.mediaGeneration = 0
this.requests = new Set()
this.status = null
this.busy = false
this.pendingAction = null
this.error = ""
this.detail = null
this.ownedSession = null
}
emit() { this.publish({ replay: this.status, replayBusy: this.busy, replayError: this.error, replayDetail: this.detail,
replayVideoReady: !!this.buffer?.buffered.length, replayBuffering: this.needsBuffer() }) }
async json(path, body = null) {
const request = new AbortController(), generation = this.generation
this.requests.add(request)
if (path === "start") this.startRequest = request
try {
const data = await requestJson(`./api/replay/${path}`, { fetcher: this.fetcher, timeout: 6000,
later: this.later, cancel: this.cancelTimer, request: { signal: request.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) } })
return path === "start" || this.active && generation === this.generation ? data : null
} catch (error) {
if (!this.active || generation !== this.generation) return null
if (error.status === 401 || ["setup_required", "access_unavailable"].includes(error.code)) { this.close(false); this.unauthorized(); return null }
throw error
} finally { this.requests.delete(request); if (this.startRequest === request) this.startRequest = null }
}
async open(routeId, startSegment = null) {
this.close(false)
this.active = true
this.status = this.detail = null; this.error = ""
this.routeId = routeId
this.startSegment = startSegment
this.emit()
const generation = this.generation
try {
const [detail] = await Promise.all([this.json(`detail?routeId=${encodeURIComponent(routeId)}`), this.refresh()])
if (!this.active || generation !== this.generation) return
this.detail = detail; this.emit()
if (!this.error && detail?.playable && this.status?.offroad && TERMINAL.has(this.status.state)) await this.start()
} catch (error) { if (this.active) { this.error = error.message; this.emit() } }
}
async refresh() {
if (!this.active || this.reading) return
const generation = this.generation
this.reading = true
try {
const status = await this.json(`status?clientId=${this.clientId}`)
if (!status) return
if (!validReplayStatus(status)) throw new Error("Replay returned an unsupported status.")
this.receive(status)
} catch (error) {
if (this.active) { this.retireMedia(); this.status = null; this.error = error.message || "Replay disconnected. Refresh before continuing."; this.emit() }
} finally {
if (generation === this.generation) {
this.reading = false
if (this.active) { this.cancelTimer(this.timer); this.timer = this.later(() => this.refresh(), 500) }
}
}
}
receive(status) {
const prior = this.status
if (prior?.ownerId === status.ownerId && prior.sessionId === status.sessionId && status.revision < prior.revision) return
if (!this.ownedSession && this.startAttempted && status.owned && ID.test(status.sessionId) && status.routeId === this.routeId) { this.ownedSession = status.sessionId; this.error = "" }
this.status = status
if (status.lastRequestId === this.pendingAction || TERMINAL.has(status.state) || !status.offroad) { this.pendingAction = null; this.busy = false }
if (this.ownedSession && (status.sessionId !== this.ownedSession || !status.owned)) { this.ownedSession = null; this.retireMedia() }
if (!status.offroad || TERMINAL.has(status.state) || ["preparing", "seeking", "stopping"].includes(status.state) || status.mediaReady === false) this.retireMedia()
else if (status.sessionId === this.ownedSession && status.codec &&
((!this.source && !this.videoFailed) || prior?.epoch !== status.epoch)) this.beginMedia(status)
const video = this.video()
if (video && this.source) {
if (status.paused && status.state !== "ended") {
video.pause()
if (this.buffer?.buffered.length) {
const position = replayBufferTime(this.buffer.buffered, true)
if (Math.abs(video.currentTime - position) > .001) video.currentTime = position
}
}
else this.playVideo()
}
if (this.source && this.buffer && (prior?.paused && !status.paused || status.state === "ended" && this.sequence <= status.media?.lastSequence)) {
this.mediaDemand = true; this.cancelTimer(this.mediaTimer); this.loadMedia(this.mediaGeneration, status.sessionId, status.epoch)
}
this.emit()
}
playVideo() {
const generation = this.mediaGeneration, video = this.video()
const ended = this.status?.state === "ended"
if (ended && this.sequence > this.status.media?.lastSequence && this.source?.readyState === "open" && !this.buffer?.updating) {
this.cancelTimer(this.mediaTimer)
this.source.endOfStream()
}
if (!video?.paused || this.status?.paused && !ended || this.needsBuffer() || this.bufferAhead() <= 0) return
video.play()?.catch(error => {
if (!this.active || generation !== this.mediaGeneration || error.name === "AbortError") return
this.error = error.name === "NotAllowedError" ? "Select Play to allow video playback in this browser." : "Replay video could not play. Retry the video or stop replay."
this.emit()
})
}
bufferAhead() {
const video = this.video(), ranges = this.buffer?.buffered
for (let i = 0; video && i < (ranges?.length || 0); i++) {
if (ranges.start(i) <= video.currentTime && video.currentTime < ranges.end(i)) return ranges.end(i) - video.currentTime
}
return 0
}
needsBuffer() {
return !!this.source && !this.status?.paused && this.status?.state !== "ended" && !!this.video()?.paused && this.bufferAhead() < PLAY_BUFFER
}
videoWaiting() {
if (!this.active || !this.source || this.status?.paused || this.status?.state === "ended") return
this.video().pause()
this.mediaDemand = true
this.cancelTimer(this.mediaTimer)
this.loadMedia(this.mediaGeneration, this.status.sessionId, this.status.epoch)
this.emit()
}
async action(operation, extra = {}) {
if (!this.active || operation !== "stop" && (this.busy || !this.status)) return
const generation = this.generation, requestId = token()
this.pendingAction = operation === "control" ? requestId : null
this.busy = true; this.error = ""; this.emit()
try {
const status = await this.json(operation, { clientId: this.clientId, requestId, ...extra })
if (!status) return
if (operation === "start" && ID.test(status.sessionId)) {
if (!this.active || generation !== this.generation) { this.sendStop(status.sessionId); return }
this.ownedSession = status.sessionId
}
if (!validReplayStatus(status)) throw new Error("Replay returned an unsupported status.")
this.receive(status)
} catch (error) {
this.pendingAction = null
if (this.active) { this.error = error.message || "Replay action could not complete."; await this.refresh() }
} finally { this.busy = this.pendingAction !== null; if (this.active) this.emit() }
}
start() {
if (!this.active || this.busy || !this.status) return
if (!mediaSources().length) { this.error = STREAMING_UNAVAILABLE; this.emit(); return }
this.startAttempted = true; this.videoFailed = false; this.autoplay = true
return this.action("start", { routeId: this.routeId, target: "web", expectedOwnerId: this.status.ownerId,
...(this.startSegment == null ? {} : { segment: this.startSegment }) })
}
control(action, value) {
this.autoplay = false
if (!this.ownedSession || this.status?.sessionId !== this.ownedSession) return
if (action === "seek") { if (!number(value)) return; value = replaySeekPosition(this.status, value) }
return this.action("control", { sessionId: this.ownedSession, expectedRevision: this.status.revision, action,
...(action === "pause" ? { paused: value } : action === "seek" ? { position: value } : { rate: value }) })
}
stop() { return this.ownedSession && this.action("stop", { sessionId: this.ownedSession }) }
retireMedia() {
this.mediaGeneration++
this.mediaRequest?.abort(); this.mediaRequest = null
this.cancelTimer(this.mediaTimer)
this.rejectAppend?.(new Error("Replay video retired")); this.rejectAppend = null
this.source = this.buffer = null
const video = this.video()
if (video) {
if (this.mediaSeeking) video.removeEventListener("seeking", this.mediaSeeking)
video.pause(); video.removeAttribute("src"); video.load()
}
this.mediaSeeking = null
if (this.url) this.revokeURL(this.url)
this.url = null
}
beginMedia(status) {
this.retireMedia()
this.videoFailed = false; this.error = ""
if (!status.offroad || !status.codec || status.mediaReady === false) return
const mime = `video/mp4; codecs="${status.codec}"`
const sources = mediaSources(), Source = sources.find(Source => Source.isTypeSupported(mime))
if (!Source) {
this.videoFailed = true
this.error = sources.length ? "This browser does not support this replay's H.264 video. Update your browser or try another browser." : STREAMING_UNAVAILABLE
if (this.ownedSession === status.sessionId) this.sendStop(status.sessionId)
this.emit(); return
}
const generation = this.mediaGeneration
try {
const video = this.video(), source = this.source = this.mediaSource(Source)
const load = () => this.loadMedia(generation, status.sessionId, status.epoch)
this.mediaDemand = true
if (Source === globalThis.ManagedMediaSource) {
video.disableRemotePlayback = true
const resume = () => {
if (!this.active || generation !== this.mediaGeneration) return
this.mediaDemand = true
if (this.buffer) { this.cancelTimer(this.mediaTimer); load() }
}
source.addEventListener("startstreaming", resume)
source.addEventListener("endstreaming", () => {
if (!this.active || generation !== this.mediaGeneration || video.seeking) return
this.mediaDemand = false
if (this.buffer && !this.needsBuffer()) this.cancelTimer(this.mediaTimer)
})
// Safari can omit startstreaming after a seek; resume the existing loader explicitly.
video.addEventListener("seeking", this.mediaSeeking = resume)
}
source.addEventListener("sourceopen", () => {
if (!this.active || generation !== this.mediaGeneration) return
this.cancelTimer(this.mediaTimer)
try {
const buffer = this.buffer = source.addSourceBuffer(mime)
buffer.addEventListener("bufferedchange", event => {
if (!this.active || generation !== this.mediaGeneration || !event.removedRanges.length) return
for (let i = 0; i < event.removedRanges.length; i++) {
if (!buffer.buffered.length || event.removedRanges.start(i) <= video.currentTime && video.currentTime < event.removedRanges.end(i)) {
this.beginMedia(this.status); return
}
}
})
this.sequence = 0
this.nextFragment = status.media?.firstSequence || 1
load()
} catch (error) { this.videoFailed = true; this.mediaFailed(error, generation) }
}, { once: true })
this.url = this.createURL(source)
video.src = this.url
this.mediaTimer = this.later(() => {
if (!this.active || generation !== this.mediaGeneration) return
this.videoFailed = true
this.mediaFailed(new Error("Replay video could not open. Retry video or stop replay."), generation)
}, 4000)
} catch (error) { this.videoFailed = true; this.mediaFailed(error, generation) }
}
append(bytes, generation) { return this.updateBuffer(buffer => buffer.appendBuffer(bytes), generation) }
updateBuffer(change, generation) {
return new Promise((resolve, reject) => {
if (!this.buffer || generation !== this.mediaGeneration) { reject(new Error("Replay video retired")); return }
const buffer = this.buffer
const cleanup = () => { buffer.removeEventListener("updateend", done); buffer.removeEventListener("error", failed); if (this.rejectAppend === failed) this.rejectAppend = null }
const done = () => { cleanup(); resolve() }
const failed = () => { cleanup(); reject(new Error("Replay video could not decode.")) }
this.rejectAppend = failed
buffer.addEventListener("updateend", done, { once: true })
buffer.addEventListener("error", failed, { once: true })
try { change(buffer) } catch (error) { cleanup(); reject(error) }
})
}
async loadMedia(generation, sessionId, epoch) {
if (!this.active || generation !== this.mediaGeneration || !this.buffer || this.mediaRequest ||
this.source?.readyState === "ended" || this.mediaDemand === false && this.buffer.buffered.length && !this.needsBuffer()) return
const load = () => this.loadMedia(generation, sessionId, epoch)
this.cancelTimer(this.mediaTimer)
if (this.bufferAhead() >= MAX_BUFFER_AHEAD) { this.mediaTimer = this.later(load, MEDIA_INTERVAL); return }
const request = this.mediaRequest = new AbortController()
const timeout = this.later(() => request.abort(), 4000)
try {
const response = await this.fetcher(`./api/replay/media?sessionId=${sessionId}&clientId=${this.clientId}&epoch=${epoch}&sequence=${this.sequence}`, { credentials: "same-origin", cache: "no-store", signal: request.signal })
if (!this.active || generation !== this.mediaGeneration || this.source?.readyState === "ended") return
if (response.status === 401) { this.close(false); this.unauthorized(); return }
if (response.status === 202) { this.mediaTimer = this.later(load, this.status?.paused || this.status?.state === "ended" ? 500 : MEDIA_INTERVAL); return }
if (!response.ok) {
const error = await response.json().catch(() => ({}))
if (error.code === "media_evicted") { await this.refresh(); if (generation === this.mediaGeneration && this.status) this.beginMedia(this.status); return }
throw new Error(error.error || "Replay video is unavailable.")
}
if (response.headers.get("Content-Type")?.split(";")[0] !== "video/mp4" || Number(response.headers.get("X-Replay-Epoch")) !== epoch) throw new Error("Replay video epoch changed.")
const length = Number(response.headers.get("Content-Length"))
if (!integer(length) || length < 1 || length > 1024 * 1024) throw new Error("Replay video exceeded its media budget.")
const bytes = await response.arrayBuffer()
if (!this.active || generation !== this.mediaGeneration) return
if (bytes.byteLength !== length) throw new Error("Replay video was incomplete.")
await this.append(bytes, generation)
if (!this.active || generation !== this.mediaGeneration) return
const next = Number(response.headers.get("X-Replay-Next-Sequence"))
if (!integer(next) || next <= this.sequence) throw new Error("Replay video sequence is invalid.")
this.sequence = this.sequence === 0 ? this.nextFragment : next
const video = this.video(), buffer = this.buffer
if (buffer.buffered.length) {
// The owner starts paused so its first composed frame can reach the viewer.
if (this.autoplay) {
this.autoplay = false
if (this.status.paused && this.status.state !== "ended") await this.control("pause", false)
if (!this.active || generation !== this.mediaGeneration) return
}
const start = buffer.buffered.start(0), end = buffer.buffered.end(buffer.buffered.length - 1)
if (this.status.paused && this.status.state !== "ended" || this.bufferAhead() === 0 && video.currentTime < end) {
const position = replayBufferTime(buffer.buffered, this.status.paused && this.status.state !== "ended")
if (Math.abs(video.currentTime - position) > .001) video.currentTime = position
}
if (video.currentTime - start > 3) {
await this.updateBuffer(buffer => buffer.remove(start, video.currentTime - 2), generation)
}
}
this.playVideo()
this.emit()
if (this.source?.readyState !== "ended") this.mediaTimer = this.later(load, !this.buffer.buffered.length || this.needsBuffer() ? 0 : MEDIA_INTERVAL)
} catch (error) { this.mediaFailed(error, generation) }
finally { this.cancelTimer(timeout); if (this.mediaRequest === request) this.mediaRequest = null }
}
videoError() { this.videoFailed = true; this.mediaFailed(new Error("Replay video could not decode. Retry the video or stop replay."), this.mediaGeneration) }
mediaFailed(error, generation) {
if (!this.active || generation !== this.mediaGeneration) return
this.retireMedia(); this.error = error.message || "Replay video disconnected. Stop or retry the video."; this.emit()
}
sendStop(sessionId) {
this.fetcher("./api/replay/stop", { method: "POST", credentials: "same-origin", keepalive: true,
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, clientId: this.clientId, requestId: token() }) }).catch(() => {})
}
close(stop = true) {
if (stop && this.active && this.ownedSession) this.sendStop(this.ownedSession)
this.active = this.reading = this.autoplay = this.startAttempted = false; this.generation++
this.cancelTimer(this.timer)
for (const request of this.requests) if (request !== this.startRequest) request.abort()
this.requests.clear(); this.retireMedia(); this.ownedSession = null
}
}
export const ReplayViewer = {
components: { GxDialog, GxNotice, GxState, GxIconButton },
props: { routeId: { type: String, required: true }, startSegment: { type: Number, default: null }, unauthorized: { type: Function, required: true } },
emits: ["close"],
data: () => ({ client: null, replay: null, replayBusy: false, replayError: "", replayDetail: null, replayVideoReady: false, replayBuffering: false, seekPosition: null }),
computed: {
playing() { return this.client?.ownedSession === this.replay?.sessionId && !!this.replay?.sessionId && !TERMINAL.has(this.replay.state) },
controlsBusy() { return this.replayBusy || !this.replay?.offroad || ["preparing", "seeking", "stopping"].includes(this.replay.state) },
seekGaps() { return replaySeekGaps(this.replay) },
canStart() { return this.replay?.offroad && TERMINAL.has(this.replay.state) && !this.replayBusy && this.replayDetail?.playable === true },
},
mounted() {
this.client = markRaw(new ReplayClient({ video: () => this.$refs.video, unauthorized: this.unauthorized, publish: update => Object.assign(this.$data, update) }))
this.visibility = () => { if (document.hidden) { this.client.close(); this.$emit("close") } }
document.addEventListener("visibilitychange", this.visibility)
this.client.open(this.routeId, this.startSegment)
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.client.close() },
methods: {
replayTime,
close() { this.client.close(); this.$emit("close") },
seek(value) { this.seekPosition = null; this.client.control("seek", Number(value)) },
cycleRate() {
const rates = [.5, 1, 2]
this.client.control("rate", rates[(rates.indexOf(this.replay.rate) + 1) % rates.length])
},
fullscreen() { return document.fullscreenElement === this.$refs.player ? document.exitFullscreen() : this.$refs.player.requestFullscreen?.() },
},
template: `<GxDialog class="gx-replay" labelledby="gx-replay-title" @close="close">
<div class="gx-recordings__player-head"><div><h3 id="gx-replay-title">Route replay</h3><p class="gx-note">{{ routeId }}<template v-if="startSegment !== null"> · from segment {{ startSegment }}</template> · Recorded driving view · Vehicle offroad only</p></div><GxIconButton label="Close replay" icon="bi-x-lg" @click="close" /></div>
<div ref="player" class="gx-replay__player">
<video ref="video" class="gx-replay__video" playsinline muted aria-label="Recorded route with driving interface" @waiting="client?.videoWaiting()" @error="client?.videoError()"></video>
<GxState class="gx-replay__loading" v-if="!replayError && (!replay || replayBuffering || !replayVideoReady && (playing || replayBusy || !replayDetail) || ['preparing', 'seeking'].includes(replay.state))" loading>{{ replay?.state === 'seeking' ? 'Seeking recorded route…' : replayBuffering ? 'Buffering replay…' : 'Loading replay…' }}</GxState>
<GxIconButton v-if="playing" class="gx-replay__fullscreen" label="Fullscreen video" icon="bi-fullscreen" @click="fullscreen" />
<div v-if="playing" class="gx-replay__media-controls">
<label class="gx-replay__timeline"><span>{{ replayTime((seekPosition ?? replay.position)-replay.start) }} / {{ replayTime(replay.end-replay.start) }}</span><input type="range" aria-label="Replay route position" :min="replay.start" :max="replay.end" step="0.05" :value="seekPosition ?? replay.position" :disabled="controlsBusy" @input="seekPosition=Number($event.target.value)" @change="seek($event.target.value)" /></label>
<div class="gx-replay__controls">
<button type="button" class="gx-btn" aria-label="Back ten seconds" :disabled="controlsBusy" @click="seek(replay.position-10)">-10 s</button>
<button v-if="replay.state === 'ended'" type="button" class="gx-btn" aria-label="Return to start" :disabled="controlsBusy" @click="seek(replay.start)">Restart</button>
<button v-else type="button" class="gx-btn" :disabled="controlsBusy" @click="client.control('pause', !replay.paused)">{{ replay.paused ? 'Play' : 'Pause' }}</button>
<button type="button" class="gx-btn" aria-label="Forward ten seconds" :disabled="controlsBusy" @click="seek(replay.position+10)">+10 s</button>
<button type="button" class="gx-btn" aria-label="Replay speed" :disabled="controlsBusy" @click="cycleRate">{{ replay.rate }}x</button>
<button type="button" class="gx-btn" :disabled="replayBusy" @click="close">Stop</button>
</div>
</div>
</div>
<GxNotice v-if="replayError" tone="danger">{{ replayError }}<button v-if="!playing" class="gx-btn gx-btn--tonal" :disabled="replayBusy" @click="client.open(routeId, startSegment)">Retry replay</button><button v-else class="gx-btn gx-btn--tonal" :disabled="controlsBusy || !replay.codec || replay.mediaReady === false" @click="client.beginMedia(replay)">Retry video</button></GxNotice>
<GxNotice v-if="replay && !replay.offroad" tone="warn">Turn off the vehicle before replaying a drive.</GxNotice>
<GxNotice v-if="replay?.errorCode" tone="warn">Replay stopped: {{ replay.errorCode.replaceAll('_', ' ') }}</GxNotice>
<GxNotice v-if="replay?.sessionId && !playing && !canStart" tone="info">Another replay is active. Close it in its viewer before starting this route.</GxNotice>
<p v-if="replayDetail?.reason" class="gx-note">{{ replayDetail.reason }}</p>
<p v-if="playing && seekGaps.length" class="gx-note">Missing recordings: <span v-for="(gap, i) in seekGaps" :key="i">{{ i ? ", " : "" }}{{ replayTime(gap[0]-replay.start) }}–{{ replayTime(gap[1]-replay.start) }}</span>. Seeks move to the nearest recorded position.</p>
</GxDialog>`,
}
@@ -0,0 +1,30 @@
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { createRouteLeave } from "./route-leave.js"
const start = () => {
const hash = location.hash.slice(1) || "/"
try { return decodeURIComponent(hash) } catch { return hash }
}
export const route = reactive({ path: start(), direction: 1 })
const index = () => Number.isInteger(history.state?.galaxyIndex) ? history.state.galaxyIndex : null
const url = (path) => `${location.pathname}${location.search}#${encodeURI(path)}`
let navigation
let pendingGuard = null
export function navigate(path, before = null) { navigation.navigate(path, before) }
export function ensureBackEntry() { navigation.ensureBackEntry() }
export function navigateBack() { navigation.back() }
export function setRouteLeaveGuard(guard) { pendingGuard = guard; navigation?.setGuard(guard) }
export function startRouter() {
history.replaceState({ ...history.state, galaxyIndex: index() ?? 0 }, "", url(route.path))
let position = index()
navigation = createRouteLeave({
read: () => ({ path: start(), index: index() }),
push: (path, position) => history.pushState({ galaxyIndex: position }, "", url(path)),
replace: (path, position) => history.replaceState({ galaxyIndex: position }, "", url(path)),
go: (delta) => history.go(delta),
show: () => { route.direction = index() < position ? -1 : 1; position = index(); route.path = start(); window.scrollTo(0, 0) },
})
navigation.setGuard(pendingGuard)
window.addEventListener("popstate", () => navigation.changed())
window.addEventListener("hashchange", () => navigation.changed())
}
@@ -0,0 +1,141 @@
import { GxNotice } from "./notice.js"
export const channels = ["webPush", "discord", "webhook", "ntfy"]
export const labels = { webPush: "Browser push", discord: "Discord", webhook: "General webhook", ntfy: "ntfy" }
export function validNotifications(value) {
return value?.schemaVersion === 1 && typeof value.queueFull === "boolean" &&
Number.isInteger(value.subscriptionCount) && value.subscriptionCount >= 0 && value.subscriptionCount <= 16 &&
Array.isArray(value.subscriptions) && value.subscriptions.length === value.subscriptionCount &&
value.subscriptions.every(s => typeof s?.id === "string" && /^[0-9a-f]{32}$/.test(s.id) && Object.keys(s).length === 1) &&
typeof value.deliverySemantics === "string" && value.deliverySemantics.length < 256 &&
(value.applicationServerKey === undefined || typeof value.applicationServerKey === "string" && /^[A-Za-z0-9_-]{87}$/.test(value.applicationServerKey)) &&
channels.every(name => {
const c = value.channels?.[name]
return typeof c?.enabled === "boolean" && typeof c.configured === "boolean" && Number.isInteger(c.pending) && c.pending >= 0 && c.pending <= 512 &&
["idle", "queued", "sending", "sent", "failed", "cancelled"].includes(c.lastState) &&
["", "http", "expired", "timeout", "transport", "delivery_unknown", "stale"].includes(c.lastError)
})
}
export class NotificationClient {
constructor({ publish, unauthorized, fetcher = (...args) => fetch(...args) }) {
this.publish = publish; this.unauthorized = unauthorized; this.fetcher = fetcher
this.revision = 0; this.controller = null; this.pending = false; this.hasStatus = false; this.timer = null
}
stop() { this.revision++; this.controller?.abort(); this.controller = null; this.pending = false; clearTimeout(this.timer); this.timer = null }
async run(payload = null) {
this.stop()
const revision = this.revision
this.controller = new AbortController()
this.pending = true
const foreground = payload !== null || !this.hasStatus
if (foreground) this.publish({ notificationBusy: true, notificationError: "" })
this.timer = setTimeout(() => {
if (revision !== this.revision) return
this.stop()
this.publish({ notificationBusy: false, notificationError: "Notification request timed out. Refresh status before repeating a test." })
}, 4000)
try {
const response = await this.fetcher("./api/sentry/notifications", { credentials: "same-origin", cache: "no-store",
signal: this.controller.signal, ...(payload === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }) })
if (revision !== this.revision) return false
if (response.status === 401) { this.unauthorized(); return false }
const value = await response.json()
if (revision !== this.revision) return false
if (!response.ok) throw new Error(value?.error || "Sentry notifications are unavailable.")
if (!validNotifications(value)) throw new Error("Notification status is unavailable.")
this.hasStatus = true
this.publish({ notifications: value, notificationBusy: false, notificationError: "" })
return true
} catch (error) {
if (revision === this.revision && error.name !== "AbortError") this.publish({ notificationBusy: false, notificationError: error.message || "Notification request failed." })
return false
} finally {
if (revision === this.revision) { clearTimeout(this.timer); this.timer = null; this.pending = false; if (foreground) this.publish({ notificationBusy: false }) }
}
}
}
export const SentryNotifications = {
components: { GxNotice },
name: "SentryNotifications",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ channels, labels, notifications: null, notificationBusy: false, notificationError: "",
drafts: Object.fromEntries(channels.map(name => [name, { url: "", token: "" }])) }),
created() { this.client = new NotificationClient({ publish: update => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) { this.client.stop(); clearInterval(this.poll) } else this.start() }
document.addEventListener("visibilitychange", this.visibility)
this.start()
},
beforeUnmount() { this.client.stop(); clearInterval(this.poll); document.removeEventListener("visibilitychange", this.visibility) },
methods: {
start() {
clearInterval(this.poll)
if (this.mode !== "local" || document.hidden) return
this.client.run()
this.poll = setInterval(() => { if (!this.notificationBusy && !this.client.pending) this.client.run() }, 5000)
},
async save(name, enabled) {
const draft = this.drafts[name]
if (await this.client.run({ action: "configure", channel: name, enabled, url: draft.url, token: draft.token })) {
draft.url = ""; draft.token = ""
}
},
test(name) { return this.client.run({ action: "test", channel: name }) },
async forget(name) { if (await this.client.run({ action: "forget", channel: name })) { this.drafts[name].url = ""; this.drafts[name].token = "" } },
remove(id) { return this.client.run({ action: "unsubscribe", id }) },
async subscribe() {
if (!isSecureContext || !("serviceWorker" in navigator) || !("PushManager" in window)) {
this.notificationError = "Browser push needs HTTPS and a browser with Push API support."
return
}
try {
const permission = await Notification.requestPermission()
if (permission !== "granted") throw new Error("Allow browser notifications to subscribe.")
if (!await this.client.run({ action: "pushKey" })) return
const key = this.notifications.applicationServerKey
const bytes = Uint8Array.from(atob(key.replace(/-/g, "+").replace(/_/g, "/") + "="), c => c.charCodeAt(0))
const registration = await navigator.serviceWorker.register("./sentry-push-worker.js")
await navigator.serviceWorker.ready
const subscription = await registration.pushManager.getSubscription() || await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: bytes })
const value = subscription.toJSON()
if (!await this.client.run({ action: "subscribe", subscription: { endpoint: value.endpoint, keys: value.keys } })) return
await this.save("webPush", true)
} catch (error) { this.notificationError = error.message || "Browser push subscription failed." }
},
},
template: `
<section class="gx-card gx-home__card" aria-label="Sentry notifications" :inert="notificationBusy" :aria-busy="notificationBusy || undefined">
<h2>Notifications</h2>
<p class="gx-note">Receive new locally recorded motion events. Notification channels work independently.</p>
<p v-if="mode !== 'local'">Notification configuration is available on your connected device.</p>
<template v-else>
<GxNotice tone="danger" v-if="notificationError">{{ notificationError }}</GxNotice>
<p v-if="notificationBusy" class="gx-settings__save-status" role="status">Updating notifications…</p>
<GxNotice tone="danger" v-if="notifications?.queueFull">The notification queue is full. Some events could not be queued.</GxNotice>
<template v-if="notifications">
<p class="gx-note">{{ notifications.deliverySemantics }}</p>
<section v-for="name in channels" :key="name" class="gx-card gx-sentry-channel">
<h3>{{ labels[name] }}</h3>
<p>{{ notifications.channels[name].enabled ? 'Enabled' : 'Disabled' }} · {{ notifications.channels[name].configured ? 'Configured' : 'Not configured' }}</p>
<p role="status">Delivery: {{ notifications.channels[name].lastState }} · Pending: {{ notifications.channels[name].pending }}</p>
<GxNotice tone="danger" v-if="notifications.channels[name].lastError">Delivery status: {{ notifications.channels[name].lastError.replaceAll('_', ' ') }}</GxNotice>
<div v-if="name !== 'webPush'" class="gx-sentry-channel__fields">
<label>HTTPS URL <input v-model="drafts[name].url" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved URL"></label>
<label>Bearer token (optional) <input v-model="drafts[name].token" class="gx-field" type="password" autocomplete="off" placeholder="Leave blank to keep saved token"></label>
</div>
<div class="gx-sentry-channel__actions gx-actions">
<button v-if="name !== 'webPush'" class="gx-btn gx-btn--tonal" type="button" @click="save(name, true)">Save and enable</button>
<button v-else class="gx-btn gx-btn--tonal" type="button" @click="subscribe">Subscribe this browser</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!notifications.channels[name].configured" @click="save(name, !notifications.channels[name].enabled)">{{ notifications.channels[name].enabled ? 'Disable' : 'Enable' }}</button>
<button class="gx-btn gx-btn--tonal" type="button" :disabled="!notifications.channels[name].enabled || !notifications.channels[name].configured" @click="test(name)">Send test</button>
<button class="gx-btn gx-btn--tonal" type="button" @click="forget(name)">Forget configuration</button>
</div>
</section>
<p v-if="notifications.subscriptionCount">Subscribed browsers: {{ notifications.subscriptionCount }}</p>
<button v-for="(subscription, index) in notifications.subscriptions" :key="subscription.id" class="gx-btn gx-btn--tonal" type="button" @click="remove(subscription.id)">Remove browser {{ index + 1 }}</button>
</template>
</template>
</section>`,
}
@@ -0,0 +1,89 @@
import { GxState } from "./state.js"
import { SettingsPage } from "./settings.js"
import { GxNotice } from "./notice.js"
import { SentryNotifications } from "./sentry-notifications.js"
import { SnapshotFeed } from "./snapshot-feed.js"
import { deleteRecords, storedSize } from "./record-deletion.js"
// Historical motion records and independently configured notifications.
const ID = /^[0-9a-f]{32}$/
export function validSentryEvents(value) {
if (value?.schemaVersion !== 1 || value.source !== "local" || typeof value.scanIncomplete !== "boolean" ||
value.capacity !== 512 || !Array.isArray(value.events) || value.events.length > 512) return false
if (value.storageBytes !== undefined && (!Number.isSafeInteger(value.storageBytes) || value.storageBytes < 0)) return false
if (value.storageIncomplete !== undefined && typeof value.storageIncomplete !== "boolean") return false
const seen = new Set()
return value.events.every((event) => {
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images"].includes(key)) ||
(event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 ||
new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) ||
typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) ||
!["warning", "alarm", "selfie"].includes(event.kind) || !Number.isSafeInteger(event.systemTimeMs) ||
event.systemTimeMs <= 0 || event.systemTimeMs > 8_640_000_000_000_000) return false
seen.add(event.eventId)
return true
})
}
export const systemTime = (milliseconds) => Number.isSafeInteger(milliseconds) && milliseconds > 0 && milliseconds <= 8_640_000_000_000_000
? new Date(milliseconds).toLocaleString() : "Unavailable"
export class SentryEventsFeed extends SnapshotFeed {
static endpoint = "./api/sentry/events"
static valid = validSentryEvents
static subject = "local motion events"
static unavailable = "Local motion events are unavailable."
static invalid = "Local motion records are unavailable."
stop() { this.deleting = false; super.stop() }
emit() { this.publish({ status: this.status, data: this.data, error: this.error, deleting: this.deleting === true }) }
remove(eventId, confirm) {
if (eventId != null && !ID.test(eventId)) return Promise.resolve(false)
return deleteRecords(this, "./api/sentry/events/delete", eventId == null ? { all: true, confirmed: true } : { eventId }, {
valid: validSentryEvents, confirm, subject: "Sentry events",
accept: data => { this.data = data; this.status = "ready"; this.error = ""; this.emit() },
})
}
}
export const SentryEventsPage = {
name: "SentryEventsPage",
components: { GxState, GxNotice, SentryNotifications, SettingsPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({ status: "idle", data: null, error: "", deleting: false }),
created() { this.feed = new SentryEventsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
methods: { systemTime, storedSize },
template: `
<div class="gx-view gx-home" aria-label="Sentry motion events">
<header class="gx-settings__header gx-page-header"><div><h2>Sentry</h2>
<p>Review motion events, configure motion monitoring, and manage notifications. Timestamps use the device clock and may be inaccurate.</p></div></header>
<SettingsPage :mode="mode" :unauthorized="unauthorized" initial-page="sentry" title="" />
<SentryNotifications :mode="mode" :unauthorized="unauthorized" />
<GxState v-if="mode !== 'local'">Local motion records are unavailable in preview.</GxState>
<template v-else>
<GxState v-if="status === 'loading'" loading>Reading local motion events…</GxState>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<template v-if="status === 'ready' && data">
<div class="gx-actions"><p>{{ data.events.length }} events · {{ data.storageIncomplete ? 'At least ' : '' }}{{ storedSize(data.storageBytes) }} stored</p><button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(null)">Delete All</button></div>
<GxNotice v-if="data.scanIncomplete" tone="warn">This scan was incomplete. More local motion events may exist.</GxNotice>
<GxState v-if="!data.events.length">No local motion events found in this scan.</GxState>
<section v-for="event in data.events" :key="event.eventId" class="gx-card gx-home__card">
<h2>{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}</h2>
<p>System time: {{ systemTime(event.systemTimeMs) }}</p>
<small>Event ID: {{ event.eventId }}</small>
<button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(event.eventId)">Delete</button>
<img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" />
</section>
</template>
</template>
</div>`,
}
@@ -0,0 +1,461 @@
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { connectionError } from "./polling.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
import { GalaxySettingRow, settingControl } from "./galaxy-setting-row.js"
import { OnroadLayoutPage } from "./onroad-layout.js"
import { FavoritesPage } from "./favorites.js"
import { SoundPacks } from "./sound-packs.js"
import { CloudProviderPage } from "./cloud-provider.js"
import { GxNotice } from "./notice.js"
const post = (fetcher, path, body, signal) => fetcher(path, {
method: "POST", credentials: "same-origin", cache: "no-store", signal,
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
})
export const SETTINGS_SECTIONS = Object.freeze([
{ id: "lateral", label: "Lateral (Steering)", icon: "bi-arrows-move", pages: ["aol", "lane_change", "torque", "lane"] },
{ id: "longitudinal", label: "Longitudinal (Speed & Following)", icon: "bi-speedometer2", pages: ["profiles", "conditional", "curve", "slc", "aggressive", "standard", "relaxed", "traffic"] },
{ id: "wheel", label: "Wheel Controls", icon: "bi-controller", pages: ["wheel"] },
{ id: "visual", label: "Visual (Display & UI)", icon: "bi-eye", pages: ["appearance", "ui_layout", "favorites", "pip"] },
{ id: "sounds", label: "Sounds & Alerts", icon: "bi-volume-up", pages: ["sounds"] },
{ id: "device", label: "Device & Data", icon: "bi-hdd", pages: ["display", "data"] },
{ id: "developer", label: "Developer", icon: "bi-code-slash", pages: ["developer"] },
])
// Crop numeric rows and the inline editor row are controlled from the dedicated
// Blind Spot Camera page; they stay writable through the settings API for the
// widget/VASM editor but must not surface in the generic toggles list.
export const isPipCropRow = (row) => ["Visual camera crop editor", "Camera crop size", "Restore default camera crop"].includes(row.label) ||
/^Vehicle (left|right) crop/.test(row.label || "")
const SECTION_LINKS = {
device: [{ label: "Display", page: "display", value: "Adjust screen brightness and display timing" }, { label: "Data Uploads", page: "data", value: "Choose mobile-data uploads and usage-statistics sharing" }],
visual: [{ label: "Driving Screen Widgets, Colors & Layout", page: "ui_layout" }, { label: "Quick Select", page: "favorites" }, { label: "Blind Spot Camera and Preview", page: "pip" }],
}
const activeSettings = new Set()
export const settingsBusy = state => ["updating", "saving"].includes(state.status) || !!state.pending || !!state.reviewing
export class SettingsFeed {
constructor({ publish, unauthorized = () => { }, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timer = null
this.pollTimer = null
this.polling = false
this.saving = false
this.page = "hub"
this.data = null
this.pending = null
this.error = ""
}
stop() {
activeSettings.delete(this)
this.active = false
this.generation++
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timer = this.pollTimer = null
this.polling = false
this.saving = false
this.data = null
this.pending = null
this.error = ""
this.publish({ status: "idle", data: null, pending: null, error: "" })
}
start(page = "hub") {
this.stop()
this.active = true
activeSettings.add(this)
this.page = page
return this.load(page)
}
async run(operation, { saving = false, background = false } = {}) {
if (!this.active) return null
const generation = ++this.generation
this.request?.abort()
if (this.timer !== null) this.cancelTimer(this.timer)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const request = new AbortController()
this.request = request
this.saving = saving
this.polling = background
let failed = false
this.timer = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timer = null
this.saving = false
this.polling = false
if (!background) this.data = this.pending = null
else this.expireMonitorStatus()
this.error = saving ? "Saving timed out. Checking saved values before another change." : "Settings could not connect. Reconnecting automatically…"
this.publish({ status: background && this.data ? "ready" : "unavailable", data: this.data, pending: this.pending, error: this.error })
this.scheduleParkedRefresh()
}, 4000)
try {
const response = await operation(request.signal)
if (!this.active || generation !== this.generation || request.signal.aborted) return null
const accessCode = response.status === 503 ? (await response.clone().json().catch(() => null))?.code : null
if (!this.active || generation !== this.generation || request.signal.aborted) return null
if (response.status === 401 || ["access_unavailable", "setup_required"].includes(accessCode)) {
this.stop()
this.unauthorized()
return null
}
if (response.status === 409) {
const error = new Error("Saved settings changed. Refresh this page.")
error.stale = true
throw error
}
if (!response.ok) throw new Error(response.status === 503 ? "The device could not read these preferences. Reconnecting automatically…" : "The device could not complete the settings request. Check your connection and try again.")
const data = await response.json().catch(() => { throw new Error("Galaxy could not load settings. Refresh to reconnect.") })
if (!this.active || generation !== this.generation || request.signal.aborted) return null
return data
} catch (error) {
failed = true
if (this.active && generation === this.generation && !request.signal.aborted) {
this.pending = null
this.expireMonitorStatus()
if (error?.stale) this.data = null
this.publish({
status: this.data ? "ready" : "unavailable", data: this.data, pending: null,
error: this.error = connectionError(error)
})
}
return null
} finally {
if (this.request === request) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.request = this.timer = null
this.saving = false
this.polling = false
if (failed) this.scheduleParkedRefresh()
}
}
}
expireMonitorStatus() {
if (this.data?.page === "sentry") this.data = { ...this.data, subtitle: "Motion monitor status is unavailable. Reconnecting automatically…" }
}
scheduleParkedRefresh() {
if (!this.active || this.pending || this.request || this.saving || this.pollTimer !== null) return
const generation = this.generation
this.pollTimer = this.later(() => {
this.pollTimer = null
if (this.active && this.generation === generation && !this.pending && !this.request && !this.saving)
this.load(this.page, { keepData: true, quiet: true })
}, this.error ? 3000 : 1500)
}
async load(page = this.page, { keepData = page === this.page && this.data !== null, quiet = keepData } = {}) {
if (!this.active || this.saving || (quiet && (this.pending || this.request))) return
this.page = page
this.pending = null
if (!keepData) this.data = null
if (!quiet) this.publish({ status: keepData ? "saving" : this.error ? "unavailable" : "loading", data: this.data, pending: null, error: this.error })
const operation = this.run((signal) => this.fetcher(`./api/settings/pages/${encodeURIComponent(page)}`,
{ credentials: "same-origin", cache: "no-store", signal }), { background: quiet })
const attempt = this.generation
const data = await operation
if (data && this.active && this.generation === attempt && this.page === page) {
this.data = data
this.error = ""
this.publish({ status: "ready", data, pending: null, error: "" })
}
this.scheduleParkedRefresh()
}
async preview(row, direction, draft = null) {
if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview",
{ view: this.data.view, row, direction, ...(draft === null ? {} : { draft }) }, signal))
if (data) {
this.pending = data
this.publish({ status: "ready", data: this.data, pending: data, error: "" })
}
}
async resetDefault(row) {
if (!this.active || !this.data?.view || !this.data.rows?.[row]?.resetAvailable ||
this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/reset-default",
{ view: this.data.view, row }, signal))
if (data) {
this.pending = data
await this.confirm()
}
}
async previewValue(row, value) {
if (!this.active || !this.data?.view || this.pending || (this.request && !this.polling)) return
this.publish({ status: "updating", data: this.data, pending: null, error: "" })
const data = await this.run((signal) => post(this.fetcher, "./api/settings/preview",
{ view: this.data.view, row, value }, signal))
if (data) {
this.pending = data
await this.confirm()
}
}
cancel() {
this.pending = null
if (this.active) this.publish({ status: "ready", data: this.data, pending: null, error: "" })
this.scheduleParkedRefresh()
}
async confirm() {
if (!this.active || !this.pending?.intent) return
const intent = this.pending.intent
this.pending = null
this.publish({ status: "saving", data: this.data, pending: null, error: "" })
const page = this.page
const operation = this.run((signal) => post(this.fetcher, "./api/settings/confirm",
{ intent, confirmed: true }, signal), { saving: true })
const attempt = this.generation
const result = await operation
if (result?.saved === true) {
for (const feed of activeSettings) {
if (feed !== this && !feed.pending && !feed.saving) feed.load(feed.page, { keepData: true, quiet: true })
}
}
if (this.active && this.generation === attempt) {
const reload = this.load(page, { keepData: true, quiet: false })
const refreshed = this.generation
await reload
if (result?.saved !== true && this.active && this.generation === refreshed && this.data) this.publish({
status: "ready", data: this.data, pending: null,
error: "Save could not be confirmed. Current saved values are shown; review them before trying again."
})
}
}
}
export const SettingsPage = {
emits: ["nested"],
components: { GxDialog, GxState, GalaxySettingRow, OnroadLayoutPage, FavoritesPage, SoundPacks, CloudProviderPage, GxNotice },
props: {
mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
initialPage: { type: String, default: "hub" }, title: { type: String, default: "Toggles" },
initialSection: { type: String, default: null }, returnTo: { type: Function, default: null }
},
setup(props) {
const state = reactive({ status: "idle", data: null, pending: null, error: "", query: "", searchOpen: false, section: "lateral", layoutOpen: false, layoutPanel: "widgets", favoritesOpen: false, soundChoicesDirty: false, parents: [], developerOpen: props.initialSection === "developer", panelDirection: 1 })
const feed = new SettingsFeed({ publish: (update) => Object.assign(state, update), unauthorized: props.unauthorized })
return { state, feed, sections: SETTINGS_SECTIONS }
},
data() { return { saveStatusVisible: false } },
computed: {
busy() { return settingsBusy(this.state) },
savingPreference() { return ["updating", "saving"].includes(this.state.status) },
activeSection() {
if (this.state.developerOpen) return this.sections.at(-1)
const page = (this.state.data?.page || "hub").split("/")[0]
return this.sections.find((section) => page === "hub" ? section.id === this.state.section : section.pages.includes(page)) || this.sections[0]
},
atSectionRoot() {
if (this.state.developerOpen || (!this.state.data && this.initialPage === "hub" && !this.state.parents?.length)) return true
return this.state.data?.page === "hub" ||
(this.activeSection.pages.length === 1 && this.state.data?.page === this.activeSection.pages[0])
},
visibleRows() {
const needle = this.state.query.trim().toLocaleLowerCase()
const rows = this.state.data?.rows || []
const inHub = this.initialPage === "hub" && this.state.data?.page === "hub"
const section = this.activeSection
const entries = inHub && SECTION_LINKS[section.id]
? SECTION_LINKS[section.id].map((row, index) => ({ row: { ...row, available: true, action: false, value: row.value || "Customize " + row.label.toLocaleLowerCase() }, index }))
: rows.map((row, index) => ({ row, index })).filter(({ row }) => !inHub || section.pages.includes(row.page))
if (inHub && section.id === "longitudinal") {
entries.sort((a, b) => section.pages.indexOf(a.row.page) - section.pages.indexOf(b.row.page))
}
return entries.filter(({ row }) => !isPipCropRow(row)).filter(({ row, index }) => {
const previous = rows[index - 1]
return !(["sounds", "display"].includes(this.state.data?.page) && row.repairValue === "Auto" &&
previous?.choices?.includes("Auto") && settingControl(previous) === "slider" && row.label === `Use Auto ${previous.label}`)
}).filter(({ row }) =>
!needle || `${row.label} ${row.value} ${row.reason}`.toLocaleLowerCase().includes(needle))
},
},
watch: {
savingPreference: {
flush: "sync",
handler(saving) {
clearTimeout(this._saveStatusTimer)
if (saving) {
this.saveStatusVisible = true
this._saveStatusUntil = performance.now() + 600
} else {
this._saveStatusTimer = setTimeout(() => { this.saveStatusVisible = false },
Math.max(0, this._saveStatusUntil - performance.now()))
}
},
},
"state.pending"() { this.flushSoundChoices() },
"state.status"(status, previous) {
if (previous === "saving" && status === "ready") this.state.soundChoicesDirty = false
else this.flushSoundChoices()
},
},
mounted() { if (this.mode === "local") this.feed.start(this.state.developerOpen ? "developer" : this.initialPage) },
beforeUnmount() { this.feed.stop(); clearTimeout(this._saveStatusTimer) },
methods: {
open(page) {
if (this.busy) return
this.$emit?.("nested")
this.state.query = ""
this.state.panelDirection = 1
if (page === "ui_layout" || page === "appearance") {
this.feed.stop()
this.state.layoutPanel = page === "appearance" ? "display" : "widgets"
this.state.layoutOpen = true
}
else if (page === "favorites") { this.feed.stop(); this.state.favoritesOpen = true }
else {
if (this.state.data?.page && this.state.data.page !== page) (this.state.parents ||= []).push(this.state.data.page)
this.feed.load(page)
}
},
togglePageSearch() {
this.state.searchOpen = !this.state.searchOpen
if (this.state.searchOpen) this.$nextTick(() => this.$refs.pageSearch?.focus())
else this.state.query = ""
},
async openCropEditor() { (await import("./router.js")).navigate("/cameras/pip") },
// The embedded editor switches targets through the routed editor, which keeps the leave guard.
async openLayoutTarget(target) { (await import("./router.js")).navigate(target === "projection" ? "/theme_maker/android_auto" : "/theme_maker") },
rowKey(row, index) { return `${this.state.data.page}:${row.key || row.page || index}` },
closeLayout() { this.state.layoutOpen = false; this.state.section = "visual"; this.feed.start(this.initialPage) },
closeFavorites() { this.state.favoritesOpen = false; this.state.section = "visual"; this.feed.start("hub") },
selectSection(section) {
if (this.busy) return
if (this.sections) {
const from = this.sections.findIndex((item) => item.id === this.state.section)
const to = this.sections.findIndex((item) => item.id === section.id)
if (from !== -1 && to !== -1) this.state.panelDirection = to < from ? -1 : 1
}
this.state.parents = []
this.state.section = section.id
this.state.developerOpen = section.id === "developer"
if (!this.feed.active) { this.feed.start(section.pages.length === 1 ? section.pages[0] : "hub"); return }
this.state.query = ""
const page = section.pages.length === 1 ? section.pages[0] : "hub"
if (this.state.data?.page !== page) this.feed.load(page, { keepData: false, quiet: false })
},
requestRouteLeave(proceed) {
if (this.busy || this.$refs.favoritesEditor?.busy || this.$refs.cloudProvider?.busy) return
if (this.state.layoutOpen) this.$refs.layoutEditor.requestLeave(proceed)
else proceed()
},
navigateBack() {
if (this.state.pending) { this.feed.cancel(); return true }
if (this.busy) return true
if (this.state.layoutOpen) { this.$refs.layoutEditor.requestLeave("back"); return true }
if (this.state.favoritesOpen) { if (!this.$refs.favoritesEditor?.busy) this.closeFavorites(); return true }
if (this.state.developerOpen) return false
const nested = this.initialPage === "hub" ? !this.atSectionRoot : this.state.data?.page !== this.initialPage
if (!nested) return false
this.back()
return true
},
back() {
if (this.state.developerOpen) return
this.state.panelDirection = -1
const page = this.state.data?.page || this.initialPage
this.state.section = this.activeSection?.id || this.state.section
this.state.query = ""
if (page === this.initialPage && this.returnTo) { this.returnTo(); return }
const parent = this.state.parents?.pop()
if (parent) { this.feed.load(parent); return }
if (this.initialPage !== "hub") { this.feed.load(this.initialPage); return }
this.feed.load(page.includes("/") ? page.split("/")[0] :
["aggressive", "standard", "relaxed"].includes(page) ? "profiles" : "hub")
},
refreshSoundChoices() {
this.state.soundChoicesDirty = true
this.flushSoundChoices()
},
flushSoundChoices() {
if (!this.state.soundChoicesDirty || this.state.data?.page !== "sounds" || this.feed.saving || this.state.pending) return
this.state.soundChoicesDirty = false
this.feed.load("sounds", { keepData: true })
},
},
template: `
<OnroadLayoutPage ref="layoutEditor" v-if="state.layoutOpen" :mode="mode" :unauthorized="unauthorized" :initial-panel="state.layoutPanel" @target="openLayoutTarget" @close="closeLayout" />
<FavoritesPage ref="favoritesEditor" v-else-if="state.favoritesOpen" :mode="mode" :unauthorized="unauthorized" @close="closeFavorites" />
<section v-else class="gx-settings" :aria-label="title" :inert="busy" :aria-busy="busy || undefined">
<div v-if="title && (initialPage !== 'hub' || atSectionRoot)" class="gx-settings__header gx-page-header"><h2>{{ title }}</h2></div>
<div v-if="mode === 'local' && initialPage === 'hub' && atSectionRoot" class="gx-settings-tabs" aria-label="Settings sections">
<button v-for="section in sections" :key="section.id" type="button" class="gx-chip" :aria-pressed="section.id === activeSection.id" @click="selectSection(section)">{{ section.label }}</button>
</div>
<p class="gx-page-description" v-if="state.developerOpen">Choose developer driving options and cloud services.</p>
<p class="gx-page-description" v-else-if="initialPage === 'pip'">Choose when to show the camera and adjust its saved crop. Use the crop editor for a live preview.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'sounds'">Adjust alert and chime volumes. Immediate warnings keep their safety volume ramp.</p>
<p class="gx-page-description" v-else-if="(state.data?.page || initialPage) === 'display'">Adjust brightness and screen timing when custom display settings are on.</p>
<p class="gx-page-description" v-else-if="initialPage === 'lane_change'">Configure lane-change assistance, signal timing, steering pace and following gap.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'conditional' || state.data?.page.startsWith('conditional/')">Choose when to switch between Chill and Experimental. Applies on supported vehicles when StarPilot controls acceleration and braking.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'profiles'">Adjust acceleration, braking and following for each driving personality.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'traffic'">Tune low-speed following and jerk, blending toward Relaxed at higher speeds.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'aol'">Keep steering assistance active independently of cruise control.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'torque'">Tune how StarPilot applies steering torque.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'lane'">Choose lane-centering behavior and alerts.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'curve'">Set how the vehicle slows for curves ahead.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'slc'">Control how speed limits change your set speed.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'wheel'">Assign what the steering-wheel controls do.</p>
<p class="gx-page-description" v-else-if="state.data?.page === 'data'">Choose what uploads when the vehicle uses mobile data.</p>
<p class="gx-page-description" v-else-if="['aggressive','standard','relaxed'].includes(state.data?.page)">Adjust acceleration, braking and following for this driving style.</p>
<p class="gx-page-description" v-else-if="initialPage === 'sentry'">{{ state.data?.subtitle || "Checking motion monitor…" }}</p>
<GxState v-if="mode !== 'local'">Local settings are unavailable in preview.</GxState>
<template v-else>
<CloudProviderPage ref="cloudProvider" v-if="state.developerOpen" :mode="mode" :unauthorized="unauthorized" />
<Teleport to="body">
<p class="gx-settings__save-status gx-settings__save-status--animated"
:class="{ 'is-visible': saveStatusVisible }"
:aria-hidden="!saveStatusVisible" role="status">Saving preferences…</p>
</Teleport>
<GxState v-if="state.status === 'loading'" loading>Reading your device’s saved preferences…</GxState>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable' && !state.error">The device could not read these preferences. Reconnecting automatically…</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}
</GxNotice>
<div v-if="!state.developerOpen || state.data?.page === 'developer'" class="gx-panel-stage" :style="{'--panel-direction': state.panelDirection}">
<Transition name="gx-panel">
<div v-if="state.data" :key="state.data.page + activeSection.id" class="gx-settings__body">
<div v-if="state.data.page === 'pip'" class="gx-settings__actions gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" @click="openCropEditor"><i class="bi bi-crop" aria-hidden="true"></i> Crop editor</button>
</div>
<section class="gx-card gx-settings__section">
<div class="gx-section__header"><i class="bi" :class="activeSection.icon" aria-hidden="true"></i><span class="gx-section__title">{{ state.data.page === 'hub' ? activeSection.label : state.data.title }}</span>
<button v-if="state.data.page !== 'hub' && state.data.rows.length" type="button" class="gx-icon-btn" :aria-label="state.searchOpen ? 'Close page search' : 'Search within this page'" :aria-pressed="state.searchOpen" @click="togglePageSearch"><i class="bi" :class="state.searchOpen ? 'bi-x-lg' : 'bi-search'" aria-hidden="true"></i></button>
</div>
<div v-if="state.searchOpen" class="gx-settings__search"><input ref="pageSearch" v-model="state.query" class="gx-field" type="search" aria-label="Search within this page" placeholder="Search within…"></div>
<GalaxySettingRow v-for="{ row, index } in visibleRows" :key="rowKey(row, index)" :row="row" :index="index"
:busy="busy" :disabled="!!state.error || !row.available" :save-value="(index, value) => feed.previewValue(index, value)"
@open="open" @review="(index, direction) => feed.preview(index, direction)" @reset-default="index => feed.resetDefault(index)" />
<div v-if="!visibleRows.length" class="gx-empty">No matching settings.</div>
</section>
<SoundPacks v-if="state.data.page === 'sounds'" :unauthorized="unauthorized" :busy="busy" @installed="refreshSoundChoices" />
</div>
</Transition>
</div>
<GxDialog v-if="state.pending" labelledby="gx-settings-confirm-title" @close="feed.cancel()"><h3 id="gx-settings-confirm-title">Confirm Saved Preference</h3><p>{{ state.pending.question }}</p>
<div class="gx-settings__controls gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="feed.cancel()">Cancel</button>
<button type="button" class="gx-btn" @click="feed.confirm()">Save</button></div></GxDialog>
</template>
</section>`,
}
@@ -0,0 +1,563 @@
import { GxDialog } from "./dialog.js"
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { ToggleBackup } from "./toggle-backup.js"
import { GalaxySelect } from "./galaxy-select.js"
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version"])
const REQUEST_STATES = new Set(["pending", "complete", "failed"])
const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update...", "updating..."])
const PRIMARY_BRANCHES = ["StarPilot", "Dom"]
const PRIMARY_BRANCH_LABELS = { StarPilot: "Stable — StarPilot", Dom: "Development — Dom" }
const PRIMARY_BRANCH_HELP = {
StarPilot: "Stable releases. Recommended for most users.",
Dom: "Latest features and fixes under development. Updates regularly and may introduce bugs.",
}
const unavailableOperations = () => ({ parked: false, availableBranches: [], selectedTarget: null,
canCheck: false, canFastUpdate: false, canRollback: false, canDownload: false, canSelect: false, canInstall: false,
reason: "Update controls are unavailable", request: null })
export function validSoftwareSnapshot(data) {
const text = (value) => value === null || typeof value === "string"
const flag = (value) => value === null || typeof value === "boolean"
const updater = data?.updater, operations = data?.operations, request = operations?.request
return data?.schemaVersion === 1 && !!data.installed && !!updater &&
[data.installed.version, data.installed.branch, data.installed.commit, updater.state,
updater.targetBranch, updater.lastSuccessAt, updater.lastFetchAt].every(text) &&
(updater.lastCheckedAt === undefined || text(updater.lastCheckedAt)) &&
(data.installed.displayVersion === undefined || text(data.installed.displayVersion)) &&
[updater.targetChangeFound, updater.finalizedUpdateReady].every(flag) &&
(updater.failedCount === null || Number.isSafeInteger(updater.failedCount) && updater.failedCount >= 0) &&
(operations === undefined || !!operations && typeof operations.parked === "boolean" && Array.isArray(operations.availableBranches) &&
operations.availableBranches.length <= 256 && operations.availableBranches.every((branch) =>
typeof branch === "string" && branch.length > 0 && branch.length <= 128) &&
text(operations.selectedTarget) && text(operations.reason) &&
(operations.progress === undefined || operations.progress === null || typeof operations.progress === "object" &&
text(operations.progress.stage) && (operations.progress.stage === null || operations.progress.stage.length <= 128) &&
text(operations.progress.detail) && (operations.progress.detail === null || operations.progress.detail.length <= 512) &&
(operations.progress.percent === null || Number.isFinite(operations.progress.percent) &&
operations.progress.percent >= 0 && operations.progress.percent <= 100)) &&
(operations.canFastUpdate === undefined || typeof operations.canFastUpdate === "boolean") &&
(request?.selectedCommit === undefined || /^[0-9a-f]{40}$/.test(request.selectedCommit)) &&
(operations.canRollback === undefined || typeof operations.canRollback === "boolean") &&
(operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) &&
(operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") &&
(operations.history === undefined || validHistory(operations.history)) &&
[operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") &&
(request === null || !!request && typeof request.id === "string" && request.id.length <= 100 &&
ACTIONS.has(request.action) && text(request.target) && REQUEST_STATES.has(request.state) && text(request.error) &&
(request.outcome === undefined || ["up_to_date", "restarting"].includes(request.outcome))))
}
export function softwareProgress(updater, operations = {}) {
if (!updater) return null
const progress = operations.progress
const fast = updater.state === "updating..." ? updater.fast : null
const stage = progress?.stage || fast?.stage
const active = UPDATER_ACTIVE.has(updater.state) && !["unavailable", "timed_out", "error", "complete"].includes(stage)
const labels = { "checking...": "Checking for updates", "downloading...": "Downloading update",
"finalizing update...": "Finalizing update", "updating...": "Updating software",
checking: "Checking for updates", downloading: "Downloading update", finalizing: "Finalizing update",
preparing: "Preparing update", fetching: "Downloading update", validating: "Validating update",
applying: "Applying update", submodules: "Preparing dependencies", rebooting: "Restarting device",
complete: "Update complete", error: "Update failed", unavailable: "Updater status unavailable", timed_out: "Update is taking longer than expected" }
if (!active && (!stage || ["idle", "unknown"].includes(stage))) return null
const percent = Number.isFinite(progress?.percent) && progress.percent >= 0 && progress.percent <= 100 ? progress.percent : null
return { active, failed: ["error", "unavailable", "timed_out"].includes(stage), percent, label: labels[progress?.stage || fast?.stage] || progress?.stage || fast?.stage || labels[updater.state] || "Update status",
detail: progress?.detail || fast?.detail || "" }
}
export function validHistory(value) {
const rows = (items) => Array.isArray(items) && items.length <= 20 && items.every((row) =>
/^[0-9a-f]{40}$/.test(row?.hash) && typeof row.date === "string" && row.date.length <= 64 &&
typeof row.subject === "string" && row.subject.length <= 512)
return !!value && rows(value.installed) && rows(value.downloaded) &&
(value.recent === undefined || !!value.recent && (value.recent.branch === null || typeof value.recent.branch === "string") &&
(value.recent.head === null || /^[0-9a-f]{40}$/.test(value.recent.head)) && rows(value.recent.entries)) &&
[value.currentReleaseNotes, value.downloadedReleaseNotes].every((notes) => notes === null || typeof notes === "string" && notes.length <= 65536)
}
export class SoftwareStatusFeed {
constructor({ includeHistory = true, publish, unauthorized = () => {}, restarting = () => {}, fetcher = (...args) => fetch(...args),
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { includeHistory, publish, unauthorized, restarting, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.controller = null
this.timeout = null
this.pollTimer = null
this.data = null
this.attempted = false
this.busy = false
this.mutating = false
this.blocked = false
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
this.installBaseline = null
this.notice = ""
this.error = ""
}
emit(status) {
const request = this.data?.operations?.request
const baseline = this.installBaseline
if (baseline && (request?.action === "install" || request?.outcome === "restarting" ||
this.data?.operations?.progress?.stage === "rebooting" || this.data?.updater.fast?.stage === "rebooting" ||
this.error && (request?.state === "pending" && request.action === baseline.action ||
this.uncertain && baseline.action === "install"))) this.restarting(baseline)
this.publish({ status, data: this.data, busy: this.busy, uncertain: this.uncertain,
notice: this.notice, error: this.error }) }
stop() {
this.active = false
this.generation++
this.controller?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.controller = this.timeout = this.pollTimer = null
this.data = this.installBaseline = this.uncertainAction = this.actionPriorRequestId = null
this.attempted = false
this.busy = this.mutating = this.blocked = this.uncertain = false
this.notice = this.error = ""
this.emit("idle")
}
start() { this.stop(); this.active = true; return this.load() }
needsRapidPoll() {
return this.uncertain || !!this.installBaseline || this.data?.operations?.parked === false ||
this.data?.operations?.request?.state === "pending" || UPDATER_ACTIVE.has(this.data?.updater?.state)
}
schedulePoll(delay = null) {
if (!this.active || this.pollTimer !== null || this.controller) return
const generation = this.generation
this.pollTimer = this.later(() => {
this.pollTimer = null
if (this.active && generation === this.generation) this.load()
}, delay ?? (this.needsRapidPoll() ? 1000 : 5000))
}
async run(body = null) {
if (!this.active || this.mutating) return null
const generation = ++this.generation
this.controller?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const controller = new AbortController()
this.controller = controller
this.busy = body !== null || this.data === null
this.mutating = body !== null
if (body !== null) { this.error = ""; this.notice = "" }
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
this.timeout = this.later(() => {
if (!this.active || this.generation !== generation || this.controller !== controller) return
controller.abort()
this.generation++
this.controller = this.timeout = null
this.busy = this.mutating = false
this.attempted = true
this.blocked = true
if (body !== null) { this.uncertain = true; this.uncertainAction = body }
this.error = body !== null ? "The request timed out. Its result is unknown; wait for a fresh status before trying another action." :
"Software status timed out. Refresh to try again."
this.emit(this.data ? "ready" : "unavailable")
this.schedulePoll(2000)
}, 5000)
try {
const response = await this.fetcher(body === null ? "./api/software/status" + (this.includeHistory ? "" : "?history=0") : "./api/software/action", {
credentials: "same-origin", cache: "no-store", signal: controller.signal,
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }),
})
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
const payload = typeof response.json === "function" ? await response.json().catch(() => null) : null
if (!this.active || generation !== this.generation || controller.signal.aborted) return null
if (response.status === 401 || response.status === 503 &&
["access_unavailable", "setup_required"].includes(payload?.code)) {
this.stop()
this.unauthorized()
return null
}
if (!response.ok) {
const error = new Error(payload?.error || payload?.message || "Software request failed. Refresh to try again.")
error.rejected = response.status >= 400 && response.status < 500
throw error
}
if (!validSoftwareSnapshot(payload)) throw new Error("Software response is unavailable. Refresh to try again.")
this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload
this.attempted = true
this.blocked = false
if (body !== null) {
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
if (["update", "fast", "rollback", "version"].includes(body.action)) {
this.notice = `Updating ${body.branch}. Waiting for completion or reconnect.`
} else if (body.action === "install") {
this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch }
this.notice = "Restart requested. Waiting to reconnect and verify the installed build."
} else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` :
body.action === "preferences" ? "Automatic download preference saved." : ""
} else {
const observed = payload.operations.request
const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action &&
observed.id !== this.actionPriorRequestId &&
(!this.uncertainAction?.branch || observed.target === this.uncertainAction.branch) &&
(!this.uncertainAction?.selectedCommit || observed.selectedCommit === this.uncertainAction.selectedCommit)
const selectionObserved = this.uncertainAction?.action === "select" &&
payload.operations.selectedTarget === this.uncertainAction.branch
const preferenceObserved = this.uncertainAction?.action === "preferences" &&
payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads
if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) {
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
}
if (["update", "fast", "version"].includes(this.installBaseline?.action) && observed?.action === this.installBaseline.action && observed.target === this.installBaseline.target &&
(!this.installBaseline.selectedCommit || observed.selectedCommit === this.installBaseline.selectedCommit) &&
["complete", "failed"].includes(observed.state)) {
if (observed.state === "failed" || observed.outcome === "up_to_date") {
this.installBaseline = null
this.notice = observed.state === "failed" ? "" : "Installed branch is already up to date. No restart needed."
} else this.notice = "Update installed. Waiting to reconnect and verify the installed build."
}
if (this.installBaseline && payload.installed.branch === this.installBaseline.target &&
(!this.installBaseline.selectedCommit || payload.installed.commit === this.installBaseline.selectedCommit) &&
(payload.installed.commit && payload.installed.commit !== this.installBaseline.commit ||
payload.installed.branch !== this.installBaseline.branch)) {
this.installBaseline = null
this.uncertain = false
this.uncertainAction = null
this.actionPriorRequestId = null
this.notice = "Installed build verified after reconnect."
}
}
this.error = ""
this.emit("ready")
return payload
} catch (error) {
if (this.active && generation === this.generation && !controller.signal.aborted) {
this.attempted = true
this.blocked = true
if (body !== null) {
this.uncertain = !error?.rejected
this.uncertainAction = this.uncertain ? body : null
if (error?.rejected && ["install", "update", "fast", "rollback", "version"].includes(body.action)) this.installBaseline = null
}
this.error = error?.message || "Software request failed. Refresh to try again."
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
}
return null
} finally {
if (this.controller === controller) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.controller = this.timeout = null
this.busy = this.mutating = false
this.emit(this.data ? "ready" : "unavailable")
this.schedulePoll(this.error ? 2000 : null)
}
}
}
load() { return this.run() }
configureAutomaticDownloads(enabled) {
if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true ||
typeof enabled !== "boolean") return null
return this.run({ action: "preferences", automaticDownloads: enabled,
expectedAutomaticDownloads: this.data.operations.automaticDownloads })
}
action(action, branch = null, selection = null) {
const operations = this.data?.operations
const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canFastUpdate", version: "canFastUpdate" }[action]
if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked ||
operations[capability] !== true || operations.request?.state === "pending") return null
if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null
if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null
if (["update", "fast", "versions", "version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null
if (action === "rollback" && branch !== this.data.installed.branch) return null
if (action === "version") {
if (selection?.expectedCommit !== this.data.installed.commit || !/^[0-9a-f]{40}$/.test(selection?.selectedCommit) ||
operations.history?.recent?.branch !== branch ||
!operations.history.recent.entries.some((row) => row.hash === selection.selectedCommit)) return null
}
this.actionPriorRequestId = operations.request?.id ?? null
if (action === "install") this.installBaseline = { commit: this.data.installed.commit,
branch: this.data.installed.branch, target: branch, action }
if (["update", "fast", "rollback", "version"].includes(action)) this.installBaseline = { commit: this.data.installed.commit, branch: this.data.installed.branch, target: branch, action, ...(action === "version" ? { selectedCommit: selection.selectedCommit } : {}) }
if (action === "version") return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit })
return this.run(action === "check" ? { action } : { action, branch })
}
}
export const SoftwarePage = {
components: { GxDialog, GxState, GxNotice, GalaxySelect, GalaxySettingRow, ToggleBackup },
name: "SoftwarePage",
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, restarting: { type: Function, default: () => {} } },
data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "",
initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, historyKind: "recent", version: "" }),
created() {
this.feed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => {
Object.assign(this.$data, update)
if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget)
}, unauthorized: this.unauthorized, restarting: this.restarting })
},
async mounted() {
if (this.mode !== "local") return
try {
await this.feed.start()
if (this.feed.active && this.operations?.canCheck && !this.pending && !UPDATER_ACTIVE.has(this.data?.updater.state)) await this.feed.action("check")
} finally { this.initialChecking = false }
},
beforeUnmount() { this.feed.stop() },
computed: {
operations() { return this.data?.operations },
reportedProgress() { return softwareProgress(this.data?.updater, this.operations) },
updateProgress() {
if (this.error || this.operations?.request?.state === "failed") return null
const progress = this.reportedProgress
if (progress?.active) return progress
if (progress?.failed) return null
if (this.initialChecking || this.busy || this.pending || this.uncertain) return {
active: true, percent: null, label: this.initialChecking || this.operations?.request?.action === "check" ?
"Checking for updates" : this.requestMessage(this.operations?.request) || "Reading update status", detail: "" }
return null
},
updateAvailable() { return this.data?.updater.targetChangeFound === true || this.data?.updater?.finalizedUpdateReady === true },
osUpdateRequired() { return this.data?.updater.osUpdate?.required === true &&
this.data.updater.osUpdate.branch === (this.draftBranch || this.operations?.selectedTarget) &&
this.data.updater.osUpdate.branch === this.data.updater.targetBranch },
canUpdate() {
if (this.data?.updater?.finalizedUpdateReady && this.draftBranch === this.operations?.selectedTarget) return this.operations.canInstall
return this.operations?.canFastUpdate === true && this.branchOptions.some(branch => branch.name === this.draftBranch && branch.available)
},
updateBanner() {
const request = this.operations?.request
const progress = this.reportedProgress
const error = this.error || (request?.state === "failed" ? this.requestMessage(request) : "") ||
(progress?.failed ? progress.detail || progress.label : "")
if (error) return { tone: "danger", message: error }
if (this.updateProgress) return null
if (this.operations?.reason) return { tone: "warn", message: this.operations.reason }
if (this.updateAvailable && this.osUpdateRequired) return { tone: "warn",
message: this.data.updater.finalizedUpdateReady ? "Update ready — includes an operating system update. Restart to install." :
"Update available — operating system update required. This is a large download and may take a while. Keep the device powered and connected to Wi-Fi." }
if (this.notice) return { tone: "info", message: this.notice }
if (request && request.action !== "check") return { tone: "info", message: this.requestMessage(request) }
if (this.updateAvailable) return { tone: "success", message: "Update Available" }
if (this.data?.updater.targetChangeFound === false) return { tone: "success", message: "No Update Available" }
return { tone: "warn", message: "Update status unavailable. Check for updates to try again." }
},
repositoryUrl() {
const repository = this.operations?.repository
return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository || "") ? `https://github.com/${repository}` : ""
},
commitUrl() { return this.repositoryUrl && /^[0-9a-f]{7,40}$/.test(this.data?.installed.commit || "") ? `${this.repositoryUrl}/commit/${this.data.installed.commit}` : "" },
pending() { return this.operations?.request?.state === "pending" },
actionUnavailable() { return this.uncertain || this.pending || !!this.error || !this.operations?.parked },
actionDisabled() { return this.busy || this.actionUnavailable },
branchOptions() {
const available = this.operations?.availableBranches || []
return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, ...available].filter(branch => branch && branch !== "other:"))]
.map(name => ({ name, available: available.includes(name) || name === this.data?.installed.branch }))
},
primaryBranchHelp() { return PRIMARY_BRANCH_HELP[this.primaryChoice] || "" },
otherBranches() {
const available = this.operations?.availableBranches || []
const selected = this.operations?.selectedTarget
const installed = this.data?.installed?.branch
return [...new Set([selected, installed, ...available].filter(branch => branch && branch !== "other:" && !PRIMARY_BRANCHES.includes(branch)))]
.map(name => ({ name, listed: available.includes(name), current: name === installed }))
},
canStageBranch() { return !!this.draftBranch && this.draftBranch !== "other:" && this.operations?.availableBranches.includes(this.draftBranch) && this.draftBranch !== this.operations.selectedTarget },
branchChanging() { return !!this.draftBranch && this.draftBranch !== this.data?.installed.branch },
versionEntries() {
const history = this.operations?.history
return this.historyKind === "recent" ? (history?.recent?.branch === this.draftBranch ? history.recent.entries : []) : history?.[this.historyKind] || []
},
commitsUrl() { return this.data?.installed.branch ? `https://github.com/firestar5683/StarPilot/commits/${encodeURIComponent(this.data.installed.branch)}/` : "" },
},
methods: {
switchRow,
syncDraftBranch(branch) {
this.draftBranch = this.operations?.availableBranches.includes(branch) || branch === this.data?.installed.branch ? branch : this.data?.installed.branch || ""
this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : ""
},
changeBranch() { this.draftTouched = true; this.version = ""; this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" },
onPrimaryBranchChange() {
this.draftTouched = true
this.version = ""
if (this.primaryChoice === "other:") {
if (!this.otherBranches.some(option => option.name === this.draftBranch)) {
const selected = this.operations?.selectedTarget
this.draftBranch = this.otherBranches.some(option => option.name === selected) ? selected : ""
}
} else this.draftBranch = this.primaryChoice
},
onOtherBranchChange() { this.draftTouched = true; this.version = "" },
branchLabel(branch) { return PRIMARY_BRANCH_LABELS[branch] || branch },
primaryBranchHelpFor(branch) { return PRIMARY_BRANCH_HELP[branch] || "" },
versionLabel(entry) { return `${entry.subject.trim() || this.shortCommit(entry.hash)} · ${entry.date ? new Date(entry.date).toLocaleDateString() : this.shortCommit(entry.hash)}` },
shown(value) { return value ?? "Unavailable" },
reported(value) { return value ? new Date(value).toLocaleString() : "Unavailable" },
shortCommit(value) { return value ? value.slice(0, 12) : "Unavailable" },
requestMessage(request) {
if (!request) return ""
if (request.state === "failed") return request.error || "Update request failed. Refresh and try again."
if (["update", "fast", "rollback", "version"].includes(request.action)) return request.state === "pending" ? `Updating ${request.target}…` :
request.outcome === "up_to_date" ? "Installed branch is already up to date. No restart needed." :
"Update installed. Waiting to reconnect and verify the installed build."
if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : "Recent versions loaded."
if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build."
if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…"
return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved."
},
chooseBranch() {
if (!this.operations?.canSelect || this.actionDisabled || !this.canStageBranch) return
this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch",
message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" }
},
askUpdate() {
const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch
if (this.actionDisabled || !branch) return
if (this.data?.updater?.finalizedUpdateReady && branch === this.operations?.selectedTarget) return this.askInstall()
if (this.operations?.canFastUpdate !== true) return
this.dialog = { action: "update", branch, title: "Update software",
message: `Install the latest version of ${branch} and restart?`, label: "Update & restart" }
},
loadHistory(event) {
if (!event.target.open || this.operations?.history || this.busy) return
this.feed.includeHistory = true
this.feed.load()
},
async loadVersions() {
const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch
if (this.actionDisabled || !branch) return
if (branch !== this.operations.selectedTarget) {
const result = await this.feed.action("select", branch)
if (!result) return
}
return this.feed.action("versions", branch)
},
askVersion(entry) {
const branch = this.operations?.history?.recent?.branch
if (!entry || this.actionDisabled || this.operations?.canFastUpdate !== true || !branch) return
this.dialog = { action: "version", branch, selectedCommit: entry.hash, expectedCommit: this.data.installed.commit,
title: "Install recent version", message: `Install ${entry.subject} (${this.shortCommit(entry.hash)}) on ${branch} and restart?`, label: "Install" }
},
askRollback() {
const branch = this.data?.installed?.branch
if (this.actionDisabled || this.operations?.canRollback !== true || !branch) return
this.dialog = { action: "rollback", branch, title: "Previous version",
message: "Restore the previous installed version and restart? Automatic downloads will be turned off.", label: "Restore previous version" }
},
askInstall() {
if (this.actionDisabled || !this.operations?.canInstall || !this.operations.selectedTarget) return
this.dialog = { action: "install", branch: this.operations.selectedTarget, title: "Restart and install update",
message: `Restart the device to install the finalized update for ${this.operations.selectedTarget}? Keep the vehicle parked and the device accessible.`, label: "Restart & install" }
},
closeDialog() { this.dialog = null },
async confirmDialog() {
const choice = this.dialog
if (!choice) return
this.dialog = null
const result = await this.feed.action(choice.action, choice.branch, choice)
if (result && choice.action === "select") {
this.draftTouched = false
this.syncDraftBranch(result.operations.selectedTarget)
}
},
},
template: `
<div class="gx-view gx-stack" :inert="busy" :aria-busy="busy || undefined">
<h2>Software &amp; Updates</h2>
<GxState v-if="mode !== 'local'">Software updates are unavailable in preview.</GxState>
<template v-else>
<section class="gx-card gx-panel gx-stack gx-software-card">
<dl v-if="data" class="gx-software-summary">
<div><dt>Branch</dt><dd><a v-if="commitsUrl" class="gx-link" :href="commitsUrl" target="_blank" rel="noopener noreferrer">{{ data.installed.branch }}</a><span v-else>{{ shown(data.installed.branch) }}</span></dd></div>
<div><dt>Version</dt><dd>{{ shown(data.installed.displayVersion ?? data.installed.version) }}</dd></div>
<div><dt>Commit</dt><dd class="gx-actions">
<a v-if="commitUrl" class="gx-link" :href="commitUrl" target="_blank" rel="noopener noreferrer" title="View commit on GitHub"><i class="bi bi-github" aria-hidden="true"></i> {{ shortCommit(data.installed.commit) }}</a>
<span v-else>{{ shortCommit(data.installed.commit) }}</span>
</dd></div>
</dl>
<GxNotice v-if="updateBanner" :tone="updateBanner.tone">{{ updateBanner.message }}</GxNotice>
<div v-if="updateProgress" class="gx-update-progress" role="status">
<div class="gx-update-progress__heading"><span>{{ updateProgress.label }}</span><span v-if="updateProgress.percent !== null">{{ Math.round(updateProgress.percent) }}%</span></div>
<div v-if="updateProgress.active || updateProgress.percent !== null" class="gx-update-progress__track" role="progressbar" :aria-label="updateProgress.label"
aria-valuemin="0" aria-valuemax="100" :aria-valuenow="updateProgress.percent ?? undefined">
<div class="gx-update-progress__fill" :class="{'gx-update-progress__fill--indeterminate': updateProgress.active && updateProgress.percent === null}"
:style="updateProgress.percent !== null ? {width: updateProgress.percent + '%'} : {}"></div>
</div><p v-if="updateProgress.detail" class="gx-note">{{ updateProgress.detail }}</p>
</div>
<div v-if="!initialChecking && !updateProgress" class="gx-actions">
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button>
<button v-if="updateAvailable || branchChanging" type="button" class="gx-btn" :disabled="actionDisabled || !canUpdate" @click="askUpdate">Update</button>
</div>
<p v-if="data?.updater.lastCheckedAt || data?.updater.lastFetchAt" class="gx-note">
<span v-if="data.updater.lastCheckedAt">Checked {{ reported(data.updater.lastCheckedAt) }}</span>
<span v-if="data.updater.lastFetchAt"> · Downloaded {{ reported(data.updater.lastFetchAt) }}</span>
</p>
<GalaxySettingRow v-if="operations?.automaticDownloads !== undefined" class="gx-row--borderless"
:row="switchRow('Download updates automatically', operations.automaticDownloads, 'Prepare updates while parked; restart when ready.')"
:index="0" :busy="busy" :disabled="uncertain || !!error || !operations.canConfigure"
:save-value="(index, value) => feed.configureAutomaticDownloads(value === 'On')" />
<p v-if="operations?.automaticDownloads === null" class="gx-note">The download preference could not be read. Choose a setting while parked to repair it.</p>
<details v-if="data" class="gx-software-options"><summary>Advanced options</summary><div class="gx-stack">
<div class="gx-software-branch"><label class="gx-field-group gx-grow"><span class="gx-row__label">Branch</span>
<GalaxySelect v-model="primaryChoice" class="gx-field gx-field--full" aria-label="Target branch" :disabled="actionUnavailable || !operations.canSelect" @change="onPrimaryBranchChange">
<option value="" disabled>Choose a branch</option>
<option value="StarPilot" :data-description="primaryBranchHelpFor('StarPilot')">Stable — StarPilot</option>
<option value="Dom" :data-description="primaryBranchHelpFor('Dom')">Development — Dom</option>
<option value="other:">Other branches…</option>
</GalaxySelect>
</label></div>
<p v-if="primaryBranchHelp" class="gx-note">{{ primaryBranchHelp }}</p>
<div v-if="primaryChoice === 'other:'" class="gx-software-other-branches">
<label for="gx-other-branch" class="gx-row__label">Other branches</label>
<p class="gx-note">Additional branches from this installation's repository.</p>
<GalaxySelect id="gx-other-branch" v-model="draftBranch" class="gx-field gx-field--full" aria-label="Other branches" :disabled="actionUnavailable || !operations.canSelect" @change="onOtherBranchChange">
<option value="" disabled>{{ otherBranches.length ? 'Select another branch' : 'No other branches available' }}</option>
<option v-for="branch in otherBranches" :key="branch.name" :value="branch.name" :disabled="!branch.listed">{{ branch.name }}{{ branch.current ? ' (current)' : '' }}{{ !branch.listed && !branch.current ? ' (unavailable)' : '' }}</option>
</GalaxySelect>
</div>
<p v-if="draftBranch && !operations.availableBranches.includes(draftBranch)" class="gx-note">This branch is not in the updater's available list and cannot be selected yet.</p>
<p v-else-if="!operations.availableBranches.length" class="gx-note">No branch list is available yet. Check for updates to refresh it.</p>
<p v-if="branchChanging" class="gx-note">Update installs {{ draftBranch }} and restarts the device.</p>
<div class="gx-actions">
<button v-if="canStageBranch" type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canSelect" @click="chooseBranch">Save target</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canDownload || !operations.selectedTarget || draftBranch !== operations.selectedTarget" @click="feed.action('download', operations.selectedTarget)">Download only</button>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || operations.canRollback !== true" @click="askRollback">Previous version</button>
</div>
<p class="gx-note">Download only prepares an update without restarting. Previous version restores your last installed build.</p>
</div></details>
<details v-if="data" class="gx-software-options" @toggle="loadHistory($event)"><summary>Versions &amp; release notes</summary><div class="gx-stack">
<div class="gx-tabs gx-actions" aria-label="Version source">
<button v-for="source in [{key:'recent',label:'Available'},{key:'installed',label:'Installed'}, ...(operations.history?.downloaded.length ? [{key:'downloaded',label:'Downloaded'}] : [])]" :key="source.key" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="historyKind === source.key" @click="historyKind=source.key; version=''">{{ source.label }}</button>
</div>
<template v-if="historyKind === 'recent'">
<div class="gx-actions">
<GalaxySelect v-if="versionEntries.length" v-model="version" class="gx-field gx-filter-field" aria-label="Version"><option value="" disabled>Choose a version</option>
<option v-for="entry in versionEntries" :key="entry.hash" :value="entry.hash">{{ versionLabel(entry) }}</option></GalaxySelect>
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canFastUpdate" @click="loadVersions">Load versions</button>
<button v-if="versionEntries.length" type="button" class="gx-btn" :disabled="actionUnavailable || !version || version === data.installed.commit || !operations.canFastUpdate" @click="askVersion(versionEntries.find(entry => entry.hash === version))">Install version</button>
</div><p class="gx-note">Up to 20 compatible versions of {{ draftBranch }}.</p>
</template>
<ul v-else-if="versionEntries.length" class="gx-build-history" aria-label="Build history"><li v-for="entry in versionEntries" :key="entry.hash"><strong>{{ entry.subject.trim() || shortCommit(entry.hash) }}</strong><small>{{ reported(entry.date) }} · {{ shortCommit(entry.hash) }}</small></li></ul>
<p v-else class="gx-note">{{ operations.history ? 'No versions saved.' : 'Reading history…' }}</p>
<pre v-if="historyKind === 'installed' && operations.history?.currentReleaseNotes" class="gx-release-notes">{{ operations.history.currentReleaseNotes }}</pre>
<pre v-if="historyKind === 'downloaded' && operations.history?.downloadedReleaseNotes" class="gx-release-notes">{{ operations.history.downloadedReleaseNotes }}</pre>
</div></details>
</section>
<ToggleBackup :unauthorized="unauthorized" />
<GxDialog v-if="dialog" labelledby="gx-software-status-confirm-title" @close="closeDialog"><h3 id="gx-software-status-confirm-title">{{ dialog.title }}</h3><p>{{ dialog.message }}</p>
<div class="gx-actions"><button type="button" class="gx-btn gx-btn--tonal" @click="closeDialog">Cancel</button><button type="button" class="gx-btn" @click="confirmDialog">{{ dialog.label }}</button></div>
</GxDialog>
</template>
</div>`,
}
@@ -0,0 +1,189 @@
import { GxState } from "./state.js"
import { GxIconButton } from "./icon-button.js"
import { GxNotice } from "./notice.js"
import { connectionError } from "./polling.js"
import { reactive } from "../vendor/vue/vue.esm-browser.js"
const RUNNING = new Set(["downloading", "verifying"])
export class SoundPacksFeed {
constructor({ publish, installed = () => {}, unauthorized = () => {},
fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms),
cancelTimer = (id) => clearTimeout(id) }) {
Object.assign(this, { publish, installed, unauthorized, fetcher, later, cancelTimer })
this.active = false
this.generation = 0
this.request = null
this.timeout = null
this.pollTimer = null
this.snapshot = null
this.seenComplete = new Set()
this.busy = false
this.blocked = false
this.error = ""
}
stop() {
this.active = false
this.generation++
this.request?.abort()
if (this.timeout !== null) this.cancelTimer(this.timeout)
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.request = this.timeout = this.pollTimer = null
this.busy = false
this.blocked = false
this.snapshot = null
this.publish({ status: "idle", snapshot: null, busy: false, error: "" })
}
start() {
this.stop()
this.active = true
return this.refresh()
}
async run(path, body = null) {
if (!this.active) return
if (this.request) {
if (body === null || this.busy) return
this.request.abort()
this.cancelTimer(this.timeout)
}
const generation = ++this.generation
if (this.pollTimer !== null) this.cancelTimer(this.pollTimer)
this.pollTimer = null
const request = new AbortController()
this.request = request
this.busy = body !== null
this.publish({ status: this.snapshot ? "ready" : this.error ? "unavailable" : "loading", snapshot: this.snapshot, busy: this.busy, error: this.error })
this.timeout = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timeout = null
this.busy = false
this.blocked = true
this.publish({ status: this.snapshot ? "ready" : "unavailable", snapshot: this.snapshot, busy: false,
error: this.error = body ? "The request timed out. Its result is unknown; checking automatically…" :
"The sound catalog timed out. Reconnecting automatically…" })
this.pollTimer = this.later(() => { this.pollTimer = null; this.refresh() }, 5000)
}, 4000)
try {
const response = await this.fetcher(path, body === null
? { credentials: "same-origin", cache: "no-store", signal: request.signal }
: { method: "POST", credentials: "same-origin", cache: "no-store", signal: request.signal,
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
if (!this.active || generation !== this.generation || request.signal.aborted) return
const payload = await response.json().catch(() => null)
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (response.status === 401 || (response.status === 503 &&
["access_unavailable", "setup_required"].includes(payload?.code))) {
this.stop()
this.unauthorized()
return
}
if (!response.ok) throw new Error(payload?.error || payload?.message || "Sound request failed.")
if (!payload || !Array.isArray(payload.packs) || typeof payload.parked !== "boolean" ||
(payload.job !== null && typeof payload.job !== "object")) throw new Error("Sound catalog is unavailable.")
this.snapshot = payload
this.blocked = false
this.error = ""
this.publish({ status: "ready", snapshot: payload, busy: false, error: "" })
if (payload.job?.state === "complete" && payload.job.id != null && !this.seenComplete.has(payload.job.id)) {
this.seenComplete.add(payload.job.id)
this.installed(payload.job.pack)
}
this.pollTimer = this.later(() => {
this.pollTimer = null
this.refresh()
}, RUNNING.has(payload.job?.state) ? 1000 : 5000)
} catch (error) {
if (this.active && generation === this.generation && !request.signal.aborted) {
this.blocked = true
this.publish({ status: this.snapshot ? "ready" : "unavailable", snapshot: this.snapshot, busy: false,
error: this.error = connectionError(error) })
}
} finally {
if (this.request === request) {
if (this.timeout !== null) this.cancelTimer(this.timeout)
this.request = this.timeout = null
this.busy = false
if (this.active && this.pollTimer === null) this.pollTimer = this.later(() => { this.pollTimer = null; this.refresh() }, 5000)
}
}
}
refresh() { return this.run("./api/sounds") }
download(pack) {
if (!this.active || this.busy || this.blocked || !this.snapshot?.parked || !Array.isArray(this.snapshot.packs) ||
!this.snapshot.packs.some((item) => item.id === pack && item.installed === false) ||
RUNNING.has(this.snapshot.job?.state)) return
return this.run("./api/sounds/download", { pack })
}
cancel() {
const job = this.snapshot?.job
if (!this.active || this.busy || this.blocked || !this.snapshot?.parked || !RUNNING.has(job?.state) || job.id == null) return
return this.run("./api/sounds/cancel", { job: job.id })
}
}
export const SoundPacks = {
components: { GxState, GxIconButton, GxNotice },
props: { unauthorized: { type: Function, required: true }, disabled: { type: Boolean, default: false },
busy: { type: Boolean, default: false } },
emits: ["installed"],
setup(props, { emit }) {
const state = reactive({ status: "idle", snapshot: null, busy: false, error: "" })
const feed = new SoundPacksFeed({ publish: (update) => Object.assign(state, update),
installed: (pack) => emit("installed", pack), unauthorized: props.unauthorized })
return { state, feed }
},
computed: {
job() { return this.state.snapshot?.job },
running() { return RUNNING.has(this.job?.state) },
progress() {
const bytes = Number(this.job?.bytes), total = Number(this.job?.total)
return Number.isFinite(bytes) && Number.isFinite(total) && total > 0
? Math.min(100, Math.max(0, Math.round(bytes / total * 100))) : null
},
},
mounted() { this.feed.start() },
beforeUnmount() { this.feed.stop() },
methods: {
canDownload(pack) { return !this.disabled && !this.state.error && this.state.status === "ready" &&
this.state.snapshot?.parked === true && !this.running && pack.installed === false },
download(pack) { if (!this.busy && !this.state.busy && this.canDownload(pack)) return this.feed.download(pack.id) },
cancel() { if (!this.disabled && !this.busy && !this.state.busy) return this.feed.cancel() },
},
template: `
<section class="gx-card gx-settings__section" aria-label="Sound pack catalog" :inert="busy || state.busy" :aria-busy="busy || state.busy || undefined">
<div class="gx-section__header"><i class="bi bi-music-note-list" aria-hidden="true"></i>
<span class="gx-section__title">Sound pack catalog</span>
<span v-if="state.snapshot && !state.snapshot.parked" class="gx-note gx-settings__hint">Park the vehicle to download sound packs.</span>
</div>
<div class="gx-settings__grid gx-panel">
<GxState v-if="state.status === 'loading'" loading>Loading sound packs…</GxState>
<GxNotice tone="danger" v-else-if="state.status === 'unavailable'">Sound packs are unavailable.</GxNotice>
<GxNotice tone="danger" v-if="state.error">{{ state.error }}</GxNotice>
<p v-if="state.snapshot && !state.snapshot.packs.length" class="gx-note">No sound packs are available.</p>
<div v-for="pack in state.snapshot?.packs || []" :key="pack.id" class="gx-card gx-settings__row">
<strong>{{ pack.name }}</strong>
<div class="gx-settings__controls gx-actions">
<span v-if="pack.installed" class="gx-chip">Installed</span>
<GxIconButton v-else :label="'Download ' + pack.name" icon="bi-download" :disabled="!canDownload(pack)" @click="download(pack)" />
</div>
</div>
<div v-if="job" role="status">
<p v-if="running">{{ job.state === 'verifying' ? 'Verifying' : 'Downloading' }} {{ state.snapshot.packs.find(pack => pack.id === job.pack)?.name || 'sound pack' }}<span v-if="progress !== null"> · {{ progress }}%</span></p>
<p v-else-if="job.state === 'complete'">Sound pack installed. Choose it from the pack selector above when ready.</p>
<p v-else-if="job.state === 'cancelled'">Download cancelled.</p>
<GxNotice tone="danger" v-else-if="job.state === 'failed'">Download failed: {{ job.error || 'Unknown error.' }}</GxNotice>
<progress v-if="running && progress !== null" :value="progress" max="100" :aria-label="'Sound pack download ' + progress + '%'">{{ progress }}%</progress>
<button v-if="running" type="button" class="gx-btn gx-btn--tonal" :disabled="disabled || !!state.error || !state.snapshot.parked" @click="cancel()">Cancel download</button>
</div>
</div>
</section>`,
}
@@ -0,0 +1,48 @@
export async function requestJson(url, { fetcher = (...args) => fetch(...args), timeout = 8000,
later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id), request = {}, withResponse = false } = {}) {
const controller = new AbortController()
let timer
let rejectCanceled
const abort = () => { controller.abort(); rejectCanceled?.(Object.assign(new Error("Request canceled"), { name: "AbortError" })) }
request.signal?.addEventListener("abort", abort, { once: true })
try {
return await Promise.race([
new Promise((_, reject) => { rejectCanceled = reject; if (request.signal?.aborted) abort() }),
(async () => {
const response = await fetcher(url, { ...request, cache: "no-store", credentials: "same-origin", signal: controller.signal })
if (!response.ok && !withResponse) {
const body = await response.json?.().catch(() => null)
const error = new Error(typeof body?.error === "string" ? body.error : "Galaxy could not be reached")
error.status = response.status
error.code = body?.code
throw error
}
const data = await response.json()
return withResponse ? { response, data } : data
})(),
new Promise((_, reject) => {
timer = later(() => { controller.abort(); reject(Object.assign(new Error("Galaxy took too long to respond"), { name: "TimeoutError" })) }, timeout)
}),
])
} finally {
request.signal?.removeEventListener("abort", abort)
cancel(timer)
controller.abort()
}
}
export async function loadCatalog(options) {
const [catalog, runtime] = await Promise.all([
requestJson("./data/catalog.json", options), requestJson("./data/runtime.json", options),
])
if (runtime?.schemaVersion !== 1 || !["sample", "local"].includes(runtime.monitor)) throw new Error("Invalid Galaxy runtime")
if (catalog?.mode !== "offline-preview" || !Array.isArray(catalog.tools) || catalog.tools.some(tool =>
typeof tool?.path !== "string" || !tool.path.startsWith("/") || typeof tool.name !== "string" ||
!["partial-preview", "local-only", "unavailable"].includes(tool.availability) ||
typeof tool.icon !== "string" || typeof tool.description !== "string" ||
(tool.visibility !== undefined && tool.visibility !== "authenticated"))) {
throw new Error("Invalid Galaxy catalog")
}
return { tools: catalog.tools.slice().sort((a, b) => a.name.localeCompare(b.name)), mode: runtime.monitor,
instance: typeof runtime.instance === "string" ? runtime.instance : null }
}
@@ -0,0 +1,76 @@
// Shared boundary; the selected transport must explicitly choose its source mode.
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
const number = (value, min = 0, max = Infinity) => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max ? value : null
const text = (value) => typeof value === "string" && value.trim() ? value : null
const percent = (value) => number(value, 0, 100)
const integer = (value, min = 0) => Number.isSafeInteger(value) && value >= min ? value : null
export function normalizeSnapshot(raw, expectedMode = "synthetic-preview") {
if (!["synthetic-preview", "local-runtime"].includes(expectedMode) ||
!object(raw) || raw.mode !== expectedMode || raw.schemaVersion !== 1 ||
!Array.isArray(raw.cores) || !Array.isArray(raw.processes) || !object(raw.memory) || !object(raw.storage) ||
number(raw.sampledAt, 1) === null || !text(raw.source)) {
throw new Error("Invalid System Monitor sample")
}
const cores = raw.cores.map((core) => {
if (!object(core) || !text(core.name)) throw new Error("Invalid CPU core in sample")
return { name: core.name, percent: percent(core.percent) }
})
if (new Set(cores.map((core) => core.name)).size !== cores.length) throw new Error("Duplicate CPU core in sample")
const processes = raw.processes.map((process) => {
if (!object(process) || integer(process.pid, 1) === null || !text(process.name) || !text(process.user) ||
typeof process.kernel !== "boolean" || !text(process.state)) throw new Error("Invalid process in sample")
return { pid: process.pid, name: process.name, user: process.user, kernel: process.kernel,
state: process.state, cpu: percent(process.cpu), memoryMiB: number(process.memoryMiB) }
})
if (new Set(processes.map((process) => process.pid)).size !== processes.length) throw new Error("Duplicate process PID in sample")
const memory = { totalMiB: number(raw.memory.totalMiB, 0.01), usedMiB: number(raw.memory.usedMiB),
availableMiB: number(raw.memory.availableMiB), percent: percent(raw.memory.percent) }
const storage = { usedGiB: number(raw.storage.usedGiB), totalGiB: number(raw.storage.totalGiB) }
if (memory.totalMiB !== null && (memory.usedMiB !== null && memory.usedMiB > memory.totalMiB ||
memory.availableMiB !== null && memory.availableMiB > memory.totalMiB)) {
memory.usedMiB = null; memory.availableMiB = null; memory.percent = null
}
if (storage.totalGiB !== null && storage.usedGiB !== null && storage.usedGiB > storage.totalGiB) storage.usedGiB = null
const v = object(raw.vitals) ? raw.vitals : {}
const vitals = Object.fromEntries(["cpuTempC", "gpuTempC", "hotspotTempC", "gpuEdgeTempC", "memoryUsedBytes", "memoryTotalBytes",
"onboardMaxAgeMs", "maxAgeMs", "memoryMaxAgeMs"].map((key) => [key, number(v[key])]))
return { source: raw.source, sampledAt: raw.sampledAt, cpuPercent: percent(raw.cpuPercent), cores, memory, storage,
uptimeSeconds: number(raw.uptimeSeconds), processCount: integer(raw.processCount), processes, vitals }
}
const FEATURE_LABELS = new Map([
["selfdrive.controls.controlsd", "Steering and speed control"],
["selfdrive.modeld.modeld", "Driving model"],
["system.hardware.hardwared", "Power and temperature management"],
["system.loggerd.uploader", "Log uploads"],
["system.manager.manager", "Process manager"],
])
export const processFeature = (process) => process.kernel ? "" : FEATURE_LABELS.get(process.name.replace(/^openpilot\./, "")) || ""
const PROCESS_STATES = new Map([["R", "Running"], ["S", "Sleeping"], ["D", "Waiting"], ["T", "Stopped"],
["t", "Tracing"], ["Z", "Zombie"], ["I", "Idle"]])
export const processState = (state) => PROCESS_STATES.get(state) || state
export const displayNumber = (value, suffix = "") => value === null || value === undefined ? "—" : `${value.toFixed(1)}${suffix}`
export function vital(snapshot, field, budget, elapsedMs = 0) {
const v = snapshot?.vitals
if ((field === "memoryUsedBytes" || field === "memoryTotalBytes") && v?.memoryUsedBytes != null && v?.memoryTotalBytes != null &&
v.memoryUsedBytes > v.memoryTotalBytes) return null
return v && v[budget] > 0 && elapsedMs >= 0 && elapsedMs < v[budget] ? v[field] ?? null : null
}
export function processRows(snapshot, { query = "", scope = "comma", sort = "cpu", descending = true } = {}) {
const q = query.trim().toLowerCase()
const keys = new Set(["name", "pid", "cpu", "memoryMiB", "user", "state"])
const key = keys.has(sort) ? sort : "cpu"
return (snapshot?.processes || []).filter((p) =>
(scope === "all" || (scope === "users" ? !p.kernel : p.user === "comma")) &&
(!q || `${p.name} ${processFeature(p)} ${p.pid} ${p.user} ${processState(p.state)}`.toLowerCase().includes(q))
).slice().sort((a, b) => {
const av = a[key], bv = b[key]
if (av === null) return bv === null ? a.pid - b.pid : 1
if (bv === null) return -1
const cmp = typeof av === "number" ? av - bv : String(av).localeCompare(String(bv))
return (descending ? -cmp : cmp) || a.pid - b.pid
})
}
@@ -0,0 +1,64 @@
import { GxState } from "./state.js"
import { GxNotice } from "./notice.js"
import { displayNumber, processFeature, processRows, processState, vital } from "./system-monitor-data.js"
import { MonitorFeed } from "./monitor-feed.js"
import { GalaxySelect } from "./galaxy-select.js"
export const SystemMonitor = {
name: "SystemMonitor",
components: { GxState, GxNotice, GalaxySelect },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } },
data: () => ({ snapshot: null, status: "loading", error: "", query: "", scope: "comma", sort: "cpu", descending: true }),
mounted() {
this.feed = new MonitorFeed({ mode: this.mode, publish: (state) => Object.assign(this, state), unauthorized: this.unauthorized })
this.feed.start()
},
beforeUnmount() { this.feed?.stop() },
computed: {
rows() { return processRows(this.snapshot, this) },
captured() { return this.snapshot ? new Date(this.snapshot.sampledAt * 1000).toLocaleString() : "—" },
uptime() {
const s = this.snapshot?.uptimeSeconds
return s === null || s === undefined ? "—" : `${Math.floor(s / 3600)}h ${Math.floor(s / 60) % 60}m`
},
vramUsed() { return vital(this.snapshot, "memoryUsedBytes", "memoryMaxAgeMs") },
vramTotal() { return vital(this.snapshot, "memoryTotalBytes", "memoryMaxAgeMs") },
},
methods: {
number: displayNumber,
feature: processFeature,
state: processState,
sensor(field, budget) { return vital(this.snapshot, field, budget) },
sortBy(key) { if (this.sort === key) this.descending = !this.descending; else { this.sort = key; this.descending = ["cpu", "memoryMiB"].includes(key) } },
arrow(key) { return this.sort === key ? (this.descending ? " ↓" : " ↑") : "" },
ariaSort(key) { return this.sort !== key ? "none" : this.descending ? "descending" : "ascending" },
},
template: `
<div class="gx-monitor">
<div class="gx-monitor__toolbar gx-page-header"><div><h2>System Monitor</h2><div class="gx-note">{{ mode === 'sample' ? 'Synthetic sample' : 'Local system' }} · Captured {{ captured }}</div></div><span class="gx-chip">{{ mode === 'sample' ? 'Offline preview' : status === 'current' ? 'Live' : 'Unavailable' }}</span></div>
<p v-if="mode === 'sample'" class="gx-note">Illustrative values only. No device was read.</p>
<p v-else class="gx-note">Read-only system activity. Updates every few seconds.</p>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<GxState v-else-if="!snapshot" loading>Loading system activity…</GxState>
<template v-else>
<div class="gx-monitor__summary">
<section class="gx-card gx-monitor__metric"><span>CPU</span><strong>{{ number(snapshot.cpuPercent, '%') }}</strong><small>{{ snapshot.cores.length }} cores · overall usage</small></section>
<section class="gx-card gx-monitor__metric"><span>Memory</span><strong>{{ number(snapshot.memory.percent, '%') }}</strong><small>{{ snapshot.memory.usedMiB == null ? '—' : number(snapshot.memory.usedMiB / 1024) }} / {{ snapshot.memory.totalMiB == null ? '—' : number(snapshot.memory.totalMiB / 1024) }} GiB</small><progress v-if="snapshot.memory.percent != null" :value="snapshot.memory.percent" max="100" aria-label="Memory usage"></progress></section>
<section class="gx-card gx-monitor__metric"><span>Processes</span><strong>{{ snapshot.processCount ?? '—' }}</strong><small>Uptime {{ uptime }}</small></section>
<section class="gx-card gx-monitor__metric"><span>Storage</span><strong>{{ number(snapshot.storage.usedGiB) }} GiB</strong><small>{{ number(snapshot.storage.totalGiB) }} GiB total</small></section>
<section class="gx-card gx-monitor__metric"><span>Onboard CPU temperature</span><strong>{{ number(sensor('cpuTempC', 'onboardMaxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>Onboard GPU temperature</span><strong>{{ number(sensor('gpuTempC', 'onboardMaxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU hotspot temperature</span><strong>{{ number(sensor('hotspotTempC', 'maxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU temperature</span><strong>{{ number(sensor('gpuEdgeTempC', 'maxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU VRAM</span><strong>{{ vramUsed == null ? '—' : number(vramUsed / 1073741824) + ' GiB' }}</strong><small v-if="vramTotal != null">{{ number(vramTotal / 1073741824) }} GiB total</small><progress v-if="vramTotal> 0 && vramUsed != null" :value="vramUsed" :max="vramTotal" aria-label="eGPU VRAM usage"></progress></section>
</div>
<details class="gx-card gx-monitor__cores"><summary>CPU cores</summary><div><span v-for="core in snapshot.cores" :key="core.name">{{ core.name.toUpperCase() }} <b>{{ number(core.percent, '%') }}</b><progress v-if="core.percent != null" :value="core.percent" max="100" :aria-label="core.name + ' usage'"></progress></span></div></details>
<div class="gx-monitor__filters"><input class="gx-field" type="search" v-model="query" aria-label="Search processes" placeholder="Search feature, process, PID or user…"><GalaxySelect class="gx-field gx-field--full" v-model="scope" aria-label="Process group"><option value="comma">Comma processes</option><option value="users">Apps and services</option><option value="all">All processes</option></GalaxySelect></div>
<p class="gx-note">{{ rows.length }} processes shown. {{ snapshot.cpuPercent == null ? 'CPU sample unavailable.' : '' }}</p>
<section class="gx-card gx-monitor__table" tabindex="0" aria-label="Process table; scroll horizontally for more columns">
<table><thead><tr><th v-for="column in [['name','Process'],['pid','PID'],['cpu','CPU'],['memoryMiB','Memory'],['user','User'],['state','Status']]" :key="column[0]" :aria-sort="ariaSort(column[0])"><button type="button" @click="sortBy(column[0])">{{ column[1] }}{{ arrow(column[0]) }}</button></th></tr></thead>
<tbody><tr v-for="process in rows" :key="process.pid"><td :title="process.name">{{ process.name }}<span v-if="feature(process)" class="gx-monitor__feature"> ({{ feature(process) }})</span></td><td>{{ process.pid }}</td><td>{{ number(process.cpu, '%') }}</td><td>{{ number(process.memoryMiB) }} MiB</td><td>{{ process.user }}</td><td><span class="gx-chip">{{ state(process.state) }}</span></td></tr><tr v-if="!rows.length"><td colspan="6" class="gx-empty">No matching processes.</td></tr></tbody></table>
</section>
</template>
</div>`,
}

Some files were not shown because too many files have changed in this diff Show More