diff --git a/.gitignore b/.gitignore
index d102070961..a4716fd4b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -135,3 +135,6 @@ openpilot/selfdrive/modeld/models/*.unchunked
# Jetlink editable-install metadata.
/jetlink_repo/jetlink.egg-info/
+
+# Local Google Play resolver build output (source and lockfile are versioned).
+/tools/playlink/target/
diff --git a/openpilot/starpilot/galaxy/android_auto_setup.py b/openpilot/starpilot/galaxy/android_auto_setup.py
index 8441b4fddc..4a23290090 100644
--- a/openpilot/starpilot/galaxy/android_auto_setup.py
+++ b/openpilot/starpilot/galaxy/android_auto_setup.py
@@ -8,11 +8,13 @@ from __future__ import annotations
import os
import tempfile
import threading
+import time
+from datetime import UTC, datetime
from pathlib import Path
from collections.abc import Callable
from typing import BinaryIO
-from openpilot.starpilot.system.android_auto import apk_identity
+from openpilot.starpilot.system.android_auto import apk_identity, play_resolver
class SetupRejected(RuntimeError):
@@ -34,14 +36,17 @@ class AndroidAutoSetup:
install_ready: Callable[[], bool] = lambda: False,
service_ready: Callable[[], bool] = lambda: False,
set_enabled: Callable[[bool], None] | None = None,
- enable_bluetooth: Callable[[tuple], None] | None = None):
+ enable_bluetooth: Callable[[tuple], None] | None = None, play_install=None):
self.parked, self.enabled, self.session_valid = parked, enabled, session_valid
self.bluetooth_enabled = bluetooth_enabled
self.install_ready, self.service_ready, self.set_enabled_value = install_ready, service_ready, set_enabled
self.enable_bluetooth_value = enable_bluetooth
+ self.play_install = play_install
self.job = import_job or apk_identity.ImportJob()
self.identity_status = identity_status
+ self.token_store = self.job.token_store if isinstance(self.job, apk_identity.ImportJob) else play_resolver.TokenStore()
self._upload_lock = threading.Lock()
+ self._refresh_after = 0.0
def _require_session(self, session: tuple) -> None:
if not session or not self.session_valid(session):
@@ -55,6 +60,8 @@ class AndroidAutoSetup:
'installReady': self.install_ready(), 'serviceReady': self.service_ready(),
'identity': {key: ident[key] for key in ('installed', 'expires', 'days_left', 'warning', 'message', 'expired', 'error') if key in ident},
'import': self.job.status(),
+ 'googlePlay': {'available': play_resolver.BINARY.is_file(), 'remembered': self.token_store.exists(),
+ **({'package': self.play_install.status()} if self.play_install is not None else {})},
'maxUploadBytes': apk_identity.MAX_FILE_BYTES,
'wiredAvailable': False,
'steps': [
@@ -127,6 +134,65 @@ class AndroidAutoSetup:
path.unlink(missing_ok=True)
self._upload_lock.release()
+ def start_google_import(self, session: tuple, *, email: str, token: str, remember: bool = False) -> dict:
+ self._require_session(session)
+ if type(remember) is not bool:
+ raise SetupRejected('Remember must be a boolean')
+ play_resolver.validate_credentials(email, token)
+ if not self._upload_lock.acquire(blocking=False):
+ raise SetupRejected('Another Android Auto upload is running')
+ try:
+ self._require_session(session)
+ if self.job.busy():
+ raise SetupRejected('An Android Auto import is running')
+ self.job.start_google_import(email=email, save_token=remember,
+ **({'oauth_token': token} if token.startswith('oauth2_4/') else {'aas_token': token}),
+ enabled=lambda: self.session_valid(session))
+ self._refresh_after = time.monotonic() + 86400
+ finally:
+ self._upload_lock.release()
+ return self.status(session)
+
+ def forget_google(self, session: tuple) -> dict:
+ self._require_session(session)
+ with self._upload_lock:
+ self._require_session(session)
+ if self.job.busy():
+ raise SetupRejected('Wait for the current import before forgetting Google sign-in')
+ self.token_store.forget()
+ return self.status(session)
+
+ def maintain(self, now: float | None = None) -> None:
+ """Called by Galaxy housekeeping; at most one refresh per day, only offroad.
+
+ Uses the same job and admission lock as manual imports. Explicitly saved
+ credentials authorize renewal without the user signing in again. No account secrets
+ or signed links enter the HTTP status or logs.
+ """
+ now = time.monotonic() if now is None else now
+ if now < self._refresh_after or not self._upload_lock.acquire(blocking=False):
+ return
+ try:
+ self._refresh_after = now + 60
+ if self.job.busy() or not self.token_store.exists() or not self.enabled() or not self.parked():
+ return
+ ident = self.identity_status()
+ expires = ident.get('expires')
+ if not expires or (datetime.fromisoformat(expires) - datetime.now(UTC)).total_seconds() > 14 * 86400:
+ return
+ self._refresh_after = now + 86400
+ credentials = self.token_store.load()
+ try:
+ self.job.start_google_import(email=credentials['email'], aas_token=credentials['token'], save_token=True,
+ enabled=lambda: self.enabled() and self.parked() and self.token_store.exists())
+ finally:
+ credentials.clear()
+ except (OSError, ValueError, TypeError, RuntimeError):
+ # Malformed/revoked credentials must not crash Galaxy or cause retry storms.
+ self._refresh_after = now + 86400
+ finally:
+ self._upload_lock.release()
+
def remove(self, session: tuple, *, disconnect: Callable[[], None] | None = None) -> dict:
self._require_session(session)
with self._upload_lock:
@@ -136,5 +202,6 @@ class AndroidAutoSetup:
if disconnect is not None:
disconnect()
self._require_session(session)
+ self.token_store.forget()
apk_identity.remove_identity()
return self.status(session)
diff --git a/openpilot/starpilot/galaxy/http_server.py b/openpilot/starpilot/galaxy/http_server.py
index 4209104b32..020f6866bd 100644
--- a/openpilot/starpilot/galaxy/http_server.py
+++ b/openpilot/starpilot/galaxy/http_server.py
@@ -57,6 +57,15 @@ class _LocalHTTPServer(ThreadingHTTPServer):
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
@@ -76,7 +85,8 @@ class _LocalHTTPServer(ThreadingHTTPServer):
for name in ('map_source', 'settings_source', 'vehicle_selection_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'):
+ 'pairing_authority', 'evidence_source', 'device_state_source', 'navigation_source', 'drive_physical_source', 'notification_source',
+ 'android_auto_setup_source', 'play_install_source'):
close = getattr(getattr(self, name, None), 'close', None)
if callable(close):
cleanup.callback(close)
diff --git a/openpilot/starpilot/galaxy/play_install.py b/openpilot/starpilot/galaxy/play_install.py
new file mode 100644
index 0000000000..f3e2b8dcd9
--- /dev/null
+++ b/openpilot/starpilot/galaxy/play_install.py
@@ -0,0 +1,176 @@
+"""Downloads the pinned Google Play resolver in the background after Galaxy setup, while parked."""
+import fcntl
+import hashlib
+import os
+from pathlib import Path
+import platform
+import shutil
+import tempfile
+import threading
+import time
+import urllib.request
+from urllib.parse import urlsplit
+
+from openpilot.starpilot.system.android_auto.play_package import PACKAGE, RUNTIME
+
+
+class Paused(RuntimeError):
+ pass
+
+
+def checked_url(url):
+ parsed = urlsplit(url)
+ if (parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port not in (None, 443)
+ or parsed.hostname not in ('github.com', 'release-assets.githubusercontent.com', 'objects.githubusercontent.com')):
+ raise ValueError('Untrusted package URL')
+ return url
+
+
+class ReleaseRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ return super().redirect_request(req, fp, code, msg, headers, checked_url(newurl))
+
+
+def download(asset, destination, check, progress):
+ digest = hashlib.sha256()
+ received = 0
+ opener = urllib.request.build_opener(ReleaseRedirect())
+ request = urllib.request.Request(checked_url(asset['url']), headers={'User-Agent': 'StarPilot-Galaxy-Setup'})
+ with opener.open(request, timeout=10) as response, destination.open('wb') as output:
+ if response.status != 200:
+ raise ValueError('Incomplete package response')
+ while True:
+ check()
+ block = response.read(64 * 1024)
+ if not block:
+ break
+ received += len(block)
+ if received > asset['bytes']:
+ raise ValueError('Package exceeds expected size')
+ output.write(block)
+ digest.update(block)
+ progress(received)
+ if received != asset['bytes'] or digest.hexdigest() != asset['sha256']:
+ raise ValueError('Package integrity check failed')
+
+
+class PlayInstall:
+ def __init__(self, *, ready, parked, destination=RUNTIME, package=PACKAGE, enabled=None, fetch=download):
+ self.ready, self.parked, self.destination, self.package = ready, parked, destination, package
+ self.enabled = (Path('/AGNOS').is_file() and platform.machine() == 'aarch64') if enabled is None else enabled
+ self.fetch = fetch
+ self.lock = threading.Lock()
+ self.stop = threading.Event()
+ self.thread = None
+ self.retry_at = 0.0
+ self.failures = 0
+ self.state, self.percent = 'pending', 0
+ self.setup_seen = destination.parent / '.galaxy-setup-complete'
+
+ def mark_setup_complete(self):
+ if self.enabled and not self.setup_seen.exists():
+ self.setup_seen.parent.mkdir(parents=True, exist_ok=True)
+ self.setup_seen.touch(mode=0o600)
+
+ def installed(self):
+ try:
+ return ((self.destination / 'package-id').read_text().strip() == self.package['id'] and
+ (self.destination / 'playlink').is_file())
+ except OSError:
+ return False
+
+ def status(self):
+ with self.lock:
+ return {'state': self.state, 'percent': self.percent}
+
+ def maintain(self):
+ if not self.enabled or self.stop.is_set():
+ return
+ with self.lock:
+ if self.state == 'ready' or self.thread is not None and self.thread.is_alive():
+ return
+ if self.installed():
+ self.state, self.percent = 'ready', 100
+ return
+ if time.monotonic() < self.retry_at:
+ return
+ try:
+ setup_ready = self.setup_seen.exists() or self.ready()
+ parked = self.parked()
+ except Exception:
+ self._retry_later()
+ return
+ if not setup_ready:
+ return
+ if not parked:
+ self.state = 'paused'
+ return
+ self.state, self.percent = 'downloading', 0
+ self.thread = threading.Thread(target=self._run, name='galaxy-play-install', daemon=True)
+ self.thread.start()
+
+ def _retry_later(self):
+ # Early failures are usually the network still coming up after boot: 30 s, 1, 2, 4 min, then every 5 min.
+ self.state, self.retry_at = 'retrying', time.monotonic() + min(300, 30 * 2 ** self.failures)
+ self.failures += 1
+
+ def _run(self):
+ if platform.system() == 'Linux':
+ try:
+ os.setpriority(os.PRIO_PROCESS, threading.get_native_id(), 10)
+ except OSError:
+ pass
+ deadline = time.monotonic() + 1800
+ def check():
+ if self.stop.is_set() or not self.parked():
+ raise Paused()
+ if time.monotonic() > deadline:
+ raise TimeoutError()
+ resolver = self.package['resolver']
+ def progress(received):
+ with self.lock:
+ self.percent = min(99, received * 100 // resolver['bytes'])
+ try:
+ self.destination.parent.mkdir(parents=True, exist_ok=True)
+ with (self.destination.parent / '.install.lock').open('a') as lockfile:
+ fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ if not self.installed():
+ check()
+ # A power loss cannot run TemporaryDirectory cleanup. The exclusive
+ # lock proves no other installer still owns these staging directories.
+ for leftover in self.destination.parent.glob('.download-*'):
+ if leftover.is_dir() and not leftover.is_symlink():
+ shutil.rmtree(leftover)
+ # Room for the resolver and a margin.
+ if shutil.disk_usage(self.destination.parent).free < 64 * 1024**2:
+ raise OSError('Insufficient storage')
+ with tempfile.TemporaryDirectory(prefix='.download-', dir=self.destination.parent) as directory:
+ runtime = Path(directory) / 'runtime'
+ runtime.mkdir()
+ helper = runtime / 'playlink'
+ self.fetch(resolver, helper, check, progress)
+ check()
+ with self.lock:
+ self.state = 'installing'
+ with helper.open('rb') as executable:
+ header = executable.read(20)
+ if header[:5] != b'\x7fELF\x02' or int.from_bytes(header[18:20], 'little') != 183:
+ raise ValueError('Resolver is not ARM64')
+ helper.chmod(0o755)
+ (runtime / 'package-id').write_text(self.package['id'] + '\n')
+ check()
+ # Each package id installs to its own directory, so a running older version is never replaced.
+ runtime.rename(self.destination)
+ with self.lock:
+ self.state, self.percent, self.failures = 'ready', 100, 0
+ except Paused:
+ with self.lock:
+ self.state, self.retry_at = 'paused', time.monotonic() + 10
+ except Exception:
+ with self.lock:
+ self._retry_later()
+
+ def close(self):
+ self.stop.set()
+ if self.thread is not None:
+ self.thread.join(timeout=12)
diff --git a/openpilot/starpilot/galaxy/server.py b/openpilot/starpilot/galaxy/server.py
index e73a46bf21..e236e5d69b 100644
--- a/openpilot/starpilot/galaxy/server.py
+++ b/openpilot/starpilot/galaxy/server.py
@@ -149,6 +149,9 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
evidence_source.close()
raise
configuration_allowed = pairing_authority.configuration_allowed if pairing_authority is not None else parked
+ from openpilot.starpilot.galaxy.play_install import PlayInstall
+ play_install = PlayInstall(ready=lambda: access.status().status == AccessStatus.CONFIGURED_LOCAL or pairing.read() is not None,
+ parked=configuration_allowed)
def cloud_allowed():
from openpilot.common.params import Params
return bool(parked()) and bool(cloud_offroad() if cloud_offroad is not None else Params().get_bool('IsOffroad'))
@@ -332,6 +335,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
def enable_bluetooth(identity):
bluetooth_owner().request('power', enabled=True, session=identity, power_on_for_android_auto=True)
aa_setup_source = AndroidAutoSetup(parked=configuration_allowed, enabled=lambda: Params().get_bool('AndroidAutoEnabled'),
+ play_install=play_install,
session_valid=bluetooth_session_valid, bluetooth_enabled=bluetooth_ready,
install_ready=install_ready, service_ready=service_ready,
set_enabled=lambda value: Params().put_bool('AndroidAutoEnabled', value),
@@ -578,6 +582,10 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
return
if path == '/api/auth/session':
if self.direct_local():
+ try:
+ play_install.mark_setup_complete()
+ except OSError:
+ pass # Optional package provisioning must not prevent Galaxy access.
with session_lock:
token = self.session_token()
if not sessions.valid(token, local_generation):
@@ -1313,6 +1321,7 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
if path not in ('/api/connect/provider', '/api/auth/login', '/api/auth/logout', '/api/settings/preview', '/api/settings/confirm',
'/api/settings/reset-default', '/api/settings/restore', '/api/recordings/action',
'/api/galaxy/pair', '/api/galaxy/unpair', '/api/galaxy/device-name', '/api/galaxy/hotspot', '/api/cameras/snapshot',
+ '/api/android-auto/google-import', '/api/android-auto/google-forget',
'/api/android-auto/layout', '/api/android-auto/enable', '/api/android-auto/control', '/api/android-auto/pairing',
'/api/android-auto/pairing/response', '/api/android-auto/pairing/cancel', '/api/android-auto/pairing/select',
'/api/ui/layout', '/api/ui/layout/preview', '/api/favorites/slots',
@@ -1942,6 +1951,33 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
aa_registry_source.revoke(identity)
self.respond(200, b'{"authenticated":false}', cookie='galaxy_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0')
return
+ if path in ('/api/android-auto/google-import', '/api/android-auto/google-forget'):
+ from openpilot.starpilot.galaxy.android_auto_setup import SetupRejected
+ from openpilot.starpilot.system.android_auto.play_resolver import PlayError
+ identity = self.settings_session()
+ if identity is None:
+ self.json(401, {'error': 'Sign in to Galaxy'})
+ return
+ forgetting = path.endswith('google-forget')
+ if not isinstance(payload, dict) or (forgetting and payload) or (not forgetting and
+ (set(payload) - {'email', 'token', 'remember'} or not {'email', 'token'} <= set(payload) or
+ type(payload.get('remember', False)) is not bool)):
+ self.json(400, {'error': 'Google account email, token and optional remember boolean required'})
+ return
+ try:
+ result = aa_setup_owner().forget_google(identity) if forgetting else aa_setup_owner().start_google_import(identity, **payload)
+ except PlayError as error:
+ self.json(400, {'error': str(error)})
+ except SetupRejected as error:
+ self.json(409, {'error': str(error)})
+ except (OSError, RuntimeError):
+ self.json(503, {'error': 'Google Play import is unavailable'})
+ else:
+ if self.settings_session() == identity:
+ self.json(200 if forgetting else 202, result)
+ else:
+ self.json(401, {'error': 'Sign in to Galaxy'})
+ return
if path == '/api/android-auto/enable':
if type(payload) is not dict or set(payload) not in ({'enabled'}, {'enabled', 'enableBluetooth'}) or \
type(payload['enabled']) is not bool or type(payload.get('enableBluetooth', False)) is not bool or \
@@ -2494,6 +2530,15 @@ def make_server(*, port=8082, host='127.0.0.1', monitor=None, owner=None, crashe
server.software_operations_source = software_operations_source
if drive_stats_source is not None:
server.drive_stats_source = drive_stats_source
+ def maintain_android_auto():
+ play_install.maintain()
+ from openpilot.starpilot.system.android_auto.play_resolver import TOKEN_PATH
+ if aa_setup_source is not None or TOKEN_PATH.is_file():
+ maintain = getattr(aa_setup_owner(), 'maintain', None)
+ if callable(maintain):
+ maintain()
+ server.maintain_android_auto = maintain_android_auto
+ server.play_install_source = play_install
server.start_drive_history = lambda: statistics_owner().start()
return server
diff --git a/openpilot/starpilot/galaxy/test_android_auto_http.py b/openpilot/starpilot/galaxy/test_android_auto_http.py
index c687c29c8b..06cda1e1bb 100644
--- a/openpilot/starpilot/galaxy/test_android_auto_http.py
+++ b/openpilot/starpilot/galaxy/test_android_auto_http.py
@@ -56,6 +56,7 @@ def api(tmp_path):
'message': 'Package status'},
bluetooth_enabled=lambda: state['bluetooth_enabled'], install_ready=lambda: state['install_ready'],
service_ready=lambda: True, set_enabled=set_enabled, enable_bluetooth=enable_bluetooth)
+ state['setup_owner'] = setup
class FakeClient:
def __init__(self):
self.calls = []
@@ -440,3 +441,32 @@ def test_outgoing_car_selection_requires_current_pairing_session_and_valid_addre
state['parked'] = True
assert request('POST', '/api/android-auto/pairing/cancel', '{}', cookie=cookie, content_type='application/json')[0] == 200
assert select() == 409
+
+
+def test_google_import_is_authenticated_bounded_and_validated(api):
+ request, job, state, _ = api
+ job.start_google_import = Mock(side_effect=lambda **kwargs: setattr(job, 'running', True))
+ path = '/api/android-auto/google-import'
+ valid = {'email': 'a@example.com', 'token': 'oauth2_4/test', 'remember': False}
+ assert request('POST', path, json.dumps(valid), content_type='application/json')[0] == 401
+ _, _, headers = request('POST', '/api/auth/login', '{"password":"password123"}', content_type='application/json')
+ cookie = headers['Set-Cookie'].split(';', 1)[0]
+ for payload in (None, [], {}, {'email': 1, 'token': 'oauth2_4/test'}, {**valid, 'remember': 1},
+ {**valid, 'token': 'not-a-token'}, {**valid, 'url': 'http://elsewhere'}):
+ assert request('POST', path, json.dumps(payload), cookie=cookie, content_type='application/json')[0] == 400
+ assert request('POST', path, json.dumps({**valid, 'token': 'x' * 5000}), cookie=cookie, content_type='application/json')[0] == 413
+ state.update(parked=False, enabled=False)
+ code, status, _ = request('POST', path, json.dumps(valid), cookie=cookie, content_type='application/json')
+ assert code == 202 and status['import']['state'] == 'running'
+ assert 'oauth2_4/test' not in json.dumps(status)
+ assert request('POST', path, json.dumps(valid), cookie=cookie, content_type='application/json')[0] == 409
+ assert request('POST', '/api/android-auto/google-forget', '{}', cookie=cookie, content_type='application/json')[0] == 409
+ job.start_google_import.assert_called_once()
+
+
+def test_on_device_browser_api_is_gone(api):
+ request, _, _, _ = api
+ _, _, headers = request('POST', '/api/auth/login', '{"password":"password123"}', content_type='application/json')
+ cookie = headers['Set-Cookie'].split(';', 1)[0]
+ start = {'operation': 'start', 'email': 'user@example.com', 'remember': False}
+ assert request('POST', '/api/android-auto/google-browser', json.dumps(start), cookie=cookie, content_type='application/json')[0] != 200
diff --git a/openpilot/starpilot/galaxy/test_android_auto_setup.py b/openpilot/starpilot/galaxy/test_android_auto_setup.py
index 0d7e0f5716..32a66b1957 100644
--- a/openpilot/starpilot/galaxy/test_android_auto_setup.py
+++ b/openpilot/starpilot/galaxy/test_android_auto_setup.py
@@ -217,3 +217,58 @@ def test_session_loss_while_enabling_bluetooth_never_enables_android_auto(tmp_pa
with pytest.raises(SetupRejected, match='session expired'):
service.enable(session, True, enable_bluetooth=True)
setter.assert_not_called()
+
+
+def google_setup(tmp_path):
+ from openpilot.starpilot.system.android_auto import apk_identity, play_resolver
+ state = {'enabled': True, 'parked': True, 'session': ('galaxy', '1')}
+ store = play_resolver.TokenStore(tmp_path / 'aa_token')
+ job = apk_identity.ImportJob(work_dir=tmp_path / 'imports', token_store=store)
+ job.start_google_import = Mock()
+ service = AndroidAutoSetup(parked=lambda: state['parked'], enabled=lambda: state['enabled'],
+ session_valid=lambda session: session == state['session'], import_job=job,
+ identity_status=lambda: {'installed': True, 'expires': '2020-01-01T00:00:00+00:00'})
+ return service, job, state, store
+
+
+def test_google_import_session_admission_and_exclusive_upload_lock(tmp_path):
+ service, job, state, _ = google_setup(tmp_path)
+ with pytest.raises(SetupRejected):
+ service.start_google_import(('old', '1'), email='a@example.com', token='oauth2_4/test')
+ with service._upload_lock, pytest.raises(SetupRejected):
+ service.start_google_import(state['session'], email='a@example.com', token='oauth2_4/test')
+ job.start_google_import.assert_not_called()
+ service.start_google_import(state['session'], email='a@example.com', token='oauth2_4/test', remember=True)
+ kwargs = job.start_google_import.call_args.kwargs
+ assert kwargs['save_token'] and kwargs['enabled']()
+ state['session'] = None
+ assert not kwargs['enabled']()
+
+
+def test_refresh_requires_opt_in_offroad_and_throttles_failed_attempts(tmp_path):
+ service, job, state, store = google_setup(tmp_path)
+ service.maintain(0)
+ job.start_google_import.assert_not_called()
+ store.save('a@example.com', 'aas_et/test')
+ state['parked'] = False
+ service.maintain(60)
+ job.start_google_import.assert_not_called()
+ state['parked'] = True
+ service.maintain(120)
+ job.start_google_import.assert_called_once()
+ kwargs = job.start_google_import.call_args.kwargs
+ assert kwargs['aas_token'] == 'aas_et/test' and kwargs['enabled']()
+ service.maintain(180)
+ job.start_google_import.assert_called_once()
+ state['enabled'] = False
+ assert not kwargs['enabled']()
+ service.forget_google(state['session'])
+ assert not store.exists()
+
+
+def test_refresh_skips_identity_not_due_for_renewal(tmp_path):
+ service, job, _, store = google_setup(tmp_path)
+ store.save('a@example.com', 'aas_et/test')
+ service.identity_status = lambda: {'installed': True, 'expires': '2099-01-01T00:00:00+00:00'}
+ service.maintain(0)
+ job.start_google_import.assert_not_called()
diff --git a/openpilot/starpilot/galaxy/test_play_install.py b/openpilot/starpilot/galaxy/test_play_install.py
new file mode 100644
index 0000000000..96a1fe0188
--- /dev/null
+++ b/openpilot/starpilot/galaxy/test_play_install.py
@@ -0,0 +1,146 @@
+import hashlib
+import io
+import time
+from unittest.mock import Mock
+
+import pytest
+
+from openpilot.starpilot.galaxy.play_install import Paused, PlayInstall, checked_url, download
+
+
+def wait(job):
+ job.thread.join(timeout=3)
+ assert not job.thread.is_alive()
+
+
+@pytest.fixture
+def provision(tmp_path):
+ helper = b'\x7fELF\x02' + bytes(13) + (183).to_bytes(2, 'little')
+ contents = {'resolver': helper}
+ manifest = {'id': 'release-1', 'resolver': {'sha256': hashlib.sha256(helper).hexdigest(), 'bytes': len(helper), 'url': 'resolver'}}
+ state = {'ready': False, 'parked': True}
+ calls = []
+ def fetch(asset, destination, check, progress):
+ calls.append(asset['url'])
+ check()
+ destination.write_bytes(contents[asset['url']])
+ progress(asset['bytes'])
+ job = PlayInstall(ready=lambda: state['ready'], parked=lambda: state['parked'], destination=tmp_path / 'installed' / 'release-1',
+ package=manifest, enabled=True, fetch=fetch)
+ yield job, state, calls
+ job.close()
+
+
+def test_waits_for_setup_then_installs_once(provision):
+ job, state, calls = provision
+ job.maintain()
+ assert job.thread is None and not calls
+ abandoned = job.destination.parent / '.download-interrupted'
+ abandoned.mkdir(parents=True)
+ (abandoned / 'partial').write_bytes(b'interrupted download')
+ state['ready'] = True
+ job.maintain()
+ wait(job)
+ assert job.status() == {'state': 'ready', 'percent': 100}
+ assert job.installed()
+ assert calls == ['resolver']
+ assert not list(job.destination.parent.glob('.download-*'))
+ job.maintain()
+ assert calls == ['resolver']
+ restarted = PlayInstall(ready=lambda: False, parked=lambda: False, destination=job.destination,
+ package=job.package, enabled=True, fetch=Mock(side_effect=AssertionError))
+ restarted.maintain()
+ assert restarted.status()['state'] == 'ready'
+
+
+def test_local_setup_marker_survives_restart(provision):
+ job, _, calls = provision
+ job.mark_setup_complete()
+ job.maintain()
+ wait(job)
+ assert job.installed() and calls
+
+
+def test_park_admission_and_cancellation_leave_no_partial_install(provision):
+ job, state, calls = provision
+ state.update(ready=True, parked=False)
+ job.maintain()
+ assert job.status()['state'] == 'paused' and not calls
+ state['parked'] = True
+ original = job.fetch
+ def move(asset, destination, check, progress):
+ original(asset, destination, check, progress)
+ state['parked'] = False
+ job.fetch = move
+ job.maintain()
+ wait(job)
+ assert job.status()['state'] == 'paused'
+ assert not job.destination.exists()
+ assert not list(job.destination.parent.glob('.download-*'))
+
+
+def test_failed_download_backs_off_and_preserves_older_release(provision):
+ job, state, calls = provision
+ state['ready'] = True
+ older = job.destination.parent / 'previous'
+ older.mkdir(parents=True)
+ (older / 'keep').write_text('old runtime')
+ job.fetch = Mock(side_effect=OSError('signed-url-must-not-leak'))
+ job.maintain()
+ wait(job)
+ assert job.status()['state'] == 'retrying'
+ assert job.retry_at > time.monotonic()
+ job.maintain()
+ assert job.fetch.call_count == 1
+ assert (older / 'keep').read_text() == 'old runtime'
+ assert not job.destination.exists()
+
+
+@pytest.mark.parametrize('url', ['http://github.com/file', 'https://github.com.evil.test/file',
+ 'https://user:password@github.com/file', 'https://example.com/file'])
+def test_rejects_untrusted_download_and_redirect_urls(url):
+ with pytest.raises(ValueError):
+ checked_url(url)
+
+
+@pytest.mark.parametrize('body,size,digest', [(b'bad', 3, '0'*64), (b'oversize', 2, '0'*64), (b'short', 10, '0'*64)])
+def test_download_integrity_failure(tmp_path, monkeypatch, body, size, digest):
+ response = io.BytesIO(body)
+ response.status = 200
+ monkeypatch.setattr('urllib.request.build_opener', lambda *_: Mock(open=Mock(return_value=response)))
+ with pytest.raises(ValueError):
+ download({'url': 'https://github.com/release/file', 'bytes': size, 'sha256': digest}, tmp_path / 'download', lambda: None, lambda _: None)
+
+
+def test_download_checks_permission_before_consuming_body(tmp_path, monkeypatch):
+ response = io.BytesIO(b'content')
+ response.status = 200
+ monkeypatch.setattr('urllib.request.build_opener', lambda *_: Mock(open=Mock(return_value=response)))
+ def paused():
+ raise Paused()
+ with pytest.raises(Paused):
+ download({'url': 'https://github.com/release/file', 'bytes': 7, 'sha256': '0'*64}, tmp_path / 'download', paused, lambda _: None)
+
+
+def test_retry_delay_starts_short_and_caps_at_five_minutes(provision):
+ job, _, _ = provision
+ delays = []
+ for _ in range(6):
+ before = time.monotonic()
+ job._retry_later()
+ delays.append(round(job.retry_at - before))
+ assert delays == [30, 60, 120, 240, 300, 300]
+ assert job.status()['state'] == 'retrying'
+
+
+
+def test_rejects_resolver_that_is_not_arm64(provision):
+ job, state, _ = provision
+ state['ready'] = True
+ def fetch(asset, destination, check, progress):
+ destination.write_bytes(b'#!/bin/sh\n')
+ job.fetch = fetch
+ job.maintain()
+ wait(job)
+ assert job.status()['state'] == 'retrying'
+ assert not job.destination.exists()
diff --git a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
index 3ccd8570ce..e4d77e3d0f 100644
--- a/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
+++ b/openpilot/starpilot/galaxy/tests/test_android_auto.mjs
@@ -292,9 +292,9 @@ console.log('Remote-capable binary uploader: progress, credentials, failure, tim
// renders an inert DOM node and hides every setup control in real browsers.
const { compile } = await import('../web/vendor/vue/vue.esm-browser.js')
const render = compile(AndroidAutoPage.template, { decodeEntities: value => value })
-const vm = page(setup)
+const vm = page({ ...setup, identity: { ...setup.identity, installed: false } })
vm.mode = 'local'
-vm.installOpen = true // the package picker lives in the install sheet
+vm.installOpen = true // the package picker lives in the install sheet, shown only before setup
const tree = render(vm, [])
const tags = []
function visit(node) {
@@ -305,6 +305,14 @@ function visit(node) {
visit(tree)
assert(!tags.includes('template'), 'setup cannot live inside an inert template element')
assert(tags.includes('input') && tags.includes('button'), 'setup renders package selection and actions')
+// Once installed, Manage package shows status and removal only; setup needs removal first.
+const managed = page({ ...setup, identity: { ...setup.identity, installed: true }, googlePlay: { available: true, remembered: true } })
+managed.installOpen = true
+const managedTags = []
+const collect = (node) => { if (!node || typeof node !== 'object') return; if (typeof node.type === 'string') managedTags.push(node.type); if (Array.isArray(node.children)) node.children.forEach(collect) }
+collect(render(managed, []))
+assert(!managedTags.includes('input'), 'manage view offers no setup inputs')
+assert(managedTags.includes('dl'), 'manage view shows status and renewal')
for (const [field, reason] of [['installReady', /display|encoder/],
['serviceReady', /service/], ['bluetoothEnabled', /Bluetooth/], ['parked', /Park/]]) {
const blocked = page({ ...setup, [field]: false })
@@ -393,3 +401,60 @@ connectingPage.runtime = { running: true, state: 'connecting_bluetooth' }
assert.equal(connectingPage.projecting, false)
connectingPage.runtime.state = 'streaming'
assert.equal(connectingPage.projecting, true)
+
+// Google import keeps session material out of page state after submission.
+const { googleInstallChecks } = await import('../web/js/android-auto.js')
+const googlePage = page({ ...setup, googlePlay: { available: true, remembered: false } })
+googlePage.googleEmail = 'user@example.com'
+googlePage.googleToken = 'oauth2_4/test-private'
+googlePage.googleRemember = false
+let googleCall
+googlePage.feed = { async googleImport(...args) { googleCall = args; assert.equal(googlePage.googleToken, ''); return true } }
+assert.equal(await googlePage.googleImport(), true)
+assert.deepEqual(googleCall, ['user@example.com', 'oauth2_4/test-private', false])
+googlePage.googleToken = 'another-private-value'
+googlePage.closeInstall()
+assert.equal(googlePage.googleToken, '')
+assert.deepEqual(googleInstallChecks({ state: 'running', stage: 'resolving' }).map(c => c.status), ['done', 'active', 'pending', 'pending'])
+assert.deepEqual(googleInstallChecks({ state: 'failed', stage: 'downloading' }).map(c => c.status), ['done', 'done', 'failed', 'pending'])
+assert.match(googleInstallChecks({ state: 'running', stage: 'downloading', downloaded: 5, total: 10 })[2].label, /50%/)
+assert(googleInstallChecks({ state: 'done', stage: 'done' }).every(c => c.status === 'done'))
+assert.match(AndroidAutoPage.template, /Upload a file/)
+assert.match(AndroidAutoPage.template, /Turn off automatic renewal/)
+assert.match(AndroidAutoPage.template, /Remove Android Auto support/)
+assert.match(AndroidAutoPage.template, /will just sit and spin/)
+assert.equal(AndroidAutoPage.computed.renewLabel.call({ setup: { googlePlay: { remembered: true } } }), 'Auto-renew on')
+assert.equal(AndroidAutoPage.computed.renewLabel.call({ setup: { googlePlay: { remembered: false } } }), 'Auto-renew off')
+assert.match(AndroidAutoPage.template, /Manage package/)
+const { cleanToken, tokenProblem } = await import('../web/js/android-auto.js')
+assert.equal(cleanToken(' "oauth2_4/abc-DEF_123" '), 'oauth2_4/abc-DEF_123')
+assert.equal(cleanToken('oauth_token=oauth2_4/abc;'), 'oauth2_4/abc')
+assert.equal(cleanToken('oauth2_4%2Fabc'), 'oauth2_4/abc')
+assert.equal(tokenProblem(''), '')
+assert.equal(tokenProblem('oauth2_4/abc'), '')
+assert.match(tokenProblem('SAPISID-value'), /oauth2_4\//)
+assert.match(tokenProblem('oauth2_4/abc def'), /spaces/)
+const cleaning = page({ ...setup, googlePlay: { available: true, remembered: false } })
+Object.assign(cleaning, { googleEmail: 'user@example.com', googleToken: ' "oauth2_4/pasted" ', googleRemember: true })
+let cleanedCall
+cleaning.feed = { async googleImport(...args) { cleanedCall = args; return true } }
+assert.equal(await cleaning.googleImport(), true)
+assert.deepEqual(cleanedCall, ['user@example.com', 'oauth2_4/pasted', true])
+// Auto-renew is on by default, and turning it on later always saves the renewal credential.
+assert.equal(AndroidAutoPage.data().googleRemember, true)
+const renewal = page({ ...setup, identity: { ...setup.identity, installed: true }, googlePlay: { available: true, remembered: false } })
+Object.assign(renewal, { googleRemember: false, installMethod: 'manual', googleToken: 'stale' })
+renewal.startRenewal()
+assert.equal(renewal.renewing, true)
+assert.equal(renewal.installMethod, 'google')
+assert.equal(renewal.googleToken, '')
+Object.assign(renewal, { googleEmail: 'user@example.com', googleToken: 'oauth2_4/renew', googleRemember: false })
+let renewCall
+renewal.feed = { async googleImport(...args) { renewCall = args; return true } }
+assert.equal(await renewal.googleImport(), true)
+assert.deepEqual(renewCall, ['user@example.com', 'oauth2_4/renew', true])
+renewal.closeInstall()
+assert.equal(renewal.renewing, false)
+assert.match(AndroidAutoPage.template, /Turn on automatic renewal/)
+assert.match(AndroidAutoPage.template, /Use a computer for this/)
+console.log('Google Play import: credential clearing, progress, retry and manual fallback passed')
diff --git a/openpilot/starpilot/galaxy/web/css/galaxy.css b/openpilot/starpilot/galaxy/web/css/galaxy.css
index f2fd6dc13c..e4837e5af1 100644
--- a/openpilot/starpilot/galaxy/web/css/galaxy.css
+++ b/openpilot/starpilot/galaxy/web/css/galaxy.css
@@ -1455,6 +1455,59 @@ input[type="checkbox"]:disabled { opacity: .4; cursor: not-allowed; }
.gx-aa-checks .gx-aa-check--active::before { border-top-color: var(--primary); animation: gx-spin .9s linear infinite; }
.gx-aa-checks .gx-aa-check--failed { color: var(--error); }
.gx-aa-checks .gx-aa-check--failed::before { content: "✕"; border: 0; background: var(--error); color: var(--on-error); }
+.gx-aa-google, .gx-aa-google__form { display: grid; gap: var(--sp-3); min-width: 0; }
+.gx-aa-google__form label { display: grid; gap: var(--sp-1); }
+.gx-aa-google__form .gx-field { width: 100%; min-width: 0; }
+.gx-aa-google__form label:has(input[type="checkbox"]) { display: block; }
+.gx-aa-methods { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--sp-2); }
+.gx-aa-method { position: relative; display: flex; align-items: flex-start; gap: var(--sp-3); min-height: var(--touch-target); padding: var(--sp-3) var(--sp-4);
+ border: 1px solid var(--outline); border-radius: var(--radius-sm); background: var(--surface-container); color: var(--on-surface); font: inherit;
+ text-align: left; cursor: pointer; transition: border-color var(--motion-fast), background var(--motion-fast); }
+.gx-aa-method:hover:not(:disabled) { border-color: var(--primary); }
+.gx-aa-method:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
+.gx-aa-method[aria-checked="true"] { border-color: var(--primary); background: var(--primary-container); box-shadow: inset 0 0 0 1px var(--primary); }
+.gx-aa-method:disabled { opacity: .5; cursor: default; }
+.gx-aa-method > i { font-size: 1.25rem; line-height: 1.4; color: var(--primary); }
+.gx-aa-method > span:not(.gx-aa-method__badge) { display: grid; gap: 2px; min-width: 0; }
+.gx-aa-method small { color: var(--text-muted); font-size: var(--fs-sm); line-height: 1.35; }
+.gx-aa-method__badge { position: absolute; top: calc(-1 * var(--sp-2)); right: var(--sp-3); padding: 1px var(--sp-2); border-radius: var(--radius-full);
+ background: var(--primary); color: var(--on-primary); font-size: var(--fs-xs); font-weight: var(--fw-bold); }
+.gx-aa-google__email { display: grid; gap: var(--sp-1); width: 100%; font-size: var(--fs-sm); color: var(--on-surface-variant); }
+.gx-aa-google__email.gx-field, .gx-aa-google__email .gx-field { width: 100%; min-width: 0; box-sizing: border-box; }
+.gx-aa-google__remember { display: flex; align-items: flex-start; gap: var(--sp-2); cursor: pointer; }
+.gx-aa-google__remember input { margin-top: 3px; accent-color: var(--primary); }
+.gx-aa-google__remember span { display: grid; gap: 2px; }
+.gx-aa-google__forget { justify-self: start; }
+.gx-aa-prep { display: grid; gap: var(--sp-3); width: 100%; box-sizing: border-box; padding: var(--sp-3) var(--sp-4); border-radius: var(--radius-sm);
+ background: var(--surface-container); border: 1px solid var(--outline); }
+.gx-aa-prep progress { width: 100%; accent-color: var(--primary); }
+.gx-aa-prep--retrying { border-color: var(--warning); }
+.gx-aa-prep--retrying > strong > i { color: var(--warning); margin-right: var(--sp-2); }
+.gx-aa-prep > strong { font-size: var(--fs-lg); }
+.gx-aa-prep progress { height: 10px; }
+.gx-aa-prep__percent { font-size: var(--fs-xl); font-weight: var(--fw-bold); color: var(--primary); }
+.gx-aa-prep__percent small { margin-left: var(--sp-3); font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--text-muted); }
+.gx-aa-prep p { line-height: 1.5; }
+.gx-aa-trademark { display: block; font-size: var(--fs-xs); opacity: .8; }
+.gx-aa-wiki ol.gx-aa-token__steps { margin-top: var(--sp-4); padding-left: 0; gap: var(--sp-4); }
+.gx-aa-token__steps > li > p { margin: 0; color: var(--on-surface); line-height: 1.5; }
+.gx-aa-token > p { line-height: 1.5; }
+.gx-aa-token__steps small { color: var(--text-muted); line-height: 1.45; }
+.gx-aa-token__steps .gx-aa-google__form { width: 100%; }
+.gx-aa-token__how { margin: 0; padding-left: 1.2em; display: grid; gap: var(--sp-1); font-size: var(--fs-sm); }
+.gx-aa-token code, .gx-aa-token kbd { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; padding: 1px 5px; border-radius: 6px;
+ background: var(--surface-container-high); border: 1px solid var(--outline); color: var(--on-surface); }
+.gx-aa-token__browsers > button { min-height: 36px; padding: var(--sp-1) var(--sp-3); font-size: var(--fs-sm); }
+.gx-aa-token__steps > li > p.gx-aa-token__warning, .gx-aa-token__warning { margin: 0; padding: var(--sp-3) var(--sp-4); border: 2px solid var(--error); border-radius: var(--radius-sm);
+ background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); font-size: var(--fs-lg); font-weight: 800;
+ line-height: 1.35; text-transform: uppercase; letter-spacing: .01em; }
+.gx-aa-manage { display: grid; gap: var(--sp-4); }
+.gx-aa-manage__facts { display: grid; gap: var(--sp-3); margin: 0; }
+.gx-aa-manage__facts > div { display: grid; gap: var(--sp-1); }
+.gx-aa-manage__facts dt { font-size: var(--fs-sm); color: var(--text-muted); }
+.gx-aa-manage__facts dd { margin: 0; line-height: 1.5; }
+.gx-link { padding: 0; border: 0; background: none; color: var(--primary); font: inherit; font-weight: var(--fw-medium); text-decoration: underline; cursor: pointer; }
+.gx-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
.gx-aa-sheet__done { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; padding-block: var(--sp-3); }
.gx-aa-sheet__done > i { font-size: 3.5rem; }
.gx-aa-sheet__done .gx-aa-checks { width: auto; text-align: left; }
diff --git a/openpilot/starpilot/galaxy/web/js/android-auto.js b/openpilot/starpilot/galaxy/web/js/android-auto.js
index 6433e4434a..1c99c169d7 100644
--- a/openpilot/starpilot/galaxy/web/js/android-auto.js
+++ b/openpilot/starpilot/galaxy/web/js/android-auto.js
@@ -302,6 +302,30 @@ export class AndroidAutoFeed {
return true
}
+ async googleImport(email, token, remember) {
+ if (!this.active || this.busy || !this.setup || this.setup.import?.state === "running") return false
+ const result = await this.request("./api/android-auto/google-import", {
+ method: "POST", headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, token, remember })
+ }, 12000)
+ token = ""
+ if (result === null) return false
+ if (validSetup(result)) this.setup = result
+ this.emit()
+ await this.refresh()
+ return true
+ }
+
+ async forgetGoogle() {
+ if (!this.active || this.busy || this.setup?.import?.state === "running") return false
+ const result = await this.request("./api/android-auto/google-forget", {
+ method: "POST", headers: { "Content-Type": "application/json" }, body: "{}"
+ })
+ if (result === null) return false
+ await this.refresh()
+ return true
+ }
+
async removePackage() {
if (!this.active || this.busy || !this.setup || this.setup.import?.state === "running") return false
const result = await this.request("./api/android-auto/identity", { method: "DELETE" }, 30000)
@@ -377,7 +401,7 @@ export function importProblem(job, fileName = "", locale = undefined) {
case "UNSUPPORTED_VERSION":
return job.error || "This version stores its key differently. Choose another Android Auto release."
case "TOO_LARGE": return "This file is too large to be the Android Auto app."
- case "CANCELLED": return "Setup stopped because Android Auto was turned off. Try again."
+ case "CANCELLED": return "Setup authorization changed. Sign in to Galaxy and try again."
default: return job.error || "Something went wrong. Try again."
}
}
@@ -399,6 +423,33 @@ export function installChecks(job, locale = undefined) {
})
}
+export function googleInstallChecks(job) {
+ const stages = ["authenticating", "resolving", "downloading", "installing", "done"]
+ const at = stages.indexOf(job?.stage)
+ const percent = job?.total > 0 ? ` (${Math.min(100, Math.round(100 * job.downloaded / job.total))}%)` : ""
+ return ["Google Play Authentication", "Play Store Delivery Link", `Downloading APK${percent}`, "Extracting Certificates & Keys"]
+ .map((label, index) => ({
+ label, status: job?.state === "done" || index < at ? "done" :
+ index === at ? (job?.state === "failed" ? "failed" : "active") : "pending"
+ }))
+}
+
+const TOKEN_BROWSERS = [{ id: "chrome", name: "Chrome or Edge" }, { id: "firefox", name: "Firefox" }, { id: "safari", name: "Safari" }]
+
+// Accepts what people actually copy: surrounding quotes or spaces, a "oauth_token=" prefix, or a URL-encoded value.
+export function cleanToken(value) {
+ let token = String(value || "").trim().replace(/^oauth_token\s*[=:]\s*/i, "").replace(/^["']|["';]$/g, "").trim()
+ if (/^oauth2_4%2F/i.test(token)) { try { token = decodeURIComponent(token) } catch { /* keep as typed */ } }
+ return token
+}
+
+export function tokenProblem(value) {
+ if (!String(value || "").trim()) return ""
+ const token = cleanToken(value)
+ if (/^(oauth2_4|aas_et)\//.test(token)) return /\s/.test(token) ? "The value contains spaces. Copy only the Value cell." : ""
+ return "That doesn’t look right. Copy the Value of the row named oauth_token; it starts with oauth2_4/."
+}
+
export const AndroidAutoPage = {
components: { GxState, GxIconButton, GxNotice, GxDialog, GalaxySettingRow },
props: {
@@ -408,6 +459,8 @@ export const AndroidAutoPage = {
data: () => ({
setup: null, pairing: null, selected: null, runtime: null, receivers: [], endReason: "", busy: false, error: "",
pairValue: "", packageFile: null, uploadProgress: null, installOpen: false, removeOpen: false, installAttempted: false, installBaseline: null,
+ installMethod: "google", googleEmail: "", googleToken: "", googleRemember: true, tokenBrowser: "chrome", renewing: false,
+ tokenBrowsers: TOKEN_BROWSERS,
installFileName: "", apkmirror: APKMIRROR_URL, pairWhenReady: false, enableOpen: false
}),
mounted() {
@@ -416,7 +469,7 @@ export const AndroidAutoPage = {
document.addEventListener("visibilitychange", this.visibility)
if (!document.hidden) this.begin()
},
- beforeUnmount() { this.finishEnable(false); document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
+ beforeUnmount() { this.googleToken = ""; this.finishEnable(false); document.removeEventListener("visibilitychange", this.visibility); this.feed?.stop(true) },
watch: {
mode() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
localAccess() { if (!document.hidden) this.begin(); else this.feed?.stop(true) },
@@ -457,9 +510,26 @@ export const AndroidAutoPage = {
if (job.state === "done" && this.setup.identity.installed) return "done"
return job.state === "failed" ? "failed" : "choose"
},
- checks() { return installChecks(this.setup?.import) },
+ tokenProblem() { return tokenProblem(this.googleToken) },
+ googlePrep() {
+ const pkg = this.setup?.googlePlay?.package
+ if (!pkg || pkg.state === "ready") return null
+ const label = {
+ downloading: "Downloading",
+ installing: "Installing",
+ pending: "Preparing",
+ paused: "Paused until the car is in Park",
+ retrying: "Waiting for an internet connection",
+ }[pkg.state] || "Preparing…"
+ const progress = ["downloading", "installing"].includes(pkg.state)
+ return { state: pkg.state, label, detail: progress ? "Google Play support · about 12 MB" : "", percent: pkg.percent || 0, progress }
+ },
+ checks() { return this.setup?.import?.source === "google" ? googleInstallChecks(this.setup.import) : installChecks(this.setup?.import) },
problem() { return importProblem(this.setup?.import, this.installFileName) },
hasPackage() { return !!(this.setup?.identity.installed || this.setup?.identity.expired || this.setup?.identity.error) },
+ onPhone() { return typeof navigator !== "undefined" && (navigator.userAgentData?.mobile ?? /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "")) },
+ galaxyAddress() { return typeof location !== "undefined" ? location.host : "this Galaxy address" },
+ renewLabel() { return this.setup?.googlePlay?.remembered ? "Auto-renew on" : "Auto-renew off" },
removeReason() {
if (!this.setup) return "Load setup status first."
if (this.setup.import?.state === "running") return "Wait for the current package check to finish."
@@ -544,9 +614,18 @@ export const AndroidAutoPage = {
return this.feed.action("./api/android-auto/pairing/response", { prompt_id: prompt.id, accepted, value })
},
openInstall() {
- Object.assign(this, { installOpen: true, installAttempted: false, packageFile: null })
+ Object.assign(this, { installOpen: true, installAttempted: false, packageFile: null, renewing: false, googleRemember: true })
+ },
+ closeInstall() { this.installOpen = false; this.googleToken = ""; this.renewing = false },
+ // Auto-renew needs a fresh sign-in: the long-lived credential is only issued when a token is exchanged.
+ startRenewal() { Object.assign(this, { renewing: true, installMethod: "google", googleRemember: true, googleToken: "", installAttempted: false }) },
+ async googleImport() {
+ if (this.busy || !this.googleEmail.trim() || !this.googleToken.trim() || this.tokenProblem) return false
+ Object.assign(this, { installAttempted: true, installBaseline: this.setup.import?.started ?? null, installFileName: "" })
+ const token = cleanToken(this.googleToken)
+ this.googleToken = ""
+ return this.feed.googleImport(this.googleEmail.trim(), token, this.renewing || this.googleRemember)
},
- closeInstall() { this.installOpen = false },
chooseAgain() { Object.assign(this, { installAttempted: false, packageFile: null }) },
choosePackage(event) { this.packageFile = event.target.files?.[0] || null },
async upload() {
@@ -590,12 +669,13 @@ export const AndroidAutoPage = {
Not available on this build — the Android Auto display and encoder are missing.
-
{{ expiry.label }} · Off
+
{{ expiry.label }} · {{ renewLabel }} · Off
Starting…
{{ setup.identity.expired || setup.identity.error ? 'Reinstall' : 'Install support' }}
Update
+ Manage package
Turn On
Turn Off
@@ -611,7 +691,7 @@ export const AndroidAutoPage = {
The file won’t pick or was rejected: don’t unzip it. Use the .apk, .xapk or .apkm file exactly as downloaded.
Upload is slow or stops: stay on this page and keep the comma awake and on the same Wi-Fi until it finishes.
-
Why is this needed? Car screens only talk to devices that present Google’s Android Auto certificate. Your comma reads it from your own copy of the app, on the device. Nothing is sent anywhere, and no Android app is installed on the comma.
+
Why is this needed? Car screens only talk to devices that present Google’s Android Auto certificate. Your comma reads it from your own copy of the app, on the device. Google Play sign-in contacts Google directly; your extracted identity stays on the comma. No Android app is installed on the comma.
@@ -811,7 +891,7 @@ export const AndroidAutoPage = {
Pair each car in step 2 with Pair another . All your paired cars appear in the list. Tap Use next to the one you’re driving. Disconnect first if Android Auto is running.
Is my information private?
- Yes. The Android Auto file you install stays on your comma and isn’t sent anywhere. Connection logs also stay on the comma until you download them yourself. They leave out Wi‑Fi passwords, Bluetooth addresses, and your vehicle ID.
+ Yes. Your extracted Android Auto identity stays on your comma. Google Play import sends your sign-in to Google, and automatic updates keep a private refresh credential on the comma until you forget it. The downloaded package is deleted after import. Connection logs also stay on the comma until you download them yourself. They leave out Wi‑Fi passwords, Bluetooth addresses, and your vehicle ID.
Still stuck?
@@ -838,11 +918,11 @@ export const AndroidAutoPage = {
- Remove the package?
-
Are you sure you want to remove the package? Android Auto will disconnect first if connected. The device will not connect to your car until you re-add one.
+ Remove Android Auto support?
+
This deletes the Android Auto certificate and key from this comma and forgets your saved Google sign-in, which turns off automatic renewal. Android Auto will disconnect first if connected. Your comma won’t connect to your car until you set it up again.
Cancel
- Confirm
+ Remove
@@ -851,15 +931,116 @@ export const AndroidAutoPage = {
You’re all set
-
{{ expiry.label }}
+
{{ expiry.label }} · {{ renewLabel }}
Turn on Android Auto in step 1 to connect to your car.
Done
+
+
Android Auto Support
+
Support is set up on this comma. To set it up again, remove it first.
+
+
Status {{ expiry.label }}
+
Automatic renewal
+ On. Renews with your saved Google sign-in before it expires.
+ Off. When it expires, you’ll need to set it up again. Turning it on takes one sign-in on a computer.
+
+
+
+ Turn off automatic renewal
+ Turn on automatic renewal
+ Remove Android Auto support
+
+
- Set Up Android Auto Support
-
A quick one-time setup that enables wireless projection with your car.
-
+ Turn On Automatic Renewal
+
Sign in once more on a computer. Your comma saves a private renewal credential and refreshes Android Auto now, then renews it by itself before it expires.
+
Back
+ Set Up Android Auto Support
+
One-time setup so your car accepts your comma. Pick how to get the Android Auto app.
+ Use a computer for this. Setup needs a desktop browser’s developer tools, which phones don’t have. On a computer, open {{ galaxyAddress }} and come back to this page.
+
+
+
+ Download automatically
+ Recommended
+
+
+
+ Upload a file Use an Android Auto APK you downloaded
+
+
+
+
{{ googlePrep.label }}
+
+
+ {{ googlePrep.percent }}%{{ googlePrep.detail }}
+
+
Leave your comma on and connected to the internet. This finishes in the background, so you can close this window.
+
Want to set up now? Upload the Android Auto APK instead .
+
+
+
Sign in with your normal browser on a computer, copy one value Google gives you, and paste it here. Your comma then downloads Android Auto from Google Play. It takes about two minutes.
+
+
+ Enter your Google email
+
+
+ Keep it up to date automaticallyRenews before the certificate expires. Saves a private sign-in credential on this comma.
+
+
+ On a computer, open Google’s sign-in page. Use Chrome, Edge, Firefox or Safari, not a phone.
+ Open sign-in page
+ Or type accounts.google.com/EmbeddedSetup into the address bar.
+
+
+ Sign in with the same account and finish any verification. When asked, click I agree .
+ After you click “I agree” the page will just sit and spin. It will never finish. That’s normal. Don’t wait — go to step 4 and get the token anyway.
+
+
+ Open that tab’s cookies.
+
+ {{ b.name }}
+
+
+
+ First turn on developer tools: Safari → Settings → Advanced , then tick Show features for web developers .
+ Press ⌘ ⌥ I to open Web Inspector.
+ Click the Storage tab, then Cookies in the left column.
+
+ Press F12 on Windows or Linux, or ⌘ ⌥ I on a Mac, to open developer tools.
+
+ Click the Application tab. If you can’t see it, click » to find it.
+ In the left column, expand Cookies and click https://accounts.google.com .
+
+
+ Click the Storage tab.
+ In the left column, expand Cookies and click https://accounts.google.com .
+
+
+
+
+ Copy the oauth_token value. Find the row named oauth_token, double-click its Value and copy it. It starts with oauth2_4/.
+
+
+ Paste it here right away. It only works once and expires after a few minutes.
+
+
+
+
Starting Google Play import…
+
+
{{ problem }}
Try Again
+
{{ error }}
+
Google Play isn’t available on this device yet. Use Upload a file instead.
+
+
Download Android Auto
Open APKMirror
@@ -898,7 +1079,8 @@ export const AndroidAutoPage = {
{{ error }}
- Your package stays on this comma. Maximum size {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.
+ Google, Google Play and Android Auto are trademarks of Google LLC. StarPilot is not affiliated with or endorsed by Google.
+ The uploaded file is deleted after import. Maximum size {{ Math.floor(setup.maxUploadBytes / 1048576) }} MB.
`,
diff --git a/openpilot/starpilot/system/android_auto/apk_identity.py b/openpilot/starpilot/system/android_auto/apk_identity.py
index 92013020f8..402ac386cf 100644
--- a/openpilot/starpilot/system/android_auto/apk_identity.py
+++ b/openpilot/starpilot/system/android_auto/apk_identity.py
@@ -3,8 +3,8 @@
The car accepts a projection source only when it presents Google's Android Auto
phone certificate and proves it holds the matching key. Both are embedded in the
Android Auto app, so each user supplies their own APK (or XAPK/APKM bundle) and
-the comma extracts the identity on device. Nothing is downloaded from or sent to
-anyone except the link the user provides.
+the comma extracts the identity on device. Packages can also be fetched directly
+from Google Play using the user's own account; no project broker is involved.
Extraction needs no decompiler. The app's DEX files hold the phone certificate
and the Google Automotive Link root as PEM string constants, and the key as an
@@ -42,6 +42,7 @@ from datetime import UTC, datetime
from pathlib import Path
from openpilot.starpilot.system.android_auto import identity as identity_store
+from openpilot.starpilot.system.android_auto import play_resolver
# SHA-256 of the DER Google Automotive Link root certificate (valid until 2044).
GOOGLE_ROOT_SHA256 = "49e52efc13ad2ed09f204c3b10698bd84bb7105f510558aa14b8119a5c4ad17f"
@@ -292,12 +293,17 @@ def identity_status(directory: Path | None = None) -> dict:
# ------------------------------------------------------------------ background import
-def download(url: str, destination: Path, progress: Callable[[int, int], None] = lambda done, total: None) -> None:
+def download(url: str, destination: Path, progress: Callable[[int, int], None] = lambda done, total: None,
+ *, google_play: bool = False) -> None:
if not url.lower().startswith(("https://", "http://")):
raise IdentityImportError("Enter an http(s) link to the APK, XAPK, or APKM", "DOWNLOAD")
request = urllib.request.Request(url, headers={"User-Agent": "StarPilot-AndroidAuto/1"})
try:
- with urllib.request.urlopen(request, timeout=30) as response, open(destination, "wb") as handle:
+ opener = urllib.request.build_opener(play_resolver.GoogleRedirect(), urllib.request.ProxyHandler({})) if google_play else None
+ if google_play:
+ play_resolver.validate_url(url)
+ open_url = opener.open if opener else urllib.request.urlopen
+ with open_url(request, timeout=30) as response, open(destination, "wb") as handle:
total = int(response.headers.get("Content-Length") or 0)
if total > MAX_FILE_BYTES:
raise IdentityImportError("That file is too large to be the Android Auto app", "TOO_LARGE")
@@ -317,7 +323,9 @@ def download(url: str, destination: Path, progress: Callable[[int, int], None] =
class ImportJob:
"""One identity import at a time, in a background thread, with pollable status."""
- def __init__(self, work_dir: Path | None = None, identity_dir: Path | None = None, root_sha256: str = GOOGLE_ROOT_SHA256):
+ def __init__(self, work_dir: Path | None = None, identity_dir: Path | None = None, root_sha256: str = GOOGLE_ROOT_SHA256,
+ token_store: play_resolver.TokenStore | None = None):
+ self.token_store = token_store or play_resolver.TokenStore()
self.work_dir = work_dir or IMPORT_DIR
self.identity_dir = identity_dir
self.root_sha256 = root_sha256
@@ -349,6 +357,74 @@ class ImportJob:
self.thread = threading.Thread(target=self._run, args=(path, url, enabled), name="android_auto_identity_import", daemon=True)
self.thread.start()
+ def start_google_import(self, *, email: str, oauth_token: str | None = None, aas_token: str | None = None,
+ save_token: bool = False, enabled=None) -> None:
+ if bool(oauth_token) == bool(aas_token) or type(save_token) is not bool:
+ raise play_resolver.PlayError("Supply one Google sign-in token")
+ token = oauth_token or aas_token
+ play_resolver.validate_credentials(email, token)
+ with self.lock:
+ if self.busy():
+ raise IdentityImportError("An import is already running")
+ self.state = {"state": "running", "stage": "authenticating", "source": "google",
+ "started": datetime.now(UTC).timestamp(), "downloaded": 0, "total": 0}
+ self.thread = threading.Thread(target=self._run_google, args=(email, token, save_token, enabled),
+ name="android_auto_google_import", daemon=True)
+ self.thread.start()
+
+ def _run_google(self, email: str, token: str, remember: bool, enabled) -> None:
+ source = None
+ result = None
+
+ def progress(stage=None, **values):
+ if enabled is not None and not enabled():
+ raise IdentityImportError("Import cancelled: authorization changed", "CANCELLED")
+ self._set(**({"stage": stage} if stage else {}), **values)
+
+ try:
+ progress("authenticating")
+ self.work_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
+ fd, name = tempfile.mkstemp(prefix="google-import-", suffix=".apk", dir=self.work_dir)
+ os.close(fd)
+ source = Path(name)
+ result = play_resolver.resolve(email, token, remember=remember, progress=progress)
+ token = ""
+ progress("downloading")
+ download(result['url'], source, lambda done, total: progress(downloaded=done, total=total), google_play=True)
+ result.pop('url', None)
+ progress("installing")
+ files, metadata = extract_identity(source, root_sha256=self.root_sha256, progress=lambda _stage: progress())
+ metadata['source'] = 'Google Play (user account)'
+ progress()
+ install_identity(files, metadata, self.identity_dir)
+ progress()
+ if remember:
+ self.token_store.save(email, result['aas_token'])
+ else:
+ self.token_store.forget()
+ self._set(state="done", stage="done", finished=datetime.now(UTC).timestamp(), expires=metadata['expires'],
+ message=f"Identity installed; valid until {metadata['expires'][:10]}")
+ except IdentityImportError as error:
+ # Download exceptions may contain signed URLs. Only extraction errors are
+ # safe to display; never pass through network or unexpected error text.
+ message = 'Google Play download failed; try again' if error.code == 'DOWNLOAD' else str(error)
+ self._set(state="failed", error=message, code=error.code, finished=datetime.now(UTC).timestamp(),
+ **({"expires": error.expires} if error.expires else {}))
+ except play_resolver.PlayError as error:
+ self._set(state="failed", error=str(error), code="GOOGLE_PLAY", finished=datetime.now(UTC).timestamp())
+ except Exception:
+ self._set(state="failed", error="Google Play import failed; try again or use manual upload",
+ code="GOOGLE_PLAY", finished=datetime.now(UTC).timestamp())
+ finally:
+ token = ""
+ if result is not None:
+ result.clear()
+ if source is not None:
+ try:
+ source.unlink(missing_ok=True)
+ except OSError:
+ pass
+
def _run(self, path: Path | None, url: str, enabled=None) -> None:
def progress(**values):
if enabled is not None and not enabled():
diff --git a/openpilot/starpilot/system/android_auto/play_package.json b/openpilot/starpilot/system/android_auto/play_package.json
new file mode 100644
index 0000000000..08cee66095
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/play_package.json
@@ -0,0 +1,9 @@
+{
+ "schema": 2,
+ "resolver": {
+ "sha256": "f009b9c5964c6d49e41de9eb9d010f197d032ba81f148942a8143ad813d00ac1",
+ "bytes": 12171208,
+ "url": "https://github.com/dirwin31/openpilot/releases/download/google-browser-584069b7672b/playlink-arm64"
+ },
+ "id": "f009b9c5964c6d49e41de9eb9d010f197d032ba81f148942a8143ad813d00ac1"
+}
diff --git a/openpilot/starpilot/system/android_auto/play_package.py b/openpilot/starpilot/system/android_auto/play_package.py
new file mode 100644
index 0000000000..36524715a1
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/play_package.py
@@ -0,0 +1,6 @@
+"""Pinned Google Play resolver release asset and the directory it installs to."""
+import json
+from pathlib import Path
+
+PACKAGE = json.loads(Path(__file__).with_suffix('.json').read_text())
+RUNTIME = Path('/data/starpilot/google-play') / PACKAGE['id']
diff --git a/openpilot/starpilot/system/android_auto/play_resolver.py b/openpilot/starpilot/system/android_auto/play_resolver.py
new file mode 100644
index 0000000000..f9bb30467c
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/play_resolver.py
@@ -0,0 +1,161 @@
+"""Runs the playlink resolver and stores the opt-in Google refresh token.
+
+Secrets go over pipes, never arguments, environment or logs. Only an AAS token
+the user chose to remember is persisted.
+"""
+from __future__ import annotations
+
+import json
+import os
+import re
+import selectors
+import time
+import stat
+import subprocess
+import tempfile
+import urllib.request
+from pathlib import Path
+from urllib.parse import urlsplit
+
+from openpilot.starpilot.system.android_auto.play_package import RUNTIME
+
+BINARY = RUNTIME / 'playlink'
+TOKEN_PATH = Path('/data/starpilot/aa_token')
+MAX_TOKEN = 3072
+
+
+class PlayError(RuntimeError):
+ pass
+
+
+def validate_credentials(email: str, token: str) -> None:
+ if not isinstance(email, str) or not email.isascii() or len(email) > 254 or not re.fullmatch(r'[^\s@]{1,200}@[^\s@]{1,120}', email):
+ raise PlayError('Enter the Google account email used to sign in')
+ if not isinstance(token, str) or len(token) > MAX_TOKEN or not re.fullmatch(r'(?:oauth2_4/|aas_et/)[A-Za-z0-9._~/+=-]+', token):
+ raise PlayError('Paste a valid Google oauth_token or AAS token')
+
+
+def validate_url(url: str) -> None:
+ try:
+ parsed = urlsplit(url)
+ valid = (parsed.scheme == 'https' and parsed.hostname == 'play.googleapis.com' and parsed.port in (None, 443)
+ and not parsed.username and not parsed.password and parsed.path == '/download/by-token/download' and not parsed.fragment)
+ except (TypeError, ValueError):
+ valid = False
+ if not valid:
+ raise PlayError('Google Play returned an invalid download link')
+
+
+class GoogleRedirect(urllib.request.HTTPRedirectHandler):
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
+ parsed = urlsplit(newurl)
+ host = parsed.hostname or ''
+ if (parsed.scheme != 'https' or parsed.port not in (None, 443) or parsed.username or parsed.password or
+ not (host == 'play.googleapis.com' or host.endswith('.gvt1.com'))):
+ raise PlayError('Google Play redirected outside its download servers')
+ return super().redirect_request(req, fp, code, msg, headers, newurl)
+
+
+def resolve(email: str, token: str, *, remember: bool = False, progress=lambda stage: None) -> dict:
+ validate_credentials(email, token)
+ progress('authenticating')
+ # Callers only see allowlisted stage names; all other resolver output stays private.
+ process = None
+ output = bytearray()
+ output_size = 0
+ deadline = time.monotonic() + 120
+ stage = 'authenticating'
+ try:
+ process = subprocess.Popen([str(BINARY)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
+ env={'PATH': '/usr/bin:/bin', 'LANG': 'C.UTF-8'})
+ process.stdin.write(json.dumps({'email': email, 'token': token, 'remember': remember}).encode())
+ process.stdin.close()
+ token = ''
+ with selectors.DefaultSelector() as selector:
+ selector.register(process.stdout, selectors.EVENT_READ)
+ while True:
+ progress(stage) # also checks cancellation while Google is responding
+ if time.monotonic() >= deadline:
+ raise PlayError('Google Play timed out; try signing in again')
+ if not selector.select(timeout=0.5):
+ continue
+ chunk = os.read(process.stdout.fileno(), 4096)
+ if not chunk:
+ break
+ output.extend(chunk)
+ output_size += len(chunk)
+ if output_size > 32768:
+ raise PlayError('Google Play returned an invalid response')
+ marker = b'{"stage":"resolving"}\n'
+ if output.startswith(marker):
+ del output[:len(marker)]
+ stage = 'resolving'
+ process.wait(timeout=max(0.01, deadline - time.monotonic()))
+ if process.returncode != 0:
+ raise PlayError('Google Play sign-in or delivery failed; sign in again and ensure Play terms are accepted on your account')
+ except subprocess.TimeoutExpired:
+ raise PlayError('Google Play timed out; try signing in again') from None
+ except OSError:
+ raise PlayError('Google Play support is unavailable in this build; use manual upload') from None
+ finally:
+ token = ''
+ if process is not None:
+ if process.poll() is None:
+ process.kill()
+ process.wait()
+ process.stdout.close()
+ if not process.stdin.closed:
+ process.stdin.close()
+ try:
+ value = json.loads(output)
+ if not isinstance(value, dict) or value.get('status') != 'ok':
+ raise ValueError
+ validate_url(value['url'])
+ if remember:
+ validate_credentials(email, value['aas_token'])
+ if not value['aas_token'].startswith('aas_et/'):
+ raise ValueError
+ except (KeyError, TypeError, ValueError, PlayError):
+ raise PlayError('Google Play returned an invalid response') from None
+ progress('resolving')
+ return {'url': value['url'], **({'aas_token': value['aas_token']} if remember else {})}
+
+
+class TokenStore:
+ def __init__(self, path: Path = TOKEN_PATH):
+ self.path = path
+
+ def exists(self) -> bool:
+ return self.path.is_file()
+
+ def save(self, email: str, token: str) -> None:
+ validate_credentials(email, token)
+ if not token.startswith('aas_et/'):
+ raise PlayError('Only a refresh token can be remembered')
+ self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ fd, name = tempfile.mkstemp(prefix='.aa-token-', dir=self.path.parent)
+ try:
+ with os.fdopen(fd, 'w') as handle:
+ json.dump({'email': email, 'token': token}, handle)
+ handle.flush()
+ os.fsync(handle.fileno())
+ os.replace(name, self.path)
+ finally:
+ Path(name).unlink(missing_ok=True)
+
+ def load(self) -> dict:
+ fd = os.open(self.path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
+ with os.fdopen(fd) as handle:
+ info = os.fstat(handle.fileno())
+ if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_uid != os.getuid():
+ raise PlayError('Saved Google credentials must be private to the device user')
+ value = json.loads(handle.read(4097))
+ if not isinstance(value, dict):
+ raise PlayError('Saved Google credentials are invalid')
+ validate_credentials(value.get('email'), value.get('token'))
+ if not value['token'].startswith('aas_et/'):
+ raise PlayError('Saved Google credentials are invalid')
+ return value
+
+ def forget(self) -> None:
+ self.path.unlink(missing_ok=True)
diff --git a/openpilot/starpilot/system/android_auto/tests/test_play_resolver.py b/openpilot/starpilot/system/android_auto/tests/test_play_resolver.py
new file mode 100644
index 0000000000..49ae9e6ea3
--- /dev/null
+++ b/openpilot/starpilot/system/android_auto/tests/test_play_resolver.py
@@ -0,0 +1,140 @@
+"""No live Google account or APK required for resolver/import regression tests."""
+import json
+import os
+import sys
+from unittest.mock import Mock
+
+import pytest
+
+from openpilot.starpilot.system.android_auto import apk_identity, play_resolver
+
+EMAIL = 'user@example.com'
+TOKEN = 'oauth2_4/test-secret'
+AAS = 'aas_et/test-refresh'
+URL = 'https://play.googleapis.com/download/by-token/download?token=private'
+
+
+def fake_binary(tmp_path, monkeypatch, body):
+ binary = tmp_path / 'playlink'
+ binary.write_text(f'#!{sys.executable}\n' + body)
+ binary.chmod(0o700)
+ monkeypatch.setattr(play_resolver, 'BINARY', binary)
+
+
+def test_pipe_protocol_private_arguments_and_stages(tmp_path, monkeypatch):
+ fake_binary(tmp_path, monkeypatch, f'''import sys, json, os
+assert len(sys.argv) == 1
+assert not any("oauth2_4/" in value for value in os.environ.values())
+request = json.load(sys.stdin)
+assert request == {{"email": {EMAIL!r}, "token": {TOKEN!r}, "remember": True}}
+print('{{"stage":"resolving"}}', flush=True)
+print(json.dumps({{"status": "ok", "url": {URL!r}, "aas_token": {AAS!r}}}))
+''')
+ stages = []
+ result = play_resolver.resolve(EMAIL, TOKEN, remember=True, progress=stages.append)
+ assert result == {'url': URL, 'aas_token': AAS}
+ assert stages[0] == 'authenticating' and stages[-1] == 'resolving'
+
+
+@pytest.mark.parametrize('body', [
+ 'import sys; print("oauth2_4/private", file=sys.stderr); sys.exit(1)',
+ 'print("oauth2_4/private")',
+ 'print("x" * 40000)',
+ 'print(\'{"status":"ok","url":"http://127.0.0.1/secret"}\')',
+])
+def test_failures_never_echo_child_output(tmp_path, monkeypatch, body):
+ fake_binary(tmp_path, monkeypatch, body)
+ with pytest.raises(play_resolver.PlayError) as error:
+ play_resolver.resolve(EMAIL, TOKEN)
+ assert 'private' not in str(error.value) and '127.0.0.1' not in str(error.value)
+
+
+def test_cancellation_kills_child(tmp_path, monkeypatch):
+ pid_file = tmp_path / 'pid'
+ fake_binary(tmp_path, monkeypatch, f'import os, time\nopen({str(pid_file)!r}, "w").write(str(os.getpid()))\ntime.sleep(20)\n')
+ def progress(_stage):
+ if pid_file.exists():
+ raise RuntimeError('cancelled')
+ with pytest.raises(RuntimeError, match='cancelled'):
+ play_resolver.resolve(EMAIL, TOKEN, progress=progress)
+ with pytest.raises(ProcessLookupError):
+ os.kill(int(pid_file.read_text()), 0)
+
+
+@pytest.mark.parametrize('url', ['https://play.googleapis.com.evil/download/by-token/download',
+ 'http://play.googleapis.com/download/by-token/download',
+ 'https://user@play.googleapis.com/download/by-token/download',
+ 'https://play.googleapis.com:444/download/by-token/download',
+ 'https://play.googleapis.com/other'])
+def test_rejects_unexpected_download_hosts(url):
+ with pytest.raises(play_resolver.PlayError):
+ play_resolver.validate_url(url)
+
+
+def test_token_store_permissions_and_forget(tmp_path):
+ store = play_resolver.TokenStore(tmp_path / 'private' / 'aa_token')
+ store.save(EMAIL, AAS)
+ assert store.path.stat().st_mode & 0o777 == 0o600
+ assert store.load() == {'email': EMAIL, 'token': AAS}
+ assert not list(store.path.parent.glob('.aa-token-*'))
+ store.path.chmod(0o644)
+ with pytest.raises(play_resolver.PlayError):
+ store.load()
+ store.forget()
+ store.forget()
+ assert not store.exists()
+
+
+def test_token_store_rejects_symlink(tmp_path):
+ target = tmp_path / 'elsewhere'
+ target.write_text('{}')
+ path = tmp_path / 'aa_token'
+ path.symlink_to(target)
+ with pytest.raises(OSError):
+ play_resolver.TokenStore(path).load()
+
+
+@pytest.mark.parametrize('remember', [False, True])
+def test_google_import_installs_and_cleans_private_package(tmp_path, monkeypatch, remember):
+ store = play_resolver.TokenStore(tmp_path / 'aa_token')
+ job = apk_identity.ImportJob(work_dir=tmp_path / 'work', identity_dir=tmp_path / 'identity', token_store=store)
+ monkeypatch.setattr(play_resolver, 'resolve', Mock(return_value={'url': URL, 'aas_token': AAS}))
+ def download(url, destination, progress, **kwargs):
+ assert kwargs == {"google_play": True}
+ assert url == URL and destination.stat().st_mode & 0o077 == 0
+ destination.write_bytes(b'package')
+ progress(7, 7)
+ monkeypatch.setattr(apk_identity, 'download', download)
+ monkeypatch.setattr(apk_identity, 'extract_identity', Mock(return_value=({'key': b'private'}, {'expires': '2027-01-01T00:00:00+00:00'})))
+ install = Mock()
+ monkeypatch.setattr(apk_identity, 'install_identity', install)
+ job.start_google_import(email=EMAIL, oauth_token=TOKEN, save_token=remember)
+ job.thread.join(3)
+ assert job.status()['state'] == 'done'
+ install.assert_called_once()
+ assert not list(job.work_dir.iterdir())
+ assert store.exists() is remember
+ assert all(secret not in json.dumps(job.status()) for secret in (TOKEN, AAS, URL, EMAIL))
+
+
+def test_download_exception_redacted_and_old_identity_preserved(tmp_path, monkeypatch):
+ job = apk_identity.ImportJob(work_dir=tmp_path / 'work', token_store=play_resolver.TokenStore(tmp_path / 'token'))
+ monkeypatch.setattr(play_resolver, 'resolve', Mock(return_value={'url': URL}))
+ monkeypatch.setattr(apk_identity, 'download', Mock(side_effect=apk_identity.IdentityImportError(URL, 'DOWNLOAD')))
+ install = Mock()
+ monkeypatch.setattr(apk_identity, 'install_identity', install)
+ job.start_google_import(email=EMAIL, oauth_token=TOKEN)
+ job.thread.join(3)
+ assert job.status()['state'] == 'failed' and URL not in json.dumps(job.status())
+ assert not list(job.work_dir.iterdir())
+ install.assert_not_called()
+
+
+def test_revoked_session_never_resolves_or_installs(tmp_path, monkeypatch):
+ resolve = Mock()
+ monkeypatch.setattr(play_resolver, 'resolve', resolve)
+ job = apk_identity.ImportJob(work_dir=tmp_path / 'work')
+ job.start_google_import(email=EMAIL, oauth_token=TOKEN, enabled=lambda: False)
+ job.thread.join(3)
+ assert job.status()['code'] == 'CANCELLED'
+ resolve.assert_not_called()
diff --git a/scripts/build_playlink.sh b/scripts/build_playlink.sh
new file mode 100755
index 0000000000..35eb81d8b9
--- /dev/null
+++ b/scripts/build_playlink.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Builds the static Linux ARM64 Google Play resolver (playlink) in Docker.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+scripts/laptop_device_build.sh doctor
+image=starpilot-playlink-builder:rust1.93.1
+docker build --platform linux/arm64 -f tools/laptop_device_build/Dockerfile.playlink -t "$image" .
+mkdir -p .cache/playlink-registry .cache/playlink-target .cache/playlink
+docker run --rm --platform linux/arm64 \
+ --mount "type=bind,src=$PWD,dst=/work" \
+ --mount "type=bind,src=$PWD/.cache/playlink-registry,dst=/opt/playlink-cargo/registry" \
+ -e CARGO_TARGET_DIR=/work/.cache/playlink-target \
+ -e GIT_CONFIG_COUNT=1 -e GIT_CONFIG_KEY_0=safe.directory -e GIT_CONFIG_VALUE_0=/work \
+ "$image" bash -euc '
+ test "$(uname -sm)" = "Linux aarch64"
+ capnp --version | grep -q "version 1.0.1$"
+ cargo build --locked --release --target aarch64-unknown-linux-gnu
+ binary=/work/.cache/playlink-target/aarch64-unknown-linux-gnu/release/playlink
+ file "$binary"
+ readelf -h "$binary" | grep -q AArch64
+ if readelf -l "$binary" | grep -q INTERP; then exit 1; fi
+ if readelf -d "$binary" | grep -q NEEDED; then exit 1; fi
+ install -m 755 "$binary" /work/.cache/playlink/playlink-arm64
+ '
+printf 'Play resolver: %s/.cache/playlink/playlink-arm64\n' "$PWD"
diff --git a/tools/laptop_device_build/Dockerfile.playlink b/tools/laptop_device_build/Dockerfile.playlink
new file mode 100644
index 0000000000..bd0111ea80
--- /dev/null
+++ b/tools/laptop_device_build/Dockerfile.playlink
@@ -0,0 +1,9 @@
+FROM openpilot-local-arm64-builder:latest
+USER root
+ENV CARGO_HOME=/opt/playlink-cargo RUSTUP_HOME=/opt/playlink-rustup
+RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs -o /tmp/rustup-init.sh \
+ && sh /tmp/rustup-init.sh -y --profile minimal --default-toolchain 1.93.1 \
+ && rm /tmp/rustup-init.sh
+ENV PATH=/opt/playlink-cargo/bin:$PATH
+ENV RUSTFLAGS="-C target-feature=+crt-static"
+WORKDIR /work/tools/playlink
diff --git a/tools/playlink/Cargo.lock b/tools/playlink/Cargo.lock
new file mode 100644
index 0000000000..947e4a7b22
--- /dev/null
+++ b/tools/playlink/Cargo.lock
@@ -0,0 +1,2215 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "bincode"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
+dependencies = [
+ "bincode_derive",
+ "serde",
+ "unty",
+]
+
+[[package]]
+name = "bincode_derive"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
+dependencies = [
+ "virtue",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "configparser"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031ad3d167622974ed636505ae4556267ed4aa2aea0464122fa6bc9698a32825"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core_detect"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "either"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+dependencies = [
+ "cfg-if",
+ "core_detect",
+ "multiversion_no_op",
+ "rustversion",
+ "scopeguard",
+ "simdutf8",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "fs_extra"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "rand_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "googleplay-protobuf"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33d138c3fe9de16e9f508956cee34615ad0c48c8d78d1eb33c64f992fd82817"
+dependencies = [
+ "prost",
+ "prost-build",
+]
+
+[[package]]
+name = "gpapi"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01531d5c64819b400f0fa805806d1b71ba1ce732c3b17f253538fe6bf72f1335"
+dependencies = [
+ "bincode",
+ "bytes",
+ "configparser",
+ "futures",
+ "googleplay-protobuf",
+ "prost",
+ "reqwest",
+ "serde",
+ "tokio-dl-stream-to-disk",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys",
+ "log",
+ "simd_cesu8",
+ "thiserror",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "multimap"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
+
+[[package]]
+name = "multiversion_no_op"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "openssl"
+version = "0.10.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-src"
+version = "300.6.1+3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "openssl-src",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "petgraph"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
+dependencies = [
+ "fixedbitset",
+ "hashbrown 0.15.5",
+ "indexmap",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "prost"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-build"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042"
+dependencies = [
+ "heck",
+ "itertools",
+ "log",
+ "multimap",
+ "petgraph",
+ "prettyplease",
+ "prost",
+ "prost-types",
+ "regex",
+ "syn 2.0.119",
+ "tempfile",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "prost-types"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a"
+dependencies = [
+ "prost",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
+dependencies = [
+ "aws-lc-rs",
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.13.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "rustls-platform-verifier",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "aws-lc-rs",
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-platform-verifier"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "jni",
+ "log",
+ "once_cell",
+ "rustls",
+ "rustls-native-certs",
+ "rustls-platform-verifier-android",
+ "rustls-webpki",
+ "security-framework",
+ "security-framework-sys",
+ "webpki-root-certs",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls-platform-verifier-android"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "starpilot-playlink"
+version = "0.1.0"
+dependencies = [
+ "gpapi",
+ "openssl",
+ "serde",
+ "serde_json",
+ "tokio",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-dl-stream-to-disk"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19ac851ad6e71515d1cd67fd10c208f6bb7a6dceccecc838155f08e2eb885a62"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "reqwest",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "unty"
+version = "0.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "virtue"
+version = "0.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-root-certs"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/tools/playlink/Cargo.toml b/tools/playlink/Cargo.toml
new file mode 100644
index 0000000000..b8b5808ec1
--- /dev/null
+++ b/tools/playlink/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "starpilot-playlink"
+version = "0.1.0"
+edition = "2021"
+
+[[bin]]
+name = "playlink"
+path = "src/main.rs"
+
+[dependencies]
+gpapi = "=6.1.0"
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+openssl = { version = "0.10", features = ["vendored"] }
+
+[profile.release]
+strip = true
diff --git a/tools/playlink/src/main.rs b/tools/playlink/src/main.rs
new file mode 100644
index 0000000000..21f7982eb4
--- /dev/null
+++ b/tools/playlink/src/main.rs
@@ -0,0 +1,65 @@
+//! Reads a Google sign-in token on stdin and prints the Android Auto download link.
+//! No account, APK, URL cache or credentials are bundled.
+use gpapi::Gpapi;
+use serde::Deserialize;
+use serde_json::json;
+use std::io::{self, IsTerminal, Read, Write};
+use std::time::Duration;
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Request {
+ email: String,
+ token: String,
+ remember: bool,
+}
+
+async fn resolve(request: Request) -> Result {
+ let mut api = Gpapi::new("px_9a", &request.email);
+ if request.token.starts_with("oauth2_4/") {
+ api.request_aas_token(request.token).await.map_err(|_| ())?;
+ } else if request.token.starts_with("aas_et/") {
+ api.set_aas_token(request.token);
+ } else {
+ return Err(());
+ }
+ // Deliberately no ToS acceptance: the user accepts Play terms on their own account.
+ api.login().await.map_err(|_| ())?;
+ writeln!(io::stdout(), "{{\"stage\":\"resolving\"}}").map_err(|_| ())?;
+ io::stdout().flush().map_err(|_| ())?;
+ let (url, _, _, _) = api
+ .get_download_info("com.google.android.projection.gearhead", None)
+ .await
+ .map_err(|_| ())?;
+ let mut result = json!({"status": "ok", "url": url.ok_or(())?});
+ if request.remember {
+ result["aas_token"] = json!(api.get_aas_token().ok_or(())?);
+ }
+ Ok(result)
+}
+
+#[tokio::main]
+async fn main() {
+ // Silence panics so errors never disclose responses, cookies or credentials.
+ std::panic::set_hook(Box::new(|_| {}));
+ if io::stdin().is_terminal() || io::stdout().is_terminal() {
+ std::process::exit(2);
+ }
+ let mut input = String::new();
+ if io::stdin().take(4097).read_to_string(&mut input).is_err() || input.len() > 4096 {
+ std::process::exit(2);
+ }
+ let request = match serde_json::from_str::(&input) {
+ Ok(request) => request,
+ Err(_) => std::process::exit(2),
+ };
+ drop(input);
+ match tokio::time::timeout(Duration::from_secs(110), resolve(request)).await {
+ Ok(Ok(result)) => {
+ if writeln!(io::stdout(), "{result}").is_err() {
+ std::process::exit(1);
+ }
+ }
+ _ => std::process::exit(1),
+ }
+}
diff --git a/uv.lock b/uv.lock
index 2ab246ff5f..8fcfeb33ec 100644
--- a/uv.lock
+++ b/uv.lock
@@ -808,6 +808,7 @@ requires-dist = [
{ name = "requests" },
{ name = "ruff", marker = "extra == 'testing'" },
{ name = "scons" },
+ { name = "sentry-sdk", specifier = "==2.38.0" },
{ name = "setproctitle" },
{ name = "sounddevice" },
{ name = "teleoprtc", marker = "extra == 'vendored'", editable = "teleoprtc_repo" },