diff --git a/openpilot/starpilot/galaxy/sentry_events.py b/openpilot/starpilot/galaxy/sentry_events.py index 4fe0e0b325..ee531ef394 100644 --- a/openpilot/starpilot/galaxy/sentry_events.py +++ b/openpilot/starpilot/galaxy/sentry_events.py @@ -44,6 +44,7 @@ class SentryEvents: raise ValueError("Invalid motion event") events.append({"eventId": value["eventId"], "kind": value["kind"], "systemTimeMs": value["wallTimeNs"] // 1_000_000, + **({"simulated": True} if value.get("simulated") else {}), "images": cast(Callable[[str], list], self.store.images)(value["eventId"]) if hasattr(self.store, "images") else []}) result = {"schemaVersion": 1, "source": "local", "scanIncomplete": source["incomplete"], "capacity": source["capacity"], "events": events, diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py index c8baed2459..2c3ab3cb0e 100644 --- a/openpilot/starpilot/galaxy/server.py +++ b/openpilot/starpilot/galaxy/server.py @@ -13,7 +13,7 @@ import socket from pathlib import Path import threading import time -from urllib.parse import parse_qs, unquote, urlsplit +from urllib.parse import parse_qs, quote, unquote, urlsplit from openpilot.common.swaglog import cloudlog from openpilot.starpilot.galaxy.batch_routes import change_drive_state, delete_records, non_default_settings, preview_layout, preview_settings_recovery @@ -158,6 +158,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe summary_lock = threading.Lock() motion_events = sentry_events if sentry_events is not None else SentryEvents() sentry_events_lock = threading.Lock() + last_simulation = [float("-inf")] notification_owner = notifications snapshots = camera_snapshot if camera_snapshot is not None else CameraSnapshot() access = owner if owner is not None else default_owner() @@ -766,6 +767,30 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe else: if self.require_session(): self.json(200, result) + elif path in ('/api/galaxy/session', '/api/galaxy/session/qr.svg'): + record = pairing.read() + remote = getattr(self.server, 'remote_transport', False) + if not self.direct_local() and not (remote and record and gateway_cookie_valid(self.session_token(), record)): + self.json(403, {'error': 'A direct connection or matching gateway cookie is required'}) + return + if not self.require_session(): + return + if record is None: + self.json(200, {'paired': False}) + return + token = quote(f"{record['slug']}:{record['session']}", safe='') + if path.endswith('/qr.svg'): + from openpilot.common.qrcode import _Qr, _capacity + raw = ('starpilot-remote://add?key=' + quote(token, safe='')).encode() + version = next(version for version in range(1, 21) if 4 + 8 + len(raw) * 8 <= _capacity(version) * 8) + matrix = _Qr(version, raw).modules + size = len(matrix) + 8 + cells = ''.join(f'' for y, row in enumerate(matrix) for x, active in enumerate(row) if active) + svg = f'{cells}' + self.respond(200, svg.encode(), 'image/svg+xml', cache_control='no-store') + else: + self.json(200, {'paired': True, 'slug': record['slug'], 'url': pairing.url(record['slug']), + 'cookieName': 'galaxy_session', 'sessionToken': token}) elif path == '/api/galaxy/status': if not self.require_session(): return @@ -1688,7 +1713,24 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe self.json(401, {'error': 'Sign in to Galaxy'}) return try: - result = notification_owner.action(payload, permitted=lambda: self.settings_session() == identity) + if payload.get('action') == 'simulate': + with sentry_events_lock: + if set(payload) != {'action'}: + raise ValueError('Invalid simulation request') + if not parked(): + self.json(403, {'error': 'Park the car before simulating an event'}) + return + if clock() - last_simulation[0] < 10: + self.json(429, {'error': 'Wait ten seconds before simulating another event'}) + return + notification_owner.store.record('warning', time.monotonic_ns(), simulated=True, + permitted=lambda: self.settings_session() == identity and parked()) + last_simulation[0] = clock() + result = notification_owner.snapshot() + else: + result = notification_owner.action(payload, permitted=lambda: self.settings_session() == identity) + except LookupError: + self.json(404, {'error': 'Phone subscription not found'}) except ValueError as error: self.json(400, {'error': str(error)}) except PermissionError: diff --git a/openpilot/starpilot/galaxy/tests/test_install_app.mjs b/openpilot/starpilot/galaxy/tests/test_install_app.mjs index cb6916be75..e2173d3ebc 100644 --- a/openpilot/starpilot/galaxy/tests/test_install_app.mjs +++ b/openpilot/starpilot/galaxy/tests/test_install_app.mjs @@ -59,7 +59,7 @@ const android = { ...InstallApp.data() } InstallApp.methods.installAndroid.call(android) assert.equal(apk, './app.apk') assert.match(InstallApp.template, /Install web app/) -assert.match(InstallApp.template, /Download Android APK/) +assert.match(InstallApp.template, /Direct \(advanced\) · Download APK/) assert.match(InstallApp.template, /Update offline app \(Android\)/) assert.match(InstallApp.template, /android && !ios/) location.protocol = 'https:' diff --git a/openpilot/starpilot/galaxy/tests/test_remote_pairing.py b/openpilot/starpilot/galaxy/tests/test_remote_pairing.py index 8d27e5c6c4..9033eba0ea 100644 --- a/openpilot/starpilot/galaxy/tests/test_remote_pairing.py +++ b/openpilot/starpilot/galaxy/tests/test_remote_pairing.py @@ -58,6 +58,30 @@ class RemotePairingTest(unittest.TestCase): connection.close() return result + def test_app_key_export_requires_direct_session_or_matching_gateway_cookie(self): + for path in ('/api/galaxy/session', '/api/galaxy/session/qr.svg'): + self.assertEqual(self.request(self.local, path)[0], 401) + cookie = self.request(self.local, '/api/auth/session')[2]['Set-Cookie'].split(';', 1)[0] + code, body, headers = self.request(self.local, '/api/galaxy/session', cookie=cookie) + self.assertEqual((code, json.loads(body)), (200, {'paired': False})) + self.assertEqual(headers['Cache-Control'], 'no-store') + slug = self.pairing.pair(hashlib.sha256(b'password123').hexdigest()) + record = self.pairing.read() + token = f"{slug}%3A{record['session']}" + for path in ('/api/galaxy/session', '/api/galaxy/session/qr.svg'): + self.assertEqual(self.request(self.local, path, cookie=cookie)[0], 200) + with mock.patch('openpilot.starpilot.galaxy.server.direct_local_connection', return_value=False): + self.assertEqual(self.request(self.local, path, cookie=cookie)[0], 403) + self.assertEqual(self.request(self.remote, path, host='galaxy.firestar.link', cookie=cookie)[0], 403) + self.assertEqual(self.request(self.remote, path, host='galaxy.firestar.link', cookie='galaxy_session=wrong')[0], 403) + code, body, headers = self.request(self.remote, f'/{slug}' + path, host='galaxy.firestar.link', cookie='galaxy_session=' + token) + self.assertEqual(code, 200) + self.assertEqual(headers['Cache-Control'], 'no-store') + if not path.endswith('.svg'): + self.assertEqual(json.loads(body)['sessionToken'], token) + else: + self.assertIn(b' ({ status: null, error: '', native: !!window.StarPilotApp, sharing: false, autoStart: false, + data: () => ({ status: null, error: '', native: !!window.StarPilotApp?.uplinkState, sharing: false, autoStart: false, bluetooth: bluetoothConnection(), nativeBytes: 0, busy: false, generation: 0, alive: false }), computed: { windowAutoStart() { return !!window.StarPilotApp?.setAutoStart } }, mounted() { this.start() }, @@ -66,11 +66,12 @@ export const GalaxyCompanion = { this.busy = true try { const enabled = this.native ? !this.sharing : !this.status.enabled + if (this.native && window.StarPilotApp.setUplinkEnabled(enabled) === false) { this.sharing = false; return } const status = await requestJson('./api/companion/settings', { request: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }) } }) if (!this.alive || generation !== this.generation) return this.adopt(status) - if (this.native) { window.StarPilotApp.setUplinkEnabled(enabled); this.sharing = enabled } + if (this.native) this.sharing = enabled this.error = '' } catch (e) { if (!this.alive || generation !== this.generation) return diff --git a/openpilot/starpilot/galaxy/web/js/galaxy.js b/openpilot/starpilot/galaxy/web/js/galaxy.js index 15da64f36a..ec2fa23550 100644 --- a/openpilot/starpilot/galaxy/web/js/galaxy.js +++ b/openpilot/starpilot/galaxy/web/js/galaxy.js @@ -15,14 +15,15 @@ export const GalaxyPage = { components: { GxState, GxNotice, BluetoothNotice, InstallApp, GalaxyHotspot, GalaxyCompanion, LocalAccess }, props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true } }, data: () => ({ loading: true, paired: false, url: "", tunnelClientAvailable: false, legacyPassword: false, - legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null, + appKey: '', keyVisible: false, legacyPairingAvailable: false, password: "", busy: false, error: "", companionStatus: null, bluetooth: bluetoothConnection(), nativeVersion: window.StarPilotApp?.version?.() || '', dismissedUpdateVersion: dismissedUpdate(), ios: isIosDevice() }), computed: { availableAppVersion() { return this.companionStatus?.appAvailable ? this.companionStatus.shippedVersion : '' }, installedAppVersion() { return this.nativeVersion || this.companionStatus?.version || '' }, + playDistribution() { return window.StarPilotApp?.distribution?.() === 'play' }, appUpdateAvailable() { - return this.mode === 'local' && newerCompanionVersion(this.availableAppVersion, this.installedAppVersion) + return !this.playDistribution && this.mode === 'local' && newerCompanionVersion(this.availableAppVersion, this.installedAppVersion) }, nativeInstallerAvailable() { return typeof window.StarPilotApp?.installUpdate === 'function' }, showAppUpdate() { @@ -31,6 +32,13 @@ export const GalaxyPage = { }, mounted() { this.load() }, methods: { + async showAppKey() { + try { const data = await this.request('./api/galaxy/session'); this.appKey = data.paired ? decodeURIComponent(data.sessionToken) : ''; this.keyVisible = !!this.appKey } + catch (error) { this.error = error.message } + }, + async copyAppKey() { + try { await navigator.clipboard.writeText(this.appKey) } catch { this.error = 'Select and copy the displayed key.' } + }, installAppUpdate() { window.StarPilotApp?.installUpdate?.() }, dismissAppUpdate() { this.dismissedUpdateVersion = this.availableAppVersion @@ -84,6 +92,7 @@ export const GalaxyPage = { template: `

Galaxy Access & Install

Pair your comma, then install Galaxy from your remote link.

+

Updates come from Google Play.

Installed: {{ installedAppVersion }}. @@ -106,6 +115,11 @@ export const GalaxyPage = { Your Galaxy link remains available below. QR code for your Galaxy link

Open your Galaxy link

+

App key

Keep this key private. It grants remote access to this comma.

+ + +