Galaxy Access

Separate Galaxy authentication and local access policy from web presentation.

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>
This commit is contained in:
firestar5683
2026-09-25 14:53:21 -05:00
parent a7b1a566af
commit 7b6ffed024
49 changed files with 4306 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
"""Local Galaxy credential owner. No transport or remote pairing is implied."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import secrets
import stat
import tempfile
from collections.abc import Callable
class AccessStatus(StrEnum):
UNCONFIGURED = "unconfigured"
CONFIGURED_LOCAL = "configured_local"
LEGACY_IMPORT_AVAILABLE = "legacy_import_available"
UNAVAILABLE = "unavailable"
@dataclass(frozen=True)
class AccessResult:
status: AccessStatus
detail: str = ""
class GalaxyAccessOwner:
"""Own one versioned verifier in a private directory; never serves credentials."""
FILE = "access-v1.json"
N, R, P = 1 << 14, 8, 1
def __init__(self, root: Path, *, legacy_root: Path | None = None):
self.root = root
self.legacy_root = legacy_root
def _legacy_available(self) -> bool:
if self.legacy_root is None:
return False
paths = [self.legacy_root / name for name in ("glxyauth", "glxysession", "glxyslug")]
def metadata(path: Path) -> os.stat_result | None:
try:
return path.lstat()
except FileNotFoundError:
return None
entries = [metadata(path) for path in paths]
present = [entry is not None for entry in entries]
if any(present) and not all(present):
raise ValueError("partial legacy credentials")
if all(present) and any(not stat.S_ISREG(entry.st_mode) for entry in entries if entry is not None):
raise ValueError("unsafe legacy credentials")
return all(present)
def legacy_available(self) -> bool | None:
try:
return self._legacy_available()
except (OSError, ValueError):
return None
def _read(self) -> dict[str, object] | None:
try:
directory = self.root.lstat()
except FileNotFoundError:
return None
if not stat.S_ISDIR(directory.st_mode) or directory.st_mode & 0o077:
raise ValueError("credential directory is unsafe")
path = self.root / self.FILE
try:
info = path.lstat()
except FileNotFoundError:
if any(entry.name.startswith(".access-") for entry in self.root.iterdir()):
raise ValueError("incomplete credential write") from None
return None
if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_size > 4096:
raise ValueError("credential file is unsafe")
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "r", encoding="utf-8") as stream:
opened = os.fstat(stream.fileno())
if not stat.S_ISREG(opened.st_mode) or opened.st_mode & 0o077 or opened.st_size > 4096:
raise ValueError("credential file changed")
record = json.load(stream)
if not isinstance(record, dict) or set(record) != {"version", "algorithm", "salt", "verifier"} or \
record["version"] != 1 or record["algorithm"] != "scrypt-n16384-r8-p1":
raise ValueError("unsupported credential record")
if not all(isinstance(record[key], str) and re.fullmatch(f"[0-9a-f]{{{size}}}", record[key])
for key, size in (("salt", 32), ("verifier", 64))):
raise ValueError("invalid credential record")
return record
def status(self) -> AccessResult:
try:
record = self._read()
if record is not None:
return AccessResult(AccessStatus.CONFIGURED_LOCAL)
if self._legacy_available():
return AccessResult(AccessStatus.LEGACY_IMPORT_AVAILABLE)
return AccessResult(AccessStatus.UNCONFIGURED)
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
return AccessResult(AccessStatus.UNAVAILABLE, "Local credential state cannot be read")
def verify(self, password: str) -> bool:
return self.authenticate_generation(password) is not None
@staticmethod
def _generation(record: dict[str, object]) -> bytes:
# Internal session binding only; never send this digest to a browser.
return hashlib.sha256(bytes.fromhex(str(record["salt"])) + bytes.fromhex(str(record["verifier"]))).digest()
def current_generation(self) -> bytes | None:
try:
record = self._read()
if record is None:
return None
return self._generation(record)
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
return None
def authenticate_generation(self, password: str) -> bytes | None:
try:
record = self._read()
if record is None:
return None
derived = hashlib.scrypt(password.encode("utf-8"), salt=bytes.fromhex(str(record["salt"])),
n=self.N, r=self.R, p=self.P, dklen=32)
return self._generation(record) if hmac.compare_digest(derived, bytes.fromhex(str(record["verifier"]))) else None
except (OSError, ValueError, UnicodeError, json.JSONDecodeError):
return None
def _write(self, password: str, parked: Callable[[], bool], replace_generation: bytes | None = None) -> bool:
salt = secrets.token_bytes(16)
verifier = hashlib.scrypt(password.encode("utf-8"), salt=salt, n=self.N, r=self.R, p=self.P, dklen=32)
record = {"version": 1, "algorithm": "scrypt-n16384-r8-p1", "salt": salt.hex(), "verifier": verifier.hex()}
if not parked():
return False
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
if not stat.S_ISDIR(self.root.lstat().st_mode) or self.root.stat().st_mode & 0o077:
return False
fd, temporary = tempfile.mkstemp(prefix=".access-", dir=self.root)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(record, stream, separators=(",", ":"))
stream.flush()
os.fsync(stream.fileno())
if not parked():
return False
if replace_generation is None:
# A second writer cannot silently replace a record created after status().
os.link(temporary, self.root / self.FILE)
else:
if self.current_generation() != replace_generation:
return False
os.replace(temporary, self.root / self.FILE)
directory_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
return True
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def configure(self, password: str, parked: Callable[[], bool]) -> bool:
if not 8 <= len(password) <= 255 or not parked() or self.status().status != AccessStatus.UNCONFIGURED:
return False
try:
return self._write(password, parked)
except (OSError, ValueError, UnicodeError):
return False
def replace_for_pairing(self, password: str, parked: Callable[[], bool]) -> bool:
"""Set a new remote login password from an authorized, parked local pairing flow."""
if not 8 <= len(password) <= 255 or not parked() or self.status().status != AccessStatus.CONFIGURED_LOCAL:
return False
generation = self.current_generation()
if generation is None:
return False
try:
return self._write(password, parked, replace_generation=generation)
except (OSError, ValueError, UnicodeError):
return False
def snapshot_for_pairing(self) -> dict[str, object] | None:
return self._read()
def restore_failed_pairing(self, previous: dict[str, object] | None, changed_generation: bytes) -> bool:
if self.current_generation() != changed_generation:
return False
path = self.root / self.FILE
if previous is None:
try:
path.unlink()
directory_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
return True
except OSError:
return False
try:
fd, temporary = tempfile.mkstemp(prefix=".access-", dir=self.root)
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as stream:
json.dump(previous, stream, separators=(",", ":"))
stream.flush()
os.fsync(stream.fileno())
if self.current_generation() != changed_generation:
return False
os.replace(temporary, path)
directory_fd = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
return True
finally:
if os.path.exists(temporary):
os.unlink(temporary)
except (OSError, ValueError, UnicodeError):
return False
def import_legacy(self, password: str, parked: Callable[[], bool]) -> bool:
"""Explicit migration needs the user's password; old SHA256 is never reused as a verifier."""
status = self.status().status
if not 6 <= len(password) <= 255 or not parked() or status not in \
(AccessStatus.LEGACY_IMPORT_AVAILABLE, AccessStatus.CONFIGURED_LOCAL):
return False
try:
if not self._legacy_available():
return False
assert self.legacy_root is not None
fd = os.open(self.legacy_root / "glxyauth", os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(fd, "r", encoding="ascii") as stream:
if os.fstat(stream.fileno()).st_size > 128:
return False
old_hash = stream.read().strip()
if len(old_hash) != 64 or not re.fullmatch(r"[0-9a-f]{64}", old_hash):
return False
raw_matches = hmac.compare_digest(hashlib.sha256(password.encode()).hexdigest(), old_hash)
stripped_matches = hmac.compare_digest(hashlib.sha256(password.strip().encode()).hexdigest(), old_hash)
if not (raw_matches or stripped_matches):
return False
generation = self.current_generation() if status == AccessStatus.CONFIGURED_LOCAL else None
if status == AccessStatus.CONFIGURED_LOCAL and generation is None:
return False
return self._write(password if raw_matches else password.strip(), parked, replace_generation=generation)
except (OSError, ValueError, UnicodeError):
return False
def remove(self, parked: Callable[[], bool]) -> bool:
if not parked() or self.status().status != AccessStatus.CONFIGURED_LOCAL:
return False
try:
if not parked():
return False
(self.root / self.FILE).unlink()
return True
except OSError:
return False
def legacy_galaxy_root() -> Path | None:
from openpilot.common.hardware import PC
from openpilot.common.hardware.hw import Paths
if os.environ.get("OPENPILOT_PREFIX"):
return None
if PC:
return Path(Paths.comma_home()) / "starpilot/data/galaxy"
return Path("/data/galaxy")
def default_owner() -> GalaxyAccessOwner:
"""Share the native UI credential location without importing UI modules."""
from openpilot.starpilot.storage import galaxy_storage_root
return GalaxyAccessOwner(galaxy_storage_root(), legacy_root=legacy_galaxy_root())
+63
View File
@@ -0,0 +1,63 @@
"""Bounded, process-local browser sessions for loopback Galaxy diagnostics."""
import hashlib
import hmac
import secrets
import time
from collections.abc import Callable
class LocalSessions:
LIFETIME = 30 * 60
MAX_SESSIONS = 64
MAX_FAILURES = 5
FAILURE_PAUSE = 30
def __init__(self, clock: Callable[[], float] = time.monotonic):
self.clock = clock
self._sessions: dict[bytes, tuple[float, bytes]] = {}
self._failures = 0
self._blocked_until = 0.0
@staticmethod
def _key(token: str) -> bytes:
return hashlib.sha256(token.encode('ascii')).digest()
def _expire(self) -> None:
now = self.clock()
self._sessions = {key: value for key, value in self._sessions.items() if value[0] > now}
def throttled(self) -> bool:
if self._failures >= self.MAX_FAILURES and self.clock() < self._blocked_until:
return True
if self._blocked_until > 0 and self.clock() >= self._blocked_until:
self._failures = 0
self._blocked_until = 0.0
return False
def failed_login(self) -> None:
self._failures += 1
if self._failures >= self.MAX_FAILURES:
self._blocked_until = self.clock() + self.FAILURE_PAUSE
def create(self, generation: bytes, *, reset_failures: bool = True) -> str:
self._expire()
if len(self._sessions) >= self.MAX_SESSIONS:
oldest = min(self._sessions, key=lambda key: self._sessions[key][0])
self._sessions.pop(oldest)
token = secrets.token_urlsafe(32)
self._sessions[self._key(token)] = (self.clock() + self.LIFETIME, generation)
if reset_failures:
self._failures = 0
return token
def valid(self, token: str | None, generation: bytes | None) -> bool:
if token is None or generation is None or len(token) > 128 or not token.isascii():
return False
self._expire()
session = self._sessions.get(self._key(token))
return bool(session is not None and hmac.compare_digest(session[1], generation))
def revoke(self, token: str | None) -> None:
if token is not None and len(token) <= 128 and token.isascii():
self._sessions.pop(self._key(token), None)
+117
View File
@@ -0,0 +1,117 @@
"""Saved, opt-in local Wi-Fi access. No network effects in the HTTP process."""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import tempfile
import threading
import time
INTERFACE = 'galaxy0'
ADDRESS = '172.31.254.1'
NETWORK = '172.31.254.0/24'
STATUS_PATH = Path('/tmp/starpilot-galaxy-hotspot-status.json')
DEFAULT = {'enabled': False, 'ssid': 'Galaxy', 'password': ''}
def device_dongle_id():
from openpilot.common.params import Params
from openpilot.starpilot.connect.provider import galaxy_device_id
return galaxy_device_id(Params())
def hotspot_name(dongle_id):
if type(dongle_id) is not str or not re.fullmatch(r'[a-fA-F0-9]{16}', dongle_id):
raise ValueError('A registered comma dongle ID is required for the hotspot.')
return 'TheGalaxy-' + dongle_id[-4:].lower()
def validate_config(value):
if (type(value) is not dict or set(value) != set(DEFAULT) or type(value['enabled']) is not bool or
type(value['ssid']) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9 _-]{0,31}', value['ssid']) or
type(value['password']) is not str or not re.fullmatch(r'[\x21-\x7e]{12,63}', value['password'])):
raise ValueError('Use a 1–32 character network name and a 12–63 character password without spaces.')
return dict(value)
def atomic_json(path, value):
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
fd, temporary = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
with os.fdopen(fd, 'w') as handle:
json.dump(value, handle)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def bounded_json(path):
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
with os.fdopen(fd, 'r') as handle:
raw = handle.read(4097)
if len(raw) > 4096:
raise ValueError('Hotspot document is too large')
return json.loads(raw)
class HotspotSettings:
def __init__(self, root=None, status_path=STATUS_PATH, clock=time.monotonic, dongle_id=device_dongle_id):
if root is None:
from openpilot.starpilot.storage import galaxy_storage_root
root = galaxy_storage_root()
self.path = Path(root) / 'hotspot.json'
self.status_path, self.clock = status_path, clock
self.dongle_id = dongle_id
self.lock = threading.Lock()
def read(self):
# Resolve the physical comma identity, including when another cloud provider
# is active. Older configurable SSIDs are normalized without losing secrets.
ssid = hotspot_name(self.dongle_id())
try:
return {**validate_config(bounded_json(self.path)), 'ssid': ssid}
except FileNotFoundError:
return {**DEFAULT, 'ssid': ssid}
@staticmethod
def revision(config):
return hashlib.sha256(json.dumps(config, sort_keys=True).encode()).hexdigest()
def snapshot(self, parked):
config = self.read()
runtime = {'active': False, 'state': 'unavailable', 'reason': 'Hotspot service has not reported its status.'}
try:
saved = bounded_json(self.status_path)
if (type(saved) is dict and type(saved.get('updated')) in (int, float) and
0 <= self.clock() - saved['updated'] < 10 and
saved.get('revision') == self.revision(config)):
runtime = {key: saved[key] for key in ('active', 'state', 'reason', 'channel') if key in saved}
except (OSError, ValueError, TypeError):
pass
return {'version': 1, 'config': config, 'revision': self.revision(config), 'editable': bool(parked),
'url': f'http://{ADDRESS}:8082/', **runtime}
def save(self, payload, permitted):
if type(payload) is not dict or set(payload) != {'revision', 'config'}:
raise ValueError('Invalid hotspot settings')
with self.lock:
current = self.read()
if payload['revision'] != self.revision(current):
raise FileExistsError('Hotspot settings changed; refresh and try again.')
candidate = payload['config']
if type(candidate) is dict and candidate.get('password') == '':
candidate = {**candidate, 'password': current['password'] or secrets.token_hex(12)}
candidate = validate_config(candidate)
if candidate['ssid'] != current['ssid']:
raise ValueError('The hotspot network name is fixed by the comma dongle ID.')
if not permitted():
raise PermissionError('Park and sign in before changing the hotspot.')
atomic_json(self.path, candidate)
return self.snapshot(permitted())
@@ -0,0 +1,93 @@
"""Unprivileged manager process; a closed pipe revokes the root worker's lease."""
import json
import os
import select
import signal
import subprocess
import sys
import threading
import time
from openpilot.starpilot.galaxy.hotspot import HotspotSettings, STATUS_PATH, atomic_json
def main():
from openpilot.common.basedir import BASEDIR
from openpilot.common.hardware import PC
if PC or os.environ.get('OPENPILOT_PREFIX'):
raise RuntimeError('Hotspot requires the primary comma instance')
settings = HotspotSettings()
stop = threading.Event()
signal.signal(signal.SIGINT, lambda *_: stop.set())
signal.signal(signal.SIGTERM, lambda *_: stop.set())
child, revision, buffer, last_reply = None, None, b'', 0.0
runtime = {'active': False, 'state': 'disabled', 'reason': ''}
try:
while not stop.is_set():
try:
config = settings.read()
revision = settings.revision(config)
if not config['enabled']:
if child is not None:
child.stdin.close()
child.wait(timeout=20)
child.stdout.close()
child = None
runtime = {'active': False, 'state': 'disabled', 'reason': ''}
else:
if child is None:
child = subprocess.Popen(['sudo', '-n', 'systemd-run', '--quiet', '--pipe', '--wait', '--collect',
'--unit=starpilot-galaxy-hotspot', '--property=TimeoutStopSec=8',
'--property=KillMode=control-group', '--working-directory=' + str(BASEDIR),
sys.executable, '-u', '-m',
'openpilot.starpilot.galaxy.hotspot_worker'], cwd=BASEDIR,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
close_fds=True)
buffer = b''
last_reply = time.monotonic()
runtime = {'active': False, 'state': 'starting', 'reason': 'Starting hotspot service.'}
if child.poll() is not None:
raise RuntimeError('Worker exited')
child.stdin.write(json.dumps(config).encode() + b'\n')
child.stdin.flush()
stdout = child.stdout
if stdout is None:
raise RuntimeError('Worker output pipe unavailable')
if select.select([stdout], [], [], 1)[0]:
data = os.read(stdout.fileno(), 4097)
if not data:
raise RuntimeError('Worker disconnected')
buffer += data
if len(buffer) > 4096:
raise ValueError('Oversized status')
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
runtime = json.loads(line)
last_reply = time.monotonic()
if time.monotonic() - last_reply > 20:
raise RuntimeError('Worker unresponsive')
atomic_json(STATUS_PATH, {**runtime, 'revision': runtime.get('revision', revision), 'updated': time.monotonic()})
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError):
if child is not None:
if not child.stdin.closed:
child.stdin.close()
try:
child.wait(timeout=20)
except subprocess.TimeoutExpired:
# Lease expiry is enforced independently inside the privileged worker.
pass
child.stdout.close()
child = None
atomic_json(STATUS_PATH, {'active': False, 'state': 'error', 'reason': 'Hotspot service unavailable.',
'revision': revision, 'updated': time.monotonic()})
stop.wait(15)
stop.wait(2)
finally:
if child is not None and not child.stdin.closed:
child.stdin.close()
atomic_json(STATUS_PATH, {'active': False, 'state': 'disabled', 'reason': 'Hotspot service stopped.',
'revision': revision, 'updated': time.monotonic()})
if __name__ == '__main__':
main()
@@ -0,0 +1,81 @@
"""Small nl80211 client for the one Galaxy AP interface; no wlan0 mutations."""
import socket
import struct
def attributes(data):
result = {}
while len(data) >= 4:
size, kind = struct.unpack_from('HH', data)
if size < 4 or size > len(data):
raise ValueError('Malformed netlink attribute')
result[kind & 0x3fff] = data[4:size]
data = data[(size + 3) & ~3:]
return result
def attribute(kind, data):
size = len(data) + 4
return struct.pack('HH', size, kind) + data + b'\0' * (-size % 4)
class WirelessInterfaces:
def __init__(self):
self.sock = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, 16)
try:
self.sock.settimeout(3)
self.sock.bind((0, 0))
self.sequence = 0
self.family = int.from_bytes(self.query(16, 3, attribute(2, b'nl80211\0'))[0][1], 'little')
except BaseException:
self.sock.close()
raise
def close(self):
self.sock.close()
def query(self, family, command, payload=b'', *, dump=False, ack=False):
self.sequence += 1
message = struct.pack('BBH', command, 1, 0) + payload
flags = 0x301 if dump else (5 if ack else 1)
self.sock.sendto(struct.pack('IHHII', len(message) + 16, family, flags, self.sequence, 0) + message, (0, 0))
replies = []
while True:
data = self.sock.recv(1024 * 1024)
while len(data) >= 16:
size, kind, _, seq, _ = struct.unpack_from('IHHII', data)
if size < 16 or size > len(data):
raise ValueError('Malformed netlink message')
body, data = data[16:size], data[(size + 3) & ~3:]
if seq != self.sequence:
continue
if kind == 3:
return replies
if kind == 2:
error = struct.unpack_from('i', body)[0]
if error:
raise OSError(-error, 'Wi-Fi interface operation failed')
if ack:
return replies
continue
replies.append(attributes(body[4:]))
if not dump and not ack:
return replies
def interfaces(self):
return {row[4].rstrip(b'\0').decode(): {'index': int.from_bytes(row[3], 'little'),
'phy': int.from_bytes(row[1], 'little'), 'type': int.from_bytes(row[5], 'little')}
for row in self.query(self.family, 5, dump=True)} # GET_INTERFACE
def create(self, name, primary='wlan0'):
interfaces = self.interfaces()
if name in interfaces or interfaces[primary]['type'] != 2: # station
raise RuntimeError('Hotspot interface exists or primary Wi-Fi is not a client')
payload = attribute(1, struct.pack('I', interfaces[primary]['phy']))
payload += attribute(4, name.encode() + b'\0') + attribute(5, struct.pack('I', 3)) # AP
self.query(self.family, 7, payload, ack=True) # NEW_INTERFACE
def delete(self, name):
interfaces = self.interfaces()
if name in interfaces:
self.query(self.family, 8, attribute(3, struct.pack('I', interfaces[name]['index'])), ack=True)
@@ -0,0 +1,235 @@
"""Privileged, pipe-leased hotspot worker. Never reconfigures the station link.
The unprivileged manager child sends validated settings/heartbeats on stdin.
EOF, ten seconds without a heartbeat, or SIGTERM tears down only our AP.
"""
from __future__ import annotations
import fcntl
import ipaddress
import json
import os
from pathlib import Path
import select
import shutil
import signal
import subprocess
import sys
import time
from openpilot.starpilot.galaxy.hotspot import ADDRESS, INTERFACE, NETWORK, HotspotSettings, validate_config
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces
ALIAS = 'starpilot-galaxy-hotspot'
RUNTIME = Path('/run/starpilot-galaxy-hotspot')
STANDALONE_CHANNEL = ('g', 6)
def radio_channel(status):
fields = dict(line.split('=', 1) for line in status.splitlines() if '=' in line)
# A disconnected station remains available for AA/Wi-Fi association. Do not
# depend on an Internet connection to offer local access. Association itself
# gets priority: pause the AP while the station is negotiating its channel.
if fields.get('wpa_state') in ('DISCONNECTED', 'INACTIVE', 'SCANNING'):
return STANDALONE_CHANNEL
if fields.get('wpa_state') != 'COMPLETED':
return None
frequency = int(fields.get('freq', '0'))
if 2412 <= frequency <= 2472 and (frequency - 2407) % 5 == 0:
return 'g', (frequency - 2407) // 5
if 5180 <= frequency <= 5825 and frequency % 5 == 0:
return 'a', (frequency - 5000) // 5
return None
def overlapping_routes(rows):
subnet = ipaddress.ip_network(NETWORK)
for row in rows:
destination = row.get('dst', 'default')
if destination == 'default' or row.get('dev') == INTERFACE:
continue
if subnet.overlaps(ipaddress.ip_network(destination, strict=False)):
return True
return False
def hostapd_config(config, channel):
validate_config(config)
band, number = channel
if band not in ('a', 'g') or type(number) is not int or not 1 <= number <= 165:
raise ValueError('Invalid channel')
return (f'interface={INTERFACE}\ndriver=nl80211\nssid={config["ssid"]}\nhw_mode={band}\nchannel={number}\n' +
f'wmm_enabled=1\nauth_algs=1\nwpa=2\nwpa_passphrase={config["password"]}\n' +
'wpa_key_mgmt=WPA-PSK\nrsn_pairwise=CCMP\nmax_num_sta=4\n')
class HotspotBackend:
def __init__(self, root=RUNTIME, run=subprocess.run, interfaces=WirelessInterfaces, popen=subprocess.Popen):
self.root, self.run, self.interfaces, self.popen = root, run, interfaces, popen
self.children = []
self.owned = False
self.firewall = shutil.which('iptables-legacy') or shutil.which('iptables')
def command(self, args, check=True):
return self.run(args, check=check, text=True, capture_output=True, timeout=5)
def channel(self):
return radio_channel(self.command(['/usr/sbin/wpa_cli', '-i', 'wlan0', 'status']).stdout)
def alive(self):
return self.owned and len(self.children) == 2 and all(child.poll() is None for child in self.children)
def ready(self):
reply = self.command(['/usr/sbin/hostapd_cli', '-p', str(self.root / 'control'), '-i', INTERFACE, 'status'], check=False)
return reply.returncode == 0 and 'state=ENABLED' in reply.stdout.splitlines()
def _wireless(self, method):
client = self.interfaces()
try:
getattr(client, method)(INTERFACE)
finally:
client.close()
def stale_owned(self):
try:
return Path(f'/sys/class/net/{INTERFACE}/ifalias').read_text().strip() == ALIAS
except FileNotFoundError:
return False
def stop(self):
for child in self.children:
if child.poll() is None:
child.terminate()
for child in self.children:
try:
child.wait(timeout=2)
except subprocess.TimeoutExpired:
child.kill()
child.wait(timeout=2)
self.children.clear()
owned = self.owned or self.stale_owned()
if owned:
self._wireless('delete')
self.owned = False
if self.firewall:
# The comment distinguishes our rules even if the kernel already removed
# the AP after a worker crash. Never flush the shared forwarding chain.
for direction in ('-i', '-o'):
self.command(self.firewall_rule('-D', direction), check=False)
def firewall_rule(self, operation, direction):
return [self.firewall, '-w', '2', operation, 'FORWARD', direction, INTERFACE,
'-m', 'comment', '--comment', ALIAS, '-j', 'DROP']
def start(self, config, channel):
if not self.firewall:
raise RuntimeError('Firewall helper unavailable')
if overlapping_routes(json.loads(self.command(['ip', '-j', '-4', 'route', 'show', 'table', 'all']).stdout)):
raise RuntimeError('Hotspot subnet conflicts with an existing network')
self._wireless('create')
self.owned = True
try:
self.command(['ip', 'link', 'set', 'dev', INTERFACE, 'alias', ALIAS])
self.command(['nmcli', 'device', 'set', INTERFACE, 'managed', 'no'])
self.command(['sysctl', '-w', f'net.ipv6.conf.{INTERFACE}.disable_ipv6=1'])
# Local access only, even if the phone supplies its own default route.
for direction in ('-i', '-o'):
self.command(self.firewall_rule('-I', direction))
self.command(['ip', 'address', 'add', f'{ADDRESS}/24', 'dev', INTERFACE])
self.command(['ip', 'link', 'set', 'dev', INTERFACE, 'up'])
ap = self.root / 'hostapd.conf'
ap.write_text(hostapd_config(config, channel) + f'ctrl_interface={self.root / "control"}\n')
ap.chmod(0o600)
dhcp = self.root / 'dnsmasq.conf'
dhcp.write_text(f'interface={INTERFACE}\nbind-interfaces\nport=0\nno-resolv\nno-hosts\n' +
'dhcp-range=172.31.254.10,172.31.254.30,255.255.255.0,10m\n' +
'dhcp-option=3\ndhcp-option=6\nleasefile-ro\n')
for command in (['/usr/sbin/hostapd', str(ap)],
['/usr/sbin/dnsmasq', '--keep-in-foreground', '--conf-file=' + str(dhcp)]):
self.children.append(self.popen(command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, close_fds=True))
except BaseException:
self.stop()
raise
class HotspotController:
def __init__(self, backend, clock=time.monotonic):
self.backend, self.clock = backend, clock
self.applied, self.retry_at, self.ready_deadline = None, 0.0, 0.0
def tick(self, config):
if not config['enabled']:
self.backend.stop()
self.applied = None
return {'active': False, 'state': 'disabled', 'reason': ''}
try:
channel = self.backend.channel()
if channel is None:
self.backend.stop()
self.applied = None
return {'active': False, 'state': 'waiting', 'reason': 'Waiting for Wi-Fi radio readiness or a supported connection channel.'}
desired = (dict(config), channel)
if desired != self.applied or not self.backend.alive():
self.backend.stop()
self.applied = None
if self.clock() < self.retry_at:
return {'active': False, 'state': 'error', 'reason': 'Hotspot unavailable; retrying shortly.'}
self.retry_at = self.clock() + 15
self.backend.start(config, channel)
self.applied = desired
self.ready_deadline = self.clock() + 15
return {'active': False, 'state': 'starting', 'reason': 'Starting the local Wi-Fi network.', 'channel': channel[1]}
if not self.backend.ready():
if self.clock() >= self.ready_deadline:
raise RuntimeError('Hotspot did not become ready')
return {'active': False, 'state': 'starting', 'reason': 'Waiting for the Wi-Fi access point.', 'channel': channel[1]}
return {'active': True, 'state': 'active', 'reason': '', 'channel': channel[1]}
except (OSError, RuntimeError, ValueError, KeyError, subprocess.SubprocessError):
self.backend.stop()
self.applied = None
self.retry_at = self.clock() + 15
return {'active': False, 'state': 'error', 'reason': 'Hotspot could not start on this channel. Projection Wi-Fi was not reconfigured.'}
def main():
if os.geteuid() != 0:
raise RuntimeError('Hotspot worker requires network administration privileges')
os.umask(0o077)
RUNTIME.mkdir(mode=0o700, exist_ok=True)
if RUNTIME.is_symlink() or RUNTIME.stat().st_uid != 0:
raise RuntimeError('Unsafe hotspot runtime directory')
fd = os.open(RUNTIME / 'lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
backend = HotspotBackend()
controller = HotspotController(backend)
stopped = False
def stop(*_):
nonlocal stopped
stopped = True
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
config, buffer, last = None, b'', time.monotonic()
try:
backend.stop() # clean an interface explicitly marked as ours after a crash
while not stopped and time.monotonic() - last < 10:
if select.select([sys.stdin], [], [], 2)[0]:
data = os.read(sys.stdin.fileno(), 4097)
if not data:
break
buffer += data
if len(buffer) > 4096:
raise ValueError('Oversized settings input')
while b'\n' in buffer:
line, buffer = buffer.split(b'\n', 1)
config = validate_config(json.loads(line))
last = time.monotonic()
if config is not None:
print(json.dumps({**controller.tick(config), 'revision': HotspotSettings.revision(config)}), flush=True)
finally:
backend.stop()
os.close(fd)
if __name__ == '__main__':
main()
+191
View File
@@ -0,0 +1,191 @@
"""Bounded Galaxy HTTP connections and shared-source shutdown."""
from contextlib import ExitStack
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from functools import lru_cache
import gzip
from ipaddress import IPv4Address, ip_address, ip_network
import json
import mimetypes
from pathlib import Path
import threading
import time
class _LocalHTTPServer(ThreadingHTTPServer):
"""Bound idle browser connections without blocking other local requests."""
MAX_CONNECTIONS = 8
REQUEST_TIMEOUT = 4.0
def __init__(self, *args, **kwargs):
self._slots = threading.BoundedSemaphore(self.MAX_CONNECTIONS)
super().__init__(*args, **kwargs)
def get_request(self):
request, address = super().get_request()
request.settimeout(self.REQUEST_TIMEOUT)
return request, address
def process_request(self, request, client_address):
if not self._slots.acquire(timeout=self.REQUEST_TIMEOUT):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except Exception:
self._slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self._slots.release()
def drain_requests(self, timeout: float) -> bool:
"""Wait for accepted requests before closing their shared data sources."""
deadline = time.monotonic() + timeout
acquired = 0
try:
for _ in range(self.MAX_CONNECTIONS):
if not self._slots.acquire(timeout=max(0.0, deadline - time.monotonic())):
return False
acquired += 1
return True
finally:
for _ in range(acquired):
self._slots.release()
def service_actions(self):
super().service_actions()
maintain = getattr(self, 'maintain_android_auto', None)
if maintain is not None:
try:
maintain()
except Exception:
pass # Optional housekeeping must never stop serve_forever.
def server_close(self, *, close_sources: bool = True):
if not close_sources:
# Managed shutdown can abandon a stuck daemon request. Its readers must
# remain alive until that process exits; only retire the listening socket.
media = getattr(self, 'recording_media_source', None)
if media is not None:
media.stop_child()
super().server_close()
return
# Always close every owned source, even if another cleanup raises. The
# analysis child must stop before its parked-state reader is retired.
with ExitStack() as cleanup:
cleanup.callback(super().server_close)
media = getattr(self, 'recording_media_source', None)
if media is not None:
cleanup.callback(media.close)
for name in ('map_source', 'settings_source', 'vehicle_selection_source', 'toyota_tools_source', 'model_source', 'plots_source',
'flm_source', 'bluetooth_authority', 'bluetooth_source', 'model_manager_source', 'model_authority', 'layout_authority', 'favorites_source',
'sound_authority', 'sound_source', 'software_operations_source', 'drive_stats_authority', 'drive_stats_source',
'pairing_authority', 'evidence_source', 'device_state_source', 'navigation_source', 'drive_physical_source', 'notification_source',
'android_auto_setup_source'):
close = getattr(getattr(self, name, None), 'close', None)
if callable(close):
cleanup.callback(close)
LOCAL_NETWORKS = tuple(ip_network(network) for network in ('127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16'))
def direct_local_connection(peer: str, destination: str, headers) -> bool:
"""Passwordless access is limited to direct local sockets, never forwarded requests."""
proxy_headers = {'forwarded', 'via', 'x-real-ip', 'x-client-ip', 'x-original-forwarded-for', 'cf-connecting-ip', 'true-client-ip'}
if any(name.lower() in proxy_headers or name.lower().startswith('x-forwarded-') for name in headers):
return False
try:
return all(isinstance(address, IPv4Address) and any(address in network for network in LOCAL_NETWORKS)
for address in (ip_address(peer), ip_address(destination)))
except ValueError:
return False
def allowed_authority(host: str, local_address: str, port: int) -> bool:
"""Accept only the literal address reached by this socket, or loopback's name."""
if host == f'localhost:{port}' and local_address == '127.0.0.1':
return True
if host != f'{local_address}:{port}':
return False
try:
address = ip_address(local_address)
except ValueError:
return False
return isinstance(address, IPv4Address) and not address.is_unspecified
@lru_cache(maxsize=128)
def static_asset(path: str, identity: tuple[int, int, int]):
body = Path(path).read_bytes()
content_type = mimetypes.guess_type(path)[0] or 'application/octet-stream'
compressible = content_type.startswith('text/') or content_type in ('application/javascript', 'application/json', 'image/svg+xml')
packed = gzip.compress(body, compresslevel=5, mtime=0) if compressible and len(body) > 1024 else body
if len(packed) >= len(body):
packed = body
return body, packed, content_type
class _LocalHTTPHandler(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def parse_request(self):
parsed = super().parse_request()
# Rejected writes can leave unread bodies; only bodyless reads are reusable.
if parsed and (self.command not in ('GET', 'HEAD') or 'Content-Length' in self.headers or 'Transfer-Encoding' in self.headers):
self.close_connection = True
return parsed
def end_headers(self):
if self.close_connection:
self.send_header('Connection', 'close')
super().end_headers()
def log_message(self, format, *args): # noqa: A002 - Match BaseHTTPRequestHandler's keyword signature.
pass
def handle(self):
try:
super().handle()
except (BrokenPipeError, ConnectionResetError):
# The browser can retire an in-flight fetch or speculative connection.
self.close_connection = True
def respond(self, status, body, content_type='application/json', cookie=None, *, asset_headers=None, cache_control=None):
self.send_response(status)
self.send_header('Content-Type', content_type)
if status != 304:
self.send_header('Content-Length', str(len(body)))
self.send_header('Cache-Control', cache_control or ('private, max-age=0, must-revalidate' if asset_headers else 'no-store'))
for key, value in (asset_headers or {}).items():
self.send_header(key, value)
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Referrer-Policy', 'no-referrer')
if cookie is not None:
self.send_header('Set-Cookie', cookie)
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
def json(self, status, value):
self.respond(status, json.dumps(value, allow_nan=False).encode())
def attachment(self, filename, body, content_type):
"""A download the browser saves as ``filename`` rather than displays."""
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(body)))
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Referrer-Policy', 'no-referrer')
self.end_headers()
if self.command != 'HEAD':
self.wfile.write(body)
@@ -0,0 +1,75 @@
"""Actual LAN addresses for direct Galaxy access, independent of the tunnel."""
from ipaddress import IPv4Address, ip_network
import json
import subprocess
import threading
import time
LAN = tuple(ip_network(value) for value in ('10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '169.254.0.0/16'))
# Virtual links can carry RFC1918 addresses too; they are not a local Wi-Fi/Ethernet address.
TUNNEL_KINDS = frozenset(('tun', 'wireguard', 'gre', 'gretap', 'ipip', 'sit', 'vti', 'vti6', 'vxlan', 'geneve'))
TUNNEL_PREFIXES = ('tun', 'tap', 'wg', 'tailscale', 'zt', 'docker', 'veth', 'br-', 'p2p', 'usb', 'rndis', 'ppp', 'wwan', 'rmnet')
class LocalAccess:
def __init__(self, port=8082, *, run=subprocess.run, clock=time.monotonic):
self.port, self.run, self.clock = port, run, clock
self.lock = threading.Lock()
self.cached, self.expiry = None, 0.0
def snapshot(self):
with self.lock:
if self.cached is not None and self.clock() < self.expiry:
return self.cached
addresses = []
reason = 'Connect to the Galaxy hotspot, or put the comma and phone on the same Wi-Fi or Ethernet network.'
try:
result = self.run(['ip', '-j', '-4', 'address', 'show', 'up'], capture_output=True, timeout=1, check=True)
if len(result.stdout) > 65536:
raise ValueError('Network inventory too large')
interfaces = json.loads(result.stdout)
if not isinstance(interfaces, list):
raise ValueError('Invalid network inventory')
for interface in interfaces[:64]:
if len(addresses) >= 32:
break
if type(interface) is not dict:
continue
name = interface.get('ifname', '')
flags = interface.get('flags', [])
info_rows = interface.get('addr_info', [])
if (not isinstance(name, str) or not name or type(flags) is not list or
'UP' not in flags or 'LOOPBACK' in flags or type(info_rows) is not list or
name.lower().startswith(TUNNEL_PREFIXES) or
interface.get('operstate') in ('DOWN', 'LOWERLAYERDOWN', 'NOTPRESENT') or
isinstance(interface.get('linkinfo'), dict) and
interface['linkinfo'].get('info_kind') in TUNNEL_KINDS):
continue
for info in info_rows[:16]:
if len(addresses) >= 32:
break
if type(info) is not dict:
continue
if (info.get('family') != 'inet' or info.get('scope') != 'global' or
info.get('valid_life_time') == 0 or info.get('preferred_life_time') == 0 or
info.get('tentative') or info.get('dadfailed')):
continue
address = IPv4Address(info['local'])
if not any(address in network for network in LAN):
continue
url = f'http://{address}:{self.port}/'
if any(row['url'] == url for row in addresses):
continue
addresses.append({'interface': name[:64], 'label': f'{address} ({name[:64]})', 'url': url})
addresses.sort(key=lambda row: (row['interface'], row['url']))
except (OSError, subprocess.SubprocessError, ValueError, KeyError, TypeError, AttributeError):
reason = 'The comma could not read its network addresses. Check its Network settings and refresh.'
addresses = []
self.cached = {'available': bool(addresses), 'addresses': addresses,
'reason': '' if addresses else reason}
self.expiry = self.clock() + 5.0
return self.cached
@@ -0,0 +1,79 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/js/boot.js', route => route.fulfill({ body: '' }))
await page.route('**/api/**', route => route.fulfill({ status: 404, body: '' }))
await page.goto(process.env.GALAXY_URL || 'http://127.0.0.1:8765/')
await page.evaluate(() => { document.body.innerHTML = '<main class="gx-content"><div id="fixture"></div></main>' })
async function mount(kind) {
await page.evaluate(async kind => {
const { createApp } = await import('./vendor/vue/vue.esm-browser.js')
window.fixtureApp?.unmount()
document.querySelector('#fixture').innerHTML = ''
const component = kind === 'android' ? (await import('./js/android-auto.js')).AndroidAutoPage :
(await import('./js/record-history.js')).LocalRecordingsPage
const routeId = '2026-09-28--01-30-00'
const segments = [0, 1, 2].map(number => ({ number, segmentName: `${routeId}--${number}`, files: { fcamera: true } }))
const data = kind === 'android' ? {
setup: { enabled: false, bluetoothEnabled: true, parked: true, installReady: true, serviceReady: true,
identity: { installed: true, message: 'Package ready' }, import: { state: 'idle' }, maxUploadBytes: 200 * 1048576 },
packageFile: { name: 'android-auto-package.apk', size: 1048576 },
} : { status: 'ready', data: { routes: [] }, playing: { routeId, route: { segments }, segments,
index: 1, camera: 'fcamera', url: './api/fixture-video' } }
window.uploadClicks = 0
window.fixtureApp = createApp({ ...component,
data: () => ({ ...component.data(), ...data }),
created() {},
mounted() { if (kind === 'recording') this.$refs.playerPanel.showModal() },
beforeUnmount() {},
methods: { ...component.methods, upload() { window.uploadClicks++ }, videoError() {} },
}, { mode: 'local', localAccess: true, unauthorized() {} })
window.fixtureVM = window.fixtureApp.mount('#fixture')
}, kind)
}
async function checkActions(selector) {
const overflow = await page.locator(selector).evaluateAll(elements => elements
.filter(element => element.getClientRects().length)
.filter(element => element.scrollWidth > element.clientWidth + 1)
.map(element => element.className))
assert.deepEqual(overflow, [], 'action groups need no horizontal scrolling')
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
}
for (const width of [320, 360, 390, 600, 1280]) {
await page.setViewportSize({ width, height: 900 })
await mount('android')
await checkActions('.gx-driving__actions')
await page.getByRole('button', { name: 'Enable & upload', exact: true }).click()
assert.equal(await page.evaluate(() => window.uploadClicks), 1)
await page.evaluate(() => { window.fixtureVM.setup.enabled = true })
const upload = page.getByRole('button', { name: 'Upload package', exact: true })
await upload.click()
assert.equal(await page.evaluate(() => window.uploadClicks), 2, 'icon clicks reach the existing handler')
await page.evaluate(() => { window.fixtureVM.busy = true })
assert.equal(await upload.isDisabled(), true, 'disabled state reaches the native icon button')
await checkActions('.gx-driving__actions')
await mount('recording')
await checkActions('.gx-recordings__player-controls, .gx-recordings__actions')
const next = page.getByRole('button', { name: 'Next segment', exact: true })
await next.click()
assert.equal(await next.isDisabled(), true)
const previous = page.getByRole('button', { name: 'Previous segment', exact: true })
await previous.click()
await previous.click()
assert.equal(await previous.isDisabled(), true)
await page.getByRole('button', { name: 'Close recording player', exact: true }).click()
assert.equal(await page.getByRole('dialog', { name: 'Camera recording player' }).count(), 0)
}
assert.deepEqual(errors, [])
console.log('Android Auto and recording controls fit five widths; icon names, handlers and disabled states passed.')
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,215 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/js/boot.js', route => route.fulfill({ body: '' }))
await page.goto(process.env.GALAXY_URL || 'http://127.0.0.1:8765/')
await page.evaluate(() => {
document.body.innerHTML = '<div class="gx-app"><header class="gx-appbar">Header</header><main class="gx-content"><div id="fixture"></div></main></div>'
})
await page.evaluate(async () => {
const { createApp } = await import('./vendor/vue/vue.esm-browser.js')
const { SoftwarePage } = await import('./js/software-status.js')
const { GxState } = await import('./js/state.js')
const { GxNotice } = await import('./js/notice.js')
const { GxDialog } = await import('./js/dialog.js')
const { GxSummary } = await import('./js/summary.js')
const installed = { version: 'Fixture', branch: 'SecretGoodStarPilot', commit: 'a'.repeat(40) }
const updater = { state: 'idle', lastCheckedAt: null, lastFetchAt: null, targetChangeFound: false }
const operations = { availableBranches: ['StarPilot', 'Dom'], selectedTarget: 'StarPilot', request: null,
canCheck: true, canFastUpdate: true, canDownload: true, canSelect: true, canInstall: false }
const software = { ...SoftwarePage, created() {}, mounted() {}, beforeUnmount() {},
data: () => ({ ...SoftwarePage.data(), status: 'ready', data: { installed, updater, operations },
notice: 'Updating SecretGoodStarPilot. Waiting for completion or reconnect.', uncertain: true }) }
window.fixture = createApp({ components: { software, GxState, GxNotice, GxDialog, GxSummary },
data: () => ({ dialog: false }),
template: `<software mode="local" :unauthorized="() => {}" /><div class="gx-card-grid"><section class="gx-card gx-panel">Card</section><section class="gx-card gx-panel">Card</section></div>
<div class="gx-home__totals"><section class="gx-card"><h2>Time on the Road</h2><p>Saved history</p></section></div><section class="gx-card gx-aa-step"><h2>Android Auto</h2><p>Connect your car</p></section><GxSummary :items="[{label: 'Installed', value: 1}, {label: 'Missing', value: 0}, {label: 'Total', value: 1}]" /><GxState loading>Reading your device…</GxState><GxState>Nothing here yet.</GxState>
<GxNotice tone="success" title="Saved">Preferences saved.</GxNotice><GxNotice tone="danger">An error occurred.</GxNotice>
<div class="gx-tabs"><button class="gx-btn" aria-pressed="true">Selected</button><button class="gx-btn gx-btn--tonal">Other</button></div>
<button class="gx-btn" @click="dialog=true">Open dialog</button>
<GxDialog v-if="dialog" labelledby="fixture-title" @close="dialog=false"><h3 id="fixture-title">Confirm preference</h3><input class="gx-field" aria-label="Name" /><div class="gx-actions"><button class="gx-btn" @click="dialog=false">Cancel</button><button class="gx-btn">Save</button></div></GxDialog>`,
}).mount('#fixture')
})
for (const theme of ['dark', 'light']) {
await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme)
for (const width of [320, 390, 768, 1280, 1920, 3440]) {
await page.setViewportSize({ width, height: 900 })
for (const pinned of [false, true]) {
await page.evaluate(pinned => document.querySelector('.gx-app').classList.toggle('gx-nav-pinned', pinned), pinned)
const geometry = await page.evaluate(() => {
const content = document.querySelector('.gx-content').getBoundingClientRect()
const header = document.querySelector('.gx-appbar').getBoundingClientRect()
const notice = document.querySelector('.gx-update-progress').getBoundingClientRect()
const track = document.querySelector('.gx-update-progress__track').getBoundingClientRect()
const text = document.querySelector('.gx-update-progress__heading').getBoundingClientRect()
return { width: content.width, left: content.left, right: content.right, header: header.width,
notice: notice.width, container: document.querySelector('.gx-software-card').clientWidth - 2 * parseFloat(getComputedStyle(document.querySelector('.gx-software-card')).paddingLeft), aligned: track.top >= text.bottom,
overflow: document.documentElement.scrollWidth > innerWidth }
})
assert.equal(geometry.overflow, false, `${theme}/${width}/${pinned}: no overflow`)
assert.ok(geometry.width <= 1200 && geometry.header <= 1200)
assert.equal(await page.locator('.gx-tabs').last().evaluate(element => getComputedStyle(element).display), 'flex')
assert.equal(await page.locator('.gx-card-grid').evaluate(element => getComputedStyle(element).display), 'grid')
const summary = await page.locator('.gx-summary > div').evaluateAll(elements => elements.map(element => element.getBoundingClientRect().top))
assert.ok(summary.every(top => Math.abs(top - summary[0]) < 1), 'summary counts share one row')
assert.ok(Math.abs(geometry.notice - geometry.container) < 1 && geometry.aligned, 'progress fills the padded panel with the track below its label')
for (const selector of ['.gx-panel', '.gx-message', '.gx-home__totals > section', '.gx-aa-step']) {
const padding = await page.locator(selector).first().evaluate(element => { const style = getComputedStyle(element); return [parseFloat(style.paddingLeft), parseFloat(style.paddingRight), parseFloat(style.paddingTop), parseFloat(style.paddingBottom)] })
assert.ok(padding[0] >= 16 && padding[1] >= 16, `${selector}: horizontal padding survives all themes and widths`)
assert.ok(padding[2] >= 16 && padding[3] >= 16, `${selector}: vertical padding`)
}
assert.equal(await page.locator('.gx-message').first().evaluate(element => element.offsetWidth), await page.locator('#fixture').evaluate(element => element.clientWidth), 'page states fill the available width')
const inset = pinned && width >= 768 ? 320 : 0
assert.ok(Math.abs(geometry.left - inset - (width - geometry.right)) < 1, 'centered in available space')
}
}
}
await page.evaluate(() => { document.querySelector('.gx-app').classList.remove('gx-nav-pinned'); document.documentElement.dataset.theme = 'dark' })
await page.setViewportSize({ width: 1280, height: 900 })
await page.waitForTimeout(250)
await page.screenshot({ path: '/tmp/galaxy-design-desktop.png' })
await page.setViewportSize({ width: 390, height: 900 })
await page.screenshot({ path: '/tmp/galaxy-design-phone.png' })
const trigger = page.getByRole('button', { name: 'Open dialog', exact: true })
await trigger.click()
const dialog = page.getByRole('dialog', { name: 'Confirm preference' })
await dialog.waitFor()
assert.equal(await dialog.evaluate(element => element.contains(document.activeElement)), true)
for (let i = 0; i < 5; i++) await page.keyboard.press('Tab')
assert.equal(await dialog.evaluate(element => element.contains(document.activeElement)), true, 'Tab stays in dialog')
await page.keyboard.press('Escape')
assert.equal(await dialog.count(), 0)
assert.equal(await trigger.evaluate(element => element === document.activeElement), true, 'focus restored')
await trigger.click()
await page.mouse.click(2, 2)
assert.equal(await dialog.count(), 0, 'backdrop cancels')
await page.emulateMedia({ reducedMotion: 'reduce' })
assert.equal(await page.locator('.gx-spinner').first().evaluate(element => getComputedStyle(element).animationName), 'none')
await page.evaluate(async () => {
const { createApp } = await import('./vendor/vue/vue.esm-browser.js')
const { NavigationMap } = await import('./js/navigation-map.js')
const wrapper = document.createElement('div')
wrapper.className = 'gx-navigation--fullscreen'
wrapper.innerHTML = '<div id="map-fixture"></div>'
document.body.appendChild(wrapper)
createApp({ ...NavigationMap, mounted() {}, beforeUnmount() {},
data: () => ({ ...NavigationMap.data(), error: 'Map tiles are unavailable.' }) },
{ data: {}, stale: false }).mount('#map-fixture')
})
for (const width of [320, 1280, 3440]) {
await page.setViewportSize({ width, height: 900 })
await page.locator('.gx-navigation--fullscreen').scrollIntoViewIfNeeded()
const bounds = await page.getByRole('alert').filter({ hasText: 'Map tiles are unavailable.' }).boundingBox()
assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width && bounds.y + bounds.height <= 900,
'map errors remain visible above the canvas')
}
assert.deepEqual(errors, [])
// Exercise the real software feed: controls wait for the automatic check,
// history is fetched on demand, and secondary actions retain confirmations.
const softwareRequests = []
const snapshot = { schemaVersion: 1,
installed: {version:'Fixture',branch:'StarPilot',commit:'a'.repeat(40)},
updater: {state:'idle',targetBranch:'StarPilot',lastSuccessAt:null,lastFetchAt:null,
targetChangeFound:true,finalizedUpdateReady:true,failedCount:0},
operations: {parked:true,availableBranches:['StarPilot','Dom'],selectedTarget:'StarPilot',
canCheck:true,canFastUpdate:true,canDownload:true,canSelect:true,canInstall:true,
canRollback:true,canConfigure:true,automaticDownloads:true,reason:null,request:null} }
await page.route('**/api/software/**', async route => {
const url = new URL(route.request().url())
softwareRequests.push(url.search)
await new Promise(resolve => setTimeout(resolve,250))
const operations = {...snapshot.operations}
if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''})),downloaded:[],
currentReleaseNotes:'Fixture release notes',downloadedReleaseNotes:null}
await route.fulfill({json:{...snapshot,operations}})
})
await page.evaluate(async () => {
window.fixture.$.appContext.app.unmount()
document.querySelector('.gx-navigation--fullscreen')?.remove()
const {createApp} = await import('./vendor/vue/vue.esm-browser.js')
const {SoftwarePage} = await import('./js/software-status.js')
window.fixture = createApp(SoftwarePage,{mode:'local',unauthorized:()=>{}}).mount('#fixture')
})
const check = page.getByRole('button',{name:'Check for updates',exact:true})
await page.getByRole('progressbar').waitFor()
assert.equal(await check.count(),0,'no update controls before the automatic check finishes')
assert.equal(await page.getByRole('button',{name:'Back up toggles',exact:true}).isEnabled(),true,'backup does not wait for updater status')
await check.waitFor()
assert.equal(await check.isEnabled(),true)
assert.deepEqual(softwareRequests.slice(0,2),['?history=0',''])
for (const width of [320,390,1280,3440]) {
await page.setViewportSize({width,height:900})
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth),false,'minimal software stays within viewport')
}
assert.equal(await page.getByRole('button',{name:'Refresh software status',exact:true}).count(),0)
assert.equal(await page.getByText('Update activity',{exact:true}).count(),0)
for (const [width,path] of [[1280,'/tmp/galaxy-updates-clean-desktop.png'],[390,'/tmp/galaxy-updates-clean-phone.png']]) {
await page.setViewportSize({width,height:900})
await page.screenshot({path,fullPage:true})
}
assert.equal(await page.getByRole('button',{name:'Fast Update',exact:true}).count(),0)
assert.equal(await page.getByRole('button',{name:'Normal Update',exact:true}).count(),0)
for (const name of ['Update']) {
await page.getByRole('button',{name,exact:true}).click()
await page.getByRole('dialog').waitFor()
await page.getByRole('button',{name:'Cancel',exact:true}).click()
}
snapshot.updater.finalizedUpdateReady = false
snapshot.operations.canInstall = false
await page.evaluate(() => window.fixture.feed.load())
await page.getByRole('button',{name:'Update',exact:true}).click()
await page.getByRole('dialog',{name:'Update software'}).waitFor()
await page.getByRole('button',{name:'Cancel',exact:true}).click()
await page.getByText('Advanced options',{exact:true}).click()
await page.getByRole('button',{name:'Previous version',exact:true}).click()
await page.getByRole('dialog').waitFor()
await page.getByRole('button',{name:'Cancel',exact:true}).click()
await page.getByRole('combobox',{name:'Target branch',exact:true}).click()
await page.getByRole('option',{name:/Development — Dom/}).click()
await page.getByRole('button',{name:'Update',exact:true}).click()
await page.getByRole('dialog',{name:'Update software'}).waitFor()
assert.equal(await page.getByRole('dialog').getByText('Install the latest version of Dom and restart?',{exact:true}).count(),1)
await page.getByRole('button',{name:'Cancel',exact:true}).click()
await page.getByRole('button',{name:'Save target',exact:true}).click()
await page.getByRole('dialog',{name:'Change target branch'}).waitFor()
await page.getByRole('button',{name:'Cancel',exact:true}).click()
await page.getByText('Versions & release notes',{exact:true}).click()
await page.getByRole('button',{name:'Installed',exact:true}).click()
await page.getByText('Fixture release notes',{exact:true}).waitFor()
assert.equal(softwareRequests.at(-1),'','history is fetched only after opening release notes')
assert.equal(await page.locator('.gx-build-history li').count(),20)
assert.equal(await page.locator('.gx-build-history li strong').first().textContent(),'000000000000')
assert.equal(await page.locator('.gx-build-history').evaluate(element=>getComputedStyle(element).listStyleType),'none')
assert.equal(await page.getByRole('button',{name:'Downloaded',exact:true}).count(),0)
assert.equal(await check.isEnabled(),true,'history refresh keeps update controls enabled')
const backup = {format:'galaxy-toggles',version:1,createdAt:'2026-10-07T12:00:00Z',metric:false,vehicle:null,
settings:[{page:'data',key:'AlwaysAllowUploads',value:'Off'}],layout:null}
const restores = []
await page.route('**/api/settings/backup', route => route.fulfill({json:backup}))
await page.route('**/api/settings/restore', route => {
restores.push(route.request().postDataJSON())
return route.fulfill({json:{complete:true,restored:1,unchanged:0,skipped:[]}})
})
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button',{name:'Back up toggles',exact:true}).click()
const download = await downloadPromise
assert.equal(download.suggestedFilename(),'toggle-backup.json')
const file = {name:'toggle-backup.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(backup))}
await page.getByLabel('Choose toggle backup',{exact:true}).setInputFiles(file)
await page.getByRole('dialog',{name:'Restore toggles?'}).waitFor()
await page.getByRole('button',{name:'Cancel',exact:true}).click()
assert.equal(restores.length,0,'choosing a file alone never changes settings')
await page.getByLabel('Choose toggle backup',{exact:true}).setInputFiles(file)
await page.getByRole('dialog').getByRole('button',{name:'Restore',exact:true}).click()
await page.getByText('1 toggle restored.',{exact:true}).waitFor()
assert.deepEqual(restores,[backup])
await page.evaluate(() => window.fixture.$.appContext.app.unmount())
assert.deepEqual(errors,[])
console.log('Design system: six widths, both themes, pinned layout, update banner, state cards, modal keyboard/focus/backdrop and reduced motion passed.')
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,148 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const { readFileSync } = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const web = path.resolve(__dirname, '../../web')
const place = { id: 'coffee', name: 'Coffee Shop', address: '123 Main Street', latitude: 40, longitude: -90 }
const status = { enabled: true, hasKey: true, isMetric: true, status: 'noDestination', revision: '1',
destination: null, favorites: [], recents: [], location: null, route: [], instruction: null }
;(async () => {
const browser = await chromium.launch({ headless: true,
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) })
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
const errors = [], actions = [], searches = []
page.on('pageerror', error => errors.push(error.message))
await page.route('http://navigation.test/**', async route => {
const request = route.request(), pathname = new URL(request.url()).pathname
if (pathname === '/data/runtime.json') return route.fulfill({ json: { schemaVersion: 1, monitor: 'local' } })
if (pathname === '/api/auth/session') return route.fulfill({ json: { state: 'configured', authenticated: true, localAccess: true } })
if (pathname === '/api/device/state') return route.fulfill({ json: { state: 'parked', maxAgeMs: 3000 } })
if (pathname === '/api/navigation/search') {
const body = request.postDataJSON()
searches.push(body)
return route.fulfill({ json: { results: [{ ...place }] } })
}
if (pathname === '/api/navigation/action') {
const body = request.postDataJSON()
actions.push(body)
if (body.action === 'favorite') status.favorites = [{ ...place, ...(body.label ? { label: body.label } : {}) }]
if (body.action === 'labelFavorite') status.favorites = [{ ...place, ...(body.label ? { label: body.label } : {}) }]
status.revision = String(Number(status.revision) + 1)
return route.fulfill({ json: status })
}
if (pathname.startsWith('/api/')) return route.fulfill({ json: status })
if (pathname === '/' && new URL(request.url()).searchParams.has('shell')) return route.fulfill({ contentType: 'text/html', body:
`<meta name="viewport" content="width=device-width,initial-scale=1"><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"><div id="galaxy-app"></div><script type="module" src="/js/app.js"></script>` })
if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: `<meta name="viewport" content="width=device-width,initial-scale=1">
<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"><main class="gx-content"><div id="app"></div></main>
<script type="module">import {createApp} from '/vendor/vue/vue.esm-browser.js';
import {NavigationPage} from '/js/navigation.js';
window.navigation = createApp(NavigationPage,{mode:'local',unauthorized:()=>{}}).mount('#app');</script>` })
return route.fulfill({ body: readFileSync(path.join(web, pathname)),
contentType: pathname.endsWith('.js') ? 'text/javascript' : pathname.endsWith('.css') ? 'text/css' : pathname.endsWith('.json') ? 'application/json' : pathname.endsWith('.svg') ? 'image/svg+xml' : 'application/octet-stream' })
})
await page.goto('http://navigation.test/')
const search = page.getByRole('searchbox', { name: 'Search destinations' })
await search.waitFor()
assert.equal(await page.getByText('Back to tools', { exact: true }).count(), 0)
await search.fill('coffee')
await page.getByRole('button', { name: 'Save Coffee Shop', exact: true }).waitFor()
await page.getByRole('button', { name: 'Save Coffee Shop', exact: true }).click()
const choices = page.getByRole('group', { name: 'Save place as' })
for (const width of [360, 390, 768, 1280]) {
await page.setViewportSize({ width, height: 844 })
for (const name of ['Home', 'Work', 'Other'])
assert.equal(await choices.getByRole('button', { name, exact: true }).isVisible(), true)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
if (width < 400) {
assert((await page.locator('.gx-navigation-map canvas').boundingBox()).height <= 300, 'phone map stays compact')
}
await page.screenshot({ path: '/private/tmp/navigation-compact-' + width + '.png', fullPage: true })
}
await choices.getByRole('button', { name: 'Home', exact: true }).click()
await page.waitForFunction(() => window.navigation.data.favorites[0]?.label === 'home')
assert.equal(actions.at(-1).label, 'home')
await page.getByRole('button', { name: 'Clear search', exact: true }).click()
await search.focus()
await page.getByRole('button', { name: 'Save Coffee Shop as Home, Work, or Other', exact: true }).click()
await choices.getByRole('button', { name: 'Other', exact: true }).click()
await page.waitForFunction(() => window.navigation.data.favorites[0] && !window.navigation.data.favorites[0].label)
assert.equal(actions.at(-1).action, 'labelFavorite')
assert.equal(actions.at(-1).label, null)
Object.assign(status, { network: 'online', location: { latitude: 40, longitude: -90, validForMs: 0, lastKnown: true } })
await page.evaluate(() => window.navigation.client.load())
await page.getByText('Using your last saved location to plan routes.', { exact: false }).waitFor()
assert.equal(await search.isEnabled(), true, 'a saved location works online without downloaded maps')
assert.equal(await page.getByRole('link', { name: 'download maps for offline navigation views' }).count(), 0)
status.network = 'offline'
await page.evaluate(() => window.navigation.client.load())
const offlineLink = page.getByRole('link', { name: 'download maps for offline navigation views' })
await offlineLink.waitFor()
assert.equal(await offlineLink.getAttribute('href'), '#/navigation/maps')
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 844 })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
await page.screenshot({ path: `/private/tmp/navigation-offline-${width}.png`, fullPage: true })
}
await offlineLink.click()
await page.getByText('Map Overlay Roads', { exact: true }).waitFor()
assert.equal(await page.getByRole('button', { name: 'Offline Maps', exact: true }).getAttribute('aria-pressed'), 'true')
await page.getByRole('button', { name: 'Destination', exact: true }).click()
status.network = 'unknown'
await page.evaluate(() => window.navigation.client.load())
assert.equal(await offlineLink.count(), 0, 'unknown device state must not be presented as offline')
await page.goto('http://navigation.test/?shell=1#/navigation/maps')
await page.getByText('Map Overlay Roads', { exact: true }).waitFor()
assert.equal(await page.getByRole('button', { name: 'Offline Maps', exact: true }).getAttribute('aria-pressed'), 'true')
await page.getByRole('button', { name: 'Destination', exact: true }).click()
await page.waitForURL('**#/navigation')
await page.goto('http://navigation.test/?shell=1#/navigation')
await page.getByRole('searchbox', { name: 'Search destinations' }).waitFor()
for (const theme of ['dark', 'light']) {
await page.locator('html').evaluate((el, theme) => el.dataset.theme = theme, theme)
for (const width of [360, 390, 768, 1280]) {
await page.setViewportSize({ width, height: 844 })
const header = await page.locator('.gx-appbar').boundingBox()
const title = await page.getByRole('heading', { name: 'Navigation', exact: true }).boundingBox()
assert(header && header.y >= 0 && header.height >= 44, 'shared header is visible')
assert(title.y >= header.y + header.height, 'Navigation title sits below the shared header')
// Bottom nav and the drawer menu are mutually exclusive: mobile shows the bar, desktop shows the menu.
assert.equal(await page.getByRole('button', { name: 'Menu', exact: true }).isVisible(), width >= 768)
assert.equal(await page.locator('.blur-nav').isVisible(), width < 768)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
for (const name of ['Destination', 'Offline Maps', 'Setup']) {
const tab = page.getByRole('group', { name: 'Navigation tools' }).getByRole('button', { name, exact: true })
await tab.click()
await page.mouse.move(0, 0)
assert.equal(await tab.getAttribute('aria-pressed'), 'true')
const colors = await tab.evaluate(el => {
const style = getComputedStyle(el)
return { text: style.color, background: style.backgroundColor }
})
assert.notEqual(colors.text, colors.background, 'selected tab text must remain visible')
assert.notEqual(colors.background, 'rgba(0, 0, 0, 0)', 'selected tab uses the shared selected-button style')
}
await page.getByRole('button', { name: 'Destination', exact: true }).click()
await page.screenshot({ path: `/private/tmp/navigation-shell-${theme}-${width}.png`, fullPage: true })
}
}
for (const pinned of [false, true]) {
await page.locator('.gx-app').evaluate((el, pinned) => el.classList.toggle('gx-nav-pinned', pinned), pinned)
for (const width of [768, 1024, 1280]) {
await page.setViewportSize({ width, height: 844 })
const pill = await page.locator('.gx-appbar__pill').boundingBox()
const status = await page.locator('.gx-appbar__right').boundingBox()
const searchBox = await page.locator('.gx-searchbox').boundingBox()
assert(status.x + status.width <= pill.x + pill.width, 'status stays inside the header pill')
assert(searchBox.x + searchBox.width <= status.x, 'search and device status do not overlap')
await page.getByRole('button', { name: 'Menu', exact: true }).click()
assert.equal(await page.getByRole('complementary', { name: 'Galaxy navigation' }).isVisible(), true)
await page.locator('.gx-underlay').click({ position: { x: width - 10, y: 100 } })
}
}
assert.deepEqual(errors, [])
console.log('Navigation: 360–1280px layout, inline favorite choices, atomic Home save, Other relabel, and readable tabs with shared headers in both themes passed')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,66 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const { readFileSync } = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const web = path.resolve(__dirname, '../../web')
const status = { enabled: true, hasKey: true, isMetric: true, status: 'noDestination', revision: '1',
destination: null, favorites: [], recents: [], location: null, route: [], instruction: null }
;(async () => {
const browser = await chromium.launch({ headless: true,
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}) })
try {
const page = await browser.newPage({ viewport: { width: 390, height: 844 } })
const errors = [], actions = []
page.on('pageerror', error => errors.push(error.message))
await page.route('http://navigation.test/**', async route => {
const request = route.request(), pathname = new URL(request.url()).pathname
if (pathname === '/api/navigation/action') {
const body = request.postDataJSON()
actions.push(body)
assert.equal(body.action, 'configure')
assert.equal(body.revision, status.revision)
Object.assign(status, body.patch)
status.revision = String(Number(status.revision) + 1)
return route.fulfill({ json: status })
}
if (pathname.startsWith('/api/')) return route.fulfill({ json: status })
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"><link rel="stylesheet" href="/css/navigation.css">
<main class="gx-content"><div id="app"></div></main><script type="module">
import {createApp} from '/vendor/vue/vue.esm-browser.js'; import {NavigationPage} from '/js/navigation.js';
window.navigation = createApp(NavigationPage,{mode:'local',initialTab:'setup',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' })
})
await page.goto('http://navigation.test/')
await page.waitForFunction(() => !!window.navigation?.data)
for (const [key, label] of [['avoidTolls', 'Avoid tolls'], ['avoidHighways', 'Avoid highways'], ['avoidFerries', 'Avoid ferries']]) {
const toggle = page.getByRole('switch', { name: label, exact: true })
assert.equal(await toggle.isChecked(), false)
await toggle.check({ force: true })
await page.waitForFunction(key => window.navigation.data[key] === true && !window.navigation.busy, key)
assert.deepEqual(actions.at(-1).patch, { [key]: true })
assert.equal(await toggle.isChecked(), true)
}
await page.reload()
await page.waitForFunction(() => window.navigation?.data?.avoidFerries === true)
for (const theme of ['dark', 'light']) {
await page.locator('html').evaluate((el, theme) => el.dataset.theme = theme, theme)
for (const width of [360, 1280]) {
await page.setViewportSize({ width, height: 844 })
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
await page.screenshot({ path: `/private/tmp/navigation-preferences-${theme}-${width}.png`, fullPage: true })
}
}
await page.evaluate(() => window.navigation.stale = true)
assert.equal(await page.getByRole('switch', { name: 'Avoid tolls', exact: true }).isDisabled(), true)
await page.evaluate(() => window.navigation.client.load())
await page.getByRole('switch', { name: 'Avoid tolls', exact: true }).uncheck({ force: true })
await page.waitForFunction(() => window.navigation.data.avoidTolls === false && !window.navigation.busy)
assert.deepEqual(actions.at(-1).patch, { avoidTolls: false })
assert.equal(status.avoidHighways, true)
assert.equal(status.avoidFerries, true)
assert.deepEqual(errors, [])
console.log('Navigation preferences: independent toggles, revision-scoped saves, reload, stale-state disabling, and responsive dark/light layouts passed')
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,144 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const layout = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c',
'import json; from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata; print(json.dumps(dict(document=default_document(), defaults=default_document(), metadata=customization_metadata(), revision="a"*64, editable=True, valid=True, activeProfile="large")))'], { encoding: 'utf8' }))
;(async () => {
const browser = await chromium.launch({headless:true})
const page = await browser.newPage({viewport:{width:1280,height:900}})
const errors=[]
page.on('pageerror',e=>errors.push(e.message))
const jpeg=await page.evaluate(()=>{const c=document.createElement('canvas');c.width=1344;c.height=760;const x=c.getContext('2d');x.fillStyle='#a04020';x.fillRect(0,0,c.width,c.height);return c.toDataURL('image/jpeg').split(',')[1]})
let confidence='0.85',version=0,saves=0,defaults=0,draws=0,cameraFail=false,disconnected=false
const row={label:'Detection confidence',value:confidence,minimum:0.8,maximum:1,step:0.01,available:true,action:true,choices:[],defaultValue:'0.90',resetAvailable:true,revision:'r1'}
await page.route('**/data/runtime.json',r=>r.fulfill({json:{schemaVersion:1,monitor:'local'}}))
await page.route('**/api/**',async r=>{
const path=new URL(r.request().url()).pathname
if(disconnected)return r.abort('internetdisconnected')
if(path==='/api/device/state')return r.fulfill({json:{state:'parked',maxAgeMs:3000}})
if(path==='/api/vehicle-selection')return r.fulfill({json:{version:1,parked:true,readable:true,valid:true,selected:null,selectedLabel:'Auto detection',reported:null,view:'vehicle-view',choices:[{platform:'KIA_CEED',make:'Kia',label:'Ceed'}]}})
if(path==='/api/auth/session') return r.fulfill({json:{state:'configured',authenticated:true,localAccess:true}})
if(path==='/api/cameras/snapshot'){draws++;if(cameraFail)return r.fulfill({status:503,json:{error:'Camera unavailable'}});return r.fulfill({contentType:'image/jpeg',body:Buffer.from(jpeg,'base64')})}
if(path.startsWith('/api/settings/pages/')) {
const pip=path.endsWith('/pip')
return r.fulfill({json:{page:path.split('/').at(-1),view:'view'+version++,parked:true,editorRow:1,rows:[{...row,value:confidence,revision:confidence},{label:'Camera window regions',available:true,action:true}],editor:pip?{width:1344,height:760,cropSize:300,centerLeft:null,centerRight:[1000,350],invert:true}:{width:1344,height:760,cameraLeft:[],cameraRight:[[800,100],[1200,100],[1200,550],[800,550]]}}})
}
if(path==='/api/settings/preview'||path==='/api/settings/reset-default') {defaults+=path.endsWith('reset-default')?1:0;return r.fulfill({json:{intent:'i1',question:'Save?',proposed:'0.9'}})}
if(path==='/api/settings/confirm') {saves++;return r.fulfill({json:{saved:true}})}
if(path==='/api/ui/layout') {
if(r.request().method()==='POST') {
layout.document=r.request().postDataJSON().document
layout.revision='b'.repeat(64)
}
return r.fulfill({json:layout})
}
if(path==='/api/recordings/local')return r.fulfill({json:{schemaVersion:1,source:'local',partialHistory:true,scanIncomplete:false,routes:[{routeId:'00000042--abcdef1234',segmentCount:1,displayName:'Test drive',preserved:false,segments:[{number:0,segmentName:'00000042--abcdef1234--0',files:{rlog:true,qlog:false,fcamera:true,dcamera:true,ecamera:true,qcamera:true},logFiles:['rlog.zst']}]}]}})
return r.fulfill({status:503,json:{error:'Fixture unavailable'}})
})
await page.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + '#/cameras/vasm')
await page.getByRole('heading',{name:'Camera Window Regions'}).waitFor()
await page.waitForFunction(()=>{const c=document.querySelector('.gx-vasm__canvas');return c?.getContext('2d').getImageData(20,20,1,1).data[0]>100})
assert.equal(await page.getByRole('status').filter({hasText:'Waiting for'}).count(),0)
const saved=page.waitForResponse(r=>r.url().endsWith('/api/settings/confirm'))
await page.getByRole('button',{name:'Reset Detection confidence to default'}).click()
await saved
await page.waitForFunction(()=>!document.querySelector('[role="dialog"]'))
assert.equal(defaults,1);assert.equal(saves,1)
confidence='0.95'
await page.getByText('0.95',{exact:false}).first().waitFor({timeout:5000})
await page.screenshot({path:'/tmp/galaxy-regression-vasm.png'})
await page.evaluate(()=>location.hash='/cameras/pip')
await page.getByRole('heading',{name:'Camera Crop'}).waitFor()
await page.getByLabel('Camera crop size',{exact:true}).waitFor()
await page.waitForFunction(()=>{const c=document.querySelector('.gx-pip .gx-vasm__canvas');return !!c&&c.width>0})
await page.setViewportSize({width:390,height:844})
assert.equal(await page.getByLabel('Camera crop size',{exact:true}).count(),1, 'crop size slider sits with the preview')
assert.equal(await page.locator('.gx-pip__preview').count(),0, 'the separate selected-circle preview is removed')
const toolbarCenters=await page.locator('.gx-pip .gx-actions button').evaluateAll(buttons=>buttons.map(button=>{const rect=button.getBoundingClientRect();return rect.y+rect.height/2}))
assert.ok(Math.max(...toolbarCenters)-Math.min(...toolbarCenters)<1,'camera actions occupy one row')
await page.waitForFunction(()=>document.querySelector('.gx-camera-status')?.textContent==='Snapshot ready')
const frozenCaptures=draws
await page.waitForTimeout(1800)
assert.equal(draws,frozenCaptures,'camera stops fetching after warm-up')
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false)
await page.screenshot({path:'/tmp/galaxy-polish-pip-phone.png'})
await page.setViewportSize({width:1280,height:900})
await page.evaluate(()=>location.hash='/theme_maker')
await page.getByRole('button',{name:'PiP left side camera',exact:true}).waitFor()
await page.getByRole('button',{name:'PiP right side camera',exact:true}).waitFor()
const rightBefore = structuredClone(layout.document.layouts.large.pip_right)
await page.getByRole('button',{name:'PiP left side camera',exact:true}).click()
await page.getByLabel('X',{exact:true}).fill('400')
await page.getByLabel('X',{exact:true}).dispatchEvent('change')
await page.getByRole('button',{name:'Remove from layout',exact:true}).click()
const tray=page.locator('.gx-layout__tray-item').filter({hasText:'PiP left side camera'})
await tray.getByRole('button',{name:'Add',exact:true}).click()
const layoutSaved=page.waitForResponse(r=>r.url().endsWith('/api/ui/layout')&&r.request().method()==='POST')
await page.getByRole('button',{name:'Save changes',exact:true}).click()
await layoutSaved
assert.equal(layout.document.layouts.large.pip_left.enabled,true)
assert.equal(layout.document.layouts.large.pip_left.x,400)
assert.deepEqual(layout.document.layouts.large.pip_right, rightBefore)
assert.equal(layout.document.layouts.large.vasm, undefined)
await page.waitForFunction(()=>[...document.querySelectorAll('button')].some(button=>button.textContent==='Save changes'&&button.disabled))
await page.screenshot({path:'/tmp/galaxy-regression-layout.png'})
await page.evaluate(()=>location.hash='/cameras/pip')
await page.getByLabel('Camera crop size',{exact:true}).waitFor()
await page.waitForFunction(()=>{const c=document.querySelector('.gx-pip .gx-vasm__canvas');return c?.getContext('2d').getImageData(20,20,1,1).data[0]>100})
await page.evaluate(()=>location.hash='/recordings')
await page.getByRole('button',{name:'Watch Test drive',exact:true}).waitFor()
await page.getByText('Test drive',{exact:true}).waitFor()
await page.screenshot({path:'/tmp/galaxy-regression-recordings-desktop.png'})
await page.getByRole('button',{name:'Watch Test drive',exact:true}).click()
await page.getByRole('dialog',{name:'Camera recording player'}).waitFor()
assert.equal(await page.getByRole('combobox',{name:'Video segment',exact:true}).count(),1)
assert.equal(await page.getByRole('link',{name:'Download drive',exact:true}).count(),1)
await page.screenshot({path:'/tmp/galaxy-regression-recordings-player.png'})
await page.getByRole('dialog',{name:'Camera recording player'}).getByRole('button',{name:'Close recording player',exact:true}).click()
await page.getByRole('button',{name:'View drive logs',exact:true}).click()
assert.equal(await page.getByRole('link',{name:'Download all logs (.tar)',exact:false}).count(),1)
assert.equal(await page.locator('.gx-recordings input[type="checkbox"]').count(),0)
await page.setViewportSize({width:390,height:844})
await page.screenshot({path:'/tmp/galaxy-regression-recordings-phone.png'})
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),false)
cameraFail=true
await page.evaluate(()=>location.hash='/cameras/vasm')
await page.getByRole('alert').filter({hasText:'open its camera preview'}).waitFor()
assert.equal(await page.getByText('Waiting for a live cabin frame.',{exact:false}).count(),0)
cameraFail=false
await page.evaluate(()=>location.hash='/vehicle')
await page.getByRole('heading',{name:'Vehicle Selection',exact:true}).waitFor()
const vehicleDefault=page.getByRole('button',{name:'Reset Detection confidence to default'})
await vehicleDefault.waitFor()
await page.evaluate(()=>{
window.buttonDisabledTransitions=[]
const button=document.querySelector('[aria-label="Reset Detection confidence to default"]')
window.defaultButtonObserver=new MutationObserver(()=>window.buttonDisabledTransitions.push(button.disabled))
window.defaultButtonObserver.observe(button,{attributes:true,attributeFilter:['disabled']})
})
await page.waitForTimeout(3500)
assert.deepEqual(await page.evaluate(()=>window.buttonDisabledTransitions),[],'background reads must not toggle Default')
disconnected=true
const offlineAlert=page.getByRole('alert').filter({hasText:'Reconnecting automatically'})
await offlineAlert.first().waitFor()
await page.evaluate(()=>{
window.alertDisappearances=0
const banner=[...document.querySelectorAll('[role="alert"]')].find(element=>element.textContent.includes('Reconnecting automatically'))
window.alertObserver=new MutationObserver(()=>{if(!banner.isConnected)window.alertDisappearances++})
window.alertObserver.observe(document.querySelector('.gx-content'),{childList:true,subtree:true})
})
await page.waitForTimeout(4500)
assert.equal(await page.evaluate(()=>window.alertDisappearances),0,'network error must remain during retries')
assert.equal(await page.getByRole('button',{name:/^(Refresh|Retry)/}).count(),0)
disconnected=false
await page.waitForFunction(()=>![...document.querySelectorAll('[role="alert"]')].some(element=>element.textContent.includes('Reconnecting automatically')),{},{timeout:10000})
await page.waitForFunction(()=>!document.querySelector('[aria-label="Reset Detection confidence to default"]').disabled)
await page.evaluate(()=>{window.defaultButtonObserver.disconnect();window.alertObserver.disconnect()})
await page.evaluate(()=>location.hash='/tools')
await page.getByRole('heading',{name:'Tools',exact:true}).waitFor()
const sidebar=page.locator('.gx-nav-section').filter({has:page.locator('.gx-nav-section__title',{hasText:/^Tools$/})})
assert.deepEqual(await sidebar.locator('.gx-nav-item span').allTextContents(), (await page.locator('.gx-menu-tile > span').allTextContents()).filter(name => name !== 'Galaxy Access & Install'))
assert.deepEqual(errors,[])
console.log('Browser checks passed: VASM frame/default/reactive updates, PiP crop frame, widget registry, recordings and phone layout. Captures:',draws)
await browser.close()
})().catch(e=>{console.error(e);process.exit(1)})
@@ -0,0 +1,231 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
;(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 = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/settings-state-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.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + 'settings-state-test')
const pages = await page.evaluate(async () => {
const vue = await import('./vendor/vue/vue.esm-browser.js')
const settings = await import('./js/settings.js')
window.mount = (component, props = {}) => {
window.app?.unmount()
window.app = vue.createApp(component, { mode: 'local', unauthorized() {}, ...props })
window.vm = window.app.mount('#test')
}
window.requests = []
window.fetch = (url, options = {}) => url === './api/sounds'
? Promise.resolve({ ok: true, status: 200, json: async () => ({ parked: true, job: null, packs: [{ id: 'one', name: 'Test pack', installed: false }] }) })
: new Promise(resolve => requests.push({ url, options, resolve }))
window.reply = async (body, status = 200) => {
const response = { ok: status === 200, status, json: async () => body, clone() { return this } }
requests.shift().resolve(response)
await vue.nextTick()
}
window.snapshot = name => ({ page: name, title: name, view: 'view', parked: true, rows: [
{ key: 'switch', label: 'Test switch', value: 'Off', choices: ['Off', 'On'], available: true, action: true },
{ key: 'slider', label: 'Test slider', value: '1', defaultValue: '0', resetAvailable: true, minimum: 0, maximum: 10, step: 1, available: true, action: true },
{ key: 'select', label: 'Test select', value: 'Stock', choices: ['Stock', 'Custom'], available: true, action: true },
] })
window.SettingsPage = settings.SettingsPage
return [...new Set(settings.SETTINGS_SECTIONS.flatMap(section => section.pages))]
.filter(name => !['ui_layout', 'favorites', 'developer'].includes(name))
.concat(['sentry', 'vasm', 'standard/acceleration', 'standard/braking', 'standard/following'])
})
async function waiting(path) {
await page.waitForFunction(path => requests[0]?.url.includes(path), path)
}
async function verify(selector, value) {
// Several paint frames must retain the pending value and full opacity.
const result = await page.evaluate(async ({ selector, value }) => {
for (let i = 0; i < 6; i++) {
await new Promise(requestAnimationFrame)
const input = document.querySelector(selector)
if (input.disabled || !input.closest('[inert]') || (input.type === 'checkbox' ? input.checked : input.value) !== value) return false
const visible = input.type === 'checkbox' ? input.nextElementSibling : input
if (getComputedStyle(visible).opacity !== '1') return false
for (const button of document.querySelectorAll('.gx-setting-default, [aria-label="Download Test pack"]')) {
if (button.disabled || getComputedStyle(button).opacity !== '1') return false
}
}
return true
}, { selector, value })
assert.equal(result, true, `Pending ${selector} stays stable`)
}
for (const name of pages) {
await page.evaluate(name => { requests = []; mount(SettingsPage, { initialPage: name }) }, name)
await waiting('/pages/')
await page.evaluate(name => reply(snapshot(name)), name)
await page.getByRole('switch', { name: 'Test switch' }).waitFor()
const saved = await page.evaluate(name => snapshot(name), name)
for (const [index, selector, value] of [[0, '[role="switch"]', true], [1, 'input[type="range"]', '7'], [2, '.gx-select select', 'Custom']]) {
if (index === 0) await page.locator(selector).click()
else if (index === 1) await page.locator(selector).evaluate(input => {
input.value = '7'
input.dispatchEvent(new Event('input', { bubbles: true }))
input.dispatchEvent(new Event('change', { bubbles: true }))
})
else {
await page.getByRole('combobox', { name: 'Test select' }).click()
await page.getByRole('option', { name: 'Custom', exact: true }).click()
}
await waiting('/preview')
if (index < 2) await verify(selector, value)
else assert.equal(await page.getByRole('combobox', { name: 'Test select' }).textContent(), 'Custom')
await page.evaluate(() => reply({ intent: 'intent', question: 'Save?' }))
await waiting('/confirm')
if (index < 2) await verify(selector, value)
saved.rows[index].value = index === 0 ? 'On' : value
await page.evaluate(() => reply({ saved: true }))
await waiting('/pages/')
if (index < 2) await verify(selector, value)
await page.evaluate(saved => reply(saved), saved)
await page.waitForFunction(selector => !document.querySelector(selector).closest('[inert]'), selector)
}
}
// Error recovery must restore the confirmed value and retain the row node.
await page.locator('[role="switch"]').click()
await waiting('/preview')
await verify('[role="switch"]', false)
await page.evaluate(() => { window.rowNode = document.querySelector('.gx-row'); return reply({ error: 'Rejected' }, 400) })
await page.waitForFunction(() => document.querySelector('[role="switch"]').checked)
assert.equal(await page.evaluate(() => rowNode === document.querySelector('.gx-row')), true)
// Reset Default is the same guarded preview/confirm/readback sequence.
await page.evaluate(() => { vm.feed.load() })
await waiting('/pages/')
await page.evaluate(name => reply(snapshot(name)), pages.at(-1))
await page.getByRole('button', { name: 'Reset Test slider to default' }).click()
await waiting('/reset-default')
await verify('input[type="range"]', '1')
await page.evaluate(() => reply({ intent: 'reset', question: 'Reset?' }))
await waiting('/confirm')
await verify('input[type="range"]', '1')
await page.evaluate(() => reply({ saved: true }))
await waiting('/pages/')
await verify('input[type="range"]', '1')
await page.evaluate(name => { const data = snapshot(name); data.rows[1].value = '0'; return reply(data) }, pages.at(-1))
await page.waitForFunction(() => !document.querySelector('.gx-setting-default'))
const layout = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c',
'import json; from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata; print(json.dumps(dict(document=default_document(), defaults=default_document(), metadata=customization_metadata(), revision="a"*64, editable=True, valid=True, activeProfile="large")))'], { encoding: 'utf8' }))
const audited = await page.evaluate(async layout => {
const { nextTick } = await import('./vendor/vue/vue.esm-browser.js')
const clone = value => JSON.parse(JSON.stringify(value))
const prepareLayout = vm => {
vm.feed.publish({ status: 'ready', data: clone(layout), draft: clone(layout.document) })
vm.state.draft.clock24Hour = !vm.state.draft.clock24Hour
}
const prepareModels = vm => Object.assign(vm, { loading: false, selectionUncertain: false,
activeSmallModel: 'one', activeBigModel: '', models: ['one', 'two'].map(value => ({ value, label: value, requiresGpu: false, installed: true, selectable: true })),
capabilities: { select: true, download: true, downloadAll: true, refresh: true, favorites: true },
status: { isOnroad: false, downloading: false, randomizer: false, models: [], capabilities: { select: true, download: true, downloadAll: true, refresh: true, favorites: true } } })
const cases = [
['pip', 'PipPage', vm => {
const data = { ...snapshot('pip'), editorRow: 0, editor: { width: 1344, height: 760, cropSize: 300, centerLeft: [350, 350], centerRight: [1000, 350], invert: false } }
vm.feed.publish({ status: 'ready', data }); vm.state.imageName = 'Cabin snapshot'
}],
['vasm', 'VasmPage', vm => {
const data = { ...snapshot('vasm'), editorRow: 0, editor: { width: 1344, height: 760, cameraLeft: [[100, 100], [500, 100], [500, 500]], cameraRight: [[800, 100], [1000, 100], [1000, 500]] } }
vm.feed.publish({ status: 'ready', data }); vm.state.imageName = 'Cabin snapshot'
}],
['longitudinal-curves', 'LongitudinalCurvesPage', vm => vm.feed.publish({ status: 'ready', data: snapshot('standard/acceleration') })],
['favorites', 'FavoritesPage', vm => vm.feed.publish({ status: 'ready', data: { editable: true, valid: true,
slots: Array.from({ length: 3 }, () => ({ key: 'test', label: 'Test', enabled: true, show_onroad: true })),
options: [{ key: 'test', label: 'Test', section: 'Visual' }], states: Array.from({ length: 3 }, () => ({ stateLabel: 'Off', reason: '' })) } })],
['controllers', 'ControllersPage', vm => vm.feed.publish({ status: { editable: true, available: true, revision: 'a'.repeat(64), enabled: true,
slots: Array.from({ length: 13 }, (_, index) => ({ index, key: null, label: `Button ${index}` })),
devices: [], options: [], learning: null, testing: false, bindings: [], lastPress: null }, busy: false })],
['cloud-provider', 'CloudProviderPage', vm => { vm.status = { selected: 'comma', active: 'comma', canSelect: true,
providers: [{ id: 'comma', label: 'comma' }, { id: 'konik', label: 'konik' }] } }],
['software-status', 'SoftwarePage', vm => vm.feed.publish({ status: 'ready', data: { installed: { branch: 'Dom', version: 'Test', commit: 'a'.repeat(40) },
updater: { state: 'idle', finalizedUpdateReady: false }, operations: { parked: true, selectedTarget: 'Dom', availableBranches: ['Dom'],
canCheck: true, canFastUpdate: true, canDownload: true, canSelect: true, canConfigure: true, automaticDownloads: true, request: null } } })],
['android-auto', 'AndroidAutoPage', vm => Object.assign(vm, { setup: { enabled: true, parked: true, installReady: true, serviceReady: true,
bluetoothEnabled: true, identity: { installed: true }, import: { state: 'idle' } }, runtime: { running: false, auto_connect: true }, pairing: { active: false, devices: [] } })],
['offline-road-maps', 'OfflineRoadMapsPanel', vm => { vm.data = { areas: [], settings: { saveDriven: true }, service: {}, position: null,
constants: { minRadiusKm: 2, maxRadiusKm: 200, defaultRadiusKm: 25, maxAreas: 24 } } }],
['onroad-layout', 'OnroadLayoutPage', prepareLayout],
['onroad-layout', 'OnroadLayoutPage', prepareLayout, { projection: true }],
['flm', 'FlmLiveEditor', vm => {
const surface = { profile: 'torque_universal', knobs: { ff_gain_left: .3 }, baseValues: null }
vm.live = { available: true, editable: true, vehicle: 'Test GM', controller: 'starpilot', knobs: { ff_gain_left: { min: -.4, max: .6 } },
defaults: surface, curveDefaults: [.16, .18, .2, .23, .27], preconditions: {}, inactiveKnobs: {},
state: { saved: { profile1: { label: 'Test', surface } }, active: 'profile1', applied: true } }
vm.choose('profile1')
}],
['models', 'ModelsPage', prepareModels],
['model-laboratory', 'LaboratoryPage', vm => Object.assign(vm, { loading: false,
configuration: { enabled: false, lateralModel: 'one', longitudinalModel: 'two' }, status: {
configuration: { enabled: false, lateralModel: 'one', longitudinalModel: 'two' },
isOnroad: false, runtimeSupported: true, chestnutReady: true, runtime: {}, download: { downloading: false },
models: ['one', 'two'].map(value => ({ value, label: value, small: true, modelLabEligible: true, modelLabArtifactAvailable: true, modelLabArtifactInstalled: true })),
capabilities: { configure: true, download: true, delete: true, cancel: true, refresh: true } } })],
['sentry-notifications', 'SentryNotifications', vm => { vm.notifications = { subscriptionCount: 0, subscriptions: [],
channels: Object.fromEntries(vm.channels.map(name => [name, { enabled: true, configured: true, pending: 0, lastState: 'idle', lastError: '' }])) } }],
['drive-state', 'DriveStatePanel', vm => { vm.state = { mode: 'auto', available: true, effective: 'offroad', overrideAllowed: true } }],
['bluetooth', 'BluetoothPage', vm => { vm.status = { parked: true, available: true, powered: true, saved: [], nearby: [], pairing: null } }],
['toggle-backup', 'ToggleBackup', () => {}],
['flm', 'FlmPage', vm => Object.assign(vm, { inventoryStatus: 'ready', inventory: { segments: [] }, selected: ['test--0'] })],
['recording-actions', 'RecordingActions', () => {}, { recording: { displayName: 'Test drive', routeId: 'test', preserved: false } }],
['vehicle-controls', 'VehicleControlsPage', vm => { vm.state.status = 'ready'; vm.state.data = { parked: true, readable: true, selected: null, vehicles: [] } }],
['galaxy', 'GalaxyPage', vm => Object.assign(vm, { loading: false, paired: true, url: 'https://galaxy.test' })],
['navigation', 'NavigationPage', vm => { vm.data = { enabled: true, hasKey: false, favorites: [], recents: [] } }],
['map-operations', 'MapOperationsPanel', vm => { vm.setup = { parked: true, packageReady: true, snapshotReady: true }; vm.catalog = { regions: [] } }],
]
for (const [file, name, prepare, props] of cases) {
const component = (await import(`./js/${file}.js`))[name]
mount({ ...component, mounted() {}, beforeUnmount() {} }, { go() {}, localAccess: true, ...props })
prepare(vm)
await nextTick()
const controls = [...document.querySelectorAll('#test button, #test input, #test select, #test .gx-switch__track, #test .gx-row__label')]
.map(node => ({ node, disabled: node.disabled, opacity: getComputedStyle(node).opacity }))
if (!controls.length) throw new Error(`${name}: no controls rendered`)
const setBusy = value => {
if (name === 'SentryNotifications') vm.notificationBusy = value
else if (['ControllersPage', 'VehicleControlsPage'].includes(name)) vm.state.busy = value
else if (vm.state?.status) vm.state.status = value ? 'saving' : 'ready'
else vm.busy = value
}
setBusy(true)
await nextTick()
for (let frame = 0; frame < 4; frame++) {
await new Promise(requestAnimationFrame)
for (const { node, disabled, opacity } of controls) {
if (!node.isConnected || node.disabled !== disabled || getComputedStyle(node).opacity !== opacity)
throw new Error(`${name}: control flickered during saving: ${node.getAttribute('aria-label') || node.textContent}`)
}
}
setBusy(false)
await nextTick()
}
const component = (await import('./js/models.js')).ModelsPage
mount({ ...component, mounted() {}, beforeUnmount() {} })
prepareModels(vm)
vm.manager.action = (action, model) => new Promise(resolve => {
window.finishModel = success => {
if (success) vm.activeSmallModel = model.value
resolve(success ? { message: 'Selection saved.' } : null)
}
})
await nextTick()
return cases.map(([, name]) => name)
}, layout)
for (const success of [false, true]) {
await page.getByRole('combobox', { name: 'Active Small', exact: true }).click()
await page.getByRole('option', { name: 'two', exact: true }).click()
await page.waitForFunction(() => !!vm.busy)
assert.equal(await page.getByRole('combobox', { name: 'Active Small', exact: true, includeHidden: true }).textContent(), 'two')
await page.evaluate(success => finishModel(success), success)
await page.waitForFunction(() => !vm.busy)
assert.equal(await page.getByRole('combobox', { name: 'Active Small', exact: true }).textContent(), success ? 'two' : 'one')
}
assert.deepEqual(errors, [])
console.log(`Settings state: ${pages.length} settings pages and ${audited.length} specialized pages; delayed preview/save/readback, Default/download buttons, stable control nodes/opacity and failed-save recovery passed.`)
await page.evaluate(() => app.unmount())
} finally { await browser.close() }
})().catch(error => { console.error(error); process.exitCode = 1 })
@@ -0,0 +1,45 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({headless:true})
const page = await browser.newPage()
const errors = []
page.on('pageerror', e => errors.push(e.message))
await page.goto(process.env.GALAXY_URL || 'http://127.0.0.1:8765/')
await page.evaluate(async () => {
const {createApp, reactive} = await import('./vendor/vue/vue.esm-browser.js')
const {ControllersPage} = await import('./js/controllers.js')
const {CloudProviderPage} = await import('./js/cloud-provider.js')
const {CamerasPage} = await import('./js/cameras.js')
const {GxNotice} = await import('./js/notice.js')
document.querySelector('#galaxy-app').remove()
const root = document.createElement('div');root.id='status-test';root.className='gx-content';document.body.append(root)
window.fixture=reactive({controllers:{status:null,busy:true,error:''},provider:{status:null,busy:false,error:''}})
createApp({components:{Controllers:{...ControllersPage,setup:()=>({state:window.fixture.controllers,feed:{refresh(){}}}),mounted(){},beforeUnmount(){}},
Provider:{...CloudProviderPage,data:()=>window.fixture.provider,mounted(){},beforeUnmount(){}},CamerasPage,GxNotice},
template:`<div class="gx-settings"><Controllers mode="local" :unauthorized="()=>{}"/><Provider mode="local" :unauthorized="()=>{}"/><CamerasPage mode="sample" :go="()=>{}" :unauthorized="()=>{}"/></div>`}).mount(root)
})
for (const width of [320,390,1280,3440]) {
await page.setViewportSize({width,height:1000})
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false)
const tiles=await page.locator('.gx-grid').boundingBox(), snapshot=await page.locator('.gx-settings > .gx-card').last().boundingBox()
assert.ok(snapshot.y-tiles.y-tiles.height >= 15,'tiles have shared section spacing before snapshot')
}
assert.equal(await page.locator('.gx-controllers .gx-alert').count(),1)
assert.ok(await page.locator('.gx-controllers .gx-alert--info .gx-spinner').count())
await page.evaluate(()=>Object.assign(window.fixture.controllers,{busy:false,error:'Controller Buttons are unavailable. Reload before trying again.'}))
assert.equal(await page.locator('.gx-controllers .gx-alert').count(),1)
assert.equal(await page.locator('.gx-controllers .gx-alert--danger').count(),1)
assert.equal(await page.getByText('Checking attached controllers…',{exact:true}).count(),0)
assert.equal(await page.getByRole('button',{name:'Refresh',exact:true}).isEnabled(),true)
const provider={active:'comma',selected:'comma',restartRequired:false,canSelect:true,providers:[{id:'comma',label:'comma'},{id:'konik',label:'Konik'}]}
await page.evaluate(value=>{window.fixture.provider.status=value},provider)
assert.equal(await page.locator('.gx-settings__developer-body .gx-alert').count(),0,'active provider needs no banner')
await page.evaluate(()=>{window.fixture.provider.status.canSelect=false})
assert.equal(await page.locator('.gx-settings__developer-body .gx-alert--warn').count(),1)
await page.evaluate(()=>Object.assign(window.fixture.provider.status,{restartRequired:true,selected:'konik'}))
assert.equal(await page.locator('.gx-settings__developer-body .gx-alert').count(),1,'restart and parked messages do not duplicate')
assert.deepEqual(errors,[])
await browser.close()
console.log('Status consistency: single loading/error banner, refresh, developer state and responsive camera spacing passed.')
})().catch(e=>{console.error(e);process.exit(1)})
@@ -0,0 +1,78 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 390, height: 900 } })
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/data/runtime.json', route => route.fulfill({ json: { schemaVersion: 1, monitor: 'local' } }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const json = path === '/api/auth/session' ? { state: 'configured', authenticated: true, localAccess: true } :
path === '/api/device/state' ? { state: 'parked', maxAgeMs: 3000 } :
path.startsWith('/api/settings/pages/') ? { page: 'hub', view: 'test', parked: true, rows: [] } : null
return route.fulfill({ status: json ? 200 : 503, json: json || { error: 'Unavailable in this test' } })
})
const base = process.env.GALAXY_URL || 'http://127.0.0.1:8765/'
await page.goto(base)
const nav = page.getByRole('navigation', { name: 'Primary navigation' })
await nav.getByRole('button', { name: 'Home', exact: true }).waitFor()
async function checkSlide(name, direction) {
await nav.getByRole('button', { name, exact: true }).click()
await page.waitForFunction(() => !!document.querySelector('.gx-route-enter-active'))
const motion = await page.locator('.gx-route-enter-active').evaluate(element => {
const matrix = new DOMMatrix(getComputedStyle(element).transform)
return { x: matrix.m41, y: matrix.m42 }
})
assert.ok(motion.x * direction > 0, `${name} enters from the correct horizontal side`)
assert.equal(motion.y, 0)
await page.waitForTimeout(500)
const geometry = await nav.evaluate(element => {
const pill = element.querySelector('.gx-nav-indicator').getBoundingClientRect()
const button = element.querySelector('[aria-current="page"]').getBoundingClientRect()
return { delta: Math.abs(pill.x - button.x), widthDelta: Math.abs(pill.width - button.width) }
})
assert.ok(geometry.delta < 2 && geometry.widthDelta < 2, 'glass indicator settles over the selected tab')
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
}
await checkSlide('Tools', 1)
await checkSlide('Home', -1)
await checkSlide('Routes', 1)
await checkSlide('Toggles', -1)
// Fast changes must leave only one interactive page and the correct selected tab.
await nav.getByRole('button', { name: 'Tools', exact: true }).click()
await nav.getByRole('button', { name: 'Home', exact: true }).click()
await page.waitForTimeout(600)
assert.equal(await page.locator('.gx-route:not([inert])').count(), 1)
assert.equal(await nav.getByRole('button', { name: 'Home', exact: true }).getAttribute('aria-current'), 'page')
await page.goto(base + '#/model_laboratory')
const heading = page.getByText('Available models', { exact: true })
await heading.waitFor()
for (const width of [320, 360, 390, 600, 1280]) {
await page.setViewportSize({ width, height: 900 })
const layout = await page.evaluate(() => {
const title = [...document.querySelectorAll('.gx-section__title')].find(element => element.textContent === 'Available models')
const summary = title.closest('.gx-card').querySelector('.gx-summary')
const cells = [...summary.children].map(element => element.getBoundingClientRect())
return { headerHeight: title.parentElement.getBoundingClientRect().height,
summaryHeight: summary.getBoundingClientRect().height,
equalRows: cells.every(cell => Math.abs(cell.top - cells[0].top) < 1),
overflow: document.documentElement.scrollWidth > innerWidth }
})
assert.ok(layout.headerHeight < 80 && layout.summaryHeight < 100, 'model summary stays compact')
assert.equal(layout.equalRows, true, 'all three counts share one row')
assert.equal(layout.overflow, false)
}
await page.setViewportSize({ width: 390, height: 900 })
await page.emulateMedia({ reducedMotion: 'reduce' })
await nav.getByRole('button', { name: 'Home', exact: true }).click()
await page.waitForTimeout(50)
assert.equal(await page.locator('.gx-route-enter-active, .gx-route-leave-active').count(), 0)
assert.equal(await page.locator('.gx-nav-indicator').evaluate(element => getComputedStyle(element).transitionDuration), '0s')
assert.deepEqual(errors, [])
console.log('Directional tab slides, moving glass selection, rapid navigation, compact model summary and reduced motion passed.')
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,51 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
await page.route('**/data/runtime.json', route => route.fulfill({ json: { schemaVersion: 1, monitor: 'local' } }))
await page.route('**/api/**', route => {
const path = new URL(route.request().url()).pathname
const data = path === '/api/auth/session' ? { state: 'configured', authenticated: true, localAccess: true } :
path === '/api/device/state' ? { state: 'parked', maxAgeMs: 3000 } :
{ page: 'hub', view: 'search', parked: true, rows: [] }
return route.fulfill({ json: data })
})
await page.goto((process.env.GALAXY_URL || 'http://127.0.0.1:8765/') + '#/tools')
await page.getByRole('heading', { name: 'Tools', exact: true }).waitFor()
await page.locator('.gx-device-state[aria-label="Parked"]').waitFor()
for (const width of [320, 360, 390, 480, 600, 767, 768, 1024, 1440]) {
await page.setViewportSize({ width, height: 900 })
const layout = await page.evaluate(() => {
const rect = selector => {
const element = document.querySelector(selector), bounds = element.getBoundingClientRect()
return { x: bounds.x, right: bounds.right, width: bounds.width, visible: !!element.getClientRects().length }
}
return { home: rect('.gx-appbar__home'), search: rect(innerWidth < 768 ? '.gx-search-toggle' : '.gx-searchwrap'),
state: rect('.gx-device-state'), overflow: document.documentElement.scrollWidth > innerWidth }
})
assert.equal(layout.overflow, false, `${width}px: no page overflow`)
assert.ok(layout.home.right <= layout.search.x, `${width}px: logo and search do not overlap`)
assert.ok(layout.search.right <= layout.state.x, `${width}px: search and state do not overlap`)
if (width < 768) {
assert.ok(layout.state.width <= 24, 'mobile state stays minimal')
await page.getByRole('button', { name: 'Search toggles', exact: true }).click()
const search = page.getByRole('combobox', { name: 'Search toggles', exact: true })
await search.waitFor()
await search.fill('wheel')
await search.press('Escape')
await page.getByRole('button', { name: 'Search toggles', exact: true }).waitFor()
assert.equal(await page.locator('.gx-appbar__home').isVisible(), true)
} else {
assert.equal(await page.locator('.gx-device-state__label').isVisible(), true)
await page.getByRole('combobox', { name: 'Search toggles', exact: true }).fill('wheel')
await page.getByRole('combobox', { name: 'Search toggles', exact: true }).press('Escape')
}
}
assert.deepEqual(errors, [])
await browser.close()
console.log('Top bar: logo/search/state separation at nine widths, compact accessible state, search open/close and desktop labels passed')
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,180 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const assert = require('node:assert/strict')
const { readFileSync } = require('node:fs')
const { resolve } = require('node:path')
;(async () => {
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage()
const errors = []
page.on('pageerror', error => errors.push(error.message))
const base = process.env.GALAXY_URL || 'http://127.0.0.1:8765/'
const catalog = JSON.parse(readFileSync(resolve(__dirname, '../../web/data/catalog.json')))
let local = false, delayed = false
const groups = [['aol', 'Always On Lateral'], ['lane_change', 'Lane Changes'],
['torque', 'Steering and Torque'], ['lane', 'Lane Centering'],
['profiles','Long Planner'], ['standard','Standard Personality']]
await page.route('**/data/runtime.json', route => route.fulfill({
json: { schemaVersion: 1, monitor: local ? 'local' : 'sample' },
}))
await page.route('**/api/**', async route => {
const path = new URL(route.request().url()).pathname
if (path === '/api/auth/session') return route.fulfill({
json: { state: 'configured', authenticated: true, localAccess: true },
})
if (path === '/api/device/state') return route.fulfill({ json: { state: 'parked', maxAgeMs: 3000 } })
if (path === '/api/android-auto/logs') return route.fulfill({ json: { schemaVersion: 1,
sessions: [
{ name: 'session-000003.jsonl', size: 51712, modifiedAt: 1791354093, outcome: "projected, then failed: finding android auto: Could not find the car's Android Auto service: [Errno 112] Host is down", car: 'Honda T20', transport: 'wireless', trigger: 'car_connected' },
{ name: 'session-000002.jsonl', size: 716, modifiedAt: 1791340980, outcome: 'stopped at connecting_bluetooth', car: '', transport: 'wireless', trigger: 'manual' },
],
others: [{ name: 'car_ui.log', size: 4096, modifiedAt: 1791354093 }],
} })
if (path.startsWith('/api/settings/pages/')) {
if (delayed) await new Promise(resolve => setTimeout(resolve, 600))
const id = path.split('/').at(-1)
return route.fulfill({ json: { page: id, title: id, view: 'test', parked: true,
rows: id === 'hub' ? groups.map(([page, label]) => ({ page, label, value: 'Configure ' + label, available: true })) :
[{ label: 'Test preference', key: 'test', value: 'Off', choices: ['Off', 'On'], action: true, available: true }],
} })
}
return route.fulfill({ status: 503, json: { error: 'Device unavailable. Reconnect to read this tool.' } })
})
const paths = [...new Set(['/', '/tools', '/settings', '/developer/connect', '/cameras/events',
'/cameras/sentry-settings', '/cameras/pip', '/cameras/vasm', '/theme_maker/android_auto',
'/logs/crashes', '/logs/android-auto', '/logs/monitor', '/logs/troubleshoot', '/logs/tmux', '/tuning/plots', '/tuning/flm',
'/driving/longitudinal-curves', '/appearance', '/driving/conditional', '/driving/curve', '/driving/slc', '/driving/profiles', '/driving/traffic',
...groups.filter(([page]) => page !== "standard").map(([page]) => '/driving/' + page), ...catalog.tools.map(tool => tool.path)])]
for (const theme of ['dark', 'light']) {
for (const width of (process.env.GALAXY_NAV_ONLY ? [] : [320, 1280, 3440])) {
await page.setViewportSize({ width, height: 900 })
for (const path of paths) {
await page.goto(base + '#' + path)
await page.waitForTimeout(350)
await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false,
`${theme} ${width}px ${path}: no page overflow`)
if (path === '/navigation') {
const map = await page.locator('.gx-navigation--fullscreen').boundingBox()
assert.ok(Math.abs(map.width - width) < 1 && Math.abs(map.height - 900) < 1, 'map intentionally fills viewport')
for (const tab of ['Offline Maps', 'Setup']) {
await page.getByRole('tab', { name: tab, exact: true }).click()
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${tab}: no overflow`)
}
}
const content = await page.locator('.gx-content').boundingBox()
assert.ok(content.width <= 1200, `${path}: bounded content`)
}
}
}
local = true
await page.reload()
for (const width of (process.env.GALAXY_NAV_ONLY ? [] : [390, 3440])) {
await page.setViewportSize({ width, height: 900 })
for (const path of paths) {
await page.goto(base + '#' + path)
await page.waitForTimeout(350)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `local ${width}px ${path}: no overflow`)
}
}
await page.setViewportSize({ width: 390, height: 900 })
await page.goto(base + '#/logs/android-auto')
await page.locator('.gx-aa-log').first().waitFor()
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, 'Android Auto logs fit a phone viewport')
const phoneCards = await page.locator('.gx-aa-log').evaluateAll(cards => cards.map(card => {
const row = card.getBoundingClientRect(), body = card.querySelector('.gx-aa-log__body').getBoundingClientRect()
const download = card.querySelector('.gx-aa-log__download').getBoundingClientRect()
return { leftInset: body.left - row.left, rightInset: row.right - download.right, width: download.width, height: download.height }
}))
assert.ok(phoneCards.every(card => card.leftInset >= 11 && card.rightInset >= 11), 'log text and downloads are padded inside cards')
assert.ok(phoneCards.every(card => card.width === 44 && card.height === 44), 'individual downloads remain compact touch targets')
assert.deepEqual(await page.locator('.gx-aa-log__download').evaluateAll(links => links.map(link => link.getAttribute('download'))),
['session-000003.jsonl', 'session-000002.jsonl', 'car_ui.log'])
await page.setViewportSize({ width: 1280, height: 900 })
const desktop = await page.locator('.gx-aa-logs').evaluate(section => {
const bounds = section.getBoundingClientRect()
return { width: bounds.width, center: bounds.left + bounds.width / 2 }
})
assert.ok(desktop.width <= 960 && Math.abs(desktop.center - 640) < 1, 'Android Auto logs are centered and readable on desktop')
await page.setViewportSize({ width: 390, height: 900 })
await page.goto(base + '#/settings')
const manage = page.getByRole('button', { name: 'Manage', exact: true }).first()
await manage.waitFor()
const widths = await page.locator('.gx-row').evaluateAll(rows => rows.map(row => row.getBoundingClientRect().width))
assert.ok(widths.every(width => Math.abs(width - widths[0]) < 1), 'settings groups share one width')
await manage.click()
await page.waitForTimeout(100)
assert.equal(await page.locator('.gx-settings-tabs').count(), 0)
assert.equal(await page.getByRole('heading', { name: 'Toggles', exact: true }).count(), 0)
await page.getByRole('button', { name: 'Back', exact: true }).click()
await manage.waitFor()
assert.ok(await page.locator('.gx-settings-tabs button').first().evaluate(element => element.offsetHeight < 32), 'Toggles section chips remain compact')
const tabsTop = (await page.locator(".gx-settings-tabs").boundingBox()).y
const sectionNames = await page.locator('.gx-settings-tabs button').allTextContents()
for (const name of sectionNames) {
await page.locator('.gx-settings-tabs').getByRole('button', { name, exact: true }).click()
await page.waitForTimeout(100)
assert.ok(Math.abs((await page.locator(".gx-settings-tabs").boundingBox()).y - tabsTop) < 1, `${name}: descriptions do not move tabs`)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false, `${name}: no overflow`)
}
await page.locator('.gx-settings-tabs button').first().click()
const longitudinal = page.getByRole('button',{name:'Longitudinal (Speed & Following)',exact:true})
await longitudinal.click()
await page.waitForTimeout(150)
assert.equal(await longitudinal.getAttribute('aria-pressed'),'true')
assert.notEqual(await longitudinal.evaluate(e => getComputedStyle(e).backgroundColor),
await page.locator('.gx-settings-tabs button').first().evaluate(e => getComputedStyle(e).backgroundColor), 'active tab is visibly highlighted')
for (const back of ['page','browser']) {
await page.locator('.gx-row').filter({hasText:'Long Planner'}).getByRole('button',{name:'Manage'}).click()
await page.waitForTimeout(100)
await page.getByRole('button',{name:'Back',exact:true}).click()
await longitudinal.waitFor()
await page.locator('.gx-row').filter({hasText:'Standard Personality'}).getByRole('button',{name:'Manage'}).click()
await page.waitForTimeout(100)
if(back === 'browser') await page.evaluate(() => history.back())
else await page.getByRole('button',{name:'Back',exact:true}).click()
await longitudinal.waitFor()
assert.equal(await longitudinal.getAttribute('aria-pressed'),'true','Back returns to actual longitudinal parent')
assert.equal(await page.locator('.gx-section__title').innerText(),'Longitudinal (Speed & Following)')
}
await page.locator('.gx-settings-tabs button').first().click()
await page.goto(base + '#/tools')
await page.locator('.gx-appbar').waitFor()
await page.evaluate(async () => (await import('./js/router.js')).navigate('/settings'))
await manage.click()
await page.waitForTimeout(250)
assert.equal(await page.locator('.gx-settings-tabs').count(),0)
await page.evaluate(() => history.back())
await page.locator('.gx-settings-tabs').waitFor()
assert.equal(await page.evaluate(() => location.hash),'#/settings')
await manage.click()
await page.waitForTimeout(250)
await page.getByRole('button',{name:'Back',exact:true}).click()
await page.locator('.gx-settings-tabs').waitFor()
assert.equal(await page.evaluate(() => location.hash),'#/settings')
await page.evaluate(() => history.back())
await page.waitForFunction(() => location.hash === '#/tools')
await page.waitForTimeout(30)
assert.equal(await page.evaluate(async () => (await import('./js/router.js')).route.direction),-1)
await page.evaluate(async () => (await import('./js/router.js')).navigate('/settings'))
await manage.waitFor()
delayed = true
await page.reload()
const loading = page.getByRole('status').filter({ hasText: 'Reading your device' })
await loading.waitFor()
const bounds = await loading.boundingBox()
assert.ok(Math.abs(bounds.x + bounds.width / 2 - 195) < 1, 'loading card is centered')
await page.waitForTimeout(750)
await page.goto(base + '#/android-auto')
await page.waitForTimeout(300)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
assert.deepEqual(errors, [], 'preview routes and unavailable Android Auto setup render without exceptions')
console.log(process.env.GALAXY_NAV_ONLY ? "Passed focused Toggles highlighting, stable tabs, nested page/browser Back, loading and setup failure checks." : `Passed ${paths.length} routes at three widths in both themes, settings navigation, loading alignment and setup failure.`)
await browser.close()
})().catch(error => { console.error(error); process.exit(1) })
@@ -0,0 +1,275 @@
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright')
const { execFileSync } = require('node:child_process')
const { readFileSync } = require('node:fs')
const path = require('node:path')
const assert = require('node:assert/strict')
const web = path.resolve(__dirname, '../../web')
const snapshot = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c',
'import json; from openpilot.starpilot.ui.onroad_customization import default_document, customization_metadata; print(json.dumps(dict(document=default_document(), defaults=default_document(), metadata=customization_metadata(), revision="layers", editable=True, valid=True, activeProfile="large")))'], {encoding:'utf8'}))
const projection = JSON.parse(execFileSync(process.env.PYTHON || 'python3', ['-c',
'import json; from openpilot.starpilot.system.android_auto.projection_layout import default_layout_for_viewport, layout_metadata_for_viewport; from openpilot.starpilot.ui.onroad_customization import default_document; d=default_layout_for_viewport((2880,1080)); c=default_document(); print(json.dumps(dict(version=1,document=d,defaults=d,metadata=layout_metadata_for_viewport((2880,1080)),screen=dict(width=1280,height=720,margin_width=0,margin_height=240),revision="aa-layers",editable=True,valid=True,colors=dict(palette=c["palette"],widgetColors=c["widgetColors"]["large"],roadColors={}))))'], {encoding:'utf8'}))
;(async () => {
const browser = await chromium.launch({headless:true, ...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? {executablePath:process.env.PLAYWRIGHT_EXECUTABLE_PATH} : {})})
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://layers.test/**', async route => {
const pathname = new URL(route.request().url()).pathname
if (pathname === '/data/runtime.json') return route.fulfill({json:{schemaVersion:1, monitor:'local'}})
if (pathname === '/api/auth/session') return route.fulfill({json:{state:'configured', authenticated:true, localAccess:true}})
if (pathname === '/api/device/state') return route.fulfill({json:{state:'parked', maxAgeMs:3000}})
if (pathname === '/' && new URL(route.request().url()).searchParams.has('shell')) 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"><div id="galaxy-app"></div><script type="module" src="/js/app.js"></script>`})
if (pathname.startsWith('/api/')) return route.fulfill({json: pathname === '/api/android-auto/layout' ? projection : snapshot})
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:new URLSearchParams(location.search).has('projection'), unauthorized:()=>{}}).mount('#app');
</script>`})
const filename = path.join(web, pathname)
return route.fulfill({body:readFileSync(filename), contentType:pathname.endsWith('.js')?'text/javascript':pathname.endsWith('.css')?'text/css':'application/octet-stream'})
})
await page.goto('http://layers.test/')
const rows = page.locator('.gx-layout__layer')
await rows.first().waitFor()
const initial = await rows.locator('.gx-layout__layer-name').allTextContents()
const source = await rows.last().boundingBox(), destination = await rows.first().boundingBox()
const beforeMouse = await page.evaluate(() => JSON.stringify(window.editor.state.draft))
await page.mouse.move(source.x + 30, source.y + source.height / 2)
await page.mouse.down()
await page.mouse.move(destination.x + 30, destination.y + destination.height / 2, {steps: 5})
assert.equal(await page.locator('.gx-layout__drag-ghost').isVisible(), true)
assert.equal(await page.locator('.gx-layout__outline').first().isVisible(), true)
assert.equal(await page.locator('.is-drop-target').count(), 1)
assert.equal(await page.evaluate(() => window.editor.renderWidgets.at(-1).id), 'pip_left')
assert.equal(await page.evaluate(() => window.editor.state.history.undo.length), 0)
await page.screenshot({path:'/private/tmp/theme-live-layer-drag.png'})
await page.mouse.up()
assert.equal(await page.locator('.gx-layout__drag-ghost').count(), 0)
assert.equal(await page.evaluate(() => window.editor.state.history.undo.length), 1)
await page.getByRole('button', {name:'Undo', exact:true}).click()
assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeMouse)
await page.getByRole('button', {name:'Redo', exact:true}).click()
assert.equal((await rows.locator('.gx-layout__layer-name').allTextContents())[0], initial.at(-1))
const order = await page.evaluate(() => window.editor.state.draft.widgetOrder.large
.filter(id => window.editor.state.draft.layouts.large[id].enabled))
assert.equal(order.at(-1), 'pip_left')
assert.equal(await page.getByRole('button', {name:/Move .* (forward|backward)/}).count(), 0)
await page.setViewportSize({width:390,height:844})
await rows.first().evaluate(row => row.scrollIntoView({block:'center'}))
const handle = await rows.first().locator('.gx-layout__row-grip').boundingBox()
const target = await rows.nth(1).boundingBox()
const client = await page.context().newCDPSession(page)
await client.send('Emulation.setTouchEmulationEnabled', {enabled:true})
const touch = (x, y) => [{x, y, id:1}]
await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(handle.x + handle.width / 2, handle.y + handle.height / 2)})
await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(target.x + target.width / 2, target.y + target.height / 2)})
await client.send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]})
assert.equal(await page.evaluate(() => window.editor.state.draft.widgetOrder.large
.filter(id => window.editor.state.draft.layouts.large[id].enabled).at(-2)), 'pip_left')
// A canceled touch must leave the saved draft order untouched.
const beforeCancel = await page.evaluate(() => JSON.stringify(window.editor.state.draft))
await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(handle.x + handle.width / 2, handle.y + handle.height / 2)})
await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(target.x + target.width / 2, target.y + target.height / 2)})
await client.send('Input.dispatchTouchEvent', {type:'touchCancel', touchPoints:[]})
assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeCancel)
assert.equal(await page.evaluate(() => window.editor.state.layerDrag), null)
await page.screenshot({path:'/private/tmp/theme-widget-layers.png'})
// A tap (including a little finger movement) selects without changing the order.
const firstRow = await rows.first().boundingBox()
const tapX = firstRow.x + firstRow.width / 2, tapY = firstRow.y + firstRow.height / 2
const beforeTap = await page.evaluate(() => JSON.stringify(window.editor.state.draft))
await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(tapX, tapY)})
await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(tapX + 2, tapY + 2)})
await client.send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]})
await page.getByRole('combobox', {name:'Selected widget', exact:true}).waitFor()
assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeTap)
await page.getByRole('button', {name:'Widgets', exact:true}).click()
await rows.first().locator('.gx-layout__layer-name').click()
await page.getByRole('combobox', {name:'Selected widget', exact:true}).waitFor()
// Both outputs retain the same workspace and all editor features at phone and desktop sizes.
for (const aa of [false, true]) {
await page.goto('http://layers.test/' + (aa ? '?projection=1' : ''))
await rows.first().waitFor()
// Swiping names scrolls the page without rearranging widgets or starting an edit.
await page.setViewportSize({width:390, height:844})
await client.send('Emulation.setTouchEmulationEnabled', {enabled:true})
await rows.first().scrollIntoViewIfNeeded()
const name = await rows.first().locator('.gx-layout__layer-name').boundingBox()
const beforeScroll = await page.evaluate(() => ({draft:JSON.stringify(window.editor.state.draft), y:scrollY}))
const scrollX = name.x + name.width / 2, scrollY = name.y + name.height / 2
await client.send('Input.dispatchTouchEvent', {type:'touchStart', touchPoints:touch(scrollX, scrollY)})
for (const distance of [30, 60, 100, 150])
await client.send('Input.dispatchTouchEvent', {type:'touchMove', touchPoints:touch(scrollX, scrollY - distance)})
await client.send('Input.dispatchTouchEvent', {type:'touchEnd', touchPoints:[]})
await page.waitForFunction(y => scrollY > y, beforeScroll.y)
assert.equal(await page.evaluate(() => JSON.stringify(window.editor.state.draft)), beforeScroll.draft)
assert.equal(await page.evaluate(() => window.editor.state.layerDrag), null)
assert.equal(await page.evaluate(() => window.editor.state.inspectorPanel), 'widgets')
// Device preview receives the tentative order before release on both outputs.
await page.setViewportSize({width:1280, height:1000})
await client.send('Emulation.setTouchEmulationEnabled', {enabled:false})
await page.getByRole('button', {name:'Device preview', exact:true}).click()
const dragId = await rows.last().getAttribute('data-layer-id')
const deviceSource = await rows.last().boundingBox(), deviceTarget = await rows.first().boundingBox()
const previewRequest = page.waitForRequest(request => {
if (!request.url().endsWith('/api/ui/layout/preview')) return false
const doc = request.postDataJSON()?.document, order = doc?.widgetOrder
const widgets = aa ? doc.widgets : doc.layouts.large
return (aa ? order : order?.large)?.filter(id => widgets[id].enabled).at(-1) === dragId
})
await page.mouse.move(deviceSource.x + 30, deviceSource.y + deviceSource.height / 2)
await page.mouse.down()
await page.mouse.move(deviceTarget.x + 30, deviceTarget.y + deviceTarget.height / 2, {steps:5})
await previewRequest
assert.equal(await page.locator('.gx-layout__device-outline .gx-layout__outline').isVisible(), true)
assert.equal(await page.evaluate(() => window.editor.state.layerDrag.moved), true)
await page.mouse.up()
await page.getByRole('button', {name:'Arrange widgets', exact:true}).click()
for (const width of [360, 390, 768, 1280, 1920]) {
await page.setViewportSize({width, height:900})
await page.evaluate(() => window.scrollTo(0, 0))
const undoLabel = page.getByRole('button', {name:'Undo', exact:true}).locator('.gx-layout__action-label')
assert.equal(await undoLabel.isVisible(), await page.locator('.gx-layout').evaluate(el => el.clientWidth > 850))
for (const name of ['Undo', 'Redo', 'Reload saved', 'Reset to stock StarPilot', 'Save changes']) {
const action = page.getByRole('button', {name, exact:true})
assert.equal(await action.isVisible(), true)
if (width <= 768) assert.equal(await action.locator('svg').isVisible(), true)
}
assert.equal(await page.getByLabel('More actions', {exact:true}).count(), 0)
const saveButton = page.getByRole('button', {name:'Save changes', exact:true})
assert.equal(await saveButton.isVisible(), true)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
await page.getByRole('button', {name:'Edit widget', exact:true}).click()
assert.equal(await page.getByRole('combobox', {name:'Selected widget', exact:true}).isVisible(), true)
assert.equal(await page.getByLabel('X', {exact:true}).isVisible(), true)
assert.equal(await page.locator('svg.gx-layout__preview .gx-layout__remove').count(), 0)
await page.getByRole('button', {name:'Widgets', exact:true}).click()
await rows.first().locator('.gx-layout__remove').click()
await page.getByRole('button', {name:'Remove widget', exact:true}).click()
await page.getByRole('button', {name:'Edit widget', exact:true}).click()
assert.equal(await page.getByRole('button', {name:'Add to layout', exact:true}).isVisible(), true)
await page.getByRole('button', {name:'Add to layout', exact:true}).click()
if (!aa) {
await page.getByRole('button', {name:'Road colors', exact:true}).click()
assert.equal(await page.getByRole('combobox', {name:'Path style', exact:true}).isVisible(), true)
}
await page.getByRole('button', {name:'Device preview', exact:true}).click()
assert.equal(await page.locator('svg.gx-layout__preview').isVisible(), false)
await page.getByRole('button', {name:'Arrange widgets', exact:true}).click()
assert.equal(await page.locator('svg.gx-layout__preview').isVisible(), true)
await page.getByRole('button', {name:'Widgets', exact:true}).click()
await page.evaluate(() => window.scrollTo(0, 0))
await page.screenshot({path:'/private/tmp/theme-workspace-' + (aa ? 'aa-' : 'comma-') + width + '.png', fullPage:true})
}
if (aa) {
// Home and Work independently switch between compact icons and word cards.
await page.setViewportSize({width:390, height:844})
await page.getByRole('button', {name:'Edit widget', exact:true}).click()
await page.evaluate(() => { window.editor.add('nav_home'); window.editor.add('nav_work'); window.editor.state.selected = 'nav_home' })
const homeDisplay = page.getByRole('group', {name:'Home display', exact:true})
await homeDisplay.getByRole('button', {name:'Icons', exact:true}).click()
assert.equal(await page.evaluate(() => window.editor.state.draft.layouts.large.nav_home.display), 'icons')
assert.equal(await page.evaluate(() => window.editor.state.draft.layouts.large.nav_work.display), 'words')
const homePreview = page.locator('svg.gx-layout__preview .gx-layout__widget').filter({has:page.locator('.gx-layout__hit[width="110"]')})
assert.equal(await homePreview.count(), 1)
assert.equal(await homePreview.locator('.gx-layout-widget-art path').count(), 1)
assert.equal(await homePreview.locator('.gx-layout-widget-art text').count(), 0)
await page.getByRole('button', {name:'Undo', exact:true}).click()
assert.equal(await page.evaluate(() => window.editor.state.draft.layouts.large.nav_home.display), 'words')
await page.getByRole('button', {name:'Redo', exact:true}).click()
assert.equal(await homeDisplay.getByRole('button', {name:'Icons', exact:true}).getAttribute('aria-pressed'), 'true')
await page.evaluate(() => { window.editor.state.selected = 'nav_work' })
await page.getByRole('group', {name:'Work display', exact:true}).getByRole('button', {name:'Icons', exact:true}).click()
assert.equal(await homePreview.count(), 2)
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
await page.screenshot({path:'/private/tmp/android-auto-favorite-icons.png',fullPage:true})
await page.getByRole('group', {name:'Work display', exact:true}).getByRole('button', {name:'Words', exact:true}).click()
assert.equal(await homePreview.count(), 1)
assert.equal(await page.evaluate(() => window.editor.layout.bookmark.enabled), false)
await page.getByRole('combobox', {name:'Selected widget', exact:true}).click()
await page.getByRole('option', {name:/^Bookmark/}).click()
await page.getByRole('button', {name:'Add to layout', exact:true}).click()
const bookmarkDisplay = page.getByRole('group', {name:'Bookmark display', exact:true})
await bookmarkDisplay.getByRole('button', {name:'Icons', exact:true}).click()
await page.getByLabel('Show background', {exact:true}).uncheck()
const opacity = page.locator('#layout-widget-opacity')
await opacity.fill('45')
await opacity.dispatchEvent('change')
const bookmarkPreview = page.locator('.gx-layout__widget[aria-label^="Bookmark"] .gx-layout-widget-art')
assert.equal(await bookmarkPreview.locator('rect').count(), 0)
assert.equal(await bookmarkPreview.locator('path').count(), 1)
assert.equal(await bookmarkPreview.locator('g[opacity="0.45"]').count(), 1)
assert.equal(await page.evaluate(() => window.editor.layout.nav_work.opacity), 100)
await page.getByRole('button', {name:'Undo', exact:true}).click()
assert.equal(await opacity.inputValue(), '100')
await page.getByRole('button', {name:'Redo', exact:true}).click()
assert.equal(await opacity.inputValue(), '45')
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true)
await page.screenshot({path:'/private/tmp/android-auto-bookmark-widget.png', fullPage:true})
await page.evaluate(() => {
window.editor.state.draft.layouts.large.current_speed.x += 1
window.editor.requestLeave(() => { window.leaveCompleted = true })
})
assert.equal(await page.getByText('Leave without saving your changes?', {exact:true}).isVisible(), true)
assert.equal(await page.getByRole('button', {name:'Undo', exact:true}).count(), 0)
for (const name of ['Keep editing', 'Discard and leave', 'Save changes'])
assert.equal(await page.getByRole('button', {name, exact:true}).isVisible(), true)
await page.getByRole('button', {name:'Keep editing', exact:true}).click()
await page.evaluate(() => window.editor.requestLeave(() => { window.leaveCompleted = true }, 'modal'))
const leaveDialog = page.getByRole('alertdialog')
assert.equal(await leaveDialog.getByText('Leave without saving your changes?', {exact:true}).isVisible(), true)
for (const name of ['Keep editing', 'Discard and leave', 'Save changes'])
assert.equal(await leaveDialog.getByRole('button', {name, exact:true}).isVisible(), true)
await leaveDialog.getByRole('button', {name:'Keep editing', exact:true}).click()
assert.equal(await page.evaluate(() => window.leaveCompleted), undefined)
}
}
// Exercise the actual menu/route guard while the widget list is scrolled on phones.
for (const route of ['/theme_maker', '/theme_maker/android_auto']) {
for (const width of [360, 390, 1280]) {
await page.setViewportSize({width, height:844})
await page.goto('http://layers.test/?shell=1#' + route)
await rows.first().waitFor()
await page.getByRole('button', {name:'Edit widget', exact:true}).click()
const x = page.getByLabel('X', {exact:true})
await x.fill(String(Number(await x.inputValue()) + 1))
await x.press('Tab')
await page.getByRole('button', {name:'Widgets', exact:true}).click()
await rows.last().scrollIntoViewIfNeeded()
if (width < 768) await page.locator('.blur-nav').getByRole('button', {name:'Tools', exact:true}).click()
else {
await page.getByRole('button', {name:'Menu', exact:true}).click()
await page.getByRole('complementary', {name:'Galaxy navigation'}).getByRole('button', {name:'Tools', exact:true}).click()
}
const dialog = page.getByRole('alertdialog', {name:'Leave without saving your changes?'})
await dialog.waitFor()
assert.equal(new URL(page.url()).hash, '#' + route, 'navigation waits for confirmation')
const bounds = await dialog.boundingBox()
assert(bounds.x >= 0 && bounds.y >= 0 && bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844)
assert.equal(await dialog.evaluate(el => el.scrollWidth <= el.clientWidth), true)
for (const name of ['Keep editing', 'Discard and leave', 'Save changes']) {
const button = dialog.getByRole('button', {name, exact:true})
assert.equal(await button.isVisible(), true)
assert((await button.boundingBox()).height >= 48)
}
await page.screenshot({path:`/private/tmp/theme-leave-${route.endsWith('android_auto') ? 'aa' : 'comma'}-${width}.png`})
await dialog.getByRole('button', {name:'Keep editing', exact:true}).click()
assert.equal(await dialog.count(), 0)
assert.equal(new URL(page.url()).hash, '#' + route)
assert.equal(await page.getByRole('button', {name:'Save changes', exact:true}).isEnabled(), true)
if (width < 768) await page.locator('.blur-nav').getByRole('button', {name:'Tools', exact:true}).click()
else {
await page.getByRole('button', {name:'Menu', exact:true}).click()
await page.getByRole('complementary', {name:'Galaxy navigation'}).getByRole('button', {name:'Tools', exact:true}).click()
}
await dialog.getByRole('button', {name:'Discard and leave', exact:true}).click()
await page.waitForURL('**#/tools')
assert.equal(await dialog.count(), 0)
}
}
assert.deepEqual(errors, [])
console.log('Theme Maker: mouse/touch layering, both outputs, all editor panels, preview switching and 360–1920px layouts passed')
} finally { await browser.close() }
})().catch(error => {console.error(error);process.exitCode=1})
File diff suppressed because one or more lines are too long
@@ -0,0 +1,168 @@
"""Local access owner tests use only disposable directories and no service."""
import hashlib
import json
import os
from pathlib import Path
import tempfile
import unittest
from openpilot.starpilot.galaxy.access import AccessStatus, GalaxyAccessOwner
class TestGalaxyAccess(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.base = Path(self.temp.name)
self.owner = GalaxyAccessOwner(self.base / "new", legacy_root=self.base / "old")
self.parked = lambda: True
def test_versioned_private_atomic_store_and_no_remote_claim(self):
self.assertEqual(self.owner.status().status, AccessStatus.UNCONFIGURED)
self.assertFalse(self.owner.configure("password123", lambda: False))
self.assertTrue(self.owner.configure("password123", self.parked))
path = self.owner.root / self.owner.FILE
record = json.loads(path.read_text())
self.assertEqual(set(record), {"version", "algorithm", "salt", "verifier"})
self.assertNotIn("password123", path.read_text())
self.assertEqual(record["version"], 1)
self.assertEqual(path.stat().st_mode & 0o777, 0o600)
self.assertEqual(self.owner.root.stat().st_mode & 0o777, 0o700)
self.assertTrue(self.owner.verify("password123"))
self.assertFalse(self.owner.verify("wrong"))
self.assertEqual(self.owner.status().status, AccessStatus.CONFIGURED_LOCAL)
self.assertFalse(self.owner.remove(lambda: False))
self.assertTrue(path.exists())
self.assertTrue(self.owner.remove(self.parked))
self.assertEqual(self.owner.status().status, AccessStatus.UNCONFIGURED)
def test_unpaired_local_pairing_can_replace_verifier_only_while_parked(self):
self.assertTrue(self.owner.configure("first-password", self.parked))
old_generation = self.owner.current_generation()
self.assertFalse(self.owner.replace_for_pairing("next-password", lambda: False))
self.assertFalse(self.owner.replace_for_pairing("short", self.parked))
self.assertTrue(self.owner.verify("first-password"))
self.assertTrue(self.owner.replace_for_pairing("next-password", self.parked))
self.assertNotEqual(self.owner.current_generation(), old_generation)
self.assertFalse(self.owner.verify("first-password"))
self.assertTrue(self.owner.verify("next-password"))
self.assertEqual((self.owner.root / self.owner.FILE).stat().st_mode & 0o777, 0o600)
def test_corrupt_partial_symlink_and_permissions_are_unavailable(self):
self.owner.root.mkdir(mode=0o700)
path = self.owner.root / self.owner.FILE
path.write_text('{"version":1}')
os.chmod(path, 0o600)
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
self.assertFalse(self.owner.configure("password123", self.parked))
path.unlink()
path.symlink_to(self.base / "elsewhere")
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
path.unlink()
os.chmod(self.owner.root, 0o755)
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
os.chmod(self.owner.root, 0o700)
path.write_text("{}")
os.chmod(path, 0o644)
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
def test_legacy_import_requires_explicit_password_and_leaves_old_files(self):
old = self.base / "old"
old.mkdir()
(old / "glxyauth").write_text(hashlib.sha256(b"oldpass123").hexdigest())
(old / "glxysession").write_text("old-session")
(old / "glxyslug").write_text("old-slug")
self.assertEqual(self.owner.status().status, AccessStatus.LEGACY_IMPORT_AVAILABLE)
self.assertFalse(self.owner.configure("newpass123", self.parked))
self.assertFalse(self.owner.import_legacy("wrongpass", self.parked))
self.assertFalse(self.owner.import_legacy("oldpass123", lambda: False))
self.assertTrue(self.owner.import_legacy("oldpass123", self.parked))
self.assertTrue(self.owner.verify("oldpass123"))
self.assertTrue((old / "glxysession").exists())
self.assertNotIn("old-session", (self.owner.root / self.owner.FILE).read_text())
def test_partial_legacy_record_is_not_treated_as_fresh_unconfigured(self):
old = self.base / "old"
old.mkdir()
(old / "glxyauth").write_text("a" * 64)
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
self.assertFalse(self.owner.configure("password123", self.parked))
def test_interrupted_atomic_write_is_unavailable(self):
self.owner.root.mkdir(mode=0o700)
partial = self.owner.root / ".access-interrupted"
partial.write_text("partial")
os.chmod(partial, 0o600)
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
self.assertFalse(self.owner.configure("password123", self.parked))
def test_noncanonical_hex_cannot_appear_configured(self):
self.assertTrue(self.owner.configure("password123", self.parked))
path = self.owner.root / self.owner.FILE
original = json.loads(path.read_text())
for key, malformed in (("salt", " " + original["salt"][1:]),
("verifier", " " + original["verifier"][1:]),
("salt", "A" * 32)):
with self.subTest(key=key, malformed=malformed):
record = dict(original, **{key: malformed})
path.write_text(json.dumps(record))
self.assertEqual(self.owner.status().status, AccessStatus.UNAVAILABLE)
self.assertFalse(self.owner.verify("password123"))
def test_parked_guard_rechecked_at_exact_mutation(self):
calls = 0
def only_first_check():
nonlocal calls
calls += 1
return calls == 1
self.assertFalse(self.owner.configure("password123", only_first_check))
self.assertFalse(self.owner.root.exists())
self.assertFalse((self.owner.root / self.owner.FILE).exists())
self.assertEqual(self.owner.status().status, AccessStatus.UNCONFIGURED)
calls = 0
def only_first_two_checks():
nonlocal calls
calls += 1
return calls <= 2
self.assertFalse(self.owner.configure("password123", only_first_two_checks))
self.assertFalse((self.owner.root / self.owner.FILE).exists())
self.assertEqual(self.owner.status().status, AccessStatus.UNCONFIGURED)
self.assertTrue(self.owner.configure("password123", self.parked))
calls = 0
self.assertFalse(self.owner.remove(only_first_check))
self.assertTrue((self.owner.root / self.owner.FILE).exists())
def test_legacy_raw_and_trimmed_password_forms_are_imported_as_verified(self):
for password, stored in ((" edge pass ", " edge pass "), (" edge pass ", "edge pass")):
with self.subTest(stored=stored), tempfile.TemporaryDirectory() as directory:
base = Path(directory)
old = base / "old"
old.mkdir()
(old / "glxyauth").write_text(hashlib.sha256(stored.encode()).hexdigest())
(old / "glxysession").write_text("legacy-session")
(old / "glxyslug").write_text("legacy-slug")
owner = GalaxyAccessOwner(base / "new", legacy_root=old)
self.assertTrue(owner.import_legacy(password, self.parked))
self.assertTrue(owner.verify(stored))
if stored != password.strip():
self.assertFalse(owner.verify(password.strip()))
def test_legacy_import_rechecks_parked_before_new_record_link(self):
old = self.base / "old"
old.mkdir()
(old / "glxyauth").write_text(hashlib.sha256(b"oldpass123").hexdigest())
(old / "glxysession").write_text("old-session")
(old / "glxyslug").write_text("old-slug")
calls = 0
def only_first_check():
nonlocal calls
calls += 1
return calls == 1
self.assertFalse(self.owner.import_legacy("oldpass123", only_first_check))
self.assertFalse(self.owner.root.exists())
self.assertFalse((self.owner.root / self.owner.FILE).exists())
self.assertEqual(self.owner.status().status, AccessStatus.LEGACY_IMPORT_AVAILABLE)
@@ -0,0 +1,212 @@
import assert from "node:assert/strict"
import { LocalAuth } from "../web/js/auth-client.js"
import { MonitorFeed } from "../web/js/monitor-feed.js"
import { readFileSync } from "node:fs"
const states = [], calls = []
const responses = [
{ ok: true, body: { state: "configured", authenticated: false } },
{ ok: false, status: 401 },
{ ok: true, body: { authenticated: true } },
{ ok: true, body: { authenticated: false } },
]
const auth = new LocalAuth({ publish: (state) => states.push(state), fetcher: async (url, options) => {
calls.push({ url, options })
const response = responses.shift()
return { ok: response.ok, status: response.status ?? 200, json: async () => response.body }
} })
await auth.check()
assert.equal(states.at(-1).status, "login")
assert.equal(await auth.login("wrongpass"), false)
assert.equal(states.at(-1).error, "Sign in failed.")
assert.equal(await auth.login("correctpass"), true)
assert.equal(states.at(-1).status, "authenticated")
assert.equal(calls[2].options.credentials, "same-origin")
assert.equal(calls[2].options.headers["Content-Type"], "application/json")
assert.equal(await auth.logout(), true)
assert.equal(states.at(-1).status, "login")
assert.equal(calls[3].options.body, "{}")
const setup = new LocalAuth({ publish: (state) => states.push(state), fetcher: async () =>
({ ok: true, json: async () => ({ state: "setup_required", authenticated: false }) }) })
await setup.check()
assert.equal(states.at(-1).status, "setup_required")
const unavailable = new LocalAuth({ publish: (state) => states.push(state), fetcher: async () =>
({ ok: true, json: async () => ({ state: "unavailable", authenticated: false }) }) })
await unavailable.check()
assert.equal(states.at(-1).status, "unavailable")
let unauthorized = 0
const samples = []
const feed = new MonitorFeed({ mode: "local", publish: (state) => samples.push(state), unauthorized: () => unauthorized++,
fetcher: async () => ({ ok: false, status: 401 }) })
feed.start()
for (let i = 0; i < 5; i++) await Promise.resolve()
assert.equal(unauthorized, 1)
assert.equal(samples.at(-1).snapshot, null)
assert.equal(feed.stopped, true)
assert.equal(feed.timer, null)
const accessLostStates = []
let accessLost = 0
const accessLostFeed = new MonitorFeed({ mode: "local", publish: (state) => accessLostStates.push(state),
unauthorized: () => accessLost++, fetcher: async () => ({ ok: false, status: 503, json: async () => ({ code: "setup_required" }) }) })
accessLostFeed.start()
for (let i = 0; i < 5; i++) await Promise.resolve()
assert.equal(accessLost, 1)
assert.equal(accessLostStates.at(-1).snapshot, null)
assert.equal(accessLostFeed.stopped, true)
const lostLogoutStates = []
const lostLogout = new LocalAuth({ publish: (state) => lostLogoutStates.push(state), fetcher: async () => ({ ok: false, status: 401 }) })
assert.equal(await lostLogout.logout(), false)
assert.equal(lostLogoutStates.at(-1).status, "login")
const previewCalls = []
const preview = new MonitorFeed({ mode: "sample", publish: () => {}, fetcher: async (url) => {
previewCalls.push(url)
return { ok: false, status: 404 }
} })
preview.start()
for (let i = 0; i < 5; i++) await Promise.resolve()
assert.deepEqual(previewCalls, ["./data/system-monitor.sample.json"])
preview.stop()
const deferred = () => { let resolve; const promise = new Promise((done) => { resolve = done }); return { promise, resolve } }
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve() }
// A slow session check may not replace a completed sign-in.
const oldCheck = deferred(), raceStates = []
const race = new LocalAuth({ publish: (state) => raceStates.push(state), fetcher: (url) =>
url.endsWith("session") ? oldCheck.promise : Promise.resolve({ ok: true, json: async () => ({ authenticated: true }) }) })
const checking = race.check()
assert.equal(await race.login("password123"), true)
oldCheck.resolve({ ok: true, json: async () => ({ state: "configured", authenticated: false }) })
await checking
assert.equal(raceStates.at(-1).status, "authenticated")
// Two successful sign-ins execute in order and only the latest publishes state.
const first = deferred(), second = deferred(), signStates = []
let loginNumber = 0
const sign = new LocalAuth({ publish: (state) => signStates.push(state), fetcher: (url) => {
if (url.endsWith("login")) return ++loginNumber === 1 ? first.promise : second.promise
return Promise.resolve({ ok: true })
} })
const firstLogin = sign.login("first-password")
const secondLogin = sign.login("second-password")
await flush()
assert.equal(loginNumber, 1)
first.resolve({ ok: true })
assert.equal(await firstLogin, false)
await flush()
assert.equal(loginNumber, 2)
second.resolve({ ok: true })
assert.equal(await secondLogin, true)
assert.equal(signStates.at(-1).status, "authenticated")
const waitingLogin = deferred(), waitingLogout = deferred(), logoutStates = [], logoutCalls = []
const serialized = new LocalAuth({ publish: (state) => logoutStates.push(state), fetcher: (url) => {
logoutCalls.push(url)
return url.endsWith("login") ? waitingLogin.promise : waitingLogout.promise
} })
const pendingSignIn = serialized.login("password123")
const pendingSignOut = serialized.logout()
await flush()
assert.deepEqual(logoutCalls, ["./api/auth/login"])
waitingLogin.resolve({ ok: true })
await pendingSignIn
await flush()
assert.deepEqual(logoutCalls, ["./api/auth/login", "./api/auth/logout"])
waitingLogout.resolve({ ok: true })
assert.equal(await pendingSignOut, true)
assert.equal(logoutStates.at(-1).status, "login")
// A login requested while logout waits must run after logout's cookie mutation.
const queuedLogin1 = deferred(), queuedLogout = deferred(), queuedLogin2 = deferred()
const orderedCalls = [], orderedStates = []
let orderedLoginNumber = 0
const ordered = new LocalAuth({ publish: (state) => orderedStates.push(state), fetcher: (url) => {
orderedCalls.push(url)
if (url.endsWith("login")) return ++orderedLoginNumber === 1 ? queuedLogin1.promise : queuedLogin2.promise
return queuedLogout.promise
} })
const orderedFirst = ordered.login("first-password")
const orderedSignOut = ordered.logout()
const orderedSecond = ordered.login("second-password")
await flush()
assert.deepEqual(orderedCalls, ["./api/auth/login"])
queuedLogin1.resolve({ ok: true })
assert.equal(await orderedFirst, false)
await flush()
assert.deepEqual(orderedCalls, ["./api/auth/login", "./api/auth/logout"])
queuedLogout.resolve({ ok: true })
assert.equal(await orderedSignOut, false)
await flush()
assert.deepEqual(orderedCalls, ["./api/auth/login", "./api/auth/logout", "./api/auth/login"])
queuedLogin2.resolve({ ok: true })
assert.equal(await orderedSecond, true)
assert.deepEqual(orderedStates, [{ status: "authenticated", error: "", localAccess: false }])
const localStates = [], localCalls = []
let localSession = { state: "configured", authenticated: true, localAccess: true }
const local = new LocalAuth({ publish: (state) => localStates.push(state), fetcher: async (url) => {
localCalls.push(url)
return { ok: true, json: async () => localSession }
} })
await local.check()
assert.deepEqual(localStates.at(-1), { status: "authenticated", error: "", localAccess: true })
assert.equal(await local.login("unused"), false)
assert.equal(await local.logout(), false)
assert.deepEqual(localCalls, ["./api/auth/session"])
assert.equal(localStates.at(-1).status, "authenticated")
local.expired()
assert.deepEqual(localStates.at(-1), { status: "checking", error: "", localAccess: false })
await local.check()
assert.equal(localStates.at(-1).localAccess, true)
assert.equal(localStates.at(-1).status, "authenticated")
localSession = { state: "configured", authenticated: false, localAccess: false }
await local.check()
assert.deepEqual(localStates.at(-1), { status: "login", error: "", localAccess: false })
localSession = { state: "configured", authenticated: false, localAccess: true }
await local.check()
assert.equal(localStates.at(-1).status, "login")
assert.equal(localStates.at(-1).localAccess, false)
localSession = { state: "configured", authenticated: true, localAccess: "true" }
await local.check()
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\(\) \}/)
const oldMonitor = deferred(), nextMonitor = deferred(), lateStates = []
let lateUnauthorized = 0, monitorRequests = 0
const lateFeed = new MonitorFeed({ mode: "local", publish: (state) => lateStates.push(state),
unauthorized: () => lateUnauthorized++, fetcher: () => ++monitorRequests === 1 ? oldMonitor.promise : nextMonitor.promise })
lateFeed.start()
lateFeed.stop()
lateFeed.start()
oldMonitor.resolve({ ok: false, status: 401 })
await flush()
assert.equal(lateUnauthorized, 0)
assert.equal(lateFeed.stopped, false)
assert.equal(lateStates.length, 0)
lateFeed.stop()
console.log("Galaxy browser auth, expiry and static-preview isolation checks passed")
const gatewayStates = [], gatewayCalls = []
let gatewayAuthenticated = true
const gateway = new LocalAuth({ publish: value => gatewayStates.push(value), fetcher: async (path) => {
gatewayCalls.push(path)
return { ok: true, json: async () => ({ state: "configured", authenticated: gatewayAuthenticated, gatewayAccess: true }) }
} })
await gateway.check()
assert.equal(gatewayStates.at(-1).status, "authenticated")
assert.equal(gatewayStates.at(-1).gatewayAccess, true)
assert.equal(await gateway.login("never-submit-to-device"), false)
assert.equal(await gateway.logout(), false)
assert.deepEqual(gatewayCalls, ["./api/auth/session"])
gatewayAuthenticated = false
await gateway.check()
assert.equal(gatewayStates.at(-1).status, "gateway_login")
@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { retireLegacyPhone } from '../web/js/connection-migration.js'
const oldKeys = ['galaxy-companion-link', 'galaxy-companion-ble-device', 'galaxy-connection-preference', 'galaxy-offline', 'galaxy-home-screen-added']
const storage = new Map([...oldKeys, 'galaxy-auth', 'map-downloads', 'unrelated'].map(key => [key, 'keep']))
const fetch = () => { throw new Error('migration must not make LAN requests') }
let updates = 0
const target = {
location: { href: 'https://galaxy.example/device/#/galaxy' },
localStorage: { removeItem: key => storage.delete(key) },
fetch,
navigator: { serviceWorker: { getRegistration: async url => ({
active: { scriptURL: url }, update: async () => { updates++ },
}) } },
}
await retireLegacyPhone(target)
assert.deepEqual([...storage.keys()], ['galaxy-auth', 'map-downloads', 'unrelated'])
assert.equal(target.fetch, fetch)
assert.equal(updates, 1)
target.navigator.serviceWorker.getRegistration = async () => ({
active: { scriptURL: 'https://galaxy.example/another-worker.js' },
update: async () => { throw new Error('unrelated worker touched') },
})
await retireLegacyPhone(target)
target.localStorage.removeItem = () => { throw new Error('storage denied') }
target.navigator.serviceWorker.getRegistration = async () => { throw new Error('worker denied') }
await retireLegacyPhone(target)
await retireLegacyPhone({})
@@ -0,0 +1,195 @@
import json
import stat
from unittest.mock import Mock
import pytest
from openpilot.starpilot.galaxy.hotspot import DEFAULT, HotspotSettings, atomic_json, hotspot_name, validate_config
from openpilot.starpilot.galaxy.hotspot_netlink import WirelessInterfaces, attribute, attributes
from openpilot.starpilot.galaxy.hotspot_worker import HotspotBackend, HotspotController, hostapd_config, overlapping_routes, radio_channel
CONFIG = {'enabled': True, 'ssid': 'TheGalaxy-6b86', 'password': '1234567890123456'}
DONGLE = 'b0c4a280b2f96b86'
@pytest.mark.parametrize('change', [{'enabled': 1}, {'ssid': 'bad\nssid'}, {'password': 'short'},
{'password': 'has spaces in it'}, {'extra': True}, {'password': 'é' * 12}])
def test_strict_settings(change):
with pytest.raises(ValueError):
validate_config({**CONFIG, **change})
def test_settings_revision_permissions_and_credentials(tmp_path):
settings = HotspotSettings(tmp_path, tmp_path / 'status', clock=lambda: 100, dongle_id=lambda: DONGLE)
assert settings.read() == {**DEFAULT, 'ssid': 'TheGalaxy-6b86'}
initial = settings.snapshot(True)
payload = {'revision': initial['revision'], 'config': {**initial['config'], 'enabled': True}}
with pytest.raises(PermissionError):
settings.save(payload, lambda: False)
assert not settings.path.exists()
saved = settings.save(payload, lambda: True)
assert len(saved['config']['password']) == 24
assert stat.S_IMODE(settings.path.stat().st_mode) == 0o600
with pytest.raises(FileExistsError):
settings.save(payload, lambda: True)
unchanged = settings.save({'revision': saved['revision'], 'config': {**saved['config'], 'password': ''}}, lambda: True)
assert unchanged['config'] == saved['config']
runtime = {'revision': saved['revision'], 'updated': 99, 'active': True, 'state': 'active', 'reason': '', 'channel': 149}
atomic_json(settings.status_path, runtime)
assert settings.snapshot(False)['active']
assert not settings.snapshot(False)['editable']
for updates in ({'updated': 80}, {'updated': 101}, {'revision': 'wrong'}):
atomic_json(settings.status_path, {**runtime, **updates})
assert settings.snapshot(True)['state'] == 'unavailable'
def test_settings_reject_symlink_and_oversize(tmp_path):
settings = HotspotSettings(tmp_path, tmp_path / 'status', dongle_id=lambda: DONGLE)
target = tmp_path / 'elsewhere'
target.write_text(json.dumps(CONFIG))
settings.path.symlink_to(target)
with pytest.raises(OSError):
settings.read()
settings.path.unlink()
settings.path.write_text(' ' * 4097)
with pytest.raises(ValueError):
settings.read()
def test_fixed_name_migration_password_and_restart(tmp_path):
settings = HotspotSettings(tmp_path, dongle_id=lambda: DONGLE)
atomic_json(settings.path, {**CONFIG, 'ssid': 'Old custom name'})
assert settings.read() == CONFIG
current = settings.snapshot(True)
with pytest.raises(ValueError, match='fixed'):
settings.save({'revision': current['revision'], 'config': {**CONFIG, 'ssid': 'Other name'}}, lambda: True)
custom = {**CONFIG, 'password': 'my-new-password-123'}
settings.save({'revision': current['revision'], 'config': custom}, lambda: True)
restarted = HotspotSettings(tmp_path, dongle_id=lambda: DONGLE)
assert restarted.read() == custom
assert hotspot_name(DONGLE.upper()) == 'TheGalaxy-6b86'
@pytest.mark.parametrize('dongle', [None, '', 'UnregisteredDevice', '1234', 'x' * 16, b'0123456789abcdef'])
def test_unregistered_name_is_not_invented(dongle):
with pytest.raises(ValueError, match='dongle ID'):
hotspot_name(dongle)
@pytest.mark.parametrize('status,expected', [('wpa_state=COMPLETED\nfreq=5745', ('a', 149)),
('wpa_state=COMPLETED\nfreq=2412', ('g', 1)), ('wpa_state=DISCONNECTED\nfreq=5745', ('g', 6)),
('wpa_state=INACTIVE', ('g', 6)), ('wpa_state=SCANNING', ('g', 6)),
('wpa_state=ASSOCIATING', None), ('wpa_state=4WAY_HANDSHAKE\nfreq=5745', None),
('wpa_state=INTERFACE_DISABLED', None), ('FAIL', None),
('wpa_state=COMPLETED\nfreq=6000', None)])
def test_channel(status, expected):
assert radio_channel(status) == expected
def test_automatic_standalone_connected_and_back():
now = [100]
backend = Mock()
backend.alive.return_value = backend.ready.return_value = True
controller = HotspotController(backend, clock=lambda: now[0])
for status, expected in [('DISCONNECTED', ('g', 6)), ('COMPLETED\nfreq=5745', ('a', 149)), ('DISCONNECTED', ('g', 6))]:
backend.channel.return_value = radio_channel('wpa_state=' + status)
assert controller.tick(CONFIG)['state'] == 'starting'
backend.start.assert_called_with(CONFIG, expected)
assert controller.tick(CONFIG)['active']
now[0] += 20
assert backend.start.call_count == 3
assert controller.tick({**CONFIG, 'enabled': False})['state'] == 'disabled'
def test_routes_and_config():
assert not overlapping_routes([{'dst': 'default'}, {'dst': '192.168.180.0/24'}, {'dst': '172.31.254.0/24', 'dev': 'galaxy0'}])
assert overlapping_routes([{'dst': '172.31.0.0/16', 'dev': 'wlan0'}])
assert overlapping_routes([{'dst': '172.31.254.1', 'dev': 'eth0'}])
config = hostapd_config(CONFIG, ('a', 149))
assert 'interface=galaxy0\n' in config and 'channel=149\n' in config and 'wpa=2\n' in config
assert 'country_code' not in config
with pytest.raises(ValueError):
hostapd_config({**CONFIG, 'ssid': 'Galaxy\ninterface=wlan0'}, ('a', 149))
def test_controller_lifecycle_and_channel_recovery():
backend = Mock()
backend.channel.return_value = ('a', 149)
backend.alive.return_value = backend.ready.return_value = True
now = [100]
controller = HotspotController(backend, clock=lambda: now[0])
assert controller.tick(CONFIG)['state'] == 'starting'
assert controller.tick(CONFIG)['active']
backend.start.assert_called_once_with(CONFIG, ('a', 149))
backend.channel.return_value = ('g', 6)
assert controller.tick(CONFIG)['state'] == 'error' # rate limit restarts
now[0] += 16
assert controller.tick(CONFIG)['state'] == 'starting'
backend.start.assert_called_with(CONFIG, ('g', 6))
backend.channel.return_value = None
assert controller.tick(CONFIG)['state'] == 'waiting'
assert controller.applied is None
assert controller.tick({**CONFIG, 'enabled': False})['state'] == 'disabled'
backend.stop.assert_called()
def test_controller_readiness_and_failed_child():
backend = Mock()
backend.channel.return_value = ('a', 149)
backend.alive.return_value = True
backend.ready.return_value = False
now = [0]
controller = HotspotController(backend, clock=lambda: now[0])
assert controller.tick(CONFIG)['state'] == 'starting'
assert controller.tick(CONFIG)['state'] == 'starting'
now[0] = 16
assert controller.tick(CONFIG)['state'] == 'error'
assert controller.applied is None
now[0] = 32
backend.start.side_effect = OSError('failed')
assert controller.tick(CONFIG)['state'] == 'error'
backend.stop.assert_called()
def test_backend_failure_rolls_back_only_owned_interface(tmp_path):
run = Mock(return_value=Mock(stdout='[]'))
interfaces = Mock()
backend = HotspotBackend(tmp_path, run=run, interfaces=interfaces)
backend.firewall = '/sbin/iptables-legacy'
def command(args, **kwargs):
if args[:3] == ['ip', 'address', 'add']:
raise OSError('failed')
return Mock(stdout='[]')
run.side_effect = command
with pytest.raises(OSError):
backend.start(CONFIG, ('a', 149))
interfaces.return_value.create.assert_called_once_with('galaxy0')
interfaces.return_value.delete.assert_called_once_with('galaxy0')
commands = [call.args[0] for call in run.call_args_list]
assert ['nmcli', 'device', 'set', 'galaxy0', 'managed', 'no'] in commands
assert all('wlan0' not in command and 'p2p0' not in command for command in commands)
assert not backend.owned
def test_backend_conflict_does_not_create(tmp_path):
interfaces = Mock()
backend = HotspotBackend(tmp_path, run=Mock(return_value=Mock(stdout='[{"dst":"172.31.0.0/16"}]')), interfaces=interfaces)
backend.firewall = '/sbin/iptables-legacy'
with pytest.raises(RuntimeError):
backend.start(CONFIG, ('a', 149))
interfaces.assert_not_called()
def test_netlink_encoding_and_refusal():
assert attributes(attribute(4, b'galaxy0\0')) == {4: b'galaxy0\0'}
with pytest.raises(ValueError):
attributes(b'\x03\x00\x01\x00')
wireless = WirelessInterfaces.__new__(WirelessInterfaces)
wireless.interfaces = Mock(return_value={'wlan0': {'type': 2, 'phy': 0, 'index': 1}, 'galaxy0': {'index': 7}})
wireless.query = Mock()
wireless.family = 22
with pytest.raises(RuntimeError):
wireless.create('galaxy0')
wireless.query.assert_not_called()
wireless.delete('galaxy0')
wireless.query.assert_called_once_with(22, 8, attribute(3, b'\x07\0\0\0'), ack=True)
@@ -0,0 +1,61 @@
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"
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)
assert.equal(validLocalAccess({ ...snapshot, addresses: [{ ...snapshot.addresses[0], url: 'javascript:alert(1)' }] }), false)
assert.equal(validLocalAccess({ ...snapshot, addresses: [{ ...snapshot.addresses[0], url: 'http://password@example.com' }] }), false)
assert.equal(validLocalAccess({ ...snapshot, available: false }), false)
const updates = [], requests = [], timers = []
let auth = 0
const feed = new LocalAccessFeed({ publish: value => updates.push(value), onUnauthorized: () => auth++,
later: (fn, ms) => { timers.push({ fn, ms }); return timers.length }, cancel() {},
fetcher: async (url, options) => { requests.push({ url, options }); return { ok: true, status: 200, json: async () => snapshot } } })
await feed.start('sample')
await feed.refresh()
assert.equal(requests.length, 0)
assert.match(updates.at(-1).error, /sample mode/)
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.deepEqual(updates.at(-2).data, snapshot)
assert.equal(timers.length, 1) // One deadline only; no periodic polling timer.
await feed.refresh()
assert.equal(requests.length, 2)
feed.fetcher = async () => ({ status: 401 })
await feed.refresh()
assert.equal(auth, 1)
assert.equal(feed.active, false)
let resolve
const pending = new LocalAccessFeed({ publish: value => updates.push(value), later: () => 1, cancel() {},
fetcher: () => new Promise(done => { resolve = done }) })
const reading = pending.start('local')
const count = updates.length
pending.stop()
resolve({ ok: true, status: 200, json: async () => snapshot })
await reading
assert.equal(updates.length, count) // Unmounted response cannot publish.
let deadline
const timeoutUpdates = []
const bounded = new LocalAccessFeed({ publish: value => timeoutUpdates.push(value), later: fn => { deadline = fn; return 1 }, cancel() {},
fetcher: (_url, options) => new Promise((_resolve, reject) => options.signal.addEventListener('abort', () => reject(Object.assign(new Error(), { name: 'AbortError' })))) })
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.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/)
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')
@@ -0,0 +1,23 @@
import assert from "node:assert/strict"
import { PollTimer } from "../web/js/polling.js"
const timers = new Map()
let next = 0, reads = 0, finish
const poller = new PollTimer({ read: () => { reads++; return new Promise(resolve => { finish = resolve }) },
later: fn => { timers.set(++next, fn); return next }, cancel: id => timers.delete(id) })
poller.start()
const retired = [...timers.values()][0]
poller.stop(); poller.start()
await retired()
assert.equal(reads, 0, "a retired callback must not read after restart")
const [id, tick] = [...timers.entries()][0]
timers.delete(id)
const running = tick()
assert.equal(reads, 1)
assert.equal(timers.size, 0, "polls do not overlap an outstanding read")
poller.stop(); poller.start()
finish(); await running
assert.equal(timers.size, 1, "a retired read must not create another polling loop")
poller.stop()
assert.equal(timers.size, 0)
console.log("Polling lifecycle: stale callbacks, single flight and stop/restart passed")
@@ -0,0 +1,62 @@
import assert from "node:assert/strict"
import fs from "node:fs"
const source = fs.readFileSync(new URL("../web/js/route-leave.js", import.meta.url), "utf8")
const { createRouteLeave } = await import("data:text/javascript;base64," + Buffer.from(source).toString("base64"))
function fixture(path = "/") {
let entries = [{path, index:0}], at = 0, shown = path, pending
const port = { read: () => entries[at], show: () => { shown = entries[at].path },
push: (path,index) => { entries = entries.slice(0,at+1); entries.push({path,index}); at++ },
replace: (path,index) => { entries[at] = {path,index} },
go: (delta) => { at += delta; nav.changed(); nav.changed() } }
const nav = createRouteLeave(port)
return {nav, port, shown: () => shown, approve: () => pending(),
dirty: () => nav.setGuard(callback => { pending = callback }),
clean: () => nav.setGuard(callback => callback()), busy: () => nav.setGuard(() => {}),
hash: path => { port.push(path,null); nav.changed(); nav.changed() } }
}
{
const f=fixture(); f.nav.navigate("/theme_maker"); f.dirty(); f.nav.navigate("/tools")
assert.equal(f.shown(),"/theme_maker"); f.approve(); assert.equal(f.shown(),"/tools")
}
{
const f=fixture(); f.nav.navigate("/tools"); f.nav.navigate("/theme_maker"); f.dirty(); f.port.go(-1)
assert.equal(f.shown(),"/theme_maker"); assert.equal(f.port.read().path,"/theme_maker")
f.approve(); assert.equal(f.shown(),"/tools"); f.port.go(1); assert.equal(f.shown(),"/tools")
f.approve(); assert.equal(f.shown(),"/theme_maker")
}
{
const f=fixture(); f.nav.navigate("/theme_maker"); f.dirty(); f.hash("/settings")
assert.equal(f.shown(),"/theme_maker"); f.approve(); assert.equal(f.shown(),"/settings")
}
{
const f=fixture(); f.nav.navigate("/theme_maker"); f.busy(); f.nav.navigate("/tools"); f.port.go(-1)
assert.equal(f.shown(),"/theme_maker"); assert.equal(f.port.read().path,"/theme_maker")
}
{
const f=fixture(); f.nav.navigate("/theme_maker"); f.dirty(); f.nav.navigate("/tools"); f.nav.navigate("/settings")
f.approve(); assert.equal(f.shown(),"/settings"); f.clean(); f.nav.back(); assert.equal(f.shown(),"/theme_maker")
}
{
const f=fixture(); f.nav.navigate("/settings"); f.dirty(); let editor="old"
f.nav.navigate("/settings", () => { editor="new" }); assert.equal(editor,"old")
f.approve(); assert.equal(editor,"new")
}
{
const f=fixture(); f.nav.navigate("/settings"); let nested=true, requests=[]
f.nav.setGuard((proceed, {back}) => { requests.push(back); if(back && nested) nested=false; else proceed() })
f.port.go(-1)
assert.equal(nested,false); assert.equal(f.shown(),"/settings"); assert.equal(f.port.read().path,"/settings")
f.nav.back(); assert.equal(f.shown(),"/"); assert.deepEqual(requests,[true,true])
}
{
const f=fixture('/settings'), nav=f.nav
let nested=true
nav.setGuard((proceed,{back})=>{if(back && nested) nested=false;else proceed()})
nav.ensureBackEntry()
assert.equal(f.port.read().index,1)
nav.back();assert.equal(nested,false)
nav.back();assert.equal(f.shown(),'/')
}
console.log("8 route-leave scenarios passed")
@@ -0,0 +1,21 @@
import assert from "node:assert/strict"
import fs from "node:fs"
const text=fs.readFileSync(new URL("../web/js/route-leave.js", import.meta.url),"utf8")
const {createRouteLeave}=await import("data:text/javascript;base64,"+Buffer.from(text).toString("base64"))
let entries=[{path:"/",index:0}],at=0,shown="/",pending,queue=[]
const port={read:()=>entries[at],show:()=>{shown=entries[at].path},
push:(path,index)=>{entries=entries.slice(0,at+1);entries.push({path,index});at++},
replace:(path,index)=>{entries[at]={path,index}},
go:delta=>queue.push(()=>{at+=delta;nav.changed();queue.push(()=>nav.changed())})}
const nav=createRouteLeave(port)
const flush=()=>{while(queue.length)queue.shift()()}
nav.navigate("/tools");nav.navigate("/theme_maker");nav.setGuard(callback=>{pending=callback})
port.go(-1);flush();assert.equal(shown,"/theme_maker");assert.equal(port.read().path,"/theme_maker")
nav.navigate("/settings");pending();assert.equal(shown,"/settings")
nav.setGuard(callback=>{pending=callback})
port.push("/logs",port.read().index);nav.changed();queue.push(()=>nav.changed());flush()
assert.equal(port.read().path,"/settings");assert.equal(shown,"/settings")
pending();flush();assert.equal(shown,"/logs")
port.go(-1);flush();assert.equal(shown,"/logs");pending();flush();assert.equal(shown,"/settings")
port.go(1);flush();assert.equal(shown,"/settings");pending();flush();assert.equal(shown,"/logs")
console.log("async Back/Forward, retained-state hash and canceled-then-new navigation passed")
@@ -0,0 +1,19 @@
import assert from "node:assert/strict"
import { guardUnload } from "../web/js/unload-guard.js"
globalThis.window = new EventTarget()
let dirty = true
const stop = guardUnload(() => dirty)
const attempt = () => {
const event = new Event("beforeunload", { cancelable: true })
// Browsers expose a writable returnValue on BeforeUnloadEvent; Node's Event does not.
Object.defineProperty(event, "returnValue", { value: "", writable: true })
return window.dispatchEvent(event)
}
assert.equal(attempt(), false, "Live reload must defer while a draft is dirty")
dirty = false
assert.equal(attempt(), true, "Saving or discarding allows the pending reload")
dirty = true
stop()
assert.equal(attempt(), true, "An unmounted page must release its guard")
console.log("Unsaved draft unload guard passed")
@@ -0,0 +1,123 @@
@import "./offline-road-maps.css";
/* A shared page shell keeps destination, maps, and setup in the same layout. */
.gx-navigation {--navigation-land:#191a1a;width:100%;max-width:1440px;margin:0 auto;min-width:0;padding-bottom:24px}
[data-theme="light"] .gx-navigation {--navigation-land:#f5f5f3}
.gx-navigation__workspace {display:grid;grid-template-columns:minmax(320px, .85fr) minmax(0,1.4fr);gap:16px;align-items:start}
.gx-navigation__panel {min-width:0;padding:18px;display:grid;gap:12px;align-content:start}
.gx-navigation__step {display:flex;gap:12px;align-items:flex-start;min-width:0}
.gx-navigation__step>div {min-width:0;flex:1}
.gx-navigation__preview-head>.gx-navigation__step {flex:1 1 220px}
.gx-navigation__step h3 {margin:0 0 4px;font-size:1.2rem}
.gx-navigation__step p {margin:0;color:var(--text-muted);font-size:.88rem;line-height:1.5}
.gx-navigation__preview {min-width:0;overflow:hidden;position:sticky;top:calc(88px + env(safe-area-inset-top,0px))}
.gx-navigation__preview-head {padding:16px 18px;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px}
.gx-navigation__trip {padding:8px 12px;border-radius:20px;background:var(--glass-active-bg);font-size:.85rem;font-weight:650;color:var(--primary)}
.gx-navigation__preview .gx-navigation-map {margin:0;border-radius:0;background:var(--navigation-land)}
.gx-navigation__preview .gx-navigation-map canvas {height:clamp(320px,52vh,640px);background:var(--navigation-land)}
.gx-navigation__preview .gx-navigation-map>.gx-alert {position:absolute;left:8px;right:8px;bottom:36px;margin:0;padding:8px 10px;font-size:.8rem}
.gx-navigation__empty {display:grid;justify-items:center;text-align:center;align-content:center;min-height:340px;padding:32px;background:radial-gradient(ellipse at center,color-mix(in srgb,var(--primary) 12%,transparent),transparent 70%)}
.gx-navigation__empty>i {font-size:3rem;color:var(--primary);margin-bottom:20px}
.gx-navigation__empty h3 {margin:0}
.gx-navigation__empty p {max-width:320px;color:var(--text-muted);line-height:1.6}
.gx-navigation .gx-btn {min-height:44px}
.gx-navigation button:focus-visible,.gx-navigation input:focus-visible {outline:2px solid var(--primary);outline-offset:3px}
.gx-navigation__section {min-width:0;overflow-wrap:anywhere}
.gx-navigation__panel>* {margin:0}
.gx-navigation__suggestions,.gx-navigation__field {background:var(--glass-bg);color:var(--glass-text);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
.gx-navigation__panel .gx-btn {border-radius:14px;font-size:.85rem;min-height:44px;padding:8px 16px}
.gx-navigation__search {display:flex;flex-wrap:nowrap;gap:8px}
.gx-navigation__panel .gx-navigation__search {margin:0}
.gx-navigation__field {flex:1;display:flex;align-items:center;gap:8px;min-width:0;height:50px;padding:0 6px 0 16px;border-radius:25px}
.gx-navigation__field>i {opacity:.6}
.gx-navigation__field input {flex:1;min-width:0;height:100%;border:0;background:none;color:inherit;font:inherit;font-size:1rem;outline:none}
.gx-navigation .gx-navigation__field input:focus-visible {outline:none;box-shadow:none}
.gx-navigation__field:focus-within {border-color:var(--glass-text);box-shadow:0 0 0 1px var(--glass-border)}
.gx-navigation__icon {display:inline-grid;place-items:center;flex:0 0 auto;width:44px;height:44px;border:0;border-radius:50%;background:none;color:inherit;font-size:1.05rem;cursor:pointer}
.gx-navigation__icon:hover:not(:disabled),.gx-navigation__icon:focus-visible {background:var(--glass-active-bg)}
.gx-navigation__icon:disabled {opacity:.45;cursor:default}
.gx-navigation__icon[aria-pressed="true"] .bi-star-fill,.gx-navigation__save--on {color:#f5b400}
.gx-navigation__suggestions {display:grid;gap:2px;padding:6px;border-radius:16px;max-height:55vh;overflow:auto}
.gx-navigation__row {display:flex;flex-wrap:wrap;align-items:center;gap:2px}
.gx-navigation__favorite-choices {display:flex;gap:6px;flex:1 1 100%;margin:4px 0}
.gx-navigation__favorite-choices .gx-btn {flex:1;min-width:0;padding:6px 10px;display:flex;align-items:center;justify-content:center;gap:6px}
.gx-navigation__suggestion {flex:1;display:flex;align-items:center;gap:12px;min-width:0;padding:10px 12px;border:0;border-radius:12px;background:none;color:inherit;text-align:left;cursor:pointer;font:inherit}
.gx-navigation__suggestion:hover:not(:disabled),.gx-navigation__suggestion:focus-visible {background:var(--glass-active-bg)}
.gx-navigation__suggestion i {color:var(--primary);font-size:1.05rem;flex:0 0 auto;width:20px;text-align:center}
.gx-navigation__suggestion .bi-star-fill {color:#f5b400}
.gx-navigation__suggestion span {display:grid;min-width:0}
.gx-navigation__suggestion strong {font-weight:600;font-size:.92rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.gx-navigation__suggestion small {opacity:.7;font-size:.78rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.gx-navigation__group {display:flex;align-items:center;justify-content:space-between;padding:8px 12px 2px;font-size:.72rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase;opacity:.7}
.gx-navigation__link {border:0;background:none;color:var(--primary);font:inherit;letter-spacing:inherit;text-transform:inherit;cursor:pointer;padding:4px}
.gx-navigation__shortcuts {display:flex;gap:8px;overflow-x:auto;scrollbar-width:none;padding:2px}
.gx-navigation__shortcuts::-webkit-scrollbar {display:none}
.gx-navigation__chip {display:inline-flex;align-items:center;gap:8px;flex:0 0 auto;min-height:40px;max-width:220px;padding:0 14px;border-radius:20px;border:1px solid var(--glass-border);background:var(--glass-bg);color:var(--glass-text);box-shadow:var(--glass-shadow);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);font:inherit;font-size:.85rem;font-weight:600;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.gx-navigation__chip i {color:var(--primary)}
.gx-navigation__chip .bi-star-fill {color:#f5b400}
.gx-navigation__chip[aria-pressed="true"] {background:var(--primary);border-color:var(--primary);color:var(--on-primary)}
.gx-navigation__chip[aria-pressed="true"] i {color:inherit}
.gx-navigation__chip:disabled {opacity:.55;cursor:default}
.gx-navigation__usage {display:grid;grid-template-columns:auto 1fr;gap:4px 16px;margin:4px 0 0;font-size:.85rem}
.gx-navigation__usage dt {opacity:.7}
.gx-navigation__usage dd {margin:0}
.gx-navigation__heading {display:flex;align-items:flex-start;gap:8px}
.gx-navigation__heading>div {flex:1;min-width:0}
.gx-navigation__heading .gx-note {margin:2px 0 0;font-size:.8rem;overflow-wrap:anywhere}
.gx-navigation__summary-title {margin:0;font-size:1.15rem;line-height:1.25;overflow-wrap:anywhere}
.gx-navigation__summary>div {display:flex;flex-wrap:wrap;align-items:baseline;gap:4px 12px;margin:8px 0 0;font-size:.9rem}
.gx-navigation__summary strong {font-size:1.35rem;color:var(--success)}
.gx-navigation__route-actions {display:flex;gap:8px;margin-top:10px;padding-bottom:0}
.gx-navigation__route-actions {flex-wrap:wrap}
.gx-navigation__route-actions .gx-btn {flex:1;font-weight:600}
.gx-navigation__panel .gx-navigation__instruction {font-size:1rem;font-weight:650;margin:8px 0 0}
.gx-navigation__panel .gx-note {font-size:.85rem}
.gx-navigation__busy {padding:8px 14px;border-radius:12px;background:var(--glass-bg);font-size:.85rem;justify-self:start}
.gx-navigation__places {gap:0}
.gx-navigation__places>li+li {border-top:1px solid var(--glass-border)}
.gx-navigation__suggestion .gx-navigation__start {color:var(--primary);opacity:1;margin-top:4px}
.gx-navigation__shortcuts .gx-navigation__chip {background:none;border:0;border-radius:8px;box-shadow:none;backdrop-filter:none;-webkit-backdrop-filter:none}
.gx-navigation__shortcuts .gx-navigation__chip:hover {background:var(--glass-active-bg)}
.gx-navigation__places>li {padding:6px 0;gap:8px;font-size:.88rem;flex-direction:row;flex-wrap:wrap;align-items:center}
.gx-navigation__places>li>span {display:grid}
.gx-navigation__places .gx-note {display:block;font-size:.76rem}
.gx-navigation__actions {gap:4px}
.gx-navigation__alternatives {display:flex;flex-wrap:wrap;gap:8px;margin:10px 0 0}
.gx-navigation__alternatives .gx-btn {min-height:38px;padding:6px 12px}
.gx-navigation-map__last {position:absolute;bottom:48px;right:16px;background:var(--glass-bg);color:var(--glass-text);border-radius:12px;padding:8px 12px;font-size:12px}
.gx-navigation-map__controls {flex-direction:column}
.gx-navigation-map__control {display:grid;place-items:center;width:44px;height:44px;border-radius:12px;border:1px solid var(--glass-border);background:var(--glass-bg);color:var(--glass-text);box-shadow:var(--glass-shadow);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);font-size:1.1rem;cursor:pointer}
.gx-navigation-map__control--active {color:var(--primary)}
.gx-navigation-map > p {position:absolute;bottom:72px;right:16px;max-width:320px;background:var(--glass-bg);border-radius:12px;padding:12px;font-size:12px}
@media(max-width:1050px) {
.gx-navigation__workspace {grid-template-columns:minmax(0,1fr)}
.gx-navigation__preview {position:relative;top:auto}
.gx-navigation__preview .gx-navigation-map canvas {height:360px;height:clamp(260px,45svh,440px)}
}
@media(max-width:540px) {
.gx-navigation {padding-bottom:8px}
.gx-navigation__panel,.gx-navigation__preview-head {padding:12px}
.gx-navigation__panel {gap:8px}
.gx-navigation__workspace {gap:12px}
.gx-navigation__search {gap:6px}
.gx-navigation__panel .gx-navigation__search[role="search"] {flex-wrap:nowrap}
.gx-navigation__field {height:44px;padding-left:10px;border-radius:16px}
.gx-navigation__search[role="search"]>.gx-btn {width:auto;flex:none;padding-inline:10px}
.gx-navigation__search[role="search"]>.gx-btn>i {display:none}
.gx-navigation__search .gx-field {min-width:0;width:100%;flex-basis:100%}
.gx-navigation__places>li {flex-direction:row;align-items:center;padding:8px}
.gx-navigation__places>li>span {flex:1 1 100px}
.gx-navigation__places .gx-btn {padding-inline:10px}
.gx-navigation__actions {justify-content:flex-end}
.gx-navigation__route-actions {flex-direction:row}
.gx-navigation__route-actions .gx-btn {padding-inline:8px;font-size:.78rem}
.gx-navigation__route-actions .gx-btn>i {display:none}
.gx-navigation__usage {grid-template-columns:1fr}
.gx-navigation__usage dd {margin-bottom:8px}
.gx-navigation__suggestions {max-height:40svh;padding:4px}
.gx-navigation__suggestion {gap:8px;padding:6px 8px;min-height:44px}
.gx-navigation__preview .gx-navigation-map canvas {height:clamp(200px,32svh,300px)}
.gx-navigation__empty {min-height:180px;padding:16px}
}
.gx-navigation__token-link { display: inline-block; margin-bottom: var(--sp-4); }
@@ -0,0 +1,26 @@
/* Offline road maps for the Android Auto overlay (#/navigation, Offline Maps tab). */
.gx-navigation__offline { display: grid; gap: 16px; padding: 0 0 96px; }
.gx-offline-roads { padding: 24px; display: grid; gap: 14px; }
/* Keep the section header flush with the card's rounded top like every other Galaxy card. */
.gx-offline-roads > .gx-section__header { margin: -24px -24px 0; }
.gx-offline-roads__stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.gx-offline-roads__stat { display: grid; gap: 2px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, currentColor 6%, transparent); }
.gx-offline-roads__stat span { font-size: .78rem; opacity: .7; }
.gx-offline-roads__stat strong { font-size: 1.05rem; }
.gx-offline-roads__stat small { opacity: .7; font-size: .76rem; display: flex; flex-wrap: wrap; gap: 4px 12px; }
.gx-offline-roads__stat--wide { grid-column: 1 / -1; }
.gx-offline-roads__bar { display: flex; height: 8px; border-radius: 6px; overflow: hidden; background: color-mix(in srgb, currentColor 12%, transparent); margin: 6px 0 2px; }
.gx-offline-roads__bar i { display: block; height: 100%; transition: width .4s ease; }
.gx-offline-roads__legend { display: inline-flex; align-items: center; gap: 6px; }
.gx-offline-roads__legend b { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.gx-offline-roads__picker { display: grid; grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr); gap: 14px; align-items: start; }
@media (max-width: 760px) { .gx-offline-roads__picker { grid-template-columns: 1fr; } }
.gx-offline-roads__map { position: relative; border-radius: 16px; overflow: hidden; background: #0e0e1a; height: 340px; }
.gx-offline-roads__map canvas { width: 100%; height: 100%; display: block; cursor: crosshair; touch-action: none; }
.gx-offline-roads__map .gx-navigation-map__controls { position: absolute; right: 10px; top: 10px; display: grid; gap: 6px; }
.gx-offline-roads__hint { position: absolute; left: 12px; bottom: 10px; padding: 6px 10px; border-radius: 10px; background: #0e0e1acc; color: #e8e6f5; font-size: .8rem; pointer-events: none; }
.gx-offline-roads__form { display: grid; gap: 8px; }
.gx-offline-roads__areas { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.gx-offline-roads__areas li { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; background: color-mix(in srgb, currentColor 5%, transparent); }
.gx-offline-roads__area { display: grid; gap: 2px; min-width: 0; flex: 1; }
.gx-offline-roads__area small { opacity: .72; }
@@ -0,0 +1,70 @@
// Read-only Android Auto session logs. State is cleared on navigation and sign-out.
export const LOGS_URL = "./api/android-auto/logs"
export const BUNDLE_URL = `${LOGS_URL}/bundle`
export const fileUrl = (name) => `${LOGS_URL}/file/${encodeURIComponent(name)}`
// compat_report's outcome is a short phrase: "projected", "failed: <error>", "stopped at rfcomm", "no session".
export const outcomeLabel = (outcome) => (outcome ? outcome[0].toUpperCase() + outcome.slice(1) : "Unknown")
export class AndroidAutoLogsFeed {
constructor({ publish, unauthorized = () => {}, fetcher = (...args) => fetch(...args) }) {
Object.assign(this, { publish, unauthorized, fetcher })
this.active = false
this.generation = 0
this.request = null
}
clear() {
this.publish({ sessions: [], others: [], status: "idle", error: "" })
}
stop() {
this.active = false
this.generation++
this.request?.abort()
this.request = null
this.clear()
}
start() {
this.stop()
this.active = true
return this.load()
}
async load() {
if (!this.active) return
const generation = ++this.generation
this.request?.abort()
const request = new AbortController()
this.request = request
const current = () => this.active && generation === this.generation && !request.signal.aborted
this.publish({ status: "loading", error: "" })
try {
const response = await this.fetcher(LOGS_URL, { signal: request.signal, cache: "no-store" })
if (!current()) return
if (response.status === 401) {
this.stop()
this.unauthorized()
return
}
if (response.status === 503) {
const body = await response.json().catch(() => null)
if (!current()) return
if (["access_unavailable", "setup_required"].includes(body?.code)) {
this.stop()
this.unauthorized()
return
}
throw new Error("Android Auto logs are unavailable")
}
if (!response.ok) throw new Error("Android Auto logs are unavailable")
const body = await response.json()
if (!current()) return
this.publish({ sessions: body.sessions ?? [], others: body.others ?? [], status: "ready", error: "" })
} catch (error) {
if (!current() || error?.name === "AbortError") return
this.publish({ sessions: [], others: [], status: "unavailable", error: error?.message || "Android Auto logs are unavailable" })
}
}
}
@@ -0,0 +1,27 @@
export const NAV = Object.freeze([
{ name: "Home", path: "/", icon: "bi-house-fill" },
{ name: "Toggles", path: "/settings", icon: "bi-toggle-on" },
{ name: "Tools", path: "/tools", icon: "bi-tools" },
{ name: "Routes", path: "/recordings", icon: "bi-camera-reels" },
])
export function primaryTab(path) {
if (path === "/") return 0
if (path === "/settings" || path.startsWith("/settings/")) return 1
if (path === "/recordings" || path.startsWith("/recordings/")) return 3
return 2
}
export const BottomNav = {
props: { path: { type: String, 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"
: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>
</nav>`,
}
@@ -0,0 +1,13 @@
// Retire only the old phone router and downloaded app shell. Keep map downloads,
// Galaxy authentication, push subscriptions and all other browser preferences.
export async function retireLegacyPhone(target = globalThis) {
try {
for (const key of ["galaxy-companion-link", "galaxy-companion-ble-device", "galaxy-connection-preference", "galaxy-offline", "galaxy-home-screen-added"])
target.localStorage?.removeItem(key)
} catch { /* Private browsing can deny storage. */ }
try {
const workerURL = new URL("./sentry-push-worker.js", target.location.href).href
const registration = await target.navigator?.serviceWorker?.getRegistration(workerURL)
if (registration?.active?.scriptURL === workerURL) await registration.update()
} catch { /* Never delay opening Galaxy for a worker update. */ }
}
@@ -0,0 +1,18 @@
// Native modal behavior provides focus trapping, Escape, and focus restoration.
export const GxDialog = {
inheritAttrs: false,
props: { labelledby: { type: String, required: true }, describedby: String, alert: Boolean },
emits: ["close"],
mounted() { this.$refs.dialog.showModal() },
beforeUnmount() { this.$refs.dialog.close() },
methods: {
backdrop(event) {
if (event.target !== this.$refs.dialog) return
const rect = event.target.getBoundingClientRect()
if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) this.$emit("close")
},
},
template: `<Teleport to="body"><dialog ref="dialog" v-bind="$attrs" class="gx-card gx-settings__dialog gx-dialog"
:role="alert ? 'alertdialog' : 'dialog'" :aria-labelledby="labelledby" :aria-describedby="describedby"
@cancel.prevent="$emit('close')" @click="backdrop"><slot></slot></dialog></Teleport>`,
}
@@ -0,0 +1,9 @@
export function haptic() {
try { globalThis.navigator?.vibrate?.(15) } catch {}
}
export function toggleHaptic(event) {
const input = event.target
if (input.matches?.('.gx-switch input[type="checkbox"], input[role="switch"]') &&
!input.disabled && !input.closest('[inert]')) haptic()
}
@@ -0,0 +1,9 @@
// Familiar actions share a full touch target, accessible name and tooltip.
export const GxIconButton = {
name: "GxIconButton",
props: {
label: { type: String, required: true },
icon: { type: String, required: true },
},
template: `<button type="button" class="gx-icon-btn gx-action-icon" :aria-label="label" :title="label"><i class="bi" :class="icon" aria-hidden="true"></i></button>`,
}
@@ -0,0 +1,5 @@
export const GalaxyLoading = {
props: { message: { type: String, default: 'Just a moment!' }, refresh: Boolean },
methods: { reload() { location.reload() } },
template: `<section 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>{{ message }}</p><button v-if="refresh" type="button" class="gx-icon-btn" aria-label="Refresh Galaxy" title="Refresh Galaxy" @click="reload"><i class="bi bi-arrow-clockwise" aria-hidden="true"></i></button></section>`,
}
@@ -0,0 +1,19 @@
// Shared submenu entry for tool groups, driving preferences and cameras so
// the icon alignment, hover and press feedback are identical everywhere.
export const MenuTile = {
name: "MenuTile",
props: {
icon: { type: String, default: "" },
title: { type: String, required: true },
description: { type: String, default: "" },
availability: { type: String, default: "" },
disabled: { type: Boolean, default: false },
},
emits: ["select"],
template: `<button type="button" class="gx-menu-tile" :disabled="disabled" @click="!disabled && $emit('select')">
<i class="bi" :class="icon" aria-hidden="true"></i>
<span>{{ title }}</span>
<small v-if="description">{{ description }}</small>
<small v-if="availability" class="gx-menu-tile__availability">{{ availability }}</small>
</button>`,
}
@@ -0,0 +1,24 @@
// Shared status/warning banner used across Galaxy pages (ported from the
// original Galaxy "GxNotice" pattern so every page uses one consistent look).
export const GxNotice = {
name: "GxNotice",
props: {
tone: { type: String, default: "info" },
icon: { type: String, default: "" },
busy: Boolean,
title: { type: String, default: "" },
},
computed: {
toneClass() { return `gx-alert--${["info", "success", "warn", "danger"].includes(this.tone) ? this.tone : "info"}` },
iconClass() {
if (this.icon) return this.icon
if (this.tone === "success") return "bi-check-circle"
return this.tone === "danger" ? "bi-x-octagon" : this.tone === "warn" ? "bi-exclamation-triangle" : "bi-info-circle"
},
},
template: `<div class="gx-alert" :class="toneClass" :role="tone === 'danger' ? 'alert' : 'status'">
<span v-if="busy" class="gx-spinner gx-alert__icon" aria-hidden="true"></span>
<i v-else class="bi gx-alert__icon" :class="iconClass" aria-hidden="true"></i>
<div class="gx-alert__body"><strong v-if="title">{{ title }}</strong><div class="gx-alert__text"><slot /></div></div>
</div>`,
}
@@ -0,0 +1,226 @@
export const PROFILES = ["large", "compact"]
const COLORS = ["cardFill", "cardBorder", "text"]
export const ROAD_COLORS = ["path", "pathEdge", "laneLines"]
// "default" is a legacy saved value that now renders as acceleration colors.
export const PATH_MODES = ["default", "color", "rainbow", "acceleration"]
// Optional widgets start off and never block placement while off.
const OPTIONAL_WIDGETS = ["driving_mode_descriptions", "clock", "stopped_timer"]
export const HEX = /^#[0-9a-fA-F]{8}$/
const keysEqual = (value, keys) => value && typeof value === "object" && !Array.isArray(value) &&
Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key))
const positive = (value) => Number.isFinite(value) && value > 0 && value <= 8192
const object = (value) => value && typeof value === "object" && !Array.isArray(value)
const frontmostOrder = (order, profile) => {
const pinned = Object.entries(profile.widgets || {}).filter(([, widget]) => widget.frontmost === true).map(([id]) => id)
return pinned.length <= 1 && (!pinned.length || order.at(-1) === pinned[0])
}
// Display preferences ride along in the draft but are not part of the rendered layout.
export const layoutDocument = ({ display, ...document }) => document
// 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,
document: { ...data.document, display: { ...data.display.values } },
defaults: { ...data.defaults, display: { ...data.display.defaults } }
}
export function orderedWidgetIds(document, metadata, profile) {
return document.widgetOrder?.[profile] || metadata.profiles[profile].widgetOrder || Object.keys(metadata.profiles[profile].widgets)
}
export function widgetPalette(document, metadata, profile, id) {
const colors = metadata.profiles[profile].widgets[id].colors
return {
...Object.fromEntries(Object.entries(colors).map(([key, value]) => [key, value ?? document.palette[key]])),
...document.widgetColors[profile][id]
}
}
export const withAlpha = (color, factor) => color.slice(0, 7) + Math.floor(parseInt(color.slice(7), 16) * factor).toString(16).padStart(2, "0")
// Steering wheels resize as a square ("size"); box widgets such as the Android Auto map take a width and height.
export function widgetSize(widget, position) {
if (widget.iconSize && position?.display === "icons") return [widget.iconSize, widget.iconSize]
if (widget.box) return [position?.width ?? widget.width, position?.height ?? widget.height]
if (widget.resizable) return [position?.size ?? widget.width, position?.size ?? widget.height]
return [widget.width, widget.height]
}
export function placementLimits(profile, id, layout = null) {
const widget = profile.widgets[id], bounds = widget.bounds || profile.bounds
const [width, height] = widgetSize(widget, layout?.[id])
return {
minX: bounds.x, maxX: bounds.x + bounds.width - width,
minY: bounds.y, maxY: bounds.y + bounds.height - height
}
}
// The largest box that still fits from the widget's current corner, within its own limits.
export function clampBox(profile, id, position, width, height) {
const widget = profile.widgets[id], box = widget.box, bounds = widget.bounds || profile.bounds
if (!box || !Number.isFinite(width) || !Number.isFinite(height)) return null
return {
width: Math.round(Math.max(box.minWidth, Math.min(box.maxWidth, bounds.x + bounds.width - position.x, width))),
height: Math.round(Math.max(box.minHeight, Math.min(box.maxHeight, bounds.y + bounds.height - position.y, height)))
}
}
export function clampPlacement(profile, id, x, y, layout = null) {
if (!Number.isFinite(x) || !Number.isFinite(y) || !Object.hasOwn(profile.widgets, id)) return null
const { minX, maxX, minY, maxY } = placementLimits(profile, id, layout)
const position = { x: Math.max(minX, Math.min(maxX, Math.round(x))), y: Math.max(minY, Math.min(maxY, Math.round(y))) }
return overlapsReserved(profile, id, position.x, position.y, layout) ? null : position
}
export function overlapsReserved(profile, id, x, y, layout = null) {
const widget = profile.widgets[id]
if (widget.layer === "underlay") return false
const [width, height] = widgetSize(widget, layout?.[id])
const intersects = (left, top, otherWidth, otherHeight) => x < left + otherWidth && x + width > left &&
y < top + otherHeight && y + height > top
if (profile.reservedZones.some((zone) => intersects(zone.x, zone.y, zone.width, zone.height))) return true
const actions = profile.widgets.speed_limit_actions
if (!actions || profile.protectedWidget !== "speed_limit_actions") return false
const positions = layout || Object.fromEntries(Object.entries(profile.widgets).map(([key, item]) => [key, item.default]))
if (id === "speed_limit_actions") return Object.entries(profile.widgets).some(([key, other]) => {
const at = positions[key]
return key !== id && key !== "speed_limit" && other.layer !== "underlay" && at &&
!(OPTIONAL_WIDGETS.includes(key) && !at.enabled) && Number.isFinite(at.x) && Number.isFinite(at.y) &&
intersects(at.x, at.y, ...widgetSize(other, at))
})
if (id === "speed_limit") return false
const at = positions.speed_limit_actions
if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return true
return intersects(at.x, at.y, actions.width, actions.height)
}
export function previewPoint(event, rect, profile) {
if (!rect?.width || !rect?.height) return null
return {
x: (event.clientX - rect.left) * profile.width / rect.width,
y: (event.clientY - rect.top) * profile.height / rect.height
}
}
// Android Auto display switches, independent of the comma screen's preferences.
export const PROJECTION_DISPLAY_SWITCHES = ["blindSpotBorder", "signalBorder", "blindSpotCamera", "hidePersonalityAlerts", "wheelPedalFeedback"]
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 ||
(Object.hasOwn(document, "speedSources") && typeof document.speedSources !== "boolean") ||
(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") ||
!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) ||
!Object.entries(document.widgetOrder).every(([name, order]) => profiles.includes(name) && Array.isArray(order) &&
order.length === Object.keys(metadata.profiles[name].widgets).length && new Set(order).size === order.length &&
order.every(id => typeof id === "string" && Object.hasOwn(metadata.profiles[name].widgets, id)) &&
frontmostOrder(order, metadata.profiles[name])))) return false
return profiles.every((name) => {
const profile = metadata.profiles[name], layout = document.layouts[name]
const road = document.roadColors[name]
if (!object(road) || !Object.entries(road).every(([key, value]) => key === "pathMode" ? PATH_MODES.includes(value) :
ROAD_COLORS.includes(key) && typeof value === "string" && HEX.test(value))) return false
const colors = document.widgetColors[name]
if (!object(colors) || !Object.entries(colors).every(([id, overrides]) => {
const fields = profile.widgets[id]?.colors
return fields && Object.keys(fields).length > 0 && object(overrides) && Object.entries(overrides).every(([key, value]) =>
Object.hasOwn(fields, key) && typeof value === "string" && HEX.test(value))
})) return false
return keysEqual(layout, Object.keys(profile.widgets)) && Object.entries(layout).every(([id, position]) => {
const widget = profile.widgets[id], limits = placementLimits(profile, id, layout)
const resizable = widget.resizable, box = widget.box, opacity = widget.opacity
return keysEqual(position, ["x", "y", "enabled", ...(resizable ? ["size"] : []), ...(box ? ["width", "height"] : []),
...(opacity ? ["opacity"] : []), ...(widget.iconSize ? ["display"] : []), ...(widget.backgroundToggle ? ["background"] : [])]) &&
(!widget.iconSize || ["words", "icons"].includes(position.display)) &&
(!widget.backgroundToggle || typeof position.background === "boolean") &&
(!opacity || (Number.isInteger(position.opacity) && position.opacity >= opacity.min && position.opacity <= opacity.max)) &&
(!resizable || (Number.isInteger(position.size) && position.size >= resizable.min && position.size <= resizable.max)) &&
(!box || (Number.isInteger(position.width) && position.width >= box.minWidth && position.width <= box.maxWidth &&
Number.isInteger(position.height) && position.height >= box.minHeight && position.height <= box.maxHeight)) &&
typeof position.enabled === "boolean" && (!widget.required || position.enabled === true) &&
Number.isFinite(position.x) && Number.isFinite(position.y) &&
position.x >= limits.minX && position.x <= limits.maxX && position.y >= limits.minY && position.y <= limits.maxY &&
((OPTIONAL_WIDGETS.includes(id) && !position.enabled) || !overlapsReserved(profile, id, position.x, position.y, layout))
})
})
}
export function validSnapshot(data) {
const projection = data?.projection === true && data?.metadata?.projection === true
if ((data?.projection === true) !== (data?.metadata?.projection === true)) return false
const profiles = projection ? ["large"] : PROFILES
if (!data || typeof data.revision !== "string" || !data.revision || typeof data.editable !== "boolean" ||
typeof data.valid !== "boolean" || !keysEqual(data.metadata?.profiles, profiles) ||
!Array.isArray(data.metadata.paletteFields) || data.metadata.paletteFields.length !== COLORS.length ||
new Set(data.metadata.paletteFields.map((field) => field.id)).size !== COLORS.length ||
!data.metadata.paletteFields.every((field) => COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) ||
!Array.isArray(data.metadata.roadColorFields) || data.metadata.roadColorFields.length !== ROAD_COLORS.length ||
new Set(data.metadata.roadColorFields.map((field) => field.id)).size !== ROAD_COLORS.length ||
!data.metadata.roadColorFields.every((field) => ROAD_COLORS.includes(field.id) && typeof field.label === "string" && HEX.test(field.default)) ||
(data.activeProfile != null && !profiles.includes(data.activeProfile))) return false
for (const profile of Object.values(data.metadata.profiles)) {
if (profile.widgetOrder != null && (!Array.isArray(profile.widgetOrder) ||
profile.widgetOrder.length !== Object.keys(profile.widgets || {}).length ||
new Set(profile.widgetOrder).size !== profile.widgetOrder.length ||
!profile.widgetOrder.every(id => typeof id === "string" && Object.hasOwn(profile.widgets, id)) ||
!frontmostOrder(profile.widgetOrder, profile))) return false
if (profile.runtimePreviewWidgets != null && (!Array.isArray(profile.runtimePreviewWidgets) ||
new Set(profile.runtimePreviewWidgets).size !== profile.runtimePreviewWidgets.length ||
!profile.runtimePreviewWidgets.every(id => typeof id === "string" && Object.hasOwn(profile.widgets, id)))) return false
const bounds = profile.bounds
if (projection && (profile.widgets?.car_exit?.kind !== "car_exit" || profile.widgets.car_exit.required !== true)) return false
if (profile.protectedWidget != null && (profile.protectedWidget !== "speed_limit_actions" ||
profile.widgets?.[profile.protectedWidget]?.kind !== "speed_limit_actions")) return false
if (!positive(profile.width) || !positive(profile.height) || typeof profile.label !== "string" ||
!bounds || !Number.isFinite(bounds.x) || !Number.isFinite(bounds.y) || bounds.x < 0 || bounds.y < 0 ||
!positive(bounds.width) || !positive(bounds.height) || bounds.x + bounds.width > profile.width ||
bounds.y + bounds.height > profile.height || !profile.widgets || Array.isArray(profile.widgets) ||
Object.keys(profile.widgets).length > 32 || !Array.isArray(profile.reservedZones) || profile.reservedZones.length > 32) return false
for (const zone of profile.reservedZones) {
if (typeof zone.label !== "string" || !Number.isFinite(zone.x) || !Number.isFinite(zone.y) ||
!positive(zone.width) || !positive(zone.height) || zone.x < bounds.x || zone.y < bounds.y ||
zone.x + zone.width > bounds.x + bounds.width || zone.y + zone.height > bounds.y + bounds.height) return false
}
for (const widget of Object.values(profile.widgets)) {
const area = widget.bounds || bounds
if (widget.visualInsetTop != null && (!Number.isFinite(widget.visualInsetTop) || widget.visualInsetTop < 0 || widget.visualInsetTop > area.height)) return false
if (!object(widget.colors) || !Object.entries(widget.colors).every(([key, value]) =>
COLORS.includes(key) && (value === null || (typeof value === "string" && HEX.test(value))))) return false
if (!Number.isFinite(area.x) || !Number.isFinite(area.y) || area.x < 0 || area.y < 0 ||
!positive(area.width) || !positive(area.height) || area.x + area.width > profile.width ||
area.y + area.height > profile.height || typeof widget.label !== "string" || typeof widget.kind !== "string" ||
!positive(widget.width) || !positive(widget.height) || widget.width > area.width || widget.height > area.height) return false
if (widget.box && (data.metadata.projection !== true || widget.kind !== "nav_map" || !object(widget.opacity) ||
![widget.box.minWidth, widget.box.maxWidth, widget.box.minHeight, widget.box.maxHeight].every(Number.isInteger) ||
widget.box.minWidth < 24 || widget.box.minHeight < 24 || widget.box.minWidth > widget.box.maxWidth ||
widget.box.minHeight > widget.box.maxHeight || widget.box.maxWidth > area.width || widget.box.maxHeight > area.height ||
![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) ||
!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) ||
!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 ||
widget.resizable.min > widget.resizable.default || widget.resizable.default > widget.resizable.max)) return false
if (widget.iconSize != null && (!projection || !["nav_home", "nav_work", "bookmark"].includes(widget.kind) ||
!Number.isInteger(widget.iconSize) || widget.iconSize < 44 || widget.iconSize > Math.min(widget.width, widget.height))) return false
if (widget.required != null && (!projection || widget.kind !== "car_exit" || widget.required !== true)) return false
if (widget.frontmost != null && (!projection || widget.kind !== "car_exit" || widget.frontmost !== true)) return false
if (widget.kind === "car_exit" && (widget.required !== true || widget.frontmost !== true)) return false
}
}
return validDocument(data.document, data.metadata) && validDocument(data.defaults, data.metadata)
}
@@ -0,0 +1,24 @@
// Shared background synchronization. Reads never replay a user's mutation.
export class PollTimer {
constructor({ read, interval = 10000, later = (fn, ms) => setTimeout(fn, ms), cancel = id => clearTimeout(id) }) {
Object.assign(this, { read, interval, later, cancel })
this.active = false
this.generation = 0
this.timer = null
}
start() { this.stop(); this.active = true; this.schedule() }
stop() { this.generation++; this.active = false; this.cancel(this.timer); this.timer = null }
schedule() {
if (!this.active || this.timer !== null) return
const generation = this.generation
this.timer = this.later(async () => {
if (!this.active || generation !== this.generation) return
this.timer = null
try { if (typeof document === "undefined" || !document.hidden) await this.read() }
finally { if (generation === this.generation) this.schedule() }
}, this.interval)
}
}
export const connectionError = (error, fallback = "Galaxy could not connect. Reconnecting automatically…") =>
error instanceof TypeError || error?.name === "NetworkError" || error?.name === "AbortError" || error?.name === "TimeoutError" ? fallback : error?.message || fallback
@@ -0,0 +1,67 @@
export function createRouteLeave(port) {
let current = port.read()
let guard = null
let restoring = null
let approved = null
let generation = 0
const commit = () => { current = port.read(); port.show() }
const ask = (proceed, back = false) => {
const request = ++generation
const once = () => { if (request === generation) { generation++; proceed() } }
if (guard) guard(once, { back })
else once()
}
return {
setGuard(value) { guard = value },
ensureBackEntry() {
if (current.index === 0 && current.path !== "/") {
const path = current.path
port.replace("/", 0)
port.push(path, 1)
commit()
}
},
navigate(path, before = null) {
if (path === current.path && !before) return
ask(() => {
if (before) before()
if (path !== current.path) { port.push(path, current.index + 1); commit() }
})
},
back() {
ask(() => {
if (current.index > 0) { approved = { index: current.index - 1 }; port.go(-1) }
else if (current.path !== "/") { port.replace("/", 0); commit() }
}, true)
},
changed() {
let target = port.read()
if (restoring) {
if (target.path === current.path && target.index === current.index) {
const move = restoring
restoring = null
ask(() => { approved = move.target; port.go(move.delta) }, move.delta < 0)
}
return
}
if (approved && target.index === approved.index && (!approved.path || target.path === approved.path)) {
approved = null
commit()
return
}
if (target.path === current.path && target.index === current.index) return
if (target.index === null || target.index === current.index) {
port.replace(target.path, current.index + 1)
target = port.read()
}
const delta = target.index - current.index
if (delta === 0) {
port.replace(current.path, current.index)
ask(() => { port.replace(target.path, target.index); commit() })
} else {
restoring = { target, delta }
port.go(-delta)
}
},
}
}
@@ -0,0 +1,62 @@
import { GxDialog } from "./dialog.js"
export const SettingsRecoveryPage = {
components: { GxDialog },
props: { mode: { type: String, default: "local" }, unauthorized: Function },
data() { return { snapshot: null, selected: [], pending: null, busy: false, error: "", result: "", timer: null } },
mounted() { if (this.mode !== "local") return; this.refresh(); this.timer = setInterval(() => { if (!this.pending && !this.busy) this.refresh() }, 10000) },
beforeUnmount() { clearInterval(this.timer) },
methods: {
async request(path, body) {
const response = await fetch(path, { method: body ? "POST" : "GET", credentials: "same-origin", cache: "no-store",
headers: body ? { "Content-Type": "application/json" } : {}, ...(body ? { body: JSON.stringify(body) } : {}) })
const value = await response.json()
if (response.status === 401 && this.unauthorized) this.unauthorized()
if (!response.ok) throw new Error(value.error || "Settings are unavailable")
return value
},
async refresh() {
try {
const selected = new Set((this.snapshot?.rows || []).filter(row => this.selected.includes(row.id)).map(row => `${row.page}:${row.label}`))
this.snapshot = await this.request("./api/settings/non-defaults")
this.selected = this.snapshot.rows.filter(row => row.resetAvailable && selected.has(`${row.page}:${row.label}`)).map(row => row.id)
this.error = ""
} catch (error) { this.error = error.message }
},
async review(ids) {
if (!ids.length || this.busy) return
this.busy = true
try { this.pending = await this.request("./api/settings/recovery/preview", { view: this.snapshot.view, selected: ids }) }
catch (error) { this.error = error.message; await this.refresh() }
finally { this.busy = false }
},
async confirm() {
this.busy = true
try {
const value = await this.request("./api/settings/recovery/confirm", { intent: this.pending.intent, confirmed: true })
this.result = value.saved ? "Selected settings restored to defaults." : `${value.applied.length} settings restored. ${value.reason}`
this.pending = null; this.selected = []; await this.refresh()
} catch (error) { this.error = error.message; this.pending = null; await this.refresh() }
finally { this.busy = false }
},
},
template: `<section class="gx-settings"><h1>Show Non-Default Values</h1>
<p>Review saved settings that differ from this vehicle’s StarPilot defaults.</p>
<p v-if="snapshot && snapshot.resettableCount < snapshot.count">Some settings are unavailable for this vehicle. Their saved values remain unchanged.</p>
<p v-if="error" role="alert">{{ error }}</p><p v-if="result" role="status">{{ result }}</p>
<div class="gx-card" v-if="snapshot"><p v-if="!snapshot.rows.length">All available settings use their defaults.</p>
<div v-for="row in snapshot.rows" :key="row.id" class="gx-setting-row gx-setting-row--modified">
<label><input type="checkbox" v-model="selected" :value="row.id" :disabled="busy || !row.resetAvailable"> {{ row.label }}</label>
<p>Current: <strong>{{ row.current }}</strong> · Default: {{ row.default }}</p>
<p v-if="!row.available">{{ row.reason }}</p>
<button @click="review([row.id])" :disabled="busy || !row.resetAvailable">Reset to Default</button>
</div>
<button @click="review(selected)" :disabled="busy || !selected.length">Reset Selected</button>
<button @click="review(snapshot.rows.filter(row => row.resetAvailable).map(row => row.id))" :disabled="busy || !snapshot.resettableCount">Reset All Non-Default Settings</button>
<button @click="refresh" :disabled="busy">Refresh</button>
</div>
<GxDialog v-if="pending" labelledby="recovery-question" @close="pending = null">
<h2 id="recovery-question">{{ pending.question }}</h2><ul><li v-for="label in pending.labels" :key="label">{{ label }}</li></ul>
<button @click="pending = null" :disabled="busy">Cancel</button><button @click="confirm" :disabled="busy">Reset Settings</button>
</GxDialog></section>`,
}
@@ -0,0 +1,86 @@
import { PollTimer, connectionError } from "./polling.js"
export class SnapshotFeed {
static interval = 10000
static unavailable = "This information is unavailable."
static invalid = "This response is unavailable."
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.status = "idle"
this.data = null
this.error = ""
this.poller = new PollTimer({ read: () => this.load(), interval: this.constructor.interval, later, cancel: cancelTimer })
}
emit() { this.publish({ status: this.status, data: this.data, error: this.error }) }
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.status = "idle"
this.data = null
this.error = ""
this.emit()
}
start() { this.stop(); this.active = true; if (this.constructor.interval) this.poller.start(); return this.load() }
async load() {
if (!this.active || this.request !== null) return
const generation = this.generation
const request = new AbortController()
this.request = request
if (!this.data && !this.error) { this.status = "loading"; this.emit() }
this.timer = this.later(() => {
if (!this.active || generation !== this.generation || this.request !== request) return
request.abort()
this.generation++
this.request = this.timer = null
this.data = null
this.status = "unavailable"
this.error = `Reading ${this.constructor.subject} timed out. Reconnecting automatically…`
this.emit()
}, 4000)
try {
const response = await this.fetcher(this.constructor.endpoint, { credentials: "same-origin", cache: "no-store", signal: request.signal })
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 (["setup_required", "access_unavailable"].includes(body?.code)) { this.stop(); this.unauthorized(); return }
throw new Error(this.constructor.unavailable)
}
if (!response.ok) throw new Error(this.constructor.unavailable)
const data = await response.json()
if (!this.active || generation !== this.generation || request.signal.aborted) return
if (!this.constructor.valid(data)) throw new Error(this.constructor.invalid)
this.data = data
this.status = "ready"
this.error = ""
this.emit()
} catch (error) {
if (this.active && generation === this.generation) {
this.data = null
this.status = "unavailable"
this.error = connectionError(error, this.constructor.unavailable + " Reconnecting automatically…")
this.emit()
}
} finally {
if (generation === this.generation) {
if (this.timer !== null) this.cancelTimer(this.timer)
this.timer = null
this.request = null
}
}
}
}
@@ -0,0 +1,10 @@
// Centered page-level loading, empty, and unavailable states. Inline feedback uses GxNotice.
export const GxState = {
name: "GxState",
props: { loading: Boolean, title: String, tone: { type: String, default: "info" } },
template: `<section class="gx-card gx-message" :role="tone === 'danger' ? 'alert' : 'status'" :aria-busy="loading || undefined">
<span v-if="loading" class="gx-spinner" aria-hidden="true"></span>
<h3 v-if="title">{{ title }}</h3><div class="gx-message__body"><slot /></div>
<div v-if="$slots.actions" class="gx-actions gx-message__actions"><slot name="actions" /></div>
</section>`,
}
@@ -0,0 +1,5 @@
// Numeric summaries get equal space instead of squeezing the section heading.
export const GxSummary = {
props: { items: { type: Array, required: true } },
template: `<dl class="gx-summary"><div v-for="item in items" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div></dl>`,
}
@@ -0,0 +1,8 @@
// Keep unsaved drafts and pending saves on the page, including during host live reload.
export function guardUnload(shouldBlock) {
const listener = event => {
if (shouldBlock()) { event.preventDefault(); event.returnValue = "" }
}
window.addEventListener("beforeunload", listener)
return () => window.removeEventListener("beforeunload", listener)
}
@@ -0,0 +1,33 @@
// Pixels and sprite locations come from the actual onroad renderer, not browser widget copies.
export async function widgetArtMetadata(blob) {
const bytes = new Uint8Array(await blob.arrayBuffer())
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
if (bytes.length < 45 || bytes.length > 4 * 1024 * 1024 ||
![137,80,78,71,13,10,26,10].every((value, index) => bytes[index] === value)) throw new Error("Invalid widget art")
const text = new TextDecoder(), signature = "starpilot-widget-art\0"
let metadata = null
for (let offset = 8; offset + 12 <= bytes.length;) {
const size = view.getUint32(offset), type = text.decode(bytes.subarray(offset + 4, offset + 8))
if (size > bytes.length - offset - 12) throw new Error("Invalid widget art chunk")
if (type === "tEXt") {
const value = text.decode(bytes.subarray(offset + 8, offset + 8 + size))
if (value.startsWith(signature)) {
if (metadata || size > 8192) throw new Error("Invalid widget art metadata")
metadata = JSON.parse(value.slice(signature.length))
}
}
offset += size + 12
}
if (!metadata || metadata.version !== 1 || !Number.isInteger(metadata.width) || !Number.isInteger(metadata.height) ||
metadata.width < 1 || metadata.width > 1024 || metadata.height < 1 || metadata.height > 1440 ||
metadata.width !== view.getUint32(16) || metadata.height !== view.getUint32(20) ||
!metadata.sprites || typeof metadata.sprites !== "object" || Array.isArray(metadata.sprites) ||
Object.keys(metadata.sprites).length > 7) throw new Error("Invalid widget art metadata")
for (const [id, rect] of Object.entries(metadata.sprites)) {
if (!/^[a-z_]+$/.test(id) || !rect || ![rect.x,rect.y,rect.width,rect.height].every(Number.isInteger) ||
rect.x < 0 || rect.y < 0 || rect.width < 1 || rect.height < 1 ||
rect.x + rect.width > metadata.width || rect.y + rect.height > metadata.height ||
![rect.offsetX ?? 0, rect.offsetY ?? 0].every(value => Number.isFinite(value) && value > -1 && value <= 0)) throw new Error("Invalid widget art rectangle")
}
return metadata
}