mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
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:
@@ -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("&", "&") })
|
||||
compile(FavoriteChoices.template, { decodeEntities: value => value.replaceAll("&", "&") })
|
||||
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("&", "&")
|
||||
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('&', '&'), 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}
|
||||
}
|
||||
@@ -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
@@ -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 & 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>`,
|
||||
}
|
||||
@@ -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 & 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>`,
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}
|
||||
@@ -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 & 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>`,
|
||||
}
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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>`,
|
||||
}
|
||||
@@ -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 & 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 & 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
Reference in New Issue
Block a user