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