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 = '
Header
' + }) + 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: `
Card
Card
+

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: `
+ + +
{{ title }}
+
`, +} 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 }}

  • {{ label }}
+ +
`, +} 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: `
+ +

{{ title }}

+
+
`, +} 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 +}