diff --git a/openpilot/starpilot/galaxy/access.py b/openpilot/starpilot/galaxy/access.py
new file mode 100644
index 0000000000..c277393c0b
--- /dev/null
+++ b/openpilot/starpilot/galaxy/access.py
@@ -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())
diff --git a/openpilot/starpilot/galaxy/auth.py b/openpilot/starpilot/galaxy/auth.py
new file mode 100644
index 0000000000..2ec1dc4e22
--- /dev/null
+++ b/openpilot/starpilot/galaxy/auth.py
@@ -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)
diff --git a/openpilot/starpilot/galaxy/hotspot.py b/openpilot/starpilot/galaxy/hotspot.py
new file mode 100644
index 0000000000..da49b51948
--- /dev/null
+++ b/openpilot/starpilot/galaxy/hotspot.py
@@ -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())
diff --git a/openpilot/starpilot/galaxy/hotspot_daemon.py b/openpilot/starpilot/galaxy/hotspot_daemon.py
new file mode 100644
index 0000000000..54a1bd6f0f
--- /dev/null
+++ b/openpilot/starpilot/galaxy/hotspot_daemon.py
@@ -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()
diff --git a/openpilot/starpilot/galaxy/hotspot_netlink.py b/openpilot/starpilot/galaxy/hotspot_netlink.py
new file mode 100644
index 0000000000..46bd1cfea2
--- /dev/null
+++ b/openpilot/starpilot/galaxy/hotspot_netlink.py
@@ -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)
diff --git a/openpilot/starpilot/galaxy/hotspot_worker.py b/openpilot/starpilot/galaxy/hotspot_worker.py
new file mode 100644
index 0000000000..e8b7f1acf4
--- /dev/null
+++ b/openpilot/starpilot/galaxy/hotspot_worker.py
@@ -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()
diff --git a/openpilot/starpilot/galaxy/http_server.py b/openpilot/starpilot/galaxy/http_server.py
new file mode 100644
index 0000000000..19dddf3760
--- /dev/null
+++ b/openpilot/starpilot/galaxy/http_server.py
@@ -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)
diff --git a/openpilot/starpilot/galaxy/local_access.py b/openpilot/starpilot/galaxy/local_access.py
new file mode 100644
index 0000000000..911f35e039
--- /dev/null
+++ b/openpilot/starpilot/galaxy/local_access.py
@@ -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
diff --git a/openpilot/starpilot/galaxy/tests/browser/compact-actions.cjs b/openpilot/starpilot/galaxy/tests/browser/compact-actions.cjs
new file mode 100644
index 0000000000..3bba752694
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/compact-actions.cjs
@@ -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 = '' })
+
+ 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) })
diff --git a/openpilot/starpilot/galaxy/tests/browser/design-system.cjs b/openpilot/starpilot/galaxy/tests/browser/design-system.cjs
new file mode 100644
index 0000000000..b2bcb68f96
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/design-system.cjs
@@ -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 = '
'
+ })
+ 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: `
+ Time on the Road
Saved history
Android Auto
Connect your car
Reading your device…Nothing here yet.
+ Preferences saved.An error occurred.
+
+
+ Confirm preference
`,
+ }).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 = ''
+ 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) })
diff --git a/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs b/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs
new file mode 100644
index 0000000000..bd6b4e4d56
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/navigation-compact.cjs
@@ -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:
+ `
+ ` })
+ if (pathname === '/') return route.fulfill({ contentType: 'text/html', body: `
+
+ ` })
+ 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 })
diff --git a/openpilot/starpilot/galaxy/tests/browser/navigation-preferences.cjs b/openpilot/starpilot/galaxy/tests/browser/navigation-preferences.cjs
new file mode 100644
index 0000000000..986b966fb5
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/navigation-preferences.cjs
@@ -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: `
+
+ ` })
+ 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 })
diff --git a/openpilot/starpilot/galaxy/tests/browser/regressions.cjs b/openpilot/starpilot/galaxy/tests/browser/regressions.cjs
new file mode 100644
index 0000000000..82cbbacaa1
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/regressions.cjs
@@ -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)})
diff --git a/openpilot/starpilot/galaxy/tests/browser/settings-state.cjs b/openpilot/starpilot/galaxy/tests/browser/settings-state.cjs
new file mode 100644
index 0000000000..6fa31b9087
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/settings-state.cjs
@@ -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:
+ '' }))
+ 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 })
diff --git a/openpilot/starpilot/galaxy/tests/browser/status-consistency.cjs b/openpilot/starpilot/galaxy/tests/browser/status-consistency.cjs
new file mode 100644
index 0000000000..df487e3b4e
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/status-consistency.cjs
@@ -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:``}).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)})
diff --git a/openpilot/starpilot/galaxy/tests/browser/tab-motion.cjs b/openpilot/starpilot/galaxy/tests/browser/tab-motion.cjs
new file mode 100644
index 0000000000..78a4e3d87c
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/tab-motion.cjs
@@ -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) })
diff --git a/openpilot/starpilot/galaxy/tests/browser/topbar.cjs b/openpilot/starpilot/galaxy/tests/browser/topbar.cjs
new file mode 100644
index 0000000000..a21e8eadfb
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/topbar.cjs
@@ -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) })
diff --git a/openpilot/starpilot/galaxy/tests/browser/ui-consistency.cjs b/openpilot/starpilot/galaxy/tests/browser/ui-consistency.cjs
new file mode 100644
index 0000000000..368a18f8c8
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/ui-consistency.cjs
@@ -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) })
diff --git a/openpilot/starpilot/galaxy/tests/browser/widget-layers.cjs b/openpilot/starpilot/galaxy/tests/browser/widget-layers.cjs
new file mode 100644
index 0000000000..e6dd459a2e
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/browser/widget-layers.cjs
@@ -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:
+ ``})
+ if (pathname.startsWith('/api/')) return route.fulfill({json: pathname === '/api/android-auto/layout' ? projection : snapshot})
+ if (pathname === '/') return route.fulfill({contentType:'text/html', body:``})
+ 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})
diff --git a/openpilot/starpilot/galaxy/tests/description-key-inventory.json b/openpilot/starpilot/galaxy/tests/description-key-inventory.json
new file mode 100644
index 0000000000..1d34d2afa6
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/description-key-inventory.json
@@ -0,0 +1 @@
+["AggressiveFollow","AggressiveFollowHigh","AggressiveJerkAcceleration","AggressiveJerkDanger","AggressiveJerkDeceleration","AggressiveJerkSpeed","AggressiveJerkSpeedDecrease","AlwaysAllowUploads","AlwaysOnLateral","AolBrakePauseSpeedMps","BlindSpotMetrics","CameraView","CurveSpeedController","CurveSpeedControllerNoLead","CustomCruise","CustomCruiseLong","CustomPersonalities","DisableOpenpilotLongitudinal","DisengageVolume","DriverCamera","EVTuning","EngageVolume","FasterLeadTakeoff","ForceAutoTuneOff","ForceStopDistanceOffset","ForceStops","FordLateralMode","GMAutoHold","GMLongitudinalTune","GMPedalLongitudinal","HKGRemoteStartBootsComma","HideLeadMarker","HidePersonalityAlerts","IgnoreIgnitionLine","KeepScreenOnGpuLoading","LaneCenterOffset","LaneCentering","LaneCenteringE2EAuthority","LaneCenteringPauseOnSignal","LaneCenteringStrength","LateralControllerSelection","LateralResumeDelay","LeadApproachBuffer","LeadInfo","LongPitch","NostalgiaMode","Offset1","Offset2","Offset3","Offset4","Offset5","Offset6","Offset7","PIPPreviewEnabled","PIPPreviewInvert","PIPPreviewShowOnBSM","PIPPreviewShowOnBlinker","PauseLateralOnSignal","PauseLateralSpeed","PromptDistractedVolume","PromptVolume","RadarRecoveryAlert","RefuseVolume","RelaxedFollow","RelaxedFollowHigh","RelaxedJerkAcceleration","RelaxedJerkDanger","RelaxedJerkDeceleration","RelaxedJerkSpeed","RelaxedJerkSpeedDecrease","RemoteStartBootsComma","ReverseCruise","SLCConfirmation","SLCConfirmationHigher","SLCConfirmationLower","SLCFallback","SLCPriority1","SLCPriority2","ScreenBrightness","ScreenBrightnessOnroad","ScreenTimeout","ScreenTimeoutOnroad","ShowBrakeStatus","ShowCSCStatus","ShowSpeedLimits","SignalMetrics","SoundPack","SpeedLimitController","StandardFollow","StandardFollowHigh","StandardJerkAcceleration","StandardJerkDanger","StandardJerkDeceleration","StandardJerkSpeed","StandardJerkSpeedDecrease","StarPilotDisplayPreferencesEnabled","SteerFriction","SteerLatAccel","StockConfidenceBallWidget","TeslaAOLDisengageOnBrake","TeslaAOLScreenTap","TeslaWakeOnCAN","ToyotaAutoHold","ToyotaRadarMode","TrafficFollow","TrafficJerkAcceleration","TrafficJerkDanger","TrafficJerkDeceleration","TrafficJerkSpeed","TrafficJerkSpeedDecrease","TruckTuning","TurnAssist","TurnOffCommaScreen","UseStarPilotLongitudinalPlanner","VoltOnePedalMode","VoltSNG","WarningImmediateVolume","WarningSoftVolume","conditional:button:DistanceButtonControl","conditional:button:LongDistanceButtonControl","conditional:button:LongModeButtonControl","conditional:button:LongStarButtonControl","conditional:button:ModeButtonControl","conditional:button:StarButtonControl","conditional:button:VeryLongDistanceButtonControl","conditional:button:VeryLongModeButtonControl","conditional:button:VeryLongStarButtonControl","conditional:ccm:launch_assist","conditional:ccm:lead","conditional:ccm:persist_manual","conditional:ccm:set_speed_margin_mps","conditional:ccm:speed_mps","conditional:ccm:speed_with_lead_mps","conditional:cem:curves","conditional:cem:curves_with_lead","conditional:cem:lead","conditional:cem:model_stop_s","conditional:cem:open_road","conditional:cem:persist_manual","conditional:cem:signal_lane_detection","conditional:cem:signal_lane_width_m","conditional:cem:signal_speed_mps","conditional:cem:slower_lead","conditional:cem:speed_mps","conditional:cem:speed_with_lead_mps","conditional:cem:stop_lights","conditional:cem:stopped_lead","conditional:manual_reset","conditional:mode","conditional:reset","curve_reset","display:auto:ScreenBrightness","display:auto:ScreenBrightnessOnroad","lane_change:auto","lane_change:close_gap","lane_change:close_gap_seconds","lane_change:delay","lane_change:enabled","lane_change:one","lane_change:pace","lane_change:reset","lane_change:speed","lane_change:width","long_repair:AggressiveFollow","long_repair:AggressiveFollowHigh","long_repair:AggressiveJerkAcceleration","long_repair:AggressiveJerkDanger","long_repair:AggressiveJerkDeceleration","long_repair:AggressiveJerkSpeed","long_repair:AggressiveJerkSpeedDecrease","long_repair:CustomPersonalities","long_repair:RelaxedFollow","long_repair:RelaxedFollowHigh","long_repair:RelaxedJerkAcceleration","long_repair:RelaxedJerkDanger","long_repair:RelaxedJerkDeceleration","long_repair:RelaxedJerkSpeed","long_repair:RelaxedJerkSpeedDecrease","long_repair:StandardFollow","long_repair:StandardFollowHigh","long_repair:StandardJerkAcceleration","long_repair:StandardJerkDanger","long_repair:StandardJerkDeceleration","long_repair:StandardJerkSpeed","long_repair:StandardJerkSpeedDecrease","long_reset:aggressive","long_reset:relaxed","long_reset:standard","pip:format:1344x760","pip:format:1928x1208","pip:mask:editor","pip:mask:left:x","pip:mask:left:y","pip:mask:right:x","pip:mask:right:y","pip:mask:size","pip:reset","profile:aggressive:acceleration","profile:aggressive:acceleration:0","profile:aggressive:acceleration:1","profile:aggressive:acceleration:2","profile:aggressive:acceleration:3","profile:aggressive:acceleration:4","profile:aggressive:acceleration:5","profile:aggressive:acceleration:6","profile:aggressive:acceleration:7","profile:aggressive:acceleration:8","profile:aggressive:acceleration:9","profile:aggressive:braking","profile:aggressive:braking:0","profile:aggressive:braking:1","profile:aggressive:braking:2","profile:aggressive:braking:3","profile:aggressive:braking:4","profile:aggressive:braking:5","profile:aggressive:braking:6","profile:aggressive:braking:7","profile:aggressive:braking:8","profile:aggressive:braking:9","profile:aggressive:following","profile:aggressive:following:0","profile:aggressive:following:1","profile:aggressive:following:2","profile:aggressive:following:3","profile:aggressive:following:4","profile:aggressive:following:5","profile:aggressive:following:6","profile:aggressive:following:7","profile:aggressive:following:8","profile:aggressive:following:9","profile:global_acceleration","profile:global_braking","profile:relaxed:acceleration","profile:relaxed:acceleration:0","profile:relaxed:acceleration:1","profile:relaxed:acceleration:2","profile:relaxed:acceleration:3","profile:relaxed:acceleration:4","profile:relaxed:acceleration:5","profile:relaxed:acceleration:6","profile:relaxed:acceleration:7","profile:relaxed:acceleration:8","profile:relaxed:acceleration:9","profile:relaxed:braking","profile:relaxed:braking:0","profile:relaxed:braking:1","profile:relaxed:braking:2","profile:relaxed:braking:3","profile:relaxed:braking:4","profile:relaxed:braking:5","profile:relaxed:braking:6","profile:relaxed:braking:7","profile:relaxed:braking:8","profile:relaxed:braking:9","profile:relaxed:following","profile:relaxed:following:0","profile:relaxed:following:1","profile:relaxed:following:2","profile:relaxed:following:3","profile:relaxed:following:4","profile:relaxed:following:5","profile:relaxed:following:6","profile:relaxed:following:7","profile:relaxed:following:8","profile:relaxed:following:9","profile:standard:acceleration","profile:standard:acceleration:0","profile:standard:acceleration:1","profile:standard:acceleration:2","profile:standard:acceleration:3","profile:standard:acceleration:4","profile:standard:acceleration:5","profile:standard:acceleration:6","profile:standard:acceleration:7","profile:standard:acceleration:8","profile:standard:acceleration:9","profile:standard:braking","profile:standard:braking:0","profile:standard:braking:1","profile:standard:braking:2","profile:standard:braking:3","profile:standard:braking:4","profile:standard:braking:5","profile:standard:braking:6","profile:standard:braking:7","profile:standard:braking:8","profile:standard:braking:9","profile:standard:following","profile:standard:following:0","profile:standard:following:1","profile:standard:following:2","profile:standard:following:3","profile:standard:following:4","profile:standard:following:5","profile:standard:following:6","profile:standard:following:7","profile:standard:following:8","profile:standard:following:9","profile:traffic:acceleration","profile:traffic:acceleration:0","profile:traffic:acceleration:1","profile:traffic:acceleration:2","profile:traffic:acceleration:3","profile:traffic:acceleration:4","profile:traffic:acceleration:5","profile:traffic:acceleration:6","profile:traffic:acceleration:7","profile:traffic:acceleration:8","profile:traffic:acceleration:9","profile:traffic:braking","profile:traffic:braking:0","profile:traffic:braking:1","profile:traffic:braking:2","profile:traffic:braking:3","profile:traffic:braking:4","profile:traffic:braking:5","profile:traffic:braking:6","profile:traffic:braking:7","profile:traffic:braking:8","profile:traffic:braking:9","profile:traffic:following","profile:traffic:following:0","profile:traffic:following:1","profile:traffic:following:2","profile:traffic:following:3","profile:traffic:following:4","profile:traffic:following:5","profile:traffic:following:6","profile:traffic:following:7","profile:traffic:following:8","profile:traffic:following:9","reset_profiles","sentry:enabled","sentry:reset","sentry:sensitivity","sentry:warning","slc_adopt","slc_reset","sounds:auto:DisengageVolume","sounds:auto:EngageVolume","sounds:auto:PromptDistractedVolume","sounds:auto:PromptVolume","sounds:auto:RefuseVolume","sounds:auto:WarningImmediateVolume","sounds:auto:WarningSoftVolume","torque:automatic_delay:value","torque:factor:mode","torque:factor:reset","torque:factor:value","torque:friction:mode","torque:friction:reset","torque:friction:value","torque:full_delay:mode","torque:full_delay:value","torque:gain:mode","torque:gain:value","torque:geometry_reset:value","torque:learning:value","torque:ratio:mode","torque:ratio:value","torque_prepare_firestar","vasm:annotation","vasm:confidence","vasm:enabled","vasm:reset","vasm:smooth","wheel:CancelButtonControl","wheel:DistanceButtonControl","wheel:LKASButtonControl","wheel:LongCancelButtonControl","wheel:LongDistanceButtonControl","wheel:LongModeButtonControl","wheel:LongStarButtonControl","wheel:MainCruiseButtonControl","wheel:ModeButtonControl","wheel:StarButtonControl","wheel:VeryLongCancelButtonControl","wheel:VeryLongDistanceButtonControl","wheel:VeryLongModeButtonControl","wheel:VeryLongStarButtonControl"]
diff --git a/openpilot/starpilot/galaxy/tests/test_access.py b/openpilot/starpilot/galaxy/tests/test_access.py
new file mode 100644
index 0000000000..85aec6a66f
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_access.py
@@ -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)
diff --git a/openpilot/starpilot/galaxy/tests/test_auth_client.mjs b/openpilot/starpilot/galaxy/tests/test_auth_client.mjs
new file mode 100644
index 0000000000..6a71b6de2a
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_auth_client.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/tests/test_connection_migration.mjs b/openpilot/starpilot/galaxy/tests/test_connection_migration.mjs
new file mode 100644
index 0000000000..ce7dc40d49
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_connection_migration.mjs
@@ -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({})
diff --git a/openpilot/starpilot/galaxy/tests/test_hotspot.py b/openpilot/starpilot/galaxy/tests/test_hotspot.py
new file mode 100644
index 0000000000..fd31181659
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_hotspot.py
@@ -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)
diff --git a/openpilot/starpilot/galaxy/tests/test_local_access.mjs b/openpilot/starpilot/galaxy/tests/test_local_access.mjs
new file mode 100644
index 0000000000..242f3e55b5
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_local_access.mjs
@@ -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')
diff --git a/openpilot/starpilot/galaxy/tests/test_polling.mjs b/openpilot/starpilot/galaxy/tests/test_polling.mjs
new file mode 100644
index 0000000000..1f6f7908be
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_polling.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/tests/test_route_leave.mjs b/openpilot/starpilot/galaxy/tests/test_route_leave.mjs
new file mode 100644
index 0000000000..cd2c219f2e
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_route_leave.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/tests/test_route_leave_async.mjs b/openpilot/starpilot/galaxy/tests/test_route_leave_async.mjs
new file mode 100644
index 0000000000..65bf3cdaf1
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_route_leave_async.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/tests/test_unload_guard.mjs b/openpilot/starpilot/galaxy/tests/test_unload_guard.mjs
new file mode 100644
index 0000000000..6faeb5a4af
--- /dev/null
+++ b/openpilot/starpilot/galaxy/tests/test_unload_guard.mjs
@@ -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")
diff --git a/openpilot/starpilot/galaxy/web/css/navigation.css b/openpilot/starpilot/galaxy/web/css/navigation.css
new file mode 100644
index 0000000000..311b26cb00
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/css/navigation.css
@@ -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); }
diff --git a/openpilot/starpilot/galaxy/web/css/offline-road-maps.css b/openpilot/starpilot/galaxy/web/css/offline-road-maps.css
new file mode 100644
index 0000000000..2c8a748296
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/css/offline-road-maps.css
@@ -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; }
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto-logs.js b/openpilot/starpilot/galaxy/web/js/android-auto-logs.js
new file mode 100644
index 0000000000..33aa932b9a
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/android-auto-logs.js
@@ -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: ", "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" })
+ }
+ }
+}
diff --git a/openpilot/starpilot/galaxy/web/js/bottom-nav.js b/openpilot/starpilot/galaxy/web/js/bottom-nav.js
new file mode 100644
index 0000000000..a7adb5584f
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/bottom-nav.js
@@ -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: ``,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/connection-migration.js b/openpilot/starpilot/galaxy/web/js/connection-migration.js
new file mode 100644
index 0000000000..ea31fad918
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/connection-migration.js
@@ -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. */ }
+}
diff --git a/openpilot/starpilot/galaxy/web/js/dialog.js b/openpilot/starpilot/galaxy/web/js/dialog.js
new file mode 100644
index 0000000000..c93744b3ca
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/dialog.js
@@ -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: ``,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/haptics.js b/openpilot/starpilot/galaxy/web/js/haptics.js
new file mode 100644
index 0000000000..f728db5648
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/haptics.js
@@ -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()
+}
diff --git a/openpilot/starpilot/galaxy/web/js/icon-button.js b/openpilot/starpilot/galaxy/web/js/icon-button.js
new file mode 100644
index 0000000000..471dc15881
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/icon-button.js
@@ -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: ``,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/loading-screen.js b/openpilot/starpilot/galaxy/web/js/loading-screen.js
new file mode 100644
index 0000000000..0c87165771
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/loading-screen.js
@@ -0,0 +1,5 @@
+export const GalaxyLoading = {
+ props: { message: { type: String, default: 'Just a moment!' }, refresh: Boolean },
+ methods: { reload() { location.reload() } },
+ template: `
Galaxy
{{ message }}
`,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/menu-tile.js b/openpilot/starpilot/galaxy/web/js/menu-tile.js
new file mode 100644
index 0000000000..5b34857684
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/menu-tile.js
@@ -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: ``,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/notice.js b/openpilot/starpilot/galaxy/web/js/notice.js
new file mode 100644
index 0000000000..c3dbd710c7
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/notice.js
@@ -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: ``,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js
new file mode 100644
index 0000000000..5e7e234dd8
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/onroad-layout-document.js
@@ -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)
+}
diff --git a/openpilot/starpilot/galaxy/web/js/polling.js b/openpilot/starpilot/galaxy/web/js/polling.js
new file mode 100644
index 0000000000..e224f1640a
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/polling.js
@@ -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
diff --git a/openpilot/starpilot/galaxy/web/js/route-leave.js b/openpilot/starpilot/galaxy/web/js/route-leave.js
new file mode 100644
index 0000000000..ad46b52791
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/route-leave.js
@@ -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)
+ }
+ },
+ }
+}
diff --git a/openpilot/starpilot/galaxy/web/js/settings-recovery.js b/openpilot/starpilot/galaxy/web/js/settings-recovery.js
new file mode 100644
index 0000000000..6c16895e7a
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/settings-recovery.js
@@ -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: `Show Non-Default Values
+ Review saved settings that differ from this vehicle’s StarPilot defaults.
+ Some settings are unavailable for this vehicle. Their saved values remain unchanged.
+ {{ error }}
{{ result }}
+ All available settings use their defaults.
+
+
+
Current: {{ row.current }} · Default: {{ row.default }}
+
{{ row.reason }}
+
+
+
+
+
+
+
+ {{ pending.question }}
+
+ `,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/snapshot-feed.js b/openpilot/starpilot/galaxy/web/js/snapshot-feed.js
new file mode 100644
index 0000000000..3a2cac3fe8
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/snapshot-feed.js
@@ -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
+ }
+ }
+ }
+}
+
+
diff --git a/openpilot/starpilot/galaxy/web/js/state.js b/openpilot/starpilot/galaxy/web/js/state.js
new file mode 100644
index 0000000000..2e81ff36d0
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/state.js
@@ -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: ``,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/summary.js b/openpilot/starpilot/galaxy/web/js/summary.js
new file mode 100644
index 0000000000..91595c18bb
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/summary.js
@@ -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: `- {{ item.label }}
- {{ item.value }}
`,
+}
diff --git a/openpilot/starpilot/galaxy/web/js/unload-guard.js b/openpilot/starpilot/galaxy/web/js/unload-guard.js
new file mode 100644
index 0000000000..fd9d7dca3a
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/unload-guard.js
@@ -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)
+}
diff --git a/openpilot/starpilot/galaxy/web/js/widget-art.js b/openpilot/starpilot/galaxy/web/js/widget-art.js
new file mode 100644
index 0000000000..f518adf271
--- /dev/null
+++ b/openpilot/starpilot/galaxy/web/js/widget-art.js
@@ -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
+}