mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-04 11:33:47 +08:00
fix fast updater hang; improve version selection list; schedule update install
This commit is contained in:
@@ -133,6 +133,13 @@ class SoftwareLayout(Widget):
|
||||
self._download_btn.set_visible(ui_state.is_offroad())
|
||||
|
||||
updater_state = ui_state.params.get("UpdaterState") or "idle"
|
||||
fast = ui_state.params.get('UpdaterFastState') or {}
|
||||
check = fast.get('check', {}) if isinstance(fast, dict) else {}
|
||||
checking_metadata = check.get('state') == 'checking'
|
||||
if checking_metadata:
|
||||
updater_state = 'checking...'
|
||||
if check.get('completedAt', 0) >= getattr(self, '_waiting_start_ts', float('inf')) and check.get('state') in ('complete', 'error'):
|
||||
self._waiting_for_updater = False
|
||||
failed_count = ui_state.params.get("UpdateFailedCount") or 0
|
||||
fetch_available = ui_state.params.get_bool("UpdaterFetchAvailable")
|
||||
update_available = ui_state.params.get_bool("UpdateAvailable")
|
||||
@@ -143,6 +150,14 @@ class SoftwareLayout(Widget):
|
||||
self._download_btn.action_item.set_enabled(False)
|
||||
# Use the mapping, with a fallback to the original state string
|
||||
display_text = STATE_TO_DISPLAY_TEXT.get(updater_state, updater_state)
|
||||
if checking_metadata:
|
||||
display_text = tr('Checking for new versions')
|
||||
elif isinstance(fast, dict) and fast.get('detail'):
|
||||
display_text = fast['detail']
|
||||
if fast.get('background'):
|
||||
display_text = tr('Background download') + ': ' + display_text
|
||||
self._download_btn.action_item.set_text(tr('CHECK'))
|
||||
self._download_btn.action_item.set_enabled(True)
|
||||
self._download_btn.action_item.set_value(display_text)
|
||||
else:
|
||||
if failed_count > 0:
|
||||
@@ -152,7 +167,7 @@ class SoftwareLayout(Widget):
|
||||
self._download_btn.action_item.set_value(tr("update available"))
|
||||
self._download_btn.action_item.set_text(tr("DOWNLOAD"))
|
||||
else:
|
||||
last_update = ui_state.params.get("LastUpdateTime")
|
||||
last_update = ui_state.params.get("UpdaterLastCheckTime") or ui_state.params.get("LastUpdateTime")
|
||||
if last_update:
|
||||
formatted = time_ago(last_update)
|
||||
self._download_btn.action_item.set_value(tr("up to date, last checked {}").format(formatted))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import pyray as rl
|
||||
from enum import IntEnum
|
||||
from collections.abc import Callable
|
||||
@@ -83,6 +84,7 @@ class CheckUpdateButton(BigButton):
|
||||
self._signal_updater(self.CHECK_FOR_UPDATE)
|
||||
|
||||
def _signal_updater(self, sig: str):
|
||||
self._check_requested_at = time.monotonic() if sig == self.CHECK_FOR_UPDATE else None
|
||||
self.set_enabled(False)
|
||||
self._state = UpdaterState.WAITING_FOR_UPDATER
|
||||
self._hide_value_t = None
|
||||
@@ -109,6 +111,22 @@ class CheckUpdateButton(BigButton):
|
||||
return
|
||||
|
||||
updater_state = ui_state.params.get("UpdaterState") or ""
|
||||
fast = ui_state.params.get('UpdaterFastState') or {}
|
||||
check = fast.get('check', {}) if isinstance(fast, dict) else {}
|
||||
requested = getattr(self, '_check_requested_at', None)
|
||||
if requested is not None and check.get('completedAt', 0) >= requested and check.get('state') in ('complete', 'error'):
|
||||
self._state = UpdaterState.IDLE
|
||||
self._check_requested_at = None
|
||||
self._hide_value_t = rl.get_time()
|
||||
if check.get('state') == 'checking':
|
||||
self.set_rotate_icon(True)
|
||||
self.set_value('checking version metadata...')
|
||||
return
|
||||
if isinstance(fast, dict) and fast.get('background') and updater_state in ('downloading...', 'finalizing update...'):
|
||||
self.set_rotate_icon(False)
|
||||
self.set_enabled(True)
|
||||
self.set_value('Background download: ' + (fast.get('detail') or updater_state))
|
||||
return
|
||||
failed_count = ui_state.params.get("UpdateFailedCount") or 0
|
||||
failed = int(failed_count) > 0
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import threading
|
||||
import time
|
||||
|
||||
from .resolver import Mode
|
||||
from .owner import Rejected
|
||||
|
||||
|
||||
LABELS = {Mode.AUTO: 'Auto', Mode.OFFROAD: 'Offroad', Mode.ONROAD: 'Onroad'}
|
||||
@@ -39,8 +40,12 @@ class DriveStateControl:
|
||||
return result
|
||||
|
||||
def change_galaxy(self, mode, revision, authorized):
|
||||
result = self.owner.request(mode, expected_revision=revision, authorized=authorized,
|
||||
override_allowed=lambda: self.physical.status(require_park=True)['allowed'])
|
||||
def offroad_allowed():
|
||||
status = self.physical.status(require_park=True)
|
||||
if not status['allowed']:
|
||||
raise Rejected(status.get('reason') or 'Park the vehicle and disengage before forcing Offroad')
|
||||
return True
|
||||
result = self.owner.request(mode, expected_revision=revision, authorized=authorized, override_allowed=offroad_allowed)
|
||||
with self.lock:
|
||||
self.cached = None
|
||||
return result
|
||||
|
||||
@@ -183,3 +183,19 @@ def test_local_force_offroad_with_ignition_on_keeps_park_authority_separate(fixt
|
||||
assert f.source.allowed() is (not moving and not engaged)
|
||||
if moving or engaged:
|
||||
assert not f.source.update_ready(True)
|
||||
|
||||
|
||||
def test_galaxy_force_offroad_preserves_exact_rejection_reason_and_auto_recovery(tmp_path):
|
||||
params = Params()
|
||||
owner = DriveStateOwner(params, tmp_path / 'reason', alive=lambda _: True)
|
||||
owner.initialize(pid=1, birth=2, boot=BOOT)
|
||||
physical = NS(status=Mock(return_value={'allowed': False, 'reason': 'Current speed and gear are unavailable'}))
|
||||
control = DriveStateControl(owner, physical, effective=lambda: False)
|
||||
revision = owner.snapshot().revision
|
||||
before = list(params.writes)
|
||||
with pytest.raises(Rejected, match='Current speed and gear are unavailable'):
|
||||
control.change_galaxy('offroad', revision, lambda: True)
|
||||
assert params.writes == before
|
||||
assert owner.snapshot().mode == Mode.AUTO
|
||||
control.change_galaxy('auto', revision, lambda: True)
|
||||
assert owner.snapshot().mode == Mode.AUTO
|
||||
|
||||
@@ -64,10 +64,12 @@ def reset_external_preferences(storage, model_root):
|
||||
locks.enter_context(_owner_lock(state, '.lock'))
|
||||
locks.enter_context(_owner_lock(model_root, '.state.lock'))
|
||||
locks.enter_context(_owner_lock(navigation_root, '.lock'))
|
||||
locks.enter_context(_owner_lock(storage / 'software' / 'auto-install', '.lock'))
|
||||
if _read(state / 'complete.json') == identity:
|
||||
return ()
|
||||
paths = {'model_preferences': model_root / 'preferences.json',
|
||||
'navigation_settings': navigation_root / 'settings.json'}
|
||||
'navigation_settings': navigation_root / 'settings.json',
|
||||
'software_install': storage / 'software' / 'auto-install' / 'settings.json'}
|
||||
journal_path = state / 'journal.json'
|
||||
journal_raw = _read(journal_path)
|
||||
if journal_raw is None:
|
||||
|
||||
@@ -38,7 +38,7 @@ it labels its sample System Monitor data and cannot edit a device.
|
||||
| Home and Tools | Local status cards, navigation, theme, drawer, pinned tiles, and hash routes. |
|
||||
| System Monitor and Plots | Bounded local process/device and driving-message samples. Unavailable data stays unknown; leaving a page stops its polling. |
|
||||
| Logs and Routes | Crash-report previews and a shared route workspace for camera footage, driving UI replay, segment details, and scoped footage/replay/log downloads. |
|
||||
| Software & Updates | Read-only installed and updater status. Refresh rereads reported Params; it does not check, download, switch branches, install, roll back, or reboot. |
|
||||
| Software & Updates | Parked update controls and a newest-first version list. Installed and fully cached commits are marked; selecting a commit offers installation or download and installation. Initially shows up to two newer commits, the installed commit, and the previous installed commit. Collapsed gaps above and below the installed commit expand independently; older history remains available below the previous version. Each gap shows its own remaining count and reveals at most ten commits per click inside a scroll area bounded by the viewport. Release notes follow the selected installed or prepared version. |
|
||||
| Driving and Device Preferences | Saved settings through typed owners shared with native controls, including SLC, lateral and longitudinal tuning, conditional modes, display, cameras, and sounds. |
|
||||
| Vehicle Controls | One saved manual vehicle choice for the next start, or Auto for normal detection. |
|
||||
| Models and Model Laboratory | Verified model catalog management and parked Laboratory configuration. Paired inference is unavailable in the production model runner. |
|
||||
@@ -48,6 +48,21 @@ it labels its sample System Monitor data and cannot edit a device.
|
||||
| Sound packs | Catalog status, verified download and cancellation through the local sound owner. |
|
||||
| Galaxy Access & Install | Remote link pairing, Galaxy installation, and an opt-in Wi-Fi hotspot. Local addresses are on Home. |
|
||||
|
||||
`Refresh` and branch changes request filtered
|
||||
history metadata in a separate Git cache and worker. They remain available during
|
||||
background staging. `Check for updates` also wakes the background updater after the metadata check. When automatic downloads are enabled, a newer commit wakes
|
||||
the separate download phase. Update and download actions retrieve the chosen
|
||||
snapshot's missing file contents; version browsing uses `--filter=blob:none` with
|
||||
bounded history depth. The branch selector lives in the version list and refreshes its metadata without changing the updater target. `Download only` prepares the selected commit without restarting; `Select previous version` selects the saved branch and commit without installing it. Downloads report Git object progress and finalization
|
||||
reports copied files; Git garbage collection is outside the finalization path.
|
||||
|
||||
Automatic installation is opt-in and defaults off. Its five numeric cron fields
|
||||
(minute, hour, day, month, weekday) default to `0 0 * * *`: daily at midnight in
|
||||
the saved local timezone shown in Advanced options. The timezone selector saves an IANA timezone with the schedule, including daylight-saving rules. The updater only restarts for a
|
||||
validated, prepared update while idle and freshly confirmed offroad. It skips
|
||||
onroad occurrences and reserves each scheduled minute before restarting to avoid
|
||||
reboot loops. These preferences live in the StarPilot software state directory.
|
||||
|
||||
The Vue router in `web/js/app.js` and the route handlers in `server.py` are the
|
||||
current route map. API groups include `/api/auth`, `/api/system/monitor`,
|
||||
`/api/software/status`, `/api/settings`, `/api/models`, `/api/maps`,
|
||||
|
||||
@@ -11,6 +11,8 @@ import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from openpilot.common.basedir import BASEDIR
|
||||
from openpilot.common.hardware import COMMA_HARDWARE
|
||||
@@ -20,7 +22,8 @@ from openpilot.starpilot.galaxy.software_status import SoftwareStatus, SoftwareU
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
from openpilot.starpilot.software.update_control import UpdaterControlError
|
||||
from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, automatic_downloads
|
||||
from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions
|
||||
from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, next_restart
|
||||
from openpilot.starpilot.software.history import METADATA_ROOT, commit_history, downloaded_versions, previous_version, release_notes, recent_versions
|
||||
|
||||
|
||||
FINALIZED = Path(os.environ.get("UPDATER_STAGING_ROOT", "/data/safe_staging")) / "finalized"
|
||||
@@ -28,7 +31,7 @@ BRANCH_PATTERN = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}\Z")
|
||||
COMMIT_PATTERN = re.compile(r"[0-9a-f]{40}\Z")
|
||||
MAX_BRANCH_LIST = 32768
|
||||
MAX_BRANCHES = 256
|
||||
SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version"))
|
||||
SOURCE_ACTIONS = frozenset(("update", "fast", "rollback", "versions", "version", "download-version"))
|
||||
PROCESS_TITLE = b"openpilot.system.updated.updated"
|
||||
|
||||
|
||||
@@ -103,7 +106,7 @@ class UpdaterProcess:
|
||||
return self.current_identity() is not None
|
||||
|
||||
def send(self, action: str, *, branch=None, commit=None, selected_commit=None) -> tuple[int, int]:
|
||||
if action not in ("check", "download", "update", "fast", "rollback", "versions", "version"):
|
||||
if action not in ("check", "download", "update", "fast", "rollback", "versions", "version", "download-version"):
|
||||
raise SoftwareOperationError("Invalid updater command", 400)
|
||||
with self.lock:
|
||||
pid = self._manager_pid()
|
||||
@@ -111,7 +114,7 @@ class UpdaterProcess:
|
||||
if self._manager_pid() != pid or self._proc_identity(pid) != start:
|
||||
raise SoftwareOperationError("Updater process changed", 503)
|
||||
try:
|
||||
if action == 'version':
|
||||
if action in ('version', 'download-version'):
|
||||
self.control.send(pid, start, action, branch=branch, commit=commit, selected_commit=selected_commit)
|
||||
elif action in ("update", "fast", "rollback", "versions"):
|
||||
self.control.send(pid, start, action, branch=branch, commit=commit)
|
||||
@@ -146,7 +149,8 @@ def _git_identity(path: Path) -> tuple[str, str] | None:
|
||||
|
||||
class SoftwareOperations:
|
||||
def __init__(self, params=None, status=None, parked=None, process=None, *, finalized=FINALIZED,
|
||||
installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None):
|
||||
installed=BASEDIR, git_identity=_git_identity, clock=time.monotonic, history_reader=commit_history, refresh=None,
|
||||
metadata=METADATA_ROOT, install_settings=None):
|
||||
self.params = params if params is not None else Params()
|
||||
self.status = status if status is not None else SoftwareStatus(self.params)
|
||||
if parked is None:
|
||||
@@ -160,6 +164,9 @@ class SoftwareOperations:
|
||||
self.process = process if process is not None else UpdaterProcess()
|
||||
self.refresh = refresh
|
||||
self.finalized = Path(finalized)
|
||||
self.staging = self.finalized.parent / 'merged'
|
||||
self.metadata = Path(metadata)
|
||||
self.install_settings = install_settings if install_settings is not None else InstallSettings()
|
||||
self.installed = Path(installed)
|
||||
self.git_identity = git_identity
|
||||
self.clock = clock
|
||||
@@ -252,6 +259,20 @@ class SoftwareOperations:
|
||||
if self._baseline is None or request is None or request["state"] != "pending" or request["action"] == "install":
|
||||
return
|
||||
state = status["updater"]["state"]
|
||||
check = (status['updater'].get('fast') or {}).get('check')
|
||||
if request['action'] in ('check', 'versions') and check is not None:
|
||||
completed = check.get('completedAt')
|
||||
if (type(completed) in (int, float) and completed >= self._started and
|
||||
(request['target'] is None or check['branch'] == request['target'])):
|
||||
if check['state'] == 'complete':
|
||||
request.update(state='complete', error=None)
|
||||
elif check['state'] == 'error':
|
||||
request.update(state='failed', error=check['detail'])
|
||||
elif current_owner is not None and self._request_owner is not None and current_owner != self._request_owner:
|
||||
request.update(state='failed', error='Metadata check was interrupted when the updater restarted. Retry the check.')
|
||||
elif self.clock() - self._started > 90:
|
||||
request.update(state='failed', error='Metadata check did not report completion. Retry the check.')
|
||||
return
|
||||
if (request["action"] not in SOURCE_ACTIONS and request["target"] is not None and
|
||||
status["updater"]["targetBranch"] != request["target"]):
|
||||
request.update(state="failed", error="Updater target changed")
|
||||
@@ -273,7 +294,12 @@ class SoftwareOperations:
|
||||
if status["updater"]["lastFetchAt"] != self._baseline[1]:
|
||||
installed = self.git_identity(self.installed)
|
||||
restarting = self._flag("DoReboot") is True or installed is not None and installed[1] != self._fast_commit
|
||||
if request['action'] == 'versions':
|
||||
if request['action'] == 'download-version':
|
||||
if self._ready(status, request['target']) and self.git_identity(self.finalized) == (request['target'], request['selectedCommit']):
|
||||
request.update(state='complete', error=None)
|
||||
else:
|
||||
request.update(state='failed', error='Selected version was not prepared; refresh and try again')
|
||||
elif request['action'] == 'versions':
|
||||
request.update(state='complete', error=None)
|
||||
else:
|
||||
request.update(state="complete", error=None, outcome="restarting" if restarting else "up_to_date")
|
||||
@@ -330,10 +356,15 @@ class SoftwareOperations:
|
||||
branches = self._branches()
|
||||
updater_idle = status["updater"]["state"] == "idle"
|
||||
pending = self.request is not None and self.request["state"] == "pending"
|
||||
fast = status['updater'].get('fast') or {}
|
||||
metadata_busy = fast.get('check', {}).get('state') == 'checking' or pending and self.request['action'] in ('check', 'versions')
|
||||
disabled_state = self._flag("DisableUpdates")
|
||||
disabled = disabled_state is not False
|
||||
reboot = self._flag("DoReboot") is not False
|
||||
usable = parked and updater_idle and not disabled and not pending and not reboot and process
|
||||
usable = parked and updater_idle and not disabled and not pending and not reboot and process and not metadata_busy
|
||||
can_check = (parked and not disabled and not reboot and process and not metadata_busy and
|
||||
status['updater']['state'] in ('idle', 'checking...', 'downloading...', 'finalizing update...') and
|
||||
(not pending or self.request['action'] in ('download', 'download-version')))
|
||||
target = self.selected_target or status["updater"]["targetBranch"] or status["installed"]["branch"]
|
||||
selected = target is not None and target in branches and status["updater"]["targetBranch"] == target
|
||||
reason = None
|
||||
@@ -345,28 +376,41 @@ class SoftwareOperations:
|
||||
reason = "Reboot is pending"
|
||||
elif not process:
|
||||
reason = "Getting the updater ready…" if COMMA_HARDWARE else "Updater status is unavailable"
|
||||
elif pending or not updater_idle:
|
||||
elif pending or not updater_idle or metadata_busy:
|
||||
reason = "Updater is busy"
|
||||
fast = status["updater"].get("fast")
|
||||
stage = fast["stage"] if fast and status["updater"]["state"] == "updating..." else status["updater"]["state"]
|
||||
fast = status["updater"].get("fast") or {}
|
||||
active = (status['updater']['state'] == 'updating...' or
|
||||
status['updater']['state'] in ('downloading...', 'finalizing update...') and
|
||||
fast.get('stage') not in ('complete', 'error'))
|
||||
stage = fast["stage"] if fast and active else status["updater"]["state"]
|
||||
if stage == "idle":
|
||||
stage = None
|
||||
detail = fast["detail"] if fast and status["updater"]["state"] == "updating..." else ""
|
||||
detail = fast["detail"] if fast and active else ""
|
||||
if not process and not updater_idle:
|
||||
stage = "unavailable"
|
||||
detail = ("Waiting for the device updater service…" if COMMA_HARDWARE else
|
||||
"Update checks require the device updater service, which is unavailable when Galaxy runs alone on a development computer.")
|
||||
elif self.request is not None and self.request.get("error") == "Updater did not report completion":
|
||||
stage, detail = "timed_out", "Request timed out; the updater may still be running"
|
||||
install = self.install_settings.read()
|
||||
prepared = self.git_identity(self.finalized) if status["updater"]["finalizedUpdateReady"] else None
|
||||
restart = next_restart(install["cron"], datetime.now(ZoneInfo(install.get("timezone", "UTC")))) if install and install["enabled"] and prepared else None
|
||||
return {
|
||||
"repository": self._repository(),
|
||||
"updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": None},
|
||||
"updaterAvailable": process, "progress": {"stage": stage, "detail": detail, "percent": fast.get('percent') if active else None,
|
||||
**({'background': fast['background']} if active and 'background' in fast else {})},
|
||||
"parked": parked, "availableBranches": branches, "selectedTarget": target,
|
||||
"canCheck": usable, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches),
|
||||
"canCheck": can_check, "canFastUpdate": usable and self._valid_branch(status["installed"]["branch"]), "canSelect": usable and bool(branches),
|
||||
"canRollback": usable and self._rollback_available(),
|
||||
"canDownload": usable and selected, "canInstall": usable and selected and self._ready(status, target),
|
||||
"reason": reason, "request": dict(self.request) if self.request is not None else None,
|
||||
"automaticDownloads": automatic_downloads(self.params), "canConfigure": parked and not reboot,
|
||||
"automaticInstalls": install['enabled'] if install else None,
|
||||
"restartSchedule": install['cron'] if install else DEFAULT_CRON,
|
||||
"installPreferenceRevision": install['revision'] if install else None,
|
||||
"restartTimezone": install.get("timezone", "UTC") if install else "UTC",
|
||||
"preparedCommit": prepared[1] if prepared else None,
|
||||
"nextRestartAt": restart.isoformat() if restart else None,
|
||||
**({"history": self._history_snapshot(status)} if include_history else {}),
|
||||
}
|
||||
|
||||
@@ -377,20 +421,32 @@ class SoftwareOperations:
|
||||
|
||||
def _history_snapshot(self, status: dict) -> dict:
|
||||
branch = self.selected_target or status['updater']['targetBranch'] or status['installed']['branch']
|
||||
key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch, status['updater']['lastFetchAt'])
|
||||
key = (status['installed']['commit'], self._param('UpdaterNewDescription'), branch,
|
||||
status['updater']['lastFetchAt'], status['updater'].get('lastCheckedAt'), status['updater']['state'])
|
||||
now = self.clock()
|
||||
if self._history is not None and self._history_key == key and 0 <= now - self._history_at < 30.:
|
||||
return self._history
|
||||
recent = recent_versions(self.installed, branch)
|
||||
staged_recent = recent_versions(self.staging, branch)
|
||||
if staged_recent['head'] is not None:
|
||||
recent = staged_recent
|
||||
elif recent['head'] is None:
|
||||
recent = recent_versions(self.finalized, branch)
|
||||
metadata = recent_versions(self.metadata, branch)
|
||||
if metadata['head'] is not None:
|
||||
recent = metadata
|
||||
installed = self.git_identity(self.installed)
|
||||
downloaded = self.git_identity(self.finalized)
|
||||
current_rows = self.history_reader(self.installed, installed[1]) if installed else []
|
||||
current_rows = self.history_reader(self.installed, installed[1], first_parent=True) if installed else []
|
||||
previous = previous_version(self.installed)
|
||||
downloaded_rows = (self.history_reader(self.finalized, downloaded[1])
|
||||
if downloaded and downloaded != installed else [])
|
||||
# Readiness and installation still require _ready(); a history entry never
|
||||
# grants permission to boot or execute a revision.
|
||||
self._history = {
|
||||
'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent,
|
||||
'installed': current_rows, 'downloaded': downloaded_rows, 'recent': recent, 'previous': previous,
|
||||
'downloadedCommits': downloaded_versions((self.staging if self.staging.exists() else self.finalized, self.installed),
|
||||
(row['hash'] for row in [*recent['entries'], *current_rows, *([previous] if previous else [])])),
|
||||
'currentReleaseNotes': release_notes(self.params, 'UpdaterCurrentReleaseNotes'),
|
||||
'downloadedReleaseNotes': release_notes(self.params, 'UpdaterNewReleaseNotes') if downloaded_rows else None,
|
||||
}
|
||||
@@ -419,9 +475,41 @@ class SoftwareOperations:
|
||||
raise SoftwareOperationError('Update preference could not be verified', 503)
|
||||
return self.snapshot()
|
||||
|
||||
def _install_preferences(self, payload: dict, authorized: Callable[[], bool]) -> dict:
|
||||
if (set(payload) not in ({'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision'},
|
||||
{'action', 'automaticInstalls', 'restartSchedule', 'expectedRevision', 'restartTimezone'}) or
|
||||
type(payload['automaticInstalls']) is not bool or type(payload['restartSchedule']) is not str or
|
||||
payload['expectedRevision'] is not None and
|
||||
(type(payload['expectedRevision']) is not str or not re.fullmatch('[0-9a-f]{32}', payload['expectedRevision']))):
|
||||
raise SoftwareOperationError('Invalid install preferences', 400)
|
||||
try:
|
||||
cron_fields(payload['restartSchedule'])
|
||||
if 'restartTimezone' in payload:
|
||||
if not isinstance(payload['restartTimezone'], str) or len(payload['restartTimezone']) > 128:
|
||||
raise ValueError('Invalid timezone')
|
||||
ZoneInfo(payload['restartTimezone'])
|
||||
except (ValueError, ZoneInfoNotFoundError) as error:
|
||||
raise SoftwareOperationError(str(error), 400) from error
|
||||
with self.lock:
|
||||
if not authorized():
|
||||
raise SoftwareOperationError('Session expired', 403)
|
||||
if not self._parked() or self._flag('DoReboot') is not False:
|
||||
raise SoftwareOperationError('Park the vehicle to change install preferences', 409)
|
||||
def admitted():
|
||||
return authorized() and self._parked() and self._flag('DoReboot') is False
|
||||
try:
|
||||
self.install_settings.save(payload['automaticInstalls'], payload['restartSchedule'], payload['expectedRevision'], admitted,
|
||||
timezone=payload.get('restartTimezone'))
|
||||
except PermissionError as error:
|
||||
raise SoftwareOperationError(str(error), 403) from error
|
||||
except ValueError as error:
|
||||
raise SoftwareOperationError(str(error), 409) from error
|
||||
return self.snapshot()
|
||||
|
||||
def action(self, action: str, payload: object, *, authorized: Callable[[], bool]) -> dict:
|
||||
if (type(payload) is not dict or type(action) is not str or
|
||||
action not in ("check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version")):
|
||||
action not in ("check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback",
|
||||
"versions", "version", "download-version")):
|
||||
raise SoftwareOperationError("Invalid software action", 400)
|
||||
assert isinstance(payload, dict)
|
||||
payload = dict(payload)
|
||||
@@ -429,24 +517,31 @@ class SoftwareOperations:
|
||||
raise SoftwareOperationError("Invalid software action", 400)
|
||||
if action == 'preferences':
|
||||
return self._preferences(payload, authorized)
|
||||
if action == 'install-preferences':
|
||||
return self._install_preferences(payload, authorized)
|
||||
fields = set(payload) - {"action"}
|
||||
if action == "check":
|
||||
if fields:
|
||||
raise SoftwareOperationError("Invalid software action", 400)
|
||||
branch = None
|
||||
else:
|
||||
required_fields = {"branch", "expectedCommit", "selectedCommit"} if action == "version" else {"branch"}
|
||||
pinned_install = action == "install" and fields != {"branch"}
|
||||
required_fields = {"branch", "expectedCommit", "selectedCommit"} if action in ("version", "download-version") or pinned_install else {"branch"}
|
||||
if fields != required_fields or not self._valid_branch(payload.get("branch")):
|
||||
raise SoftwareOperationError("Invalid branch", 400)
|
||||
if pinned_install and any(type(payload[key]) is not str or not COMMIT_PATTERN.fullmatch(payload[key])
|
||||
for key in ('expectedCommit', 'selectedCommit')):
|
||||
raise SoftwareOperationError("Invalid selected version", 400)
|
||||
branch = payload["branch"]
|
||||
assert isinstance(branch, str)
|
||||
with self.lock:
|
||||
if not authorized():
|
||||
raise SoftwareOperationError("Session expired", 403)
|
||||
view = self.snapshot()
|
||||
metadata_action = action in ('check', 'versions')
|
||||
view = self.snapshot(include_history=action in ('version', 'download-version'))
|
||||
if not view["parked"]:
|
||||
raise SoftwareOperationError("Vehicle is not safely parked", 409)
|
||||
if view["reason"] is not None:
|
||||
if metadata_action and not view['canCheck'] or not metadata_action and view["reason"] is not None:
|
||||
raise SoftwareOperationError(view["reason"], 503 if view["reason"] == "Updater status is unavailable" else 409)
|
||||
if action not in SOURCE_ACTIONS and branch is not None and branch not in view["availableBranches"]:
|
||||
raise SoftwareOperationError("Branch is not in the current updater list", 409)
|
||||
@@ -456,35 +551,44 @@ class SoftwareOperations:
|
||||
if action in SOURCE_ACTIONS:
|
||||
identity = self.git_identity(self.installed)
|
||||
if (identity is None or not self._valid_branch(identity[0]) or
|
||||
(action in ("update", "fast", "versions", "version") and branch != identity[0] and branch not in view["availableBranches"]) or
|
||||
(action in ("update", "fast", "versions", "version", "download-version") and
|
||||
branch != identity[0] and branch not in view["availableBranches"]) or
|
||||
(action == "rollback" and (branch != identity[0] or not view["canRollback"]))):
|
||||
raise SoftwareOperationError("Installed checkout or update target is unavailable", 409)
|
||||
if action == 'version':
|
||||
if action in ('version', 'download-version'):
|
||||
target = payload['selectedCommit']
|
||||
recent = view['history']['recent']
|
||||
selectable = [*recent['entries'], *(view['history']['installed'] if branch == identity[0] else [])]
|
||||
if (payload['expectedCommit'] != identity[1] or type(target) is not str or not COMMIT_PATTERN.fullmatch(target) or
|
||||
recent['branch'] != branch or not any(row['hash'] == target for row in recent['entries'])):
|
||||
recent['branch'] != branch or not any(row['hash'] == target for row in selectable)):
|
||||
raise SoftwareOperationError('Selected version or installed revision changed; refresh and try again', 409)
|
||||
if action == "install" and not view["canInstall"]:
|
||||
raise SoftwareOperationError("Finalized update does not match selected branch", 409)
|
||||
if not authorized() or not self._parked():
|
||||
raise SoftwareOperationError("Session or parked state changed", 403)
|
||||
current = self.status.snapshot()
|
||||
if (current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or
|
||||
if (not metadata_action and current["updater"]["state"] != "idle" or self._flag("DisableUpdates") is not False or
|
||||
self._flag("DoReboot") is not False or not self.process.available() or
|
||||
(action not in SOURCE_ACTIONS and branch is not None and branch not in self._branches())):
|
||||
raise SoftwareOperationError("Updater state changed", 409)
|
||||
if action == 'versions':
|
||||
self.selected_target = branch
|
||||
if action == "select":
|
||||
self.params.put("UpdaterTargetBranch", branch, block=True)
|
||||
self.selected_target = branch
|
||||
return self.snapshot()
|
||||
accepted_owner = None
|
||||
request_started = self.clock()
|
||||
if action == "install":
|
||||
status = self.status.snapshot()
|
||||
if not self._ready(status, branch) or self._flag("DoReboot") is not False:
|
||||
raise SoftwareOperationError("Update is no longer ready", 409)
|
||||
if not authorized() or not self._parked():
|
||||
raise SoftwareOperationError("Session or parked state changed", 403)
|
||||
if 'selectedCommit' in payload and (status['installed']['commit'] != payload['expectedCommit'] or
|
||||
self.git_identity(self.installed) != (status['installed']['branch'], payload['expectedCommit']) or
|
||||
self.git_identity(self.finalized) != (branch, payload['selectedCommit'])):
|
||||
raise SoftwareOperationError("Selected version changed; refresh before installing", 409)
|
||||
self.params.put_bool("DoReboot", True, block=True)
|
||||
else:
|
||||
status = self.status.snapshot()
|
||||
@@ -496,11 +600,11 @@ class SoftwareOperations:
|
||||
latest_identity = self.git_identity(self.installed)
|
||||
if latest_identity is None or latest_identity != identity:
|
||||
raise SoftwareOperationError("Installed checkout changed while preparing the update; retry when it finishes", 409)
|
||||
if action == 'version' and identity[1] != payload['expectedCommit']:
|
||||
if action in ('version', 'download-version') and identity[1] != payload['expectedCommit']:
|
||||
raise SoftwareOperationError('Installed revision changed before version request; refresh and try again', 409)
|
||||
self._fast_commit = identity[1]
|
||||
if action in SOURCE_ACTIONS:
|
||||
if action == 'version':
|
||||
if action in ('version', 'download-version'):
|
||||
accepted_owner = self.process.send(action, branch=branch, commit=identity[1], selected_commit=payload['selectedCommit'])
|
||||
else:
|
||||
accepted_owner = self.process.send(action, branch=branch, commit=identity[1])
|
||||
@@ -509,12 +613,12 @@ class SoftwareOperations:
|
||||
self.request = {"id": secrets.token_hex(8), "action": action,
|
||||
"target": branch if branch is not None else status["updater"]["targetBranch"],
|
||||
"state": "pending", "error": None}
|
||||
if action == 'version':
|
||||
if action in ('version', 'download-version', 'install') and 'selectedCommit' in payload:
|
||||
self.request['selectedCommit'] = payload['selectedCommit']
|
||||
self._baseline = baseline if action != "install" else None
|
||||
self._request_owner = accepted_owner
|
||||
self._started = self.clock()
|
||||
return self.snapshot()
|
||||
self._started = request_started
|
||||
return self.snapshot(include_history=not metadata_action)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.context is not None:
|
||||
|
||||
@@ -27,12 +27,25 @@ def _fast_state(raw):
|
||||
try:
|
||||
value = json.loads(raw) if raw is not None else None
|
||||
if not isinstance(value, dict) or value.get("version") != 1 or value.get("stage") not in (
|
||||
"preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error"):
|
||||
"preparing", "fetching", "validating", "applying", "submodules", "rebooting", "complete", "error", "finalizing"):
|
||||
return None
|
||||
detail = value.get("detail")
|
||||
if not isinstance(detail, str) or len(detail) > 512:
|
||||
return None
|
||||
return {"stage": value["stage"], "detail": detail}
|
||||
result = {"stage": value["stage"], "detail": detail}
|
||||
if type(value.get('background')) is bool:
|
||||
result['background'] = value['background']
|
||||
percent = value.get('percent')
|
||||
if type(percent) in (int, float) and 0 <= percent <= 100:
|
||||
result['percent'] = percent
|
||||
check = value.get('check')
|
||||
if (isinstance(check, dict) and check.get('state') in ('checking', 'complete', 'error') and
|
||||
isinstance(check.get('branch'), str) and len(check['branch']) <= 128 and
|
||||
isinstance(check.get('detail'), str) and len(check['detail']) <= 512):
|
||||
completed = check.get('completedAt')
|
||||
if completed is None or type(completed) in (int, float) and 0 <= completed < float('inf'):
|
||||
result['check'] = {key: check[key] for key in ('state', 'branch', 'detail', 'completedAt') if key in check}
|
||||
return result
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -123,8 +123,11 @@ const assert = require('node:assert/strict')
|
||||
const url = new URL(route.request().url())
|
||||
softwareRequests.push(url.search)
|
||||
await new Promise(resolve => setTimeout(resolve,250))
|
||||
if (route.request().method() === 'POST' && route.request().postDataJSON().action === 'versions') snapshot.operations.selectedTarget = route.request().postDataJSON().branch
|
||||
const operations = {...snapshot.operations}
|
||||
if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''})),downloaded:[],
|
||||
if (route.request().method() === 'GET' && url.search !== '?history=0') operations.history = {installed:[{hash:'a'.repeat(40),date:'2026-10-01T12:00:00Z',subject:'Installed fixture'}],downloaded:[],
|
||||
previous:{hash:'b'.repeat(40),date:'2026-09-28T12:00:00Z',subject:'Previous fixture',branch:'Dom'},
|
||||
recent:{branch:operations.selectedTarget,head:'0'.repeat(40),entries:Array.from({length:20}, (_, index) => ({hash:index.toString(16).padStart(40,'0'),date:'2026-10-07T12:00:00Z',subject:index ? `Version ${index}: rendering and update fixes` : ''}))},
|
||||
currentReleaseNotes:'Fixture release notes',downloadedReleaseNotes:null}
|
||||
await route.fulfill({json:{...snapshot,operations}})
|
||||
})
|
||||
@@ -166,25 +169,28 @@ const assert = require('node:assert/strict')
|
||||
await page.getByRole('dialog',{name:'Update software'}).waitFor()
|
||||
await page.getByRole('button',{name:'Cancel',exact:true}).click()
|
||||
await page.getByText('Advanced options',{exact:true}).click()
|
||||
await page.getByRole('button',{name:'Previous version',exact:true}).click()
|
||||
await page.getByRole('dialog').waitFor()
|
||||
await page.getByRole('button',{name:'Cancel',exact:true}).click()
|
||||
await page.getByRole('combobox',{name:'Target branch',exact:true}).click()
|
||||
await page.getByRole('option',{name:/Development — Dom/}).click()
|
||||
await page.getByRole('button',{name:'Update',exact:true}).click()
|
||||
await page.getByRole('dialog',{name:'Update software'}).waitFor()
|
||||
assert.equal(await page.getByRole('dialog').getByText('Install the latest version of Dom and restart?',{exact:true}).count(),1)
|
||||
await page.getByRole('button',{name:'Cancel',exact:true}).click()
|
||||
await page.getByRole('button',{name:'Save target',exact:true}).click()
|
||||
await page.getByRole('dialog',{name:'Change target branch'}).waitFor()
|
||||
await page.getByRole('button',{name:'Cancel',exact:true}).click()
|
||||
await page.getByText('Versions & release notes',{exact:true}).click()
|
||||
await page.getByRole('button',{name:'Installed',exact:true}).click()
|
||||
await page.getByText('Fixture release notes',{exact:true}).waitFor()
|
||||
assert.equal(softwareRequests.at(-1),'','history is fetched only after opening release notes')
|
||||
assert.equal(await page.locator('.gx-build-history li').count(),20)
|
||||
assert.equal(await page.locator('.gx-build-history li strong').first().textContent(),'000000000000')
|
||||
assert.equal(await page.locator('.gx-build-history').evaluate(element=>getComputedStyle(element).listStyleType),'none')
|
||||
assert.equal(await page.locator('.gx-version-row').count(),3)
|
||||
assert.equal(await page.locator('.gx-version-row strong').first().textContent(),'00000000')
|
||||
assert.equal(await page.locator('.gx-version-list').evaluate(element=>getComputedStyle(element).listStyleType),'none')
|
||||
assert.equal(await page.getByRole('button',{name:'No changes selected',exact:true}).isEnabled(),false)
|
||||
await page.getByRole('button',{name:'Load 10 of 18 collapsed versions',exact:true}).click()
|
||||
assert.equal(await page.locator('.gx-version-row').count(),13)
|
||||
await page.getByRole('button',{name:'Load 8 collapsed versions',exact:true}).click()
|
||||
assert.equal(await page.locator('.gx-version-row').count(),21)
|
||||
await page.getByRole('button',{name:'Select previous version',exact:true}).click()
|
||||
await page.getByRole('combobox',{name:'Version branch',exact:true}).filter({hasText:'Development — Dom'}).waitFor()
|
||||
assert.equal(await page.getByRole('dialog').count(),0,'selecting previous does not install it')
|
||||
await page.getByRole('button',{name:'Install selected version',exact:true}).click()
|
||||
await page.getByRole('dialog',{name:'Previous version'}).waitFor()
|
||||
await page.getByRole('button',{name:'Cancel',exact:true}).click()
|
||||
await page.getByRole('combobox',{name:'Version branch',exact:true}).click()
|
||||
await page.getByRole('option',{name:/Stable — StarPilot/}).click()
|
||||
await page.getByText('Fixture release notes',{exact:true}).waitFor()
|
||||
await page.getByRole('button',{name:'Load 10 of 18 collapsed versions',exact:true}).click()
|
||||
await page.getByRole('button',{name:'Load 8 collapsed versions',exact:true}).click()
|
||||
const scroll = await page.locator('.gx-version-scroll').evaluate(element=>({height:element.clientHeight,content:element.scrollHeight,viewport:innerHeight}))
|
||||
assert.ok(scroll.content>scroll.height && scroll.height<=scroll.viewport*.65+1)
|
||||
assert.equal(await page.getByRole('button',{name:'Downloaded',exact:true}).count(),0)
|
||||
assert.equal(await check.isEnabled(),true,'history refresh keeps update controls enabled')
|
||||
const backup = {format:'galaxy-toggles',version:1,createdAt:'2026-10-07T12:00:00Z',metric:false,vehicle:null,
|
||||
|
||||
@@ -26,7 +26,7 @@ last.resolve(response(snapshot('offroad')));await pending
|
||||
assert.equal(value.state.mode,'onroad')
|
||||
const denied=panel();const loading=denied.load();requests.at(-1).resolve(response({error:'Sign in to Galaxy'},401));await loading
|
||||
assert.equal(denied.state,null)
|
||||
assert.equal(denied.error,'Drive state unavailable')
|
||||
assert.equal(denied.statusError,'Drive state unavailable')
|
||||
console.log('PASS: strict status; captured revision; aborted/out-of-order status; requested versus effective; unmount; unauthorized state')
|
||||
|
||||
const onroad=panel();onroad.state={...snapshot(),effective:'onroad',overrideAllowed:false}
|
||||
@@ -49,3 +49,27 @@ requests.at(-1).resolve(response(live));await forceOnroad;await flush()
|
||||
assert.equal(onroad.state.mode,'onroad')
|
||||
assert.equal(DriveStatePanel.computed.pending.call(onroad),false)
|
||||
console.log('PASS: Galaxy Offroad dispatch uses server admission; Force Onroad remains available')
|
||||
|
||||
for (const [offroadOnly, lockout, mode, expected] of [
|
||||
[true,true,'auto',true], [true,false,'auto',false], [true,false,'offroad',true], [false,false,'auto',true],
|
||||
]) assert.equal(DriveStatePanel.computed.visible.call({offroadOnly,lockout,state:snapshot(mode)}),expected)
|
||||
assert.match(DriveStatePanel.template,/Force device offroad/)
|
||||
|
||||
// A rejected enable must not look enabled or lose its error during status polling.
|
||||
const rejectedToggle=panel();rejectedToggle.offroadOnly=true;rejectedToggle.lockout=false;rejectedToggle.state=snapshot()
|
||||
const input={checked:true}
|
||||
const attempt=rejectedToggle.toggleOffroad({target:input})
|
||||
assert.equal(input.checked,false,'wait for the server to accept the mode')
|
||||
requests.at(-1).resolve(response({error:'Current speed and gear are unavailable'},409));await flush()
|
||||
requests.at(-1).resolve(response(snapshot()));await attempt;await flush()
|
||||
assert.equal(input.checked,false)
|
||||
assert.equal(rejectedToggle.state.mode,'auto')
|
||||
assert.equal(rejectedToggle.error,'Current speed and gear are unavailable')
|
||||
assert.equal(DriveStatePanel.computed.visible.call(rejectedToggle),true,'keep the rejected action visible')
|
||||
input.checked=true
|
||||
const retry=rejectedToggle.toggleOffroad({target:input})
|
||||
assert.equal(rejectedToggle.error,'')
|
||||
requests.at(-1).resolve(response(snapshot('offroad','e'.repeat(32))));await flush()
|
||||
requests.at(-1).resolve(response(snapshot('offroad','e'.repeat(32))));await retry;await flush()
|
||||
assert.equal(input.checked,true)
|
||||
assert.equal(rejectedToggle.state.mode,'offroad')
|
||||
|
||||
@@ -15,6 +15,7 @@ from openpilot.starpilot.galaxy.server import make_remote_server, make_server
|
||||
from openpilot.starpilot.galaxy.software_operations import SoftwareOperations
|
||||
from openpilot.starpilot.galaxy.software_status import SoftwareStatus
|
||||
from openpilot.starpilot.galaxy.tests.test_software_operations import FakeProcess
|
||||
from openpilot.starpilot.software.auto_install import InstallSettings
|
||||
|
||||
|
||||
class SoftwareHttpTest(unittest.TestCase):
|
||||
@@ -37,7 +38,8 @@ class SoftwareHttpTest(unittest.TestCase):
|
||||
self.process = FakeProcess()
|
||||
self.parked = True
|
||||
self.operations = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process,
|
||||
installed=installed, finalized=root / 'finalized')
|
||||
installed=installed, finalized=root / 'finalized', metadata=root / 'metadata.git',
|
||||
install_settings=InstallSettings(root / 'install-settings'))
|
||||
self.access = GalaxyAccessOwner(root / 'access')
|
||||
self.pairing = RemotePairing(root / 'pairing')
|
||||
self.local = make_server(port=0, owner=self.access, remote_pairing=self.pairing,
|
||||
@@ -103,6 +105,24 @@ class SoftwareHttpTest(unittest.TestCase):
|
||||
self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409)
|
||||
self.assertEqual(self.process.sent, [])
|
||||
|
||||
def test_local_and_remote_auto_install_schedule_roundtrip_requires_offroad(self):
|
||||
for remote in (False, True):
|
||||
cookie = self.remote_cookie if remote else self.local_cookie
|
||||
status, value, _ = self.request('/api/software/status?history=0', remote=remote, cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
payload = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '30 2 * * 1-5',
|
||||
'expectedRevision': value['operations']['installPreferenceRevision']}
|
||||
self.parked = False
|
||||
self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409)
|
||||
self.parked = True
|
||||
status, saved, _ = self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(saved['operations']['automaticInstalls'])
|
||||
self.assertEqual(saved['operations']['restartSchedule'], '30 2 * * 1-5')
|
||||
self.assertEqual(self.request('/api/software/action', payload=payload, remote=remote, cookie=cookie)[0], 409)
|
||||
self.assertEqual(self.process.sent, [])
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
|
||||
def test_remote_auth_origin_and_parked_rules_cannot_be_bypassed(self):
|
||||
payload = {'action': 'preferences', 'automaticDownloads': False, 'expectedAutomaticDownloads': True}
|
||||
self.assertEqual(self.request('/api/software/status', remote=True)[0], 401)
|
||||
|
||||
@@ -10,6 +10,7 @@ from openpilot.starpilot.galaxy.software_operations import (
|
||||
SoftwareOperations, SoftwareOperationError, UpdaterProcess, PROCESS_TITLE,
|
||||
)
|
||||
from openpilot.starpilot.software.update_control import UpdaterControlError
|
||||
from openpilot.starpilot.software.auto_install import InstallSettings
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
@@ -34,6 +35,12 @@ class FakeProcess:
|
||||
|
||||
class SoftwareOperationsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
hardware = mock.patch('openpilot.starpilot.galaxy.software_operations.COMMA_HARDWARE', False)
|
||||
hardware.start()
|
||||
self.addCleanup(hardware.stop)
|
||||
downloads = mock.patch('openpilot.starpilot.galaxy.software_operations.downloaded_versions', return_value=[])
|
||||
downloads.start()
|
||||
self.addCleanup(downloads.stop)
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
root = Path(self.temp.name)
|
||||
@@ -51,13 +58,15 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
self.process = FakeProcess()
|
||||
self.parked = True
|
||||
self.authorized = True
|
||||
self.metadata = root / "metadata.git"
|
||||
self.finalized = root / "finalized"
|
||||
self.finalized.mkdir()
|
||||
self.installed = root / "installed"
|
||||
self.installed.mkdir()
|
||||
self.git = {self.installed: ("main", "a" * 40), self.finalized: ("release", "b" * 40)}
|
||||
self.owner = SoftwareOperations(self.params, parked=lambda: self.parked, process=self.process,
|
||||
finalized=self.finalized, installed=self.installed,
|
||||
finalized=self.finalized, metadata=self.metadata, installed=self.installed,
|
||||
install_settings=InstallSettings(root / 'install-settings'),
|
||||
git_identity=lambda path: self.git.get(path))
|
||||
self.addCleanup(self.owner.close)
|
||||
|
||||
@@ -86,7 +95,7 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
|
||||
self.parked = False
|
||||
self.owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh,
|
||||
finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path))
|
||||
finalized=self.finalized, metadata=self.metadata, installed=self.installed, git_identity=lambda path: self.git.get(path))
|
||||
self.addCleanup(self.owner.close)
|
||||
for expected in (True, False):
|
||||
order.clear()
|
||||
@@ -105,7 +114,7 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
refresh = mock.Mock(side_effect=outcome) if isinstance(outcome, Exception) else mock.Mock(return_value=outcome)
|
||||
parked = mock.Mock(return_value=True)
|
||||
owner = SoftwareOperations(self.params, parked=parked, process=self.process, refresh=refresh,
|
||||
finalized=self.finalized, installed=self.installed, git_identity=lambda path: self.git.get(path))
|
||||
finalized=self.finalized, metadata=self.metadata, installed=self.installed, git_identity=lambda path: self.git.get(path))
|
||||
self.addCleanup(owner.close)
|
||||
view = owner.snapshot(include_history=False)
|
||||
refresh.assert_called_once_with()
|
||||
@@ -174,7 +183,7 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
"detail": "Request timed out; the updater may still be running", "percent": None})
|
||||
self.assertTrue(view["updaterAvailable"])
|
||||
self.assertEqual(view["request"]["state"], "failed")
|
||||
self.assertFalse(view["canCheck"])
|
||||
self.assertTrue(view["canCheck"])
|
||||
self.assertEqual(self.params.get("UpdaterState"), "finalizing update...")
|
||||
|
||||
def test_recent_version_admission_pins_installed_snapshot_and_listed_target(self):
|
||||
@@ -197,6 +206,47 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
assert self.owner.request is not None
|
||||
self.assertEqual(self.owner.request['selectedCommit'], selected)
|
||||
|
||||
def test_branch_browsing_fetches_metadata_without_changing_updater_target(self):
|
||||
result = self.act('versions', 'release')
|
||||
self.assertEqual(self.params.get('UpdaterTargetBranch'), 'main')
|
||||
self.assertEqual(result['selectedTarget'], 'release')
|
||||
self.assertEqual(self.process.sent, ['versions'])
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
|
||||
def test_download_selected_commit_pins_request_and_does_not_reboot(self):
|
||||
selected = 'c' * 40
|
||||
recent = {'branch': 'main', 'head': selected, 'entries': [{'hash': selected, 'date': '', 'subject': 'Selected'}]}
|
||||
payload = {'action': 'download-version', 'branch': 'main', 'expectedCommit': 'a' * 40, 'selectedCommit': selected}
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent), \
|
||||
mock.patch.object(self.process, 'send') as send:
|
||||
self.owner.action('download-version', payload, authorized=lambda: True)
|
||||
send.assert_called_once_with('download-version', branch='main', commit='a' * 40, selected_commit=selected)
|
||||
request = self.owner.request
|
||||
assert request is not None
|
||||
self.assertEqual(request['selectedCommit'], selected)
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
self.git[self.finalized] = ('main', selected)
|
||||
(self.finalized / '.overlay_consistent').touch()
|
||||
self.params.put('UpdaterNewDescription', 'test / main / ccccccc / Oct 30', block=True)
|
||||
self.params.put_bool('UpdateAvailable', True, block=True)
|
||||
self.params.put('LastUpdateTime', datetime(2026, 10, 30), block=True)
|
||||
self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 30), block=True)
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}):
|
||||
self.assertEqual(self.owner.snapshot()['request']['state'], 'complete')
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
|
||||
def test_download_completion_handles_finalized_identity_disappearing(self):
|
||||
request = {'action': 'download-version', 'state': 'pending', 'target': 'main', 'selectedCommit': 'c' * 40}
|
||||
self.owner.request = request
|
||||
self.owner._baseline = (None, None, 0)
|
||||
self.params.put('LastUpdateTime', datetime(2026, 10, 30), block=True)
|
||||
self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 30), block=True)
|
||||
with mock.patch.object(self.owner, '_ready', return_value=True), \
|
||||
mock.patch.object(self.owner, 'git_identity', return_value=None):
|
||||
self.owner._refresh_request(self.owner.status.snapshot())
|
||||
self.assertEqual(request['state'], 'failed')
|
||||
self.assertEqual(request['error'], 'Selected version was not prepared; refresh and try again')
|
||||
|
||||
def test_version_installed_snapshot_cannot_change_at_final_dispatch(self):
|
||||
selected = 'c' * 40
|
||||
recent = {'branch': 'main', 'head': selected,
|
||||
@@ -308,6 +358,25 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
self.denied("install", 409, "release")
|
||||
self.assertEqual(self.process.sent, [])
|
||||
|
||||
def test_metadata_check_is_fast_and_completes_during_background_finalization(self):
|
||||
self.params.put('UpdaterState', 'finalizing update...', block=True)
|
||||
fast = {'version': 1, 'stage': 'finalizing', 'detail': 'Copying files: 44%', 'percent': 44, 'background': True}
|
||||
self.params.put('UpdaterFastState', fast, block=True)
|
||||
with mock.patch.object(self.owner, '_history_snapshot') as history:
|
||||
view = self.owner.snapshot(include_history=False)
|
||||
self.assertTrue(view['canCheck'])
|
||||
self.assertFalse(view['canFastUpdate'])
|
||||
result = self.act('check')
|
||||
history.assert_not_called()
|
||||
self.assertEqual(result['request']['state'], 'pending')
|
||||
fast['check'] = {'state': 'complete', 'branch': 'main', 'detail': 'Metadata ready', 'completedAt': self.owner._started + 1}
|
||||
self.params.put('UpdaterFastState', fast, block=True)
|
||||
result = self.owner.snapshot(include_history=False)
|
||||
self.assertEqual(result['request']['state'], 'complete')
|
||||
self.assertEqual(result['progress']['percent'], 44)
|
||||
self.assertTrue(result['progress']['background'])
|
||||
self.assertEqual(self.params.get('UpdaterState'), 'finalizing update...')
|
||||
|
||||
def test_target_change_or_failure_is_reported(self):
|
||||
self.act("select", "release")
|
||||
self.act("download", "release")
|
||||
@@ -318,6 +387,39 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
self.params.put("UpdateFailedCount", 1, block=True)
|
||||
self.assertEqual(self.owner.snapshot()["request"]["state"], "failed")
|
||||
|
||||
def test_selected_install_rejects_a_different_downloaded_commit(self):
|
||||
self.act('select', 'release')
|
||||
self.params.put_bool('UpdateAvailable', True, block=True)
|
||||
self.params.put('UpdaterNewDescription', 'test / release / bbbbbbb / Sep 27', block=True)
|
||||
(self.finalized / '.overlay_consistent').touch()
|
||||
payload = {'action': 'install', 'branch': 'release', 'expectedCommit': 'a' * 40, 'selectedCommit': 'c' * 40}
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}):
|
||||
with self.assertRaisesRegex(SoftwareOperationError, 'Selected version changed'):
|
||||
self.owner.action('install', payload, authorized=lambda: True)
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
payload['selectedCommit'] = 'b' * 40
|
||||
result = self.owner.action('install', payload, authorized=lambda: True)
|
||||
self.assertEqual(result['request']['selectedCommit'], 'b' * 40)
|
||||
self.assertTrue(self.params.get_bool('DoReboot'))
|
||||
|
||||
def test_selected_install_rechecks_commit_after_authorization(self):
|
||||
self.act('select', 'release')
|
||||
self.params.put_bool('UpdateAvailable', True, block=True)
|
||||
self.params.put('UpdaterNewDescription', 'test / release / bbbbbbb / Sep 27', block=True)
|
||||
(self.finalized / '.overlay_consistent').touch()
|
||||
payload = {'action': 'install', 'branch': 'release', 'expectedCommit': 'a' * 40, 'selectedCommit': 'b' * 40}
|
||||
calls = 0
|
||||
def authorized():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 3:
|
||||
self.git[self.finalized] = ('release', 'c' * 40)
|
||||
return True
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.validate_revision', return_value={}):
|
||||
with self.assertRaisesRegex(SoftwareOperationError, 'Selected version changed'):
|
||||
self.owner.action('install', payload, authorized=authorized)
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
|
||||
def test_counter_reset_does_not_claim_a_completed_check(self):
|
||||
self.params.put("UpdateFailedCount", 2, block=True)
|
||||
self.act("check")
|
||||
@@ -417,11 +519,56 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
history.assert_called_once()
|
||||
self.assertEqual(light, {key: value for key, value in full.items() if key != 'history'})
|
||||
|
||||
def test_install_preferences_default_off_and_validate_cron_and_revision(self):
|
||||
initial = self.owner.snapshot(include_history=False)
|
||||
self.assertFalse(initial['automaticInstalls'])
|
||||
self.assertEqual(initial['restartSchedule'], '0 0 * * *')
|
||||
body = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '0 0 * * *',
|
||||
'expectedRevision': initial['installPreferenceRevision'], 'restartTimezone': 'America/Los_Angeles'}
|
||||
result = self.owner.action('install-preferences', body, authorized=lambda: True)
|
||||
self.assertTrue(result['automaticInstalls'])
|
||||
self.assertEqual(result['restartTimezone'], 'America/Los_Angeles')
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
self.assertEqual(self.process.sent, [])
|
||||
with self.assertRaises(SoftwareOperationError) as caught:
|
||||
self.owner.action('install-preferences', body, authorized=lambda: True)
|
||||
self.assertEqual(caught.exception.status, 409)
|
||||
body['expectedRevision'] = result['installPreferenceRevision']
|
||||
body['restartTimezone'] = 'Invalid/Timezone'
|
||||
with self.assertRaises(SoftwareOperationError) as caught:
|
||||
self.owner.action('install-preferences', body, authorized=lambda: True)
|
||||
self.assertEqual(caught.exception.status, 400)
|
||||
body['restartTimezone'] = 'America/Los_Angeles'
|
||||
body['restartSchedule'] = '0 24 * * *'
|
||||
with self.assertRaises(SoftwareOperationError) as caught:
|
||||
self.owner.action('install-preferences', body, authorized=lambda: True)
|
||||
self.assertEqual(caught.exception.status, 400)
|
||||
saved = self.owner.install_settings.read()
|
||||
assert saved is not None
|
||||
self.assertEqual(saved['cron'], '0 0 * * *')
|
||||
|
||||
def test_install_preferences_recheck_offroad_before_saving(self):
|
||||
body = {'action': 'install-preferences', 'automaticInstalls': True, 'restartSchedule': '0 0 * * *', 'expectedRevision': '0' * 32}
|
||||
with self.assertRaises(SoftwareOperationError) as caught:
|
||||
self.owner.action('install-preferences', body, authorized=lambda: False)
|
||||
self.assertEqual(caught.exception.status, 403)
|
||||
checks = iter((True, False))
|
||||
with mock.patch.object(self.owner, '_parked', side_effect=lambda: next(checks)), self.assertRaises(SoftwareOperationError) as caught:
|
||||
self.owner.action('install-preferences', body, authorized=lambda: True)
|
||||
self.assertEqual(caught.exception.status, 403)
|
||||
self.assertFalse(self.owner.install_settings.path.exists())
|
||||
self.assertFalse(self.params.get_bool('DoReboot'))
|
||||
|
||||
def test_history_includes_previous_installed_version(self):
|
||||
previous = {'hash': 'd' * 40, 'date': '2026-09-28T12:00:00+00:00', 'subject': 'Previous installed build'}
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.previous_version', return_value=previous):
|
||||
self.assertEqual(self.owner.snapshot()['history']['previous'], previous)
|
||||
|
||||
def test_history_caches_local_reads_and_refreshes_when_download_identity_changes(self):
|
||||
reader = mock.Mock(side_effect=lambda path, sha: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}])
|
||||
reader = mock.Mock(side_effect=lambda path, sha, **kwargs: [{'hash': sha, 'date': '2026-09-29T12:00:00+00:00', 'subject': path.name}])
|
||||
self.owner.history_reader = reader
|
||||
self.params.put('UpdaterCurrentReleaseNotes', b'<h1>Current</h1>', block=True)
|
||||
self.params.put('UpdaterNewReleaseNotes', b'<h1>Next</h1>', block=True)
|
||||
self.params.put('UpdaterCurrentReleaseNotes', b'<p>Current</p>', block=True)
|
||||
self.params.put('UpdaterNewReleaseNotes', b'<p>Next</p>', block=True)
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions',
|
||||
return_value={'branch': 'main', 'head': 'c' * 40, 'entries': []}) as recent:
|
||||
first = self.owner.snapshot()['history']
|
||||
@@ -432,15 +579,18 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
self.assertEqual(reader.call_count, 2)
|
||||
self.assertEqual(self.owner.snapshot()['history'], first)
|
||||
self.assertEqual(reader.call_count, 2)
|
||||
self.assertEqual(recent.call_count, 1)
|
||||
self.assertEqual(recent.call_count, 3)
|
||||
self.params.put('UpdaterNewDescription', 'new description', block=True)
|
||||
self.owner.snapshot()
|
||||
self.assertEqual(reader.call_count, 4)
|
||||
self.assertEqual(recent.call_count, 2)
|
||||
self.assertEqual(recent.call_count, 6)
|
||||
self.params.put('UpdaterLastFetchTime', datetime(2026, 10, 4, 12), block=True)
|
||||
self.owner.snapshot()
|
||||
self.assertEqual(recent.call_count, 3)
|
||||
self.assertEqual(recent.call_count, 9)
|
||||
self.assertEqual(reader.call_count, 6)
|
||||
self.params.put('UpdaterLastCheckTime', datetime(2026, 10, 4, 13), block=True)
|
||||
self.owner.snapshot()
|
||||
self.assertEqual(recent.call_count, 12)
|
||||
|
||||
def test_unified_update_pins_source_and_tracks_completion(self):
|
||||
result = self.act("update", "release")
|
||||
@@ -455,6 +605,26 @@ class SoftwareOperationsTest(unittest.TestCase):
|
||||
self.assertEqual(result["state"], "complete")
|
||||
self.assertEqual(result["outcome"], "restarting")
|
||||
|
||||
def test_version_list_uses_checked_history_before_download_finishes(self):
|
||||
old = {'branch': 'main', 'head': 'b' * 40, 'entries': []}
|
||||
latest = {'branch': 'main', 'head': 'c' * 40,
|
||||
'entries': [{'hash': 'c' * 40, 'date': '2026-10-29T12:00:00+00:00', 'subject': 'Not downloaded yet'}]}
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions',
|
||||
side_effect=lambda path, branch: latest if path == self.metadata else old):
|
||||
result = self.owner.snapshot()['history']
|
||||
self.assertEqual(result['recent'], latest)
|
||||
self.assertEqual(result['downloaded'], [])
|
||||
|
||||
def test_older_installed_history_is_selectable_without_being_in_recent_list(self):
|
||||
older = {'hash': 'b' * 40, 'date': '2026-10-01T12:00:00+00:00', 'subject': 'Older installed history'}
|
||||
self.owner.history_reader = lambda path, sha, **kwargs: [older] if path == self.installed else []
|
||||
recent = {'branch': 'main', 'head': 'c' * 40, 'entries': []}
|
||||
with mock.patch('openpilot.starpilot.galaxy.software_operations.recent_versions', return_value=recent):
|
||||
result = self.owner.action('version', {'action': 'version', 'branch': 'main',
|
||||
'expectedCommit': 'a' * 40, 'selectedCommit': older['hash']}, authorized=lambda: True)
|
||||
self.assertEqual(result['request']['selectedCommit'], older['hash'])
|
||||
self.assertEqual(self.process.sent, ['version'])
|
||||
|
||||
def test_fast_current_branch_without_branch_list_or_selected_target(self):
|
||||
self.params.remove("UpdaterAvailableBranches")
|
||||
self.params.put("UpdaterTargetBranch", "release", block=True)
|
||||
|
||||
@@ -313,76 +313,34 @@ await revoked.reply(0, { error: "Sign in" }, 401)
|
||||
assert.equal(revoked.unauthorized, 1)
|
||||
assert.equal(revoked.states.at(-1).status, "idle")
|
||||
|
||||
const page = (selectedTarget = "Dom", availableBranches = ["StarPilot", "Dom", "beta"], installedBranch = "Dom") => {
|
||||
const state = { operations: { ...operations, selectedTarget, availableBranches }, data: {
|
||||
installed: { ...installed, branch: installedBranch } }, actionDisabled: false,
|
||||
draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null }
|
||||
Object.defineProperty(state,"branchOptions",{get:()=>SoftwarePage.computed.branchOptions.call(state)})
|
||||
Object.defineProperty(state,"otherBranches",{get:()=>SoftwarePage.computed.otherBranches.call(state)})
|
||||
Object.defineProperty(state,"primaryBranchHelp",{get:()=>SoftwarePage.computed.primaryBranchHelp.call(state)})
|
||||
Object.defineProperty(state,"canStageBranch",{get:()=>SoftwarePage.computed.canStageBranch.call(state)})
|
||||
const page = (selectedTarget = 'Dom', availableBranches = ['StarPilot', 'Dom', 'beta'], installedBranch = 'Dom') => {
|
||||
const state = { operations: { ...operations, selectedTarget, availableBranches },
|
||||
data: { installed: { ...installed, branch: installedBranch } }, actionDisabled: false,
|
||||
draftBranch: '', draftTouched: false, dialog: null, version: '', calls: [],
|
||||
async loadVersions() { this.calls.push(this.draftBranch) } }
|
||||
for (const key of ['branchOptions', 'previousVersion', 'otherBranches'])
|
||||
Object.defineProperty(state, key, {get:()=>SoftwarePage.computed[key].call(state)})
|
||||
return state
|
||||
}
|
||||
const staged = page()
|
||||
SoftwarePage.methods.syncDraftBranch.call(staged,"Dom")
|
||||
assert.equal(staged.draftBranch,"Dom")
|
||||
staged.draftBranch="beta"
|
||||
SoftwarePage.methods.changeBranch.call(staged)
|
||||
assert.equal(staged.draftTouched,true)
|
||||
assert.equal(staged.canStageBranch,true)
|
||||
SoftwarePage.methods.chooseBranch.call(staged)
|
||||
assert.equal(staged.dialog.action,"select") // Normal updates can still stage a target without restarting.
|
||||
assert.equal(staged.dialog.branch,"beta")
|
||||
SoftwarePage.methods.askUpdate.call({...staged, operations:{...staged.operations,canFastUpdate:true}})
|
||||
const switchPage={...staged,operations:{...staged.operations,canFastUpdate:true}}
|
||||
SoftwarePage.methods.syncDraftBranch.call(staged, 'Dom')
|
||||
staged.draftBranch = 'beta'
|
||||
await SoftwarePage.methods.changeBranch.call(staged)
|
||||
assert.equal(staged.draftTouched, true)
|
||||
assert.deepEqual(staged.calls, ['beta'], 'branch changes automatically refresh metadata')
|
||||
const switchPage = {...staged, operations: {...staged.operations, canFastUpdate:true}}
|
||||
SoftwarePage.methods.askUpdate.call(switchPage)
|
||||
assert.equal(switchPage.dialog.action,"update")
|
||||
assert.equal(switchPage.dialog.branch,"beta")
|
||||
assert.equal(switchPage.dialog.label,"Update & restart")
|
||||
const alternateTarget=page("legacy",["StarPilot","Dom","beta"],"installed-only")
|
||||
SoftwarePage.methods.syncDraftBranch.call(alternateTarget,"legacy")
|
||||
assert.equal(alternateTarget.canStageBranch,false)
|
||||
assert.equal(alternateTarget.draftBranch,"installed-only")
|
||||
assert.deepEqual(alternateTarget.branchOptions.map(branch=>branch.name),["legacy","installed-only","StarPilot","Dom","beta"])
|
||||
assert.equal(alternateTarget.branchOptions[0].available,false)
|
||||
assert.equal(alternateTarget.branchOptions[1].available,true)
|
||||
const sentinel = fixture()
|
||||
sentinel.feed.start()
|
||||
await sentinel.reply(0, snapshot({ operations: { ...operations, availableBranches: ["Dom", "other:"] } }))
|
||||
sentinel.feed.action("select", "other:")
|
||||
assert.equal(sentinel.requests.length, 1)
|
||||
assert.deepEqual(page("Dom", ["Dom", "other:"]).branchOptions.map(branch=>branch.name), ["Dom"])
|
||||
sentinel.feed.stop()
|
||||
|
||||
assert.equal(SoftwarePage.methods.branchLabel("StarPilot"), "Stable — StarPilot")
|
||||
assert.equal(SoftwarePage.methods.branchLabel("Dom"), "Development — Dom")
|
||||
|
||||
const namedBranches = page("StarPilot", ["StarPilot", "Dom", "beta"], "StarPilot")
|
||||
SoftwarePage.methods.syncDraftBranch.call(namedBranches, "StarPilot")
|
||||
assert.equal(namedBranches.primaryChoice, "StarPilot")
|
||||
assert.equal(namedBranches.draftBranch, "StarPilot")
|
||||
const otherBranches = page("beta", ["StarPilot", "Dom", "beta"], "Dom")
|
||||
SoftwarePage.methods.syncDraftBranch.call(otherBranches, "beta")
|
||||
assert.equal(otherBranches.primaryChoice, "other:")
|
||||
assert.equal(otherBranches.draftBranch, "beta")
|
||||
assert.deepEqual(otherBranches.otherBranches.map(branch => branch.name), ["beta"])
|
||||
otherBranches.primaryChoice = "Dom"
|
||||
assert.equal(otherBranches.primaryBranchHelp, "Latest features and fixes under development. Updates regularly and may introduce bugs.")
|
||||
otherBranches.draftBranch = "beta"
|
||||
otherBranches.primaryChoice = "other:"
|
||||
SoftwarePage.methods.onPrimaryBranchChange.call(otherBranches)
|
||||
assert.equal(otherBranches.draftBranch, "beta")
|
||||
assert.match(SoftwarePage.template, /Stable — StarPilot/)
|
||||
assert.match(SoftwarePage.template, /Development — Dom/)
|
||||
assert.match(SoftwarePage.template, /Other branches…/)
|
||||
assert.match(SoftwarePage.template, /onPrimaryBranchChange/)
|
||||
assert.match(SoftwarePage.template, /Check for updates/)
|
||||
assert.equal(switchPage.dialog.action, 'update')
|
||||
assert.equal(switchPage.dialog.branch, 'beta')
|
||||
assert.equal(SoftwarePage.methods.branchLabel('StarPilot'), 'Stable — StarPilot')
|
||||
assert.equal(SoftwarePage.methods.branchLabel('Dom'), 'Development — Dom')
|
||||
assert.deepEqual(page('Dom', ['Dom', 'other:']).branchOptions.map(branch=>branch.name), ['Dom'])
|
||||
assert.match(SoftwarePage.template, /aria-label="Version branch"/)
|
||||
assert.match(SoftwarePage.template, /@change="changeBranch"/)
|
||||
assert.match(SoftwarePage.template, /Download only/)
|
||||
assert.match(SoftwarePage.template, /@click="askUpdate">Update<\/button>/)
|
||||
assert.match(SoftwarePage.template, /Select previous version/)
|
||||
assert.doesNotMatch(SoftwarePage.template, /Save target/)
|
||||
assert.match(SoftwarePage.template, /displayVersion \?\? data\.installed\.version/)
|
||||
assert.match(SoftwarePage.template, /@click="askRollback">Previous version/ )
|
||||
assert.match(SoftwarePage.template, /operations\.canRollback !== true/)
|
||||
assert.doesNotMatch(SoftwarePage.template, /historical version|status only/i)
|
||||
|
||||
const preferences = fixture()
|
||||
preferences.feed.start()
|
||||
@@ -492,7 +450,7 @@ assert.equal(validSoftwareSnapshot(snapshot({ operations: { ...operations, canRo
|
||||
assert.equal(softwareProgress(updater), null)
|
||||
assert.equal(softwareProgress(updater, {progress: {stage: null, detail: "", percent: null}}), null)
|
||||
assert.deepEqual(softwareProgress({...updater, state: "finalizing update..."}),
|
||||
{active: true, failed: false, percent: null, label: "Finalizing update", detail: ""})
|
||||
{active: true, failed: false, percent: null, label: "Preparing update", detail: ""})
|
||||
assert.equal(softwareProgress({...updater, state: "downloading..."}, {progress: {stage: "Downloading", detail: "", percent: 37}}).percent, 37)
|
||||
for (const percent of [NaN, Infinity, -1, 101]) {
|
||||
assert.equal(validSoftwareSnapshot(snapshot({operations: {...operations, progress: {stage: "fetching", detail: "", percent}}})), false)
|
||||
@@ -501,7 +459,7 @@ assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {prog
|
||||
assert.ok(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: null}})))
|
||||
assert.equal(validSoftwareSnapshot(snapshot({updater: {...updater, lastCheckedAt: 12}})), false)
|
||||
assert.match(SoftwarePage.template, /<summary>Advanced options<\/summary>/)
|
||||
assert.match(SoftwarePage.template, /<summary>Versions & release notes<\/summary>/)
|
||||
assert.doesNotMatch(SoftwarePage.template, /<summary>Versions & release notes<\/summary>/)
|
||||
assert.match(SoftwarePage.template, /data\.updater\.lastCheckedAt/)
|
||||
|
||||
for (const progress of [{stage: "idle", detail: "", percent: null}, {stage: null, detail: null, percent: null}, {stage: "unknown", detail: "", percent: null}]) {
|
||||
@@ -514,7 +472,7 @@ for (const stage of ["complete", "error"]) {
|
||||
assert.equal(softwareProgress(state, {progress}).active, false)
|
||||
assert.equal(softwareProgress(state, {progress}).percent, null)
|
||||
}
|
||||
assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Finalizing update")
|
||||
assert.equal(softwareProgress({...updater, state: "finalizing update..."}, {progress: {stage: "finalizing", detail: null, percent: null}}).label, "Preparing update")
|
||||
|
||||
const lightweight = fixture(false)
|
||||
lightweight.feed.start()
|
||||
@@ -528,6 +486,11 @@ await lightweight.reply(1, snapshot({ operations: { ...operations, history } }))
|
||||
assert.deepEqual(lightweight.states.at(-1).data.operations.history, history)
|
||||
lightweight.feed.stop()
|
||||
|
||||
const versionRefreshActions = []
|
||||
await SoftwarePage.methods.loadVersions.call({ actionDisabled: false, draftBranch: 'Dom',
|
||||
operations: { selectedTarget: 'Dom' }, feed: { action: (...args) => versionRefreshActions.push(args) } })
|
||||
assert.deepEqual(versionRefreshActions, [['versions', 'Dom']], 'version refresh must use the metadata-only operation')
|
||||
|
||||
const directBranch = fixture()
|
||||
directBranch.feed.start()
|
||||
await directBranch.reply(0,snapshot({operations:{...operations,availableBranches:['Dom','beta'],canFastUpdate:true}}))
|
||||
@@ -544,7 +507,7 @@ directBranch.feed.stop()
|
||||
// A visit checks immediately, without interrupting an update already in progress.
|
||||
for (const [canCheck, pending, state, expected] of [
|
||||
[true, false, 'idle', ['check']], [false, false, 'idle', []],
|
||||
[true, true, 'idle', []], [true, false, 'downloading...', []],
|
||||
[true, true, 'idle', []], [true, false, 'downloading...', ['check']],
|
||||
]) {
|
||||
const actions = []
|
||||
const view = { mode: 'local', initialChecking: true, operations: { canCheck }, pending,
|
||||
@@ -556,7 +519,7 @@ for (const [canCheck, pending, state, expected] of [
|
||||
|
||||
const updateView = { ...SoftwarePage.data(), data: snapshot(), operations: { ...operations },
|
||||
requestMessage: SoftwarePage.methods.requestMessage }
|
||||
for (const key of ['pending', 'reportedProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) {
|
||||
for (const key of ['pending', 'reportedProgress', 'metadataProgress', 'backgroundProgress', 'updateProgress', 'updateAvailable', 'osUpdateRequired', 'updateBanner', 'repositoryUrl', 'commitUrl', 'commitsUrl']) {
|
||||
Object.defineProperty(updateView, key, { get: () => SoftwarePage.computed[key].call(updateView) })
|
||||
}
|
||||
assert.equal(updateView.updateProgress.label, 'Checking for updates')
|
||||
@@ -576,7 +539,7 @@ updateView.data = snapshot({ updater: { ...updater, targetChangeFound: false } }
|
||||
updateView.operations.request = request('check', 'complete')
|
||||
assert.equal(updateView.updateBanner.message, 'No Update Available')
|
||||
updateView.operations.request = request('check')
|
||||
assert.equal(updateView.updateProgress.label, 'Checking for updates')
|
||||
assert.equal(updateView.updateProgress.label, 'Checking for new versions')
|
||||
assert.equal(updateView.updateBanner, null)
|
||||
updateView.operations.request = request('check', 'failed', null, 'Network unavailable')
|
||||
assert.equal(updateView.updateProgress, null)
|
||||
@@ -652,7 +615,7 @@ for (const action of ['update', 'fast', 'rollback', 'install', 'version']) {
|
||||
const f = fixture(true, baseline => restarts.push(baseline))
|
||||
f.feed.start()
|
||||
await f.reply(0, snapshot({ operations: { ...operations, canFastUpdate: true, canRollback: true, canInstall: true,
|
||||
history: { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null,
|
||||
history: { installed: [], downloaded: [{ hash: 'b'.repeat(40), date: '', subject: '' }], currentReleaseNotes: null, downloadedReleaseNotes: null,
|
||||
recent: { branch: 'Dom', head: installed.commit, entries: [{ hash: 'b'.repeat(40), date: '', subject: '' }] } } } }))
|
||||
f.feed.action(action, 'Dom', { expectedCommit: installed.commit, selectedCommit: 'b'.repeat(40) })
|
||||
assert.equal(f.requests.length, 2)
|
||||
@@ -697,3 +660,146 @@ for (const [status, payload, unauthorized] of [
|
||||
f.feed.stop()
|
||||
}
|
||||
console.log('Software status and tunnel-envelope regressions passed')
|
||||
|
||||
// Metadata checks remain separate from a background finalization and its real progress.
|
||||
updateView.error = ''
|
||||
updateView.operations = { ...operations, reason: 'Updater is busy', request: request('versions'),
|
||||
progress: { stage: 'finalizing', detail: 'Copying update files: 44/100 (44%)', percent: 44, background: true } }
|
||||
updateView.data = snapshot({ updater: { ...updater, state: 'finalizing update...',
|
||||
fast: { stage: 'finalizing', check: { state: 'checking', detail: 'Fetching version metadata...' } } } })
|
||||
assert.equal(updateView.updateProgress.label, 'Checking for new versions')
|
||||
assert.equal(updateView.backgroundProgress.percent, 44)
|
||||
assert.match(updateView.backgroundProgress.detail, /44\/100/)
|
||||
assert.equal(SoftwarePage.computed.metadataActionDisabled.call(updateView), false)
|
||||
updateView.data.updater.fast.check = { state: 'complete' }
|
||||
updateView.operations.request = request('versions', 'complete')
|
||||
assert.equal(updateView.updateProgress, null)
|
||||
assert.equal(updateView.backgroundProgress.percent, 44)
|
||||
|
||||
// Clicking Check immediately replaces the previous Refresh versions result, before its POST returns.
|
||||
const nextCheck = fixture()
|
||||
nextCheck.feed.start()
|
||||
await nextCheck.reply(0, snapshot({ operations: { ...operations, request: request('versions', 'complete') } }))
|
||||
nextCheck.feed.action('check')
|
||||
assert.equal(nextCheck.states.at(-1).submittingAction, 'check')
|
||||
updateView.operations = { ...operations, request: request('versions', 'complete') }
|
||||
updateView.data = snapshot()
|
||||
updateView.busy = true
|
||||
updateView.submittingAction = nextCheck.states.at(-1).submittingAction
|
||||
assert.equal(updateView.updateProgress.label, 'Checking for new versions')
|
||||
assert.equal(updateView.updateBanner, null)
|
||||
await nextCheck.reply(1, snapshot({ operations: { ...operations, request: request('check') } }))
|
||||
assert.equal(nextCheck.states.at(-1).submittingAction, null)
|
||||
nextCheck.feed.stop()
|
||||
|
||||
updateView.busy = false
|
||||
updateView.submittingAction = null
|
||||
updateView.operations = { ...operations, request: null }
|
||||
updateView.draftBranch = 'Dom'
|
||||
updateView.data = snapshot({ updater: { ...updater, finalizedUpdateReady: true } })
|
||||
assert.deepEqual(updateView.updateBanner, { tone: 'success', message: 'Downloaded · Ready to install' })
|
||||
|
||||
// Downloading a selected version pins it without entering restart recovery.
|
||||
const selectedDownload = fixture()
|
||||
const selectedHash = 'b'.repeat(40)
|
||||
const selectionHistory = { installed: [], downloaded: [], currentReleaseNotes: null, downloadedReleaseNotes: null,
|
||||
recent: { branch: 'beta', head: selectedHash, entries: [{hash:selectedHash,date:'',subject:'Beta build'}] } }
|
||||
selectedDownload.feed.start()
|
||||
await selectedDownload.reply(0, snapshot({operations:{...operations, canFastUpdate:true, history:selectionHistory}}))
|
||||
selectedDownload.feed.action('download-version', 'beta', {expectedCommit:installed.commit,selectedCommit:selectedHash})
|
||||
assert.deepEqual(JSON.parse(selectedDownload.requests[1].options.body), {action:'download-version',branch:'beta',expectedCommit:installed.commit,selectedCommit:selectedHash})
|
||||
assert.equal(selectedDownload.feed.installBaseline, null)
|
||||
await selectedDownload.reply(1, snapshot({operations:{...operations,request:request('download-version','pending','beta')}}))
|
||||
assert.equal(selectedDownload.feed.installBaseline, null)
|
||||
selectedDownload.feed.stop()
|
||||
|
||||
const previousSelection = page('Dom',['Dom','beta'])
|
||||
previousSelection.operations.history = {previous:{hash:selectedHash,date:'',subject:'Previous',branch:'beta'}}
|
||||
previousSelection.syncDraftBranch = SoftwarePage.methods.syncDraftBranch.bind(previousSelection)
|
||||
await SoftwarePage.methods.selectPreviousVersion.call(previousSelection)
|
||||
assert.equal(previousSelection.draftBranch, 'beta')
|
||||
assert.equal(previousSelection.version, selectedHash)
|
||||
assert.deepEqual(previousSelection.calls, [], 'selecting a saved previous version must not refresh metadata')
|
||||
assert.equal(previousSelection.dialog,null,'selecting previous must not install it')
|
||||
|
||||
// A finished metadata refresh must leave the actual update status visible.
|
||||
updateView.error = ''
|
||||
updateView.busy = false
|
||||
updateView.submittingAction = null
|
||||
updateView.initialChecking = false
|
||||
updateView.draftBranch = 'Dom'
|
||||
updateView.operations = { ...operations, request: request('versions', 'complete') }
|
||||
updateView.data = snapshot({ updater: { ...updater, finalizedUpdateReady: true } })
|
||||
assert.equal(updateView.updateBanner.message, 'Downloaded · Ready to install')
|
||||
updateView.data.updater.finalizedUpdateReady = false
|
||||
assert.equal(updateView.updateBanner.message, 'Update Available')
|
||||
updateView.data.updater.targetChangeFound = false
|
||||
assert.equal(updateView.updateBanner.message, 'No Update Available')
|
||||
assert.equal(SoftwarePage.methods.requestMessage(request('versions', 'complete')), '')
|
||||
|
||||
const branchLayout = page()
|
||||
branchLayout.changeBranch = SoftwarePage.methods.changeBranch.bind(branchLayout)
|
||||
SoftwarePage.methods.syncDraftBranch.call(branchLayout, 'Dom')
|
||||
assert.equal(branchLayout.primaryChoice, 'Dom')
|
||||
branchLayout.primaryChoice = 'other:'
|
||||
await SoftwarePage.methods.onPrimaryBranchChange.call(branchLayout)
|
||||
assert.equal(branchLayout.draftBranch, '')
|
||||
assert.deepEqual(branchLayout.calls, [], 'opening Other does not refresh metadata without a branch')
|
||||
assert.deepEqual(branchLayout.otherBranches.map(branch => branch.name), ['beta'])
|
||||
branchLayout.draftBranch = 'beta'
|
||||
await branchLayout.changeBranch()
|
||||
assert.deepEqual(branchLayout.calls, ['beta'])
|
||||
branchLayout.primaryChoice = 'StarPilot'
|
||||
await SoftwarePage.methods.onPrimaryBranchChange.call(branchLayout)
|
||||
assert.equal(branchLayout.draftBranch, 'StarPilot')
|
||||
assert.deepEqual(branchLayout.calls, ['beta', 'StarPilot'])
|
||||
assert.equal(previousSelection.primaryChoice, 'other:', 'previous selection synchronizes both selectors without refreshing')
|
||||
|
||||
assert.doesNotMatch(SoftwarePage.template, /Refresh versions/)
|
||||
assert.match(SoftwarePage.template, /@click="loadVersions">Refresh/ )
|
||||
assert.match(SoftwarePage.template, /feed.action\('check'\)">Check for updates/)
|
||||
|
||||
assert.match(SoftwarePage.template, /GalaxySelect show-description v-model="primaryChoice"/)
|
||||
assert.doesNotMatch(SoftwarePage.template, /<p v-if="primaryBranchHelp"/)
|
||||
|
||||
assert.match(SoftwarePage.template, /DriveStatePanel offroad-only :lockout="operations\?\.parked === false"/)
|
||||
|
||||
|
||||
const installPreferences = fixture()
|
||||
const installOptions = {...operations, canConfigure:true, automaticInstalls:false, restartSchedule:'0 0 * * *',
|
||||
installPreferenceRevision:'0'.repeat(32),restartTimezone:'UTC'}
|
||||
assert.ok(validSoftwareSnapshot(snapshot({operations:installOptions})))
|
||||
assert.equal(validSoftwareSnapshot(snapshot({operations:{...installOptions,automaticInstalls:'yes'}})),false)
|
||||
installPreferences.feed.start()
|
||||
await installPreferences.reply(0,snapshot({operations:installOptions}))
|
||||
installPreferences.feed.configureInstallPreferences(true,' 0 0 * * * ','Asia/Tokyo')
|
||||
assert.deepEqual(JSON.parse(installPreferences.requests[1].options.body),{action:'install-preferences',automaticInstalls:true,
|
||||
restartSchedule:'0 0 * * *',expectedRevision:'0'.repeat(32),restartTimezone:'Asia/Tokyo'})
|
||||
await installPreferences.reply(1,snapshot({operations:{...installOptions,automaticInstalls:true,installPreferenceRevision:'a'.repeat(32)}}))
|
||||
assert.equal(installPreferences.feed.installBaseline,null,'enabling the schedule does not install immediately')
|
||||
installPreferences.feed.stop()
|
||||
assert.match(SoftwarePage.template,/Install updates automatically/)
|
||||
assert.match(SoftwarePage.template,/Automatic restart schedule/)
|
||||
assert.deepEqual(SoftwarePage.data().restartCronFields,['0','0','*','*','*'])
|
||||
|
||||
const previousTimezone = process.env.TZ
|
||||
try {
|
||||
process.env.TZ = 'America/Los_Angeles'
|
||||
const localTime = SoftwarePage.methods.reported('2026-11-01T00:00:00+00:00')
|
||||
assert.equal(localTime, new Date("2026-11-01T00:00:00+00:00").toLocaleString())
|
||||
} finally {
|
||||
if (previousTimezone === undefined) delete process.env.TZ
|
||||
else process.env.TZ = previousTimezone
|
||||
}
|
||||
assert.match(SoftwarePage.template, /is ready for install\. Click Update when ready\./)
|
||||
assert.match(SoftwarePage.template, /Auto install will run at/)
|
||||
|
||||
assert.match(SoftwarePage.template, /reported\(operations.nextRestartAt\)/)
|
||||
|
||||
assert.ok(SoftwarePage.computed.restartTimezones.call({restartTimezone:'Asia/Tokyo',operations:{restartTimezone:'UTC'}}).includes('Asia/Tokyo'))
|
||||
assert.equal(SoftwarePage.computed.restartScheduleChanged.call({restartCronFields:['0','0','*','*','*'],restartTimezone:'Asia/Tokyo',
|
||||
operations:{restartSchedule:'0 0 * * *',restartTimezone:'UTC'}}),true)
|
||||
let toggleSettings
|
||||
SoftwarePage.methods.configureAutomaticInstalls.call({operations:{restartSchedule:'0 0 * * *',restartTimezone:'UTC'},restartTimezone:'Asia/Tokyo',
|
||||
feed:{configureInstallPreferences:(...args)=>{toggleSettings=args}}},true)
|
||||
assert.deepEqual(toggleSettings,[true,'0 0 * * *','UTC'],'toggle preserves the saved timezone')
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { compile } from '../web/vendor/vue/vue.esm-browser.js'
|
||||
import { SoftwarePage } from '../web/js/software-status.js'
|
||||
|
||||
const rows = Array.from({ length: 20 }, (_, i) => ({ hash: (i + 1).toString(16).padStart(40, '0'),
|
||||
date: new Date(Date.UTC(2026, 9, 29 - i)).toISOString(), subject: `Version ${20 - i}` }))
|
||||
const vm = { ...SoftwarePage.data(), initialChecking: false, mode: 'local',
|
||||
draftBranch: 'Dom', data: { installed: { branch: 'Dom', commit: rows[15].hash },
|
||||
updater: { state: 'idle', targetBranch: 'Dom', finalizedUpdateReady: true }, operations: {
|
||||
parked: true, canFastUpdate: true, canInstall: true, selectedTarget: 'Dom', availableBranches: ['Dom'],
|
||||
history: { installed: rows.slice(15), downloaded: rows.slice(3), currentReleaseNotes: 'Installed notes',
|
||||
downloadedReleaseNotes: 'Downloaded notes', downloadedCommits: [rows[3].hash, rows[4].hash], recent: { branch: 'Dom', head: rows[0].hash, entries: rows } } } } }
|
||||
for (const [name, getter] of Object.entries(SoftwarePage.computed))
|
||||
Object.defineProperty(vm, name, { get: () => getter.call(vm) })
|
||||
for (const [name, method] of Object.entries(SoftwarePage.methods)) vm[name] = method.bind(vm)
|
||||
|
||||
assert.equal(vm.shortCommit('1234567890abcdef'), '12345678')
|
||||
assert.equal(vm.versionEntries.length, 20, 'merge overlapping sources without duplicates')
|
||||
assert.equal(vm.visibleVersions.length, 4)
|
||||
assert.equal(vm.visibleVersions[0].hash, rows[0].hash, 'keep newest at the top')
|
||||
assert.equal(vm.visibleVersions.at(-2).hash, rows[15].hash, 'keep installed visible')
|
||||
assert.equal(vm.visibleVersions.at(-1).hash, rows[16].hash, 'show one older version')
|
||||
assert.equal(vm.hiddenNewerCount, 13)
|
||||
assert.equal(vm.hiddenOlderCount, 3)
|
||||
assert.equal(vm.selectedVersion.hash, rows[15].hash)
|
||||
assert.equal(vm.selectedVersionAction, 'No changes selected')
|
||||
assert.equal(vm.canInstallSelection, false)
|
||||
assert.equal(vm.selectedReleaseNotes, 'Installed notes')
|
||||
|
||||
vm.version = rows[3].hash
|
||||
assert.equal(vm.selectedVersionAction, 'Install selected version')
|
||||
assert.equal(vm.selectedReleaseNotes, 'Downloaded notes')
|
||||
assert.equal(vm.canInstallSelection, true)
|
||||
vm.askSelectedVersion()
|
||||
assert.deepEqual([vm.dialog.action, vm.dialog.selectedCommit, vm.dialog.expectedCommit],
|
||||
['install', rows[3].hash, rows[15].hash])
|
||||
|
||||
vm.version = rows[0].hash
|
||||
assert.equal(vm.selectedVersionAction, 'Download and install selected version')
|
||||
assert.equal(vm.canInstallSelection, true)
|
||||
assert.equal(vm.selectedReleaseNotes, null)
|
||||
vm.askSelectedVersion()
|
||||
assert.equal(vm.dialog.action, 'version')
|
||||
assert.equal(vm.dialog.selectedCommit, rows[0].hash)
|
||||
|
||||
vm.data.updater.finalizedUpdateReady = false
|
||||
vm.version = rows[3].hash
|
||||
assert.equal(vm.selectedVersionAction, 'Install selected version', 'complete cached files remain downloaded when staging is not ready')
|
||||
vm.version = rows[4].hash
|
||||
assert.equal(vm.selectedVersionAction, 'Install selected version', 'cached commits need not be the prepared head')
|
||||
vm.askSelectedVersion()
|
||||
assert.equal(vm.dialog.action, 'version', 'cached versions still pass through pinned validation')
|
||||
vm.newerVersionLimit += 10
|
||||
assert.equal(vm.hiddenNewerCount, 3)
|
||||
assert.equal(vm.visibleVersions.length, 14, 'newer paging must not reveal older rows')
|
||||
assert.equal(vm.hiddenOlderCount, 3)
|
||||
vm.newerVersionLimit += 10
|
||||
vm.olderVersionLimit += 10
|
||||
assert.equal(vm.visibleVersions.length, 20)
|
||||
assert.equal(vm.hiddenOlderCount, 0)
|
||||
vm.newerVersionLimit = 2
|
||||
vm.olderVersionLimit = 1
|
||||
assert.equal(vm.visibleVersions.length, 4)
|
||||
|
||||
// Separate gaps remain exact across multiple pages of ten.
|
||||
const longRows = Array.from({length: 60}, (_, i) => ({hash:(i + 100).toString(16).padStart(40, '0'),date:'',subject:`Build ${i}`}))
|
||||
vm.data.operations.history.recent.entries = longRows
|
||||
vm.data.operations.history.installed = longRows.slice(35)
|
||||
vm.data.installed.commit = longRows[35].hash
|
||||
assert.equal(vm.hiddenNewerCount, 33)
|
||||
assert.equal(vm.hiddenOlderCount, 23)
|
||||
for (const remaining of [23, 13, 3, 0]) {
|
||||
const before = vm.visibleVersions.length
|
||||
const count = Math.min(10, vm.hiddenNewerCount)
|
||||
vm.newerVersionLimit += 10
|
||||
assert.equal(vm.visibleVersions.length - before, count)
|
||||
assert.equal(vm.hiddenNewerCount, remaining)
|
||||
assert.equal(vm.hiddenOlderCount, 23)
|
||||
}
|
||||
for (const remaining of [13, 3, 0]) {
|
||||
const before = vm.visibleVersions.length
|
||||
const count = Math.min(10, vm.hiddenOlderCount)
|
||||
vm.olderVersionLimit += 10
|
||||
assert.equal(vm.visibleVersions.length - before, count)
|
||||
assert.equal(vm.hiddenOlderCount, remaining)
|
||||
}
|
||||
vm.data.operations.history.recent.entries = rows
|
||||
vm.data.operations.history.installed = rows.slice(15)
|
||||
vm.data.installed.commit = rows[15].hash
|
||||
vm.data.operations.history.recent.entries = rows.slice(0, 10)
|
||||
vm.version = rows[19].hash
|
||||
assert.equal(vm.canInstallSelection, true, 'older installed history remains selectable outside the recent list')
|
||||
vm.data.operations.history.recent.entries = rows
|
||||
vm.newerVersionLimit = 2
|
||||
vm.olderVersionLimit = 1
|
||||
vm.data.installed.commit = 'f'.repeat(40)
|
||||
vm.version = ''
|
||||
assert.equal(vm.visibleVersions.at(-1).hash, 'f'.repeat(40), 'retain installed outside fetched history on its own branch')
|
||||
assert.equal(vm.selectedVersionAction, 'No changes selected')
|
||||
vm.busy = true
|
||||
vm.version = rows[0].hash
|
||||
assert.equal(vm.canInstallSelection, false)
|
||||
vm.busy = false
|
||||
vm.data.operations.parked = false
|
||||
assert.equal(vm.canInstallSelection, false)
|
||||
|
||||
compile(SoftwarePage.template, { decodeEntities: value => value })
|
||||
console.log('Version timeline selection, limits, expansion, and template passed')
|
||||
|
||||
// Pin the actual previous installed commit with independent gaps above and below it.
|
||||
vm.data.operations.parked = true
|
||||
vm.data.operations.canRollback = true
|
||||
vm.data.installed.commit = longRows[15].hash
|
||||
vm.data.operations.history.recent.entries = longRows
|
||||
vm.data.operations.history.installed = longRows.slice(15)
|
||||
vm.data.operations.history.previous = longRows[40]
|
||||
vm.newerVersionLimit = 2
|
||||
vm.olderVersionLimit = 1
|
||||
vm.collapsedOlderLimit = 0
|
||||
vm.version = ''
|
||||
assert.deepEqual(vm.visibleVersions.map(row => row.hash), [...longRows.slice(0, 2), longRows[15], longRows[40]].map(row => row.hash))
|
||||
assert.equal(vm.hiddenNewerCount, 13)
|
||||
assert.equal(vm.hiddenCollapsedOlderCount, 24)
|
||||
assert.equal(vm.hiddenOlderCount, 19)
|
||||
for (const remaining of [14, 4, 0]) {
|
||||
const before = vm.visibleVersions.length
|
||||
const count = Math.min(10, vm.hiddenCollapsedOlderCount)
|
||||
vm.collapsedOlderLimit += count
|
||||
assert.equal(vm.visibleVersions.length - before, count)
|
||||
assert.equal(vm.hiddenCollapsedOlderCount, remaining)
|
||||
assert.equal(vm.hiddenOlderCount, 19)
|
||||
}
|
||||
vm.olderVersionLimit += 10
|
||||
assert.equal(vm.hiddenOlderCount, 9)
|
||||
assert.equal(vm.visibleVersions.at(-1).hash, longRows[50].hash)
|
||||
vm.version = longRows[40].hash
|
||||
assert.equal(vm.selectedVersionAction, 'Install selected version')
|
||||
assert.equal(vm.canInstallSelection, true)
|
||||
vm.askSelectedVersion()
|
||||
assert.equal(vm.dialog.action, 'rollback')
|
||||
vm.data.operations.history.previous = longRows[0]
|
||||
assert.equal(vm.visibleVersions.filter(row => row.hash === longRows[0].hash).length, 1, 'previous version may be newer after a rollback')
|
||||
|
||||
// Diverged branches contain neither installed history nor a previous-version anchor.
|
||||
vm.data.operations.history.previous = { ...longRows[40], branch:'Dom' }
|
||||
vm.draftBranch = 'StarPilot'
|
||||
vm.data.operations.history.recent = {branch:'StarPilot',head:rows[0].hash,entries:rows.slice(0,10)}
|
||||
vm.newerVersionLimit = 2
|
||||
vm.version = ''
|
||||
assert.equal(vm.hasInstalledVersion, false)
|
||||
assert.equal(vm.listedPreviousVersion, null)
|
||||
assert.deepEqual(vm.visibleVersions.map(row => row.hash), rows.slice(0,2).map(row => row.hash))
|
||||
assert.equal(vm.hiddenNewerCount,8)
|
||||
vm.newerVersionLimit += 10
|
||||
assert.equal(vm.visibleVersions.length,10,'diverged history remains pageable without installed anchors')
|
||||
vm.syncDraftBranch(vm.previousVersion.branch)
|
||||
vm.version = vm.previousVersion.hash
|
||||
assert.equal(vm.listedPreviousVersion.hash,vm.previousVersion.hash,'explicit local previous selection needs no metadata refresh')
|
||||
|
||||
// Glow only while a different selectable commit is waiting to be installed.
|
||||
assert.equal(vm.selectionNeedsInstall, true)
|
||||
vm.data.updater.state = 'updating...'
|
||||
assert.equal(vm.selectionNeedsInstall, false)
|
||||
vm.data.updater.state = 'idle'
|
||||
vm.feed = {installBaseline:{}}
|
||||
assert.equal(vm.selectionNeedsInstall, false)
|
||||
vm.feed.installBaseline = null
|
||||
assert.equal(vm.selectionNeedsInstall, true)
|
||||
vm.busy = true
|
||||
assert.equal(vm.selectionNeedsInstall, false)
|
||||
vm.busy = false
|
||||
vm.version = vm.data.installed.commit
|
||||
assert.equal(vm.selectionNeedsInstall, false)
|
||||
@@ -1228,16 +1228,37 @@ button.gx-chip:hover {
|
||||
.gx-sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|
||||
|
||||
.gx-release-notes{white-space:pre-wrap;font:inherit;line-height:1.6;overflow-wrap:anywhere}
|
||||
.gx-build-history { display: grid; gap: var(--sp-3); max-height: 320px; overflow-y: auto; }
|
||||
.gx-build-history li { min-width: 0; padding: var(--sp-3); border: 1px solid var(--glass-border); border-radius: var(--radius-sm); overflow-wrap: anywhere; }
|
||||
.gx-build-history strong { display: block; color: var(--on-surface); }
|
||||
.gx-build-history small { display: block; margin-top: var(--sp-1); color: var(--on-surface-variant); }
|
||||
.gx-software-summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr)); gap: var(--sp-3); margin: 0; }
|
||||
.gx-software-summary dt { font-size: var(--fs-sm); color: var(--on-surface-variant); }
|
||||
.gx-software-summary dd { margin: var(--sp-1) 0 0; font-weight: var(--fw-bold); overflow-wrap: anywhere; }
|
||||
.gx-software-options { border-top: 1px solid var(--glass-border); }
|
||||
.gx-software-options > .gx-stack { padding-top: var(--sp-4); }
|
||||
.gx-software-card details{padding:12px 0}.gx-software-card summary{cursor:pointer;font-weight:600}
|
||||
.gx-version-action--pending { animation: gx-version-heartbeat 1.8s ease-in-out infinite; }
|
||||
@keyframes gx-version-heartbeat {
|
||||
0%, 45%, 100% { box-shadow: 0 0 0 0 transparent; }
|
||||
12%, 32% { box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent), 0 0 20px color-mix(in srgb, var(--primary) 55%, transparent); }
|
||||
22% { box-shadow: 0 0 0 1px color-mix(in srgb, var(--primary) 15%, transparent), 0 0 10px color-mix(in srgb, var(--primary) 30%, transparent); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.gx-version-action--pending { animation: none; box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 35%, transparent); }
|
||||
}
|
||||
.gx-restart-cron { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)) minmax(180px, 2fr); gap: var(--sp-3); }
|
||||
@media (max-width: 800px) { .gx-restart-cron { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.gx-version-scroll { max-height: min(760px, 65dvh); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; padding: 3px; }
|
||||
.gx-version-list { display: grid; gap: var(--sp-2); list-style: none; margin: 0; padding: 0; }
|
||||
.gx-version-row { display: flex; align-items: center; gap: var(--sp-3); min-height: 64px; padding: var(--sp-3); border: 2px solid var(--outline-variant); border-radius: var(--radius-sm); cursor: pointer; }
|
||||
.gx-version-row:hover { background: color-mix(in srgb, var(--primary) 6%, transparent); }
|
||||
.gx-version-row--installed { background: color-mix(in srgb, var(--primary) 9%, transparent); }
|
||||
.gx-version-row--selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.gx-version-row:focus-within { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||
.gx-version-row input { flex: 0 0 auto; width: 20px; height: 20px; accent-color: var(--primary); }
|
||||
.gx-version-content { min-width: 0; display: grid; gap: var(--sp-1); overflow-wrap: anywhere; }
|
||||
.gx-version-content small { color: var(--on-surface-variant); }
|
||||
.gx-version-meta .gx-version-badge { display: inline-block; margin-inline-start: var(--sp-2); white-space: nowrap; }
|
||||
.gx-version-badge { padding: 2px 8px; border-radius: 999px; font-size: var(--fs-sm); color: var(--on-surface-variant); background: var(--surface-container-high); }
|
||||
.gx-version-badge--installed, .gx-version-badge--downloaded { color: var(--primary); background: color-mix(in srgb, var(--primary) 14%, transparent); }
|
||||
.gx-version-gap { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-2); }
|
||||
|
||||
.gx-navigation-map { position:relative; border-radius:12px; overflow:hidden; background:#20303b; margin-bottom:16px; }
|
||||
.gx-navigation-map canvas { display:block; width:100%; height:360px; touch-action:none; cursor:grab; }
|
||||
|
||||
@@ -7,9 +7,10 @@ export const validDriveState = (value) => !!value && ["auto", "offroad", "onroad
|
||||
|
||||
export const DriveStatePanel = {
|
||||
components: { GxNotice },
|
||||
props: { offroadOnly: { type: Boolean, default: false } },
|
||||
data: () => ({ state: null, error: "", busy: false }),
|
||||
props: { offroadOnly: { type: Boolean, default: false }, lockout: { type: Boolean, default: true } },
|
||||
data: () => ({ state: null, error: "", statusError: "", busy: false }),
|
||||
computed: {
|
||||
visible() { return !this.offroadOnly || this.lockout || this.state?.mode === 'offroad' || !!this.error },
|
||||
pending() { return this.state?.mode !== "auto" && this.state?.effective !== this.state?.mode },
|
||||
},
|
||||
mounted() {
|
||||
@@ -31,13 +32,21 @@ export const DriveStatePanel = {
|
||||
const response = await fetch("./api/drive-state/status", { credentials: "same-origin", cache: "no-store", signal: this.controller.signal })
|
||||
const value = await response.json()
|
||||
if (!response.ok || !validDriveState(value)) throw new Error(value.error || "Drive state unavailable")
|
||||
if (this.active && generation === this.generation) { this.state = value; this.error = "" }
|
||||
} catch (error) { if (this.active && generation === this.generation) { this.state = null; this.error = "Drive state unavailable" } }
|
||||
if (this.active && generation === this.generation) { this.state = value; this.statusError = "" }
|
||||
} catch (error) { if (this.active && generation === this.generation) { this.state = null; this.statusError = "Drive state unavailable" } }
|
||||
finally { clearTimeout(deadline); if (generation === this.generation) this.loading = false }
|
||||
},
|
||||
async toggleOffroad(event) {
|
||||
const input = event.target
|
||||
const mode = input.checked ? 'offroad' : 'auto'
|
||||
input.checked = this.state?.mode === 'offroad'
|
||||
await this.change(mode)
|
||||
input.checked = this.state?.mode === 'offroad'
|
||||
},
|
||||
async change(mode) {
|
||||
if (this.busy || !this.state?.available) return
|
||||
if (!["onroad", "offroad", "auto"].includes(mode)) return
|
||||
this.error = ""
|
||||
const feedback = beginSave()
|
||||
const revision = this.state.revision
|
||||
this.busy = true
|
||||
@@ -57,16 +66,15 @@ export const DriveStatePanel = {
|
||||
finally { clearTimeout(deadline); this.busy = false; if (this.active && generation === this.generation) this.load() }
|
||||
},
|
||||
},
|
||||
template: `<section class="gx-card gx-force-drive" aria-label="Force Drive State" :inert="busy" :aria-busy="busy || undefined"><div class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">Force Drive State</span></div><div class="gx-panel">
|
||||
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
|
||||
template: `<section v-if="visible" class="gx-card gx-force-drive" aria-label="Force Drive State" :inert="busy" :aria-busy="busy || undefined"><div v-if="!offroadOnly" class="gx-section__header"><i class="bi bi-car-front" aria-hidden="true"></i><span class="gx-section__title">Force Drive State</span></div><div class="gx-panel">
|
||||
<GxNotice tone="danger" v-if="error || statusError">{{ error || statusError }}</GxNotice>
|
||||
<GxNotice tone="danger" v-if="state && !state.available">The drive-state manager is unavailable. Reconnect to check again.</GxNotice>
|
||||
<GxNotice v-if="pending" tone="info">Waiting for the device to switch {{ state.mode }}…</GxNotice>
|
||||
<p>Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
|
||||
<p v-if="!offroadOnly">Device: {{ state?.effective === "onroad" ? "Onroad" : state?.effective === "offroad" ? "Offroad" : "Checking…" }}</p>
|
||||
<p v-if="!offroadOnly">Auto follows the car. Force Onroad starts driving services at any time. Return to Auto restores normal operation.</p>
|
||||
<label v-if="offroadOnly" class="gx-row"><span>Force Offroad</span><input type="checkbox" role="switch"
|
||||
<label v-if="offroadOnly" class="gx-row gx-row--borderless"><span class="gx-row__info"><span class="gx-row__label">Force device offroad</span><small class="gx-row__desc">Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state.</small></span><span class="gx-switch"><input type="checkbox" role="switch"
|
||||
:checked="state?.mode === 'offroad'" :disabled="!state?.available"
|
||||
@change="change($event.target.checked ? 'offroad' : 'auto')" /></label>
|
||||
<p v-if="offroadOnly" class="gx-note">Park the vehicle and disengage controls to enable. Turn off to restore automatic drive state.</p>
|
||||
@change="toggleOffroad" /><span class="gx-switch__track"></span><span class="gx-switch__thumb"></span></span></label>
|
||||
<div v-if="!offroadOnly" class="gx-force-drive__actions gx-actions"><button v-for="mode in ['onroad', 'auto']" :key="mode" type="button"
|
||||
class="gx-btn" :class="state?.mode === mode ? '' : 'gx-btn--tonal'"
|
||||
:disabled="!state?.available"
|
||||
|
||||
@@ -4,10 +4,11 @@ let nextId = 0
|
||||
export const GalaxySelect = {
|
||||
name: "GalaxySelect",
|
||||
inheritAttrs: false,
|
||||
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean },
|
||||
props: { value: { default: undefined }, modelValue: { default: undefined }, disabled: Boolean, showDescription: Boolean },
|
||||
emits: ["change", "update:modelValue"],
|
||||
data() { return { uid: `gx-select-${++nextId}`, items: [], selected: "", label: "", open: false, name: "Choose an option", typeahead: "", typedAt: 0 } },
|
||||
computed: {
|
||||
selectedDescription() { return this.items.find(item => item.value === this.selected)?.description || "" },
|
||||
current() { return this.modelValue !== undefined ? this.modelValue : this.value },
|
||||
buttonId() { return this.$attrs.id || this.uid },
|
||||
nativeAttrs() {
|
||||
@@ -129,7 +130,7 @@ export const GalaxySelect = {
|
||||
<select ref="native" v-bind="nativeAttrs" hidden tabindex="-1" aria-hidden="true" :disabled="disabled" @change="change"><slot /></select>
|
||||
<button ref="button" :id="buttonId" class="gx-select__button" type="button" role="combobox" aria-haspopup="listbox" :aria-expanded="open" :aria-controls="uid + '-list'"
|
||||
:aria-label="name" :aria-describedby="$attrs['aria-describedby']" :disabled="disabled" @click="show" @keydown="buttonKey">
|
||||
<span>{{ label }}</span><i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
<span class="gx-grow gx-wrap"><span>{{ label }}</span><small v-if="showDescription && selectedDescription" class="gx-note gx-select__description">{{ selectedDescription }}</small></span><i class="bi bi-chevron-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
<dialog ref="menu" class="gx-select-menu" @cancel.prevent="close" @click.self="close" @keydown="menuKey">
|
||||
<div :id="uid + '-list'" role="listbox" :aria-label="name">
|
||||
|
||||
@@ -7,10 +7,9 @@ import { GalaxySelect } from "./galaxy-select.js"
|
||||
import { GalaxySettingRow, switchRow } from "./galaxy-setting-row.js"
|
||||
import { RouteCleanup } from "./route-cleanup.js"
|
||||
|
||||
const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "update", "fast", "rollback", "versions", "version"])
|
||||
const ACTIONS = new Set(["check", "download", "select", "install", "preferences", "install-preferences", "update", "fast", "rollback", "versions", "version", "download-version"])
|
||||
const REQUEST_STATES = new Set(["pending", "complete", "failed"])
|
||||
const UPDATER_ACTIVE = new Set(["checking...", "downloading...", "finalizing update...", "updating..."])
|
||||
const PRIMARY_BRANCHES = ["StarPilot", "Dom"]
|
||||
const PRIMARY_BRANCH_LABELS = { StarPilot: "Stable — StarPilot", Dom: "Development — Dom" }
|
||||
const PRIMARY_BRANCH_HELP = {
|
||||
StarPilot: "Stable releases. Recommended for most users.",
|
||||
@@ -36,6 +35,7 @@ export function validSoftwareSnapshot(data) {
|
||||
typeof branch === "string" && branch.length > 0 && branch.length <= 128) &&
|
||||
text(operations.selectedTarget) && text(operations.reason) &&
|
||||
(operations.progress === undefined || operations.progress === null || typeof operations.progress === "object" &&
|
||||
(operations.progress.background === undefined || typeof operations.progress.background === 'boolean') &&
|
||||
text(operations.progress.stage) && (operations.progress.stage === null || operations.progress.stage.length <= 128) &&
|
||||
text(operations.progress.detail) && (operations.progress.detail === null || operations.progress.detail.length <= 512) &&
|
||||
(operations.progress.percent === null || Number.isFinite(operations.progress.percent) &&
|
||||
@@ -44,6 +44,12 @@ export function validSoftwareSnapshot(data) {
|
||||
(request?.selectedCommit === undefined || /^[0-9a-f]{40}$/.test(request.selectedCommit)) &&
|
||||
(operations.canRollback === undefined || typeof operations.canRollback === "boolean") &&
|
||||
(operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) &&
|
||||
(operations.automaticInstalls === undefined || flag(operations.automaticInstalls)) &&
|
||||
(operations.preparedCommit === undefined || operations.preparedCommit === null || /^[0-9a-f]{40}$/.test(operations.preparedCommit)) &&
|
||||
(operations.nextRestartAt === undefined || text(operations.nextRestartAt)) &&
|
||||
(operations.restartSchedule === undefined || typeof operations.restartSchedule === 'string' && operations.restartSchedule.length <= 128) &&
|
||||
(operations.installPreferenceRevision === undefined || operations.installPreferenceRevision === null || /^[0-9a-f]{32}$/.test(operations.installPreferenceRevision)) &&
|
||||
(operations.restartTimezone === undefined || text(operations.restartTimezone)) &&
|
||||
(operations.canConfigure === undefined || typeof operations.canConfigure === "boolean") &&
|
||||
(operations.history === undefined || validHistory(operations.history)) &&
|
||||
[operations.canCheck, operations.canDownload, operations.canSelect, operations.canInstall].every((value) => typeof value === "boolean") &&
|
||||
@@ -59,8 +65,8 @@ export function softwareProgress(updater, operations = {}) {
|
||||
const stage = progress?.stage || fast?.stage
|
||||
const active = UPDATER_ACTIVE.has(updater.state) && !["unavailable", "timed_out", "error", "complete"].includes(stage)
|
||||
const labels = { "checking...": "Checking for updates", "downloading...": "Downloading update",
|
||||
"finalizing update...": "Finalizing update", "updating...": "Updating software",
|
||||
checking: "Checking for updates", downloading: "Downloading update", finalizing: "Finalizing update",
|
||||
"finalizing update...": "Preparing update", "updating...": "Updating software",
|
||||
checking: "Checking for updates", downloading: "Downloading update", finalizing: "Preparing update",
|
||||
preparing: "Preparing update", fetching: "Downloading update", validating: "Validating update",
|
||||
applying: "Applying update", submodules: "Preparing dependencies", rebooting: "Restarting device",
|
||||
complete: "Update complete", error: "Update failed", unavailable: "Updater status unavailable", timed_out: "Update is taking longer than expected" }
|
||||
@@ -71,10 +77,13 @@ export function softwareProgress(updater, operations = {}) {
|
||||
}
|
||||
|
||||
export function validHistory(value) {
|
||||
const rows = (items) => Array.isArray(items) && items.length <= 20 && items.every((row) =>
|
||||
const rows = (items) => Array.isArray(items) && items.length <= 100 && items.every((row) =>
|
||||
/^[0-9a-f]{40}$/.test(row?.hash) && typeof row.date === "string" && row.date.length <= 64 &&
|
||||
typeof row.subject === "string" && row.subject.length <= 512)
|
||||
return !!value && rows(value.installed) && rows(value.downloaded) &&
|
||||
(value.previous === undefined || value.previous === null || rows([value.previous])) &&
|
||||
(value.downloadedCommits === undefined || Array.isArray(value.downloadedCommits) && value.downloadedCommits.length <= 201 &&
|
||||
value.downloadedCommits.every(hash => /^[0-9a-f]{40}$/.test(hash))) &&
|
||||
(value.recent === undefined || !!value.recent && (value.recent.branch === null || typeof value.recent.branch === "string") &&
|
||||
(value.recent.head === null || /^[0-9a-f]{40}$/.test(value.recent.head)) && rows(value.recent.entries)) &&
|
||||
[value.currentReleaseNotes, value.downloadedReleaseNotes].every((notes) => notes === null || typeof notes === "string" && notes.length <= 65536)
|
||||
@@ -108,7 +117,7 @@ export class SoftwareStatusFeed {
|
||||
emit(status) {
|
||||
if (this.installBaseline && this.connectionLost) this.restarting(this.installBaseline)
|
||||
this.publish({ status, data: this.data, busy: this.busy, uncertain: this.uncertain,
|
||||
notice: this.notice, error: this.error }) }
|
||||
notice: this.notice, error: this.error, submittingAction: this.mutating ? this.submittingAction : null }) }
|
||||
|
||||
stop() {
|
||||
this.active = false
|
||||
@@ -153,6 +162,7 @@ export class SoftwareStatusFeed {
|
||||
this.controller = controller
|
||||
this.busy = body !== null || this.data === null
|
||||
this.mutating = body !== null
|
||||
this.submittingAction = body?.action ?? null
|
||||
if (body !== null) { this.error = ""; this.notice = "" }
|
||||
this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading")
|
||||
this.timeout = this.later(() => {
|
||||
@@ -205,7 +215,11 @@ export class SoftwareStatusFeed {
|
||||
error.connectionLost = payload === null
|
||||
throw error
|
||||
}
|
||||
const previous = this.data
|
||||
this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload
|
||||
if (this.data.operations.history === undefined && previous?.operations?.history &&
|
||||
previous.installed.commit === this.data.installed.commit && previous.operations.selectedTarget === this.data.operations.selectedTarget)
|
||||
this.data.operations = { ...this.data.operations, history: previous.operations.history }
|
||||
this.attempted = true
|
||||
this.blocked = false
|
||||
this.startupFailures = 0
|
||||
@@ -220,7 +234,8 @@ export class SoftwareStatusFeed {
|
||||
this.installBaseline = this.installBaseline || { commit: payload.installed.commit, branch: payload.installed.branch, target: body.branch }
|
||||
this.notice = "Restart requested. Waiting to reconnect and verify the installed build."
|
||||
} else this.notice = body.action === "select" ? `Target branch set to ${body.branch}. Check and download the update when ready.` :
|
||||
body.action === "preferences" ? "Automatic download preference saved." : ""
|
||||
body.action === "preferences" ? "Automatic download preference saved." :
|
||||
body.action === "install-preferences" ? "Automatic install preferences saved." : ""
|
||||
} else {
|
||||
const observed = payload.operations.request
|
||||
const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action &&
|
||||
@@ -231,7 +246,12 @@ export class SoftwareStatusFeed {
|
||||
payload.operations.selectedTarget === this.uncertainAction.branch
|
||||
const preferenceObserved = this.uncertainAction?.action === "preferences" &&
|
||||
payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads
|
||||
if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) {
|
||||
const installPreferenceObserved = this.uncertainAction?.action === 'install-preferences' &&
|
||||
payload.operations.automaticInstalls === this.uncertainAction.automaticInstalls &&
|
||||
payload.operations.restartSchedule === this.uncertainAction.restartSchedule &&
|
||||
payload.operations.restartTimezone === this.uncertainAction.restartTimezone &&
|
||||
payload.operations.installPreferenceRevision !== this.uncertainAction.expectedRevision
|
||||
if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved || installPreferenceObserved)) {
|
||||
this.uncertain = false
|
||||
this.uncertainAction = null
|
||||
this.actionPriorRequestId = null
|
||||
@@ -305,26 +325,39 @@ export class SoftwareStatusFeed {
|
||||
expectedAutomaticDownloads: this.data.operations.automaticDownloads })
|
||||
}
|
||||
|
||||
configureInstallPreferences(enabled, schedule, timezone = Intl.DateTimeFormat().resolvedOptions().timeZone) {
|
||||
if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true ||
|
||||
typeof enabled !== 'boolean' || typeof schedule !== 'string') return null
|
||||
return this.run({action:'install-preferences', automaticInstalls:enabled, restartSchedule:schedule.trim().split(/\s+/).join(' '),
|
||||
expectedRevision:this.data.operations.installPreferenceRevision, restartTimezone:timezone})
|
||||
}
|
||||
|
||||
action(action, branch = null, selection = null) {
|
||||
const operations = this.data?.operations
|
||||
const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canFastUpdate", version: "canFastUpdate" }[action]
|
||||
const capability = { check: "canCheck", download: "canDownload", select: "canSelect", install: "canInstall", update: "canFastUpdate", fast: "canFastUpdate", rollback: "canRollback", versions: "canCheck", version: "canFastUpdate", "download-version": "canFastUpdate" }[action]
|
||||
if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked ||
|
||||
operations[capability] !== true || operations.request?.state === "pending") return null
|
||||
operations[capability] !== true || operations.request?.state === "pending" &&
|
||||
!(["check", "versions"].includes(action) && ["download", "download-version"].includes(operations.request.action))) return null
|
||||
if (action === "select" && (branch === "other:" || !operations.availableBranches.includes(branch) || branch === operations.selectedTarget)) return null
|
||||
if (["download", "install"].includes(action) && (!branch || branch !== operations.selectedTarget)) return null
|
||||
if (["update", "fast", "versions", "version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null
|
||||
if (["update", "fast", "versions", "version", "download-version"].includes(action) && (!branch || branch !== this.data.installed.branch && !operations.availableBranches.includes(branch))) return null
|
||||
if (action === "rollback" && branch !== this.data.installed.branch) return null
|
||||
if (action === "version") {
|
||||
if (["version", "download-version"].includes(action)) {
|
||||
if (selection?.expectedCommit !== this.data.installed.commit || !/^[0-9a-f]{40}$/.test(selection?.selectedCommit) ||
|
||||
operations.history?.recent?.branch !== branch ||
|
||||
!operations.history.recent.entries.some((row) => row.hash === selection.selectedCommit)) return null
|
||||
![...operations.history.recent.entries, ...(branch === this.data.installed.branch ? operations.history.installed : [])]
|
||||
.some((row) => row.hash === selection.selectedCommit)) return null
|
||||
}
|
||||
if (action === "install" && selection?.selectedCommit &&
|
||||
(selection.expectedCommit !== this.data.installed.commit ||
|
||||
operations.history?.downloaded?.[0]?.hash !== selection.selectedCommit)) return null
|
||||
this.actionPriorRequestId = operations.request?.id ?? null
|
||||
if (action === "install") this.installBaseline = { commit: this.data.installed.commit,
|
||||
branch: this.data.installed.branch, target: branch, action }
|
||||
branch: this.data.installed.branch, target: branch, action,
|
||||
...(selection?.selectedCommit ? { selectedCommit: selection.selectedCommit } : {}) }
|
||||
if (["update", "fast", "rollback", "version"].includes(action)) this.installBaseline = { commit: this.data.installed.commit, branch: this.data.installed.branch, target: branch, action, ...(action === "version" ? { selectedCommit: selection.selectedCommit } : {}) }
|
||||
if (this.installBaseline) this.preparing(this.installBaseline)
|
||||
if (action === "version") return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit })
|
||||
if (["version", "download-version"].includes(action) || action === "install" && selection?.selectedCommit) return this.run({ action, branch, expectedCommit: selection.expectedCommit, selectedCommit: selection.selectedCommit })
|
||||
return this.run(action === "check" ? { action } : { action, branch })
|
||||
}
|
||||
}
|
||||
@@ -334,11 +367,15 @@ export const SoftwarePage = {
|
||||
name: "SoftwarePage",
|
||||
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true }, restarting: { type: Function, default: () => {} },
|
||||
preparing: { type: Function, default: () => {} }, cancelPreparation: { type: Function, default: () => {} } },
|
||||
data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "",
|
||||
initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, historyKind: "recent", version: "" }),
|
||||
data: () => ({ status: "idle", data: null, busy: false, uncertain: false, notice: "", error: "", submittingAction: null,
|
||||
initialChecking: true, draftBranch: "", primaryChoice: "", draftTouched: false, dialog: null, newerVersionLimit: 2, olderVersionLimit: 1, collapsedOlderLimit: 0, version: "", restartCronFields: ["0", "0", "*", "*", "*"], scheduleTouched: false, restartTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone }),
|
||||
created() {
|
||||
this.feed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => {
|
||||
Object.assign(this.$data, update)
|
||||
if (update.data?.operations.restartSchedule && !this.scheduleTouched) {
|
||||
this.restartCronFields = update.data.operations.restartSchedule.split(' ')
|
||||
this.restartTimezone = update.data.operations.restartTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
}
|
||||
if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget)
|
||||
}, unauthorized: this.unauthorized, restarting: this.restarting,
|
||||
preparing: this.preparing, cancelPreparation: this.cancelPreparation })
|
||||
@@ -347,16 +384,34 @@ export const SoftwarePage = {
|
||||
if (this.mode !== "local") return
|
||||
try {
|
||||
await this.feed.start()
|
||||
if (this.feed.active && this.operations?.canCheck && !this.pending && !UPDATER_ACTIVE.has(this.data?.updater.state)) await this.feed.action("check")
|
||||
if (this.feed.active && this.operations?.canCheck && (!this.pending || ['download', 'download-version'].includes(this.operations.request?.action)) &&
|
||||
this.data?.updater.state !== 'updating...') await this.feed.action("check")
|
||||
} finally { this.initialChecking = false }
|
||||
},
|
||||
beforeUnmount() { this.feed.stop() },
|
||||
computed: {
|
||||
operations() { return this.data?.operations },
|
||||
restartScheduleChanged() { return this.restartCronFields.join(' ') !== this.operations?.restartSchedule ||
|
||||
this.restartTimezone !== this.operations?.restartTimezone },
|
||||
restartTimezones() {
|
||||
return [...new Set(["UTC", this.restartTimezone, this.operations?.restartTimezone,
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone, ...(Intl.supportedValuesOf?.("timeZone") || [])].filter(Boolean))].sort()
|
||||
},
|
||||
reportedProgress() { return softwareProgress(this.data?.updater, this.operations) },
|
||||
metadataProgress() {
|
||||
const check = this.data?.updater.fast?.check
|
||||
if (['check', 'versions'].includes(this.submittingAction) || check?.state === 'checking' || this.pending && ['check', 'versions'].includes(this.operations?.request?.action))
|
||||
return { active: true, percent: null, label: 'Checking for new versions', detail: check?.state === 'checking' ? check.detail : '' }
|
||||
return null
|
||||
},
|
||||
backgroundProgress() {
|
||||
return this.reportedProgress?.active && this.operations?.progress?.background ? this.reportedProgress : null
|
||||
},
|
||||
updateProgress() {
|
||||
if (this.error || this.operations?.request?.state === "failed") return null
|
||||
if (this.metadataProgress) return this.metadataProgress
|
||||
const progress = this.reportedProgress
|
||||
if (this.backgroundProgress) return null
|
||||
if (progress?.active) return progress
|
||||
if (progress?.failed) return null
|
||||
if (this.initialChecking || this.busy || this.pending || this.uncertain) return {
|
||||
@@ -379,12 +434,13 @@ export const SoftwarePage = {
|
||||
(progress?.failed ? progress.detail || progress.label : "")
|
||||
if (error) return { tone: "danger", message: error }
|
||||
if (this.updateProgress) return null
|
||||
if (this.operations?.reason) return { tone: "warn", message: this.operations.reason }
|
||||
if (this.operations?.reason && !(this.backgroundProgress && this.operations.reason === 'Updater is busy')) return { tone: "warn", message: this.operations.reason }
|
||||
if (this.updateAvailable && this.osUpdateRequired) return { tone: "warn",
|
||||
message: this.data.updater.finalizedUpdateReady ? "Update ready — includes an operating system update. Restart to install." :
|
||||
"Update available — operating system update required. This is a large download and may take a while. Keep the device powered and connected to Wi-Fi." }
|
||||
if (this.data?.updater.finalizedUpdateReady && this.data.updater.targetBranch === (this.draftBranch || this.operations?.selectedTarget)) return { tone: "success", message: "Downloaded · Ready to install" }
|
||||
if (this.notice) return { tone: "info", message: this.notice }
|
||||
if (request && request.action !== "check") return { tone: "info", message: this.requestMessage(request) }
|
||||
if (request && !["check", "versions"].includes(request.action)) return { tone: "info", message: this.requestMessage(request) }
|
||||
if (this.updateAvailable) return { tone: "success", message: "Update Available" }
|
||||
if (this.data?.updater.targetChangeFound === false) return { tone: "success", message: "No Update Available" }
|
||||
return { tone: "warn", message: "Update status unavailable. Check for updates to try again." }
|
||||
@@ -397,66 +453,171 @@ export const SoftwarePage = {
|
||||
pending() { return this.operations?.request?.state === "pending" },
|
||||
actionUnavailable() { return this.uncertain || this.pending || !!this.error || !this.operations?.parked },
|
||||
actionDisabled() { return this.busy || this.actionUnavailable },
|
||||
metadataActionDisabled() { return this.busy || this.uncertain || !!this.error || !this.operations?.parked },
|
||||
branchOptions() {
|
||||
const available = this.operations?.availableBranches || []
|
||||
return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, ...available].filter(branch => branch && branch !== "other:"))]
|
||||
return [...new Set([this.operations?.selectedTarget, this.data?.installed.branch, this.previousVersion?.branch, ...available].filter(branch => branch && branch !== "other:"))]
|
||||
.map(name => ({ name, available: available.includes(name) || name === this.data?.installed.branch }))
|
||||
},
|
||||
primaryBranchHelp() { return PRIMARY_BRANCH_HELP[this.primaryChoice] || "" },
|
||||
otherBranches() {
|
||||
const available = this.operations?.availableBranches || []
|
||||
const selected = this.operations?.selectedTarget
|
||||
const installed = this.data?.installed?.branch
|
||||
return [...new Set([selected, installed, ...available].filter(branch => branch && branch !== "other:" && !PRIMARY_BRANCHES.includes(branch)))]
|
||||
.map(name => ({ name, listed: available.includes(name), current: name === installed }))
|
||||
otherBranches() { return this.branchOptions.filter(branch => !['StarPilot', 'Dom'].includes(branch.name)) },
|
||||
hasInstalledVersion() {
|
||||
const recent = this.operations?.history?.recent
|
||||
return this.draftBranch === this.data?.installed.branch ||
|
||||
recent?.branch === this.draftBranch && recent.entries.some(entry => entry.hash === this.data?.installed.commit)
|
||||
},
|
||||
listedPreviousVersion() {
|
||||
const previous = this.previousVersion
|
||||
if (!previous) return null
|
||||
const history = this.operations?.history
|
||||
const recent = history?.recent?.branch === this.draftBranch ? history.recent.entries : []
|
||||
const known = recent.some(entry => entry.hash === previous.hash) ||
|
||||
this.hasInstalledVersion && history.installed.some(entry => entry.hash === previous.hash)
|
||||
const locallySelected = this.version === previous.hash && this.draftBranch === (previous.branch || this.data?.installed.branch)
|
||||
return known || locallySelected ? previous : null
|
||||
},
|
||||
canStageBranch() { return !!this.draftBranch && this.draftBranch !== "other:" && this.operations?.availableBranches.includes(this.draftBranch) && this.draftBranch !== this.operations.selectedTarget },
|
||||
branchChanging() { return !!this.draftBranch && this.draftBranch !== this.data?.installed.branch },
|
||||
versionEntries() {
|
||||
const history = this.operations?.history
|
||||
return this.historyKind === "recent" ? (history?.recent?.branch === this.draftBranch ? history.recent.entries : []) : history?.[this.historyKind] || []
|
||||
if (!history) return []
|
||||
const rows = new Map()
|
||||
const recent = history.recent?.branch === this.draftBranch ? history.recent.entries : []
|
||||
const downloaded = this.data.updater.finalizedUpdateReady && this.data.updater.targetBranch === this.draftBranch && this.operations.selectedTarget === this.draftBranch ? history.downloaded.slice(0, 1) : []
|
||||
const installed = this.hasInstalledVersion ? history.installed : []
|
||||
for (const entry of [...recent, ...downloaded, ...installed, ...(this.listedPreviousVersion ? [this.listedPreviousVersion] : [])]) rows.set(entry.hash, entry)
|
||||
const current = this.data.installed.commit
|
||||
if (this.hasInstalledVersion && current && !rows.has(current)) rows.set(current, { hash: current, date: "", subject: "Installed version" })
|
||||
return [...rows.values()]
|
||||
},
|
||||
newerVersions() {
|
||||
const index = this.versionEntries.findIndex(entry => entry.hash === this.data?.installed.commit)
|
||||
return (index < 0 ? this.versionEntries : this.versionEntries.slice(0, index))
|
||||
.filter(entry => entry.hash !== this.listedPreviousVersion?.hash)
|
||||
},
|
||||
olderVersions() {
|
||||
const index = this.versionEntries.findIndex(entry => entry.hash === this.data?.installed.commit)
|
||||
return index < 0 ? [] : this.versionEntries.slice(index + 1)
|
||||
},
|
||||
previousVersion() {
|
||||
const previous = this.operations?.history?.previous
|
||||
return previous && previous.hash !== this.data?.installed.commit ? previous : null
|
||||
},
|
||||
beforePrevious() {
|
||||
const index = this.olderVersions.findIndex(entry => entry.hash === this.listedPreviousVersion?.hash)
|
||||
return this.listedPreviousVersion && index >= 0 ? this.olderVersions.slice(0, index) : []
|
||||
},
|
||||
afterPrevious() {
|
||||
const index = this.olderVersions.findIndex(entry => entry.hash === this.listedPreviousVersion?.hash)
|
||||
return this.listedPreviousVersion && index >= 0 ? this.olderVersions.slice(index + 1) : this.olderVersions
|
||||
},
|
||||
visibleVersions() {
|
||||
const installed = this.versionEntries.find(entry => entry.hash === this.data?.installed.commit)
|
||||
const previous = this.listedPreviousVersion
|
||||
return [...this.newerVersions.slice(0, this.newerVersionLimit), ...(installed ? [installed] : []),
|
||||
...(previous ? [...this.beforePrevious.slice(0, this.collapsedOlderLimit), previous] : []),
|
||||
...this.afterPrevious.slice(0, Math.max(0, this.olderVersionLimit - (previous ? 1 : 0)))]
|
||||
},
|
||||
hiddenNewerCount() { return Math.max(0, this.newerVersions.length - this.newerVersionLimit) },
|
||||
hiddenCollapsedOlderCount() { return Math.max(0, this.beforePrevious.length - this.collapsedOlderLimit) },
|
||||
hiddenOlderCount() { return Math.max(0, this.afterPrevious.length - Math.max(0, this.olderVersionLimit - (this.listedPreviousVersion ? 1 : 0))) },
|
||||
downloadedVersions() {
|
||||
return new Set([...(this.operations?.history?.downloadedCommits || []), this.downloadedCommit].filter(Boolean))
|
||||
},
|
||||
selectedVersion() { return this.versionEntries.find(entry => entry.hash === (this.version || this.data?.installed.commit)) },
|
||||
downloadedCommit() {
|
||||
return this.data?.updater.finalizedUpdateReady && this.data.updater.targetBranch === this.draftBranch && this.draftBranch === this.operations?.selectedTarget ?
|
||||
this.operations?.history?.downloaded?.[0]?.hash : null
|
||||
},
|
||||
selectedVersionAction() {
|
||||
if (!this.selectedVersion || this.selectedVersion.hash === this.data?.installed.commit) return "No changes selected"
|
||||
if (this.selectedVersion.hash === this.previousVersion?.hash) return "Install selected version"
|
||||
return this.downloadedVersions.has(this.selectedVersion.hash) ? "Install selected version" : "Download and install selected version"
|
||||
},
|
||||
canInstallSelection() {
|
||||
if (this.actionDisabled || !this.selectedVersion || this.selectedVersion.hash === this.data?.installed.commit) return false
|
||||
if (this.selectedVersion.hash === this.previousVersion?.hash) return this.operations?.canRollback === true
|
||||
if (this.selectedVersion.hash === this.downloadedCommit) return this.operations?.canInstall === true
|
||||
return this.operations?.canFastUpdate === true && this.operations.history?.recent?.branch === this.draftBranch &&
|
||||
[...this.operations.history.recent.entries, ...(this.draftBranch === this.data.installed.branch ? this.operations.history.installed : [])]
|
||||
.some(entry => entry.hash === this.selectedVersion.hash)
|
||||
},
|
||||
selectionNeedsInstall() {
|
||||
return this.canInstallSelection && this.data?.updater.state !== 'updating...' && !this.feed?.installBaseline
|
||||
},
|
||||
canDownloadSelection() {
|
||||
return !this.actionDisabled && this.operations?.canFastUpdate === true && !!this.selectedVersion &&
|
||||
this.selectedVersion.hash !== this.data?.installed.commit && !this.downloadedVersions.has(this.selectedVersion.hash) &&
|
||||
this.operations.history?.recent?.branch === this.draftBranch &&
|
||||
[...this.operations.history.recent.entries, ...(this.draftBranch === this.data.installed.branch ? this.operations.history.installed : [])]
|
||||
.some(entry => entry.hash === this.selectedVersion.hash)
|
||||
},
|
||||
selectedReleaseNotes() {
|
||||
if (this.selectedVersion?.hash === this.data?.installed.commit) return this.operations?.history?.currentReleaseNotes
|
||||
if (this.selectedVersion?.hash === this.downloadedCommit) return this.operations?.history?.downloadedReleaseNotes
|
||||
return null
|
||||
},
|
||||
commitsUrl() { return this.data?.installed.branch ? `https://github.com/StarPilotHQ/StarPilot/commits/${encodeURIComponent(this.data.installed.branch)}/` : "" },
|
||||
},
|
||||
methods: {
|
||||
switchRow,
|
||||
configureAutomaticInstalls(enabled) {
|
||||
return this.feed.configureInstallPreferences(enabled, this.operations.restartSchedule || '0 0 * * *', this.operations.restartTimezone || this.restartTimezone)
|
||||
},
|
||||
async saveRestartSchedule() {
|
||||
const result = await this.feed.configureInstallPreferences(this.operations.automaticInstalls === true, this.restartCronFields.join(' '), this.restartTimezone)
|
||||
if (result) {
|
||||
this.scheduleTouched = false
|
||||
this.restartCronFields = result.operations.restartSchedule.split(' ')
|
||||
this.restartTimezone = result.operations.restartTimezone
|
||||
}
|
||||
},
|
||||
syncDraftBranch(branch) {
|
||||
this.draftBranch = this.operations?.availableBranches.includes(branch) || branch === this.data?.installed.branch ? branch : this.data?.installed.branch || ""
|
||||
this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : ""
|
||||
this.draftBranch = branch || this.data?.installed.branch || ""
|
||||
this.primaryChoice = ['StarPilot', 'Dom'].includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? 'other:' : ''
|
||||
},
|
||||
changeBranch() { this.draftTouched = true; this.version = ""; this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" },
|
||||
onPrimaryBranchChange() {
|
||||
this.draftTouched = true
|
||||
if (this.primaryChoice === 'other:') {
|
||||
if (['StarPilot', 'Dom'].includes(this.draftBranch)) this.draftBranch = ''
|
||||
return
|
||||
}
|
||||
this.draftBranch = this.primaryChoice
|
||||
return this.changeBranch()
|
||||
},
|
||||
async changeBranch() {
|
||||
this.primaryChoice = ['StarPilot', 'Dom'].includes(this.draftBranch) ? this.draftBranch : 'other:'
|
||||
this.draftTouched = true
|
||||
this.version = ""
|
||||
if (this.primaryChoice === "other:") {
|
||||
if (!this.otherBranches.some(option => option.name === this.draftBranch)) {
|
||||
const selected = this.operations?.selectedTarget
|
||||
this.draftBranch = this.otherBranches.some(option => option.name === selected) ? selected : ""
|
||||
}
|
||||
} else this.draftBranch = this.primaryChoice
|
||||
this.newerVersionLimit = 2; this.olderVersionLimit = 1; this.collapsedOlderLimit = 0
|
||||
return this.loadVersions()
|
||||
},
|
||||
selectPreviousVersion() {
|
||||
if (this.actionDisabled || !this.previousVersion) return
|
||||
const previous = this.previousVersion
|
||||
this.draftTouched = true
|
||||
this.syncDraftBranch(previous.branch || this.data.installed.branch)
|
||||
this.version = previous.hash
|
||||
this.newerVersionLimit = 2; this.olderVersionLimit = 1; this.collapsedOlderLimit = 0
|
||||
},
|
||||
downloadSelectedVersion() {
|
||||
if (!this.canDownloadSelection) return
|
||||
return this.feed.action('download-version', this.draftBranch,
|
||||
{ expectedCommit: this.data.installed.commit, selectedCommit: this.selectedVersion.hash })
|
||||
},
|
||||
onOtherBranchChange() { this.draftTouched = true; this.version = "" },
|
||||
branchLabel(branch) { return PRIMARY_BRANCH_LABELS[branch] || branch },
|
||||
primaryBranchHelpFor(branch) { return PRIMARY_BRANCH_HELP[branch] || "" },
|
||||
versionLabel(entry) { return `${entry.subject.trim() || this.shortCommit(entry.hash)} · ${entry.date ? new Date(entry.date).toLocaleDateString() : this.shortCommit(entry.hash)}` },
|
||||
shown(value) { return value ?? "Unavailable" },
|
||||
reported(value) { return value ? new Date(value).toLocaleString() : "Unavailable" },
|
||||
shortCommit(value) { return value ? value.slice(0, 12) : "Unavailable" },
|
||||
shortCommit(value) { return value ? value.slice(0, 8) : "Unavailable" },
|
||||
requestMessage(request) {
|
||||
if (!request) return ""
|
||||
if (request.state === "failed") return request.error || "Update request failed. Refresh and try again."
|
||||
if (["update", "fast", "rollback", "version"].includes(request.action)) return request.state === "pending" ? `Updating ${request.target}…` :
|
||||
request.outcome === "up_to_date" ? "Installed branch is already up to date. No restart needed." :
|
||||
"Update installed. Waiting to reconnect and verify the installed build."
|
||||
if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : "Recent versions loaded."
|
||||
if (request.action === "download-version") return request.state === "pending" ? "Downloading selected version…" : "Downloaded · Ready to install"
|
||||
if (request.action === "versions") return request.state === "pending" ? "Loading recent versions…" : ""
|
||||
if (request.action === "install") return "Restart requested. Waiting to reconnect and verify the installed build."
|
||||
if (request.state === "pending") return request.action === "check" ? "Checking for updates…" : "Downloading and preparing update…"
|
||||
return request.action === "check" ? "Update check finished." : request.action === "download" ? "Download finished." : "Target branch saved."
|
||||
},
|
||||
chooseBranch() {
|
||||
if (!this.operations?.canSelect || this.actionDisabled || !this.canStageBranch) return
|
||||
this.dialog = { action: "select", branch: this.draftBranch, title: "Change target branch",
|
||||
message: `Set ${this.draftBranch} as the target branch? This only stages the choice. Check for updates and download separately.`, label: "Set target branch" }
|
||||
return request.action === "check" ? "Update check finished." : ["download", "download-version"].includes(request.action) ? "Downloaded · Ready to install" : "Target branch saved."
|
||||
},
|
||||
askUpdate() {
|
||||
const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch
|
||||
@@ -473,18 +634,19 @@ export const SoftwarePage = {
|
||||
},
|
||||
async loadVersions() {
|
||||
const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch
|
||||
if (this.actionDisabled || !branch) return
|
||||
if (branch !== this.operations.selectedTarget) {
|
||||
const result = await this.feed.action("select", branch)
|
||||
if (!result) return
|
||||
}
|
||||
if (this.metadataActionDisabled || !branch) return
|
||||
this.feed.includeHistory = true
|
||||
return this.feed.action("versions", branch)
|
||||
},
|
||||
askVersion(entry) {
|
||||
const branch = this.operations?.history?.recent?.branch
|
||||
if (!entry || this.actionDisabled || this.operations?.canFastUpdate !== true || !branch) return
|
||||
this.dialog = { action: "version", branch, selectedCommit: entry.hash, expectedCommit: this.data.installed.commit,
|
||||
title: "Install recent version", message: `Install ${entry.subject} (${this.shortCommit(entry.hash)}) on ${branch} and restart?`, label: "Install" }
|
||||
askSelectedVersion() {
|
||||
if (!this.canInstallSelection) return
|
||||
const entry = this.selectedVersion
|
||||
if (entry.hash === this.listedPreviousVersion?.hash) return this.askRollback()
|
||||
const downloaded = entry.hash === this.downloadedCommit
|
||||
this.dialog = { action: downloaded ? "install" : "version", branch: this.draftBranch,
|
||||
selectedCommit: entry.hash, expectedCommit: this.data.installed.commit,
|
||||
title: this.selectedVersionAction, label: this.selectedVersionAction,
|
||||
message: `${downloaded ? "Install" : "Download and install"} ${entry.subject} (${this.shortCommit(entry.hash)}) and restart the device?` }
|
||||
},
|
||||
askRollback() {
|
||||
const branch = this.data?.installed?.branch
|
||||
@@ -533,62 +695,103 @@ export const SoftwarePage = {
|
||||
</div><p v-if="updateProgress.detail" class="gx-note">{{ updateProgress.detail }}</p>
|
||||
</div>
|
||||
<div v-if="!initialChecking && !updateProgress" class="gx-actions">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="metadataActionDisabled || !operations?.canCheck" @click="feed.action('check')">Check for updates</button>
|
||||
<button v-if="updateAvailable || branchChanging" type="button" class="gx-btn" :disabled="actionDisabled || !canUpdate" @click="askUpdate">Update</button>
|
||||
</div>
|
||||
<div v-if="backgroundProgress" class="gx-update-progress" role="status" aria-live="polite">
|
||||
<div class="gx-update-progress__heading"><span>Background download · {{ backgroundProgress.label }}</span><span v-if="backgroundProgress.percent !== null">{{ Math.round(backgroundProgress.percent) }}%</span></div>
|
||||
<div class="gx-update-progress__track" role="progressbar" aria-label="Background update download" aria-valuemin="0" aria-valuemax="100" :aria-valuenow="backgroundProgress.percent ?? undefined">
|
||||
<div class="gx-update-progress__fill" :class="{'gx-update-progress__fill--indeterminate': backgroundProgress.percent === null}" :style="backgroundProgress.percent !== null ? {width: backgroundProgress.percent + '%'} : {}"></div>
|
||||
</div><p v-if="backgroundProgress.detail" class="gx-note">{{ backgroundProgress.detail }}</p>
|
||||
</div>
|
||||
<p v-if="data?.updater.lastCheckedAt || data?.updater.lastFetchAt" class="gx-note">
|
||||
<span v-if="data.updater.lastCheckedAt">Checked {{ reported(data.updater.lastCheckedAt) }}</span>
|
||||
<span v-if="data.updater.lastFetchAt"> · Downloaded {{ reported(data.updater.lastFetchAt) }}</span>
|
||||
</p>
|
||||
<p v-if="operations?.preparedCommit && operations.preparedCommit !== data?.installed.commit" class="gx-note">
|
||||
<a v-if="repositoryUrl" class="gx-link" :href="repositoryUrl + '/commit/' + operations.preparedCommit" target="_blank" rel="noopener noreferrer" title="View commit on GitHub">{{ shortCommit(operations.preparedCommit) }}</a><span v-else>{{ shortCommit(operations.preparedCommit) }}</span> is ready for install. Click Update when ready.
|
||||
<template v-if="operations.automaticInstalls && operations.nextRestartAt">Auto install will run at {{ reported(operations.nextRestartAt) }} while offroad.</template>
|
||||
</p>
|
||||
<GalaxySettingRow v-if="operations?.automaticDownloads !== undefined" class="gx-row--borderless"
|
||||
:row="switchRow('Download updates automatically', operations.automaticDownloads, 'Prepare updates while parked; restart when ready.')"
|
||||
:index="0" :busy="busy" :disabled="uncertain || !!error || !operations.canConfigure"
|
||||
:save-value="(index, value) => feed.configureAutomaticDownloads(value === 'On')" />
|
||||
<p v-if="operations?.automaticDownloads === null" class="gx-note">The download preference could not be read. Choose a setting while parked to repair it.</p>
|
||||
<details v-if="data" class="gx-software-options"><summary>Advanced options</summary><div class="gx-stack"><DriveStatePanel offroad-only />
|
||||
<div class="gx-software-branch"><label class="gx-field-group gx-grow"><span class="gx-row__label">Branch</span>
|
||||
<GalaxySelect v-model="primaryChoice" class="gx-field gx-field--full" aria-label="Target branch" :disabled="actionUnavailable || !operations.canSelect" @change="onPrimaryBranchChange">
|
||||
<option value="" disabled>Choose a branch</option>
|
||||
<GalaxySettingRow v-if="operations?.automaticInstalls !== undefined" class="gx-row--borderless"
|
||||
:row="switchRow('Install updates automatically', operations.automaticInstalls, 'Restart on the configured schedule, only while offroad.')"
|
||||
:index="1" :busy="busy" :disabled="uncertain || !!error || !operations.canConfigure"
|
||||
:save-value="(index, value) => configureAutomaticInstalls(value === 'On')" />
|
||||
<p v-if="operations?.automaticInstalls === null" class="gx-note">Install preferences are unreadable. Save the schedule to repair them.</p>
|
||||
<details v-if="data" class="gx-software-options" @toggle="loadHistory($event)"><summary>Advanced options</summary><div class="gx-stack">
|
||||
<DriveStatePanel offroad-only :lockout="operations?.parked === false" />
|
||||
<label class="gx-field-group"><span class="gx-row__label">Branch</span>
|
||||
<GalaxySelect show-description v-model="primaryChoice" class="gx-field gx-field--full" aria-label="Version branch" :disabled="metadataActionDisabled || !operations.canCheck" @change="onPrimaryBranchChange">
|
||||
<option value="StarPilot" :data-description="primaryBranchHelpFor('StarPilot')">Stable — StarPilot</option>
|
||||
<option value="Dom" :data-description="primaryBranchHelpFor('Dom')">Development — Dom</option>
|
||||
<option value="other:">Other branches…</option>
|
||||
</GalaxySelect>
|
||||
</label></div>
|
||||
<p v-if="primaryBranchHelp" class="gx-note">{{ primaryBranchHelp }}</p>
|
||||
<div v-if="primaryChoice === 'other:'" class="gx-software-other-branches">
|
||||
<label for="gx-other-branch" class="gx-row__label">Other branches</label>
|
||||
<p class="gx-note">Additional branches from this installation's repository.</p>
|
||||
<GalaxySelect id="gx-other-branch" v-model="draftBranch" class="gx-field gx-field--full" aria-label="Other branches" :disabled="actionUnavailable || !operations.canSelect" @change="onOtherBranchChange">
|
||||
</label>
|
||||
<label v-if="primaryChoice === 'other:'" class="gx-field-group"><span class="gx-row__label">Other branches</span>
|
||||
<GalaxySelect v-model="draftBranch" class="gx-field gx-field--full" aria-label="Other branches" :disabled="metadataActionDisabled || !operations.canCheck" @change="changeBranch">
|
||||
<option value="" disabled>{{ otherBranches.length ? 'Select another branch' : 'No other branches available' }}</option>
|
||||
<option v-for="branch in otherBranches" :key="branch.name" :value="branch.name" :disabled="!branch.listed">{{ branch.name }}{{ branch.current ? ' (current)' : '' }}{{ !branch.listed && !branch.current ? ' (unavailable)' : '' }}</option>
|
||||
<option v-for="branch in otherBranches" :key="branch.name" :value="branch.name">{{ branch.name }}</option>
|
||||
</GalaxySelect>
|
||||
</label>
|
||||
<div class="gx-actions gx-version-actions">
|
||||
<button type="button" class="gx-btn" :class="{'gx-version-action--pending': selectionNeedsInstall}" :disabled="!canInstallSelection" @click="askSelectedVersion">{{ selectedVersionAction }}</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="metadataActionDisabled || !operations.canCheck" @click="loadVersions">Refresh</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="!canDownloadSelection" @click="downloadSelectedVersion">Download only</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionDisabled || !previousVersion" @click="selectPreviousVersion">Select previous version</button>
|
||||
</div>
|
||||
<p v-if="draftBranch && !operations.availableBranches.includes(draftBranch)" class="gx-note">This branch is not in the updater's available list and cannot be selected yet.</p>
|
||||
<p v-else-if="!operations.availableBranches.length" class="gx-note">No branch list is available yet. Check for updates to refresh it.</p>
|
||||
<p v-if="branchChanging" class="gx-note">Update installs {{ draftBranch }} and restarts the device.</p>
|
||||
<div class="gx-actions">
|
||||
<button v-if="canStageBranch" type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canSelect" @click="chooseBranch">Save target</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canDownload || !operations.selectedTarget || draftBranch !== operations.selectedTarget" @click="feed.action('download', operations.selectedTarget)">Download only</button>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || operations.canRollback !== true" @click="askRollback">Previous version</button>
|
||||
<div v-if="visibleVersions.length" class="gx-version-scroll" role="region" aria-label="Versions" tabindex="0">
|
||||
<ol class="gx-version-list">
|
||||
<template v-for="entry in visibleVersions" :key="entry.hash">
|
||||
<li v-if="hiddenNewerCount && entry.hash === data.installed.commit" class="gx-version-gap">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="newerVersionLimit+=10">Load {{ Math.min(10, hiddenNewerCount) }}{{ hiddenNewerCount > 10 ? ' of ' + hiddenNewerCount : '' }} collapsed {{ hiddenNewerCount === 1 ? 'version' : 'versions' }}</button>
|
||||
</li>
|
||||
<li v-if="hiddenCollapsedOlderCount && entry.hash === listedPreviousVersion?.hash" class="gx-version-gap">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="collapsedOlderLimit+=Math.min(10, hiddenCollapsedOlderCount)">Load {{ Math.min(10, hiddenCollapsedOlderCount) }}{{ hiddenCollapsedOlderCount > 10 ? ' of ' + hiddenCollapsedOlderCount : '' }} collapsed {{ hiddenCollapsedOlderCount === 1 ? 'version' : 'versions' }}</button>
|
||||
</li>
|
||||
<li>
|
||||
<label class="gx-version-row" :class="{ 'gx-version-row--installed': entry.hash === data.installed.commit, 'gx-version-row--selected': entry.hash === (version || data.installed.commit) }">
|
||||
<input type="radio" name="software-version" :value="entry.hash" :checked="entry.hash === (version || data.installed.commit)" @change="version=entry.hash" :disabled="actionDisabled" />
|
||||
<span class="gx-version-content">
|
||||
<strong>{{ entry.subject.trim() || shortCommit(entry.hash) }}</strong>
|
||||
<small class="gx-version-meta">{{ entry.date ? reported(entry.date) + ' · ' : '' }}<a v-if="repositoryUrl" class="gx-link" :href="repositoryUrl + '/commit/' + entry.hash" target="_blank" rel="noopener noreferrer" title="View commit on GitHub" @click.stop>{{ shortCommit(entry.hash) }}</a><span v-else>{{ shortCommit(entry.hash) }}</span><span v-if="entry.hash === data.installed.commit && branchChanging"> · {{ data.installed.branch }}</span><span v-if="entry.hash === data.installed.commit" class="gx-version-badge gx-version-badge--installed">Installed</span><span v-else-if="entry.hash === previousVersion?.hash" class="gx-version-badge gx-version-badge--downloaded">Previous version</span><span v-else class="gx-version-badge" :class="{ 'gx-version-badge--downloaded': downloadedVersions.has(entry.hash) }">{{ downloadedVersions.has(entry.hash) ? 'Downloaded' : 'Not downloaded' }}</span></small>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!hasInstalledVersion && hiddenNewerCount" class="gx-version-gap">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="newerVersionLimit+=10">Load {{ Math.min(10, hiddenNewerCount) }} more older {{ hiddenNewerCount === 1 ? 'version' : 'versions' }}</button>
|
||||
</li>
|
||||
<li v-if="hiddenOlderCount" class="gx-version-gap">
|
||||
<button type="button" class="gx-btn gx-btn--tonal" @click="olderVersionLimit+=10">Load {{ Math.min(10, hiddenOlderCount) }} more older {{ hiddenOlderCount === 1 ? 'version' : 'versions' }}</button>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<p class="gx-note">Download only prepares an update without restarting. Previous version restores your last installed build.</p>
|
||||
</div></details>
|
||||
<details v-if="data" class="gx-software-options" @toggle="loadHistory($event)"><summary>Versions & release notes</summary><div class="gx-stack">
|
||||
<div class="gx-tabs gx-actions" aria-label="Version source">
|
||||
<button v-for="source in [{key:'recent',label:'Available'},{key:'installed',label:'Installed'}, ...(operations.history?.downloaded.length ? [{key:'downloaded',label:'Downloaded'}] : [])]" :key="source.key" type="button" class="gx-btn gx-btn--tonal" :aria-pressed="historyKind === source.key" @click="historyKind=source.key; version=''">{{ source.label }}</button>
|
||||
</div>
|
||||
<template v-if="historyKind === 'recent'">
|
||||
<div class="gx-actions">
|
||||
<GalaxySelect v-if="versionEntries.length" v-model="version" class="gx-field gx-filter-field" aria-label="Version"><option value="" disabled>Choose a version</option>
|
||||
<option v-for="entry in versionEntries" :key="entry.hash" :value="entry.hash">{{ versionLabel(entry) }}</option></GalaxySelect>
|
||||
<button type="button" class="gx-btn gx-btn--tonal" :disabled="actionUnavailable || !operations.canFastUpdate" @click="loadVersions">Load versions</button>
|
||||
<button v-if="versionEntries.length" type="button" class="gx-btn" :disabled="actionUnavailable || !version || version === data.installed.commit || !operations.canFastUpdate" @click="askVersion(versionEntries.find(entry => entry.hash === version))">Install version</button>
|
||||
</div><p class="gx-note">Up to 20 compatible versions of {{ draftBranch }}.</p>
|
||||
</template>
|
||||
<ul v-else-if="versionEntries.length" class="gx-build-history" aria-label="Build history"><li v-for="entry in versionEntries" :key="entry.hash"><strong>{{ entry.subject.trim() || shortCommit(entry.hash) }}</strong><small>{{ reported(entry.date) }} · {{ shortCommit(entry.hash) }}</small></li></ul>
|
||||
<p v-else class="gx-note">{{ operations.history ? 'No versions saved.' : 'Reading history…' }}</p>
|
||||
<pre v-if="historyKind === 'installed' && operations.history?.currentReleaseNotes" class="gx-release-notes">{{ operations.history.currentReleaseNotes }}</pre>
|
||||
<pre v-if="historyKind === 'downloaded' && operations.history?.downloadedReleaseNotes" class="gx-release-notes">{{ operations.history.downloadedReleaseNotes }}</pre>
|
||||
<div v-if="selectedReleaseNotes?.trim()"><h4>Release notes</h4><pre class="gx-release-notes">{{ selectedReleaseNotes }}</pre></div>
|
||||
<p v-else-if="selectedVersion" class="gx-note">Release notes are not available for this version.</p>
|
||||
<form v-if="operations?.restartSchedule !== undefined" class="gx-stack" @submit.prevent="saveRestartSchedule">
|
||||
<h4>Automatic restart schedule</h4>
|
||||
<div class="gx-restart-cron">
|
||||
<label v-for="(label, index) in ['Minute (0–59)', 'Hour (0–23)', 'Day (1–31)', 'Month (1–12)', 'Weekday (0–7)']" :key="index" class="gx-field-group">
|
||||
<span class="gx-row__label">{{ label }}</span>
|
||||
<input v-model="restartCronFields[index]" class="gx-field gx-field--full" type="text" maxlength="24" spellcheck="false"
|
||||
:aria-label="label" :disabled="busy || uncertain || !operations.canConfigure" @input="scheduleTouched=true" />
|
||||
</label>
|
||||
<label class="gx-field-group">
|
||||
<span class="gx-row__label">Timezone</span>
|
||||
<GalaxySelect v-model="restartTimezone" class="gx-field gx-field--full" aria-label="Schedule timezone"
|
||||
:disabled="busy || uncertain || !operations.canConfigure" @change="scheduleTouched=true">
|
||||
<option v-for="zone in restartTimezones" :key="zone" :value="zone">{{ zone }}</option>
|
||||
</GalaxySelect>
|
||||
</label>
|
||||
</div>
|
||||
<p class="gx-note">Cron: {{ restartCronFields.join(' ') }} · Schedule timezone: {{ restartTimezone }}.<br />Default: daily at local midnight. Uses the selected timezone, including daylight saving time. Use * for every value; ranges, commas and steps are supported. Weekday 0 or 7 is Sunday. Schedule is skipped if onroad.</p>
|
||||
<button type="submit" class="gx-btn gx-btn--tonal" :disabled="busy || uncertain || !operations.canConfigure || !restartScheduleChanged">Save restart schedule</button>
|
||||
</form>
|
||||
</div></details>
|
||||
</section>
|
||||
<ToggleBackup :unauthorized="unauthorized" />
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Opt-in, offroad-only restarts on a numeric five-field cron schedule."""
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import uuid
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from openpilot.starpilot.state_migration import _atomic_write, _private_dir
|
||||
|
||||
DEFAULT_CRON = '0 0 * * *'
|
||||
_LIMITS = ((0, 59), (0, 23), (1, 31), (1, 12), (0, 7))
|
||||
|
||||
|
||||
def cron_fields(expression: str) -> list[set[int]]:
|
||||
if not isinstance(expression, str) or len(expression) > 128 or len(expression.split()) != 5:
|
||||
raise ValueError('Use five cron fields: minute hour day month weekday')
|
||||
fields = []
|
||||
for field, (low, high) in zip(expression.split(), _LIMITS, strict=True):
|
||||
values = set()
|
||||
for item in field.split(','):
|
||||
parts = item.split('/')
|
||||
if len(parts) > 2:
|
||||
raise ValueError('Invalid cron step')
|
||||
step = int(parts[1]) if len(parts) == 2 else 1
|
||||
base = parts[0]
|
||||
if base == '*':
|
||||
start, end = low, high
|
||||
elif '-' in base:
|
||||
start, end = map(int, base.split('-'))
|
||||
else:
|
||||
start = int(base)
|
||||
end = high if len(parts) == 2 else start
|
||||
if not low <= start <= end <= high or not 1 <= step <= high - low + 1:
|
||||
raise ValueError('Cron field is outside its allowed range')
|
||||
values.update(range(start, end + 1, step))
|
||||
fields.append(values)
|
||||
return fields
|
||||
|
||||
|
||||
def cron_matches(expression: str, now: datetime) -> bool:
|
||||
minute, hour, day, month, weekday = cron_fields(expression)
|
||||
dow = (now.weekday() + 1) % 7
|
||||
day_matches, weekday_matches = now.day in day, dow in weekday or dow == 0 and 7 in weekday
|
||||
parts = expression.split()
|
||||
restricted = not parts[2].startswith('*') and not parts[4].startswith('*')
|
||||
calendar_matches = (day_matches or weekday_matches) if restricted else day_matches and weekday_matches
|
||||
return now.minute in minute and now.hour in hour and now.month in month and calendar_matches
|
||||
|
||||
|
||||
def next_restart(expression: str, now: datetime) -> datetime | None:
|
||||
minutes, hours, days, months, weekdays = cron_fields(expression)
|
||||
restricted = not expression.split()[2].startswith('*') and not expression.split()[4].startswith('*')
|
||||
# Eight years includes the longest gap between leap days.
|
||||
for offset in range(366 * 8):
|
||||
day = now + timedelta(days=offset)
|
||||
dow = (day.weekday() + 1) % 7
|
||||
day_matches = day.day in days
|
||||
weekday_matches = dow in weekdays or dow == 0 and 7 in weekdays
|
||||
if day.month not in months or not ((day_matches or weekday_matches) if restricted else day_matches and weekday_matches):
|
||||
continue
|
||||
for hour in sorted(hours):
|
||||
for minute in sorted(minutes):
|
||||
candidate = day.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if candidate > now and candidate.astimezone(UTC).astimezone(now.tzinfo) == candidate:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
class InstallSettings:
|
||||
def __init__(self, root: Path | None = None):
|
||||
self._root = Path(root) if root is not None else None
|
||||
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
if self._root is None:
|
||||
from openpilot.starpilot.storage import starpilot_storage_root
|
||||
self._root = starpilot_storage_root() / 'software' / 'auto-install'
|
||||
return self._root
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self.root / 'settings.json'
|
||||
|
||||
def read(self) -> dict | None:
|
||||
try:
|
||||
fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK | os.O_NOFOLLOW)
|
||||
with os.fdopen(fd, 'rb') as source:
|
||||
if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
|
||||
return None
|
||||
raw = source.read(4097)
|
||||
if len(raw) > 4096:
|
||||
return None
|
||||
value = json.loads(raw)
|
||||
keys = {'version', 'enabled', 'cron', 'revision', 'lastAttempt'}
|
||||
if (not isinstance(value, dict) or set(value) not in (keys, keys | {'timezone'}) or
|
||||
type(value['version']) is not int or value['version'] != 1 or type(value['enabled']) is not bool or not isinstance(value['revision'], str) or
|
||||
not re.fullmatch('[0-9a-f]{32}', value['revision']) or
|
||||
value['lastAttempt'] is not None and (not isinstance(value['lastAttempt'], str) or len(value['lastAttempt']) > 64)):
|
||||
return None
|
||||
ZoneInfo(value.get('timezone', 'UTC'))
|
||||
cron_fields(value['cron'])
|
||||
return value
|
||||
except FileNotFoundError:
|
||||
return {'version': 1, 'enabled': False, 'cron': DEFAULT_CRON, 'revision': '0' * 32, 'lastAttempt': None}
|
||||
except (OSError, ValueError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
@contextmanager
|
||||
def locked(self):
|
||||
_private_dir(self.root)
|
||||
fd = os.open(self.root / '.lock', os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def write(self, value: dict) -> None:
|
||||
_atomic_write(self.path, json.dumps(value, sort_keys=True).encode())
|
||||
|
||||
def save(self, enabled: bool, expression: str, expected_revision: str | None, admitted, timezone: str | None = None) -> dict:
|
||||
if type(enabled) is not bool:
|
||||
raise ValueError('Choose whether to install updates automatically')
|
||||
cron_fields(expression)
|
||||
if timezone is not None:
|
||||
ZoneInfo(timezone)
|
||||
with self.locked():
|
||||
current = self.read()
|
||||
if (current['revision'] if current else None) != expected_revision:
|
||||
raise ValueError('Install preferences changed; refresh before saving')
|
||||
if not admitted():
|
||||
raise PermissionError('Park the vehicle before changing install preferences')
|
||||
value = {'version': 1, 'enabled': enabled, 'cron': ' '.join(expression.split()), 'revision': uuid.uuid4().hex,
|
||||
'lastAttempt': current['lastAttempt'] if current else None}
|
||||
value['timezone'] = timezone or (current.get('timezone', 'UTC') if current else 'UTC')
|
||||
self.write(value)
|
||||
if self.read() != value:
|
||||
raise OSError('Install preferences could not be verified')
|
||||
return value
|
||||
|
||||
|
||||
def scheduled_restart(params, settings: InstallSettings, ready, parked, now: datetime) -> bool:
|
||||
value = settings.read()
|
||||
if value:
|
||||
now = now.astimezone(ZoneInfo(value.get('timezone', 'UTC')))
|
||||
if not value or not value['enabled'] or not cron_matches(value['cron'], now):
|
||||
return False
|
||||
occurrence = now.strftime('%Y-%m-%dT%H:%M%z')
|
||||
|
||||
def admitted():
|
||||
return (params.get_bool('IsOffroad') and not params.get_bool('DisableUpdates') and
|
||||
not params.get_bool('DoReboot') and ready() and parked())
|
||||
|
||||
with settings.locked():
|
||||
current = settings.read()
|
||||
if current is None or current != value or current['lastAttempt'] == occurrence or not admitted():
|
||||
return False
|
||||
# Reserve the occurrence before rebooting so a failed restart cannot loop within the same minute.
|
||||
settings.write({**current, 'lastAttempt': occurrence})
|
||||
if not admitted():
|
||||
return False
|
||||
params.put_bool('DoReboot', True, block=True)
|
||||
return True
|
||||
@@ -5,8 +5,10 @@ import subprocess
|
||||
import time
|
||||
import selectors
|
||||
import signal
|
||||
import tempfile
|
||||
|
||||
from openpilot.starpilot.galaxy.companion import phone_uplink_active
|
||||
from openpilot.starpilot.software.history import MAX_COMMITS
|
||||
|
||||
from openpilot.common.vendor_manifest import validate_revision
|
||||
from openpilot.common.prebuilt_manifest import revision_digest, valid_receipt
|
||||
@@ -72,26 +74,34 @@ def _stop_process(process):
|
||||
process.wait(timeout=5)
|
||||
|
||||
|
||||
def run(command, cwd, progress=None, *, timeout=None):
|
||||
downloading = 'fetch' in command or 'pull' in command or 'clone' in command
|
||||
def run(command, cwd, progress=None, *, timeout=None, download=False, cancelled=None, stdin=None):
|
||||
downloading = download or 'fetch' in command or 'pull' in command or 'clone' in command
|
||||
if downloading and phone_uplink_active():
|
||||
raise FastUpdateError('Software downloads are disabled on the phone uplink')
|
||||
environment = dict(os.environ, GIT_LFS_SKIP_SMUDGE='1', GIT_TERMINAL_PROMPT='0', GIT_ASKPASS='/bin/false', SSH_ASKPASS='/bin/false')
|
||||
if timeout is None:
|
||||
timeout = 120 if "fetch" in command or "reset" in command or "checkout" in command else 30
|
||||
output = bytearray()
|
||||
with subprocess.Popen(command, cwd=cwd, env=environment, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) as process:
|
||||
with subprocess.Popen(command, cwd=cwd, env=environment, stdin=stdin, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, start_new_session=True) as process:
|
||||
assert process.stdout is not None
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
deadline = time.monotonic() + timeout
|
||||
pending = b""
|
||||
progress_at = float('-inf')
|
||||
pending_progress = None
|
||||
try:
|
||||
while selector.get_map():
|
||||
if cancelled is not None and cancelled():
|
||||
raise FastUpdateError('Updater operation stopped')
|
||||
if downloading and phone_uplink_active():
|
||||
raise FastUpdateError('Phone uplink activated; software download stopped')
|
||||
if time.monotonic() >= deadline:
|
||||
raise subprocess.TimeoutExpired(command, timeout, output=bytes(output))
|
||||
if progress is not None and pending_progress is not None and time.monotonic() - progress_at >= 0.25:
|
||||
progress(pending_progress)
|
||||
pending_progress = None
|
||||
progress_at = time.monotonic()
|
||||
for key, _ in selector.select(min(0.1, max(0.0, deadline - time.monotonic()))):
|
||||
chunk = os.read(key.fileobj.fileno(), 4096)
|
||||
if not chunk:
|
||||
@@ -105,8 +115,18 @@ def run(command, cwd, progress=None, *, timeout=None):
|
||||
detail = line.decode('utf-8', errors='replace')
|
||||
pattern = r'(?:remote: )?(?:Enumerating objects|Counting objects|Compressing objects|Receiving objects|Resolving deltas|Total|Updating files):'
|
||||
if progress is not None and re.match(pattern, detail):
|
||||
progress(detail[-512:])
|
||||
# Progress consumers persist Params synchronously. Git can emit
|
||||
# hundreds of lines in a burst; keep draining its pipe instead
|
||||
# of forcing a disk sync for each percentage point.
|
||||
pending_progress = detail[-512:]
|
||||
now = time.monotonic()
|
||||
if now - progress_at >= 0.25:
|
||||
progress(pending_progress)
|
||||
pending_progress = None
|
||||
progress_at = now
|
||||
result = process.wait(timeout=max(0.01, deadline - time.monotonic()))
|
||||
if progress is not None and pending_progress is not None:
|
||||
progress(pending_progress)
|
||||
if result:
|
||||
raise subprocess.CalledProcessError(result, command, output=bytes(output))
|
||||
except BaseException:
|
||||
@@ -118,6 +138,18 @@ def run(command, cwd, progress=None, *, timeout=None):
|
||||
_DEFAULT_RUN = run
|
||||
|
||||
|
||||
def fetch_files(git, revision):
|
||||
missing = [line[1:] for line in git('rev-list', '--objects', '--no-object-names', '--missing=print',
|
||||
'--no-walk', revision, '--').splitlines() if line.startswith('?')]
|
||||
if missing:
|
||||
# Match Git's partial-clone bulk fetch, with visible progress and no argv-size limit.
|
||||
with tempfile.TemporaryFile() as requested:
|
||||
requested.write(('\n'.join(missing) + '\n').encode())
|
||||
requested.seek(0)
|
||||
git('-c', 'fetch.negotiationAlgorithm=noop', 'fetch', '--progress', '--no-tags', '--no-write-fetch-head',
|
||||
'--no-recurse-submodules', '--filter=blob:none', 'origin', '--stdin', stdin=requested)
|
||||
|
||||
|
||||
def fast_update(repo, branch, *, params, parked, expected_commit, current_os=None, invalidate=None, run=run, rollback=False,
|
||||
progress=None, sleep=None, selected_commit=None, versions_only=False):
|
||||
repo = Path(repo)
|
||||
@@ -132,7 +164,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
|
||||
|
||||
notify('preparing', 'Resolving active branch...')
|
||||
|
||||
def git(*arguments):
|
||||
def git(*arguments, stdin=None):
|
||||
command = [
|
||||
'git',
|
||||
'-c',
|
||||
@@ -152,8 +184,9 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
|
||||
*arguments,
|
||||
]
|
||||
if run is _DEFAULT_RUN:
|
||||
return run(command, cwd=str(repo), progress=(lambda detail: notify('fetching', detail)) if 'fetch' in arguments else None)
|
||||
return run(command, cwd=str(repo))
|
||||
return run(command, cwd=str(repo), download=not rollback and any(action in arguments for action in ('show', 'checkout', 'reset')),
|
||||
progress=(lambda detail: notify('fetching', detail)) if 'fetch' in arguments else None, stdin=stdin)
|
||||
return run(command, cwd=str(repo), **({'stdin': stdin} if stdin is not None else {}))
|
||||
|
||||
def admitted():
|
||||
if not parked():
|
||||
@@ -177,13 +210,16 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
|
||||
notify('fetching', 'Previous installed version is available locally.')
|
||||
else:
|
||||
recent = versions_only or selected_commit is not None
|
||||
history_limit = MAX_COMMITS if selected_commit is not None else RECENT_VERSION_LIMIT
|
||||
notify('fetching', f'Fetching {"recent versions" if recent else "latest shallow commit"} on {branch}...')
|
||||
git('fetch', '--progress', f'--depth={RECENT_VERSION_LIMIT if recent else 1}', '--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}')
|
||||
git('fetch', '--progress', f'--depth={history_limit if recent else 1}', '--filter=blob:none',
|
||||
'--no-tags', '--no-recurse-submodules', 'origin', f'refs/heads/{branch}')
|
||||
target = git('rev-parse', 'FETCH_HEAD^{commit}').strip()
|
||||
if recent:
|
||||
members = git('log', '--first-parent', f'-{RECENT_VERSION_LIMIT}', '--format=%H', target, '--').splitlines()
|
||||
members = git('log', '--first-parent', f'-{history_limit}', '--format=%H', target, '--').splitlines()
|
||||
if selected_commit is not None:
|
||||
if selected_commit not in members:
|
||||
installed_history = git('log', '--first-parent', f'-{MAX_COMMITS}', '--format=%H', previous, '--').splitlines() if branch == initial_branch else []
|
||||
if selected_commit not in members and selected_commit not in installed_history:
|
||||
raise FastUpdateError('Selected version is no longer in recent branch history; refresh and try again')
|
||||
target = selected_commit
|
||||
else:
|
||||
@@ -201,6 +237,9 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
|
||||
version = validate_legacy_return(git, target, branch)
|
||||
notify('validating', f'Returning to {branch} {version}; checking the legacy prebuilt release...')
|
||||
else:
|
||||
# Validation reads the manifest directly, so guard any initial blob download.
|
||||
if git('ls-tree', target, '--', 'upstream-sync.json').strip().split()[:1] == ['100644']:
|
||||
git('show', f'{target}:upstream-sync.json')
|
||||
validate_revision(repo, target)
|
||||
launch = git('show', f'{target}:launch_env.sh')
|
||||
required_os = required_os_version(launch)
|
||||
@@ -237,6 +276,7 @@ def fast_update(repo, branch, *, params, parked, expected_commit, current_os=Non
|
||||
for name in untracked:
|
||||
if name in tracked or name in directories or any(str(parent) in tracked for parent in Path(name).parents):
|
||||
raise FastUpdateError('Update would overwrite untracked data')
|
||||
fetch_files(git, target)
|
||||
admitted()
|
||||
if git('rev-parse', 'HEAD^{commit}').strip() != previous:
|
||||
raise FastUpdateError('Source changed during download')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from datetime import datetime
|
||||
from html.parser import HTMLParser
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
@@ -9,8 +10,9 @@ import subprocess
|
||||
from openpilot.starpilot.saved_source import read_saved
|
||||
|
||||
COMMIT = re.compile(r'[0-9a-f]{40}\Z')
|
||||
MAX_COMMITS = 20
|
||||
MAX_COMMITS = 100
|
||||
MAX_NOTES_BYTES = 32768
|
||||
METADATA_ROOT = Path(os.environ.get('UPDATER_METADATA_ROOT', '/data/starpilot/software/metadata.git'))
|
||||
|
||||
|
||||
def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> list[dict]:
|
||||
@@ -21,7 +23,7 @@ def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> li
|
||||
['git', '-C', str(path), '--no-pager', 'log', '--no-decorate', '--no-color', '--no-show-signature',
|
||||
*(['--first-parent'] if first_parent else []), f'-{MAX_COMMITS}', '--format=%H%x00%cI%x00%<(240,trunc)%s%x00', commit, '--'],
|
||||
check=True, capture_output=True, text=True, timeout=2).stdout
|
||||
if len(output.encode()) > 32768:
|
||||
if len(output.encode()) > MAX_COMMITS * 800:
|
||||
return []
|
||||
fields = output.split('\0')
|
||||
rows = []
|
||||
@@ -38,19 +40,106 @@ def commit_history(path: Path, commit: str, *, first_parent: bool = False) -> li
|
||||
return []
|
||||
|
||||
|
||||
def previous_version(path: Path) -> dict | None:
|
||||
try:
|
||||
commit = subprocess.run(['git', '-C', str(path), 'rev-parse', '--verify', 'refs/starpilot/previous^{commit}'],
|
||||
check=True, capture_output=True, text=True, timeout=2).stdout.strip()
|
||||
rows = commit_history(path, commit)
|
||||
if not rows:
|
||||
return None
|
||||
result = rows[0]
|
||||
saved = subprocess.run(['git', '-C', str(path), 'config', '--local', '--get', 'starpilot.previousVersion'],
|
||||
capture_output=True, text=True, timeout=2).stdout.strip().split(' ', 1)
|
||||
if len(saved) == 2 and saved[0] == commit and re.fullmatch(r'[A-Za-z0-9_][A-Za-z0-9._/+@-]{0,127}', saved[1]):
|
||||
result['branch'] = saved[1]
|
||||
return result
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None
|
||||
|
||||
|
||||
def downloaded_versions(paths, commits):
|
||||
"""Check local trees in batches, sharing checks of unchanged subtrees."""
|
||||
requested = list(dict.fromkeys(commit for commit in commits if COMMIT.fullmatch(commit)))
|
||||
available = set()
|
||||
for path in paths:
|
||||
def git(*args, data=None, path=path):
|
||||
return subprocess.run(['git', '-C', str(path), *args], input=data, check=True,
|
||||
capture_output=True, timeout=2).stdout
|
||||
|
||||
def objects(hashes):
|
||||
output = git('cat-file', '--batch', data=''.join(sha + '\n' for sha in hashes).encode())
|
||||
result = {}
|
||||
offset = 0
|
||||
while offset < len(output):
|
||||
end = output.index(b'\n', offset)
|
||||
sha, kind, size = output[offset:end].split()
|
||||
offset = end + 1
|
||||
size = int(size)
|
||||
result[sha.decode()] = (kind, output[offset:offset + size])
|
||||
offset += size + 1
|
||||
return result
|
||||
|
||||
try:
|
||||
# Enumerate local objects first. Never ask cat-file for a missing object,
|
||||
# which could cause a partial clone to fetch it just to render a badge.
|
||||
existing = set(git('cat-file', '--batch-all-objects', '--batch-check=%(objectname)').decode().splitlines())
|
||||
pending = [sha for sha in requested if sha not in available and sha in existing]
|
||||
if not pending:
|
||||
continue
|
||||
roots = {sha: body.split(b'\n', 1)[0][5:].decode() for sha, (kind, body) in objects(pending).items()
|
||||
if kind == b'commit' and body.startswith(b'tree ')}
|
||||
trees = {}
|
||||
todo = set(roots.values()) & existing
|
||||
while todo:
|
||||
following = set()
|
||||
for sha, (kind, body) in objects(todo).items():
|
||||
if kind != b'tree':
|
||||
continue
|
||||
children = []
|
||||
offset = 0
|
||||
while offset < len(body):
|
||||
space = body.index(b' ', offset)
|
||||
end = body.index(b'\0', space)
|
||||
child = body[end + 1:end + 21].hex()
|
||||
is_tree = body[offset:space] == b'40000'
|
||||
children.append((child, is_tree))
|
||||
if is_tree and child in existing:
|
||||
following.add(child)
|
||||
offset = end + 21
|
||||
trees[sha] = children
|
||||
todo = following - trees.keys()
|
||||
complete = {}
|
||||
def has_tree(sha, complete=complete, trees=trees, existing=existing):
|
||||
if sha not in complete:
|
||||
children = trees.get(sha)
|
||||
complete[sha] = children is not None and all(child in existing and (not tree or has_tree(child))
|
||||
for child, tree in children)
|
||||
return complete[sha]
|
||||
available.update(sha for sha, root in roots.items() if has_tree(root))
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
continue
|
||||
return [sha for sha in requested if sha in available]
|
||||
|
||||
|
||||
class _NotesText(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.parts = []
|
||||
self.hidden = 0
|
||||
self.heading = 0
|
||||
self.content = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
|
||||
self.heading += 1
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden += 1
|
||||
elif not self.hidden and tag in ('br', 'p', 'li', 'h1', 'h2', 'h3'):
|
||||
self.parts.append('\n')
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
|
||||
self.heading = max(0, self.heading - 1)
|
||||
if tag in ('script', 'style'):
|
||||
self.hidden = max(0, self.hidden - 1)
|
||||
elif not self.hidden and tag in ('p', 'li', 'h1', 'h2', 'h3'):
|
||||
@@ -59,6 +148,8 @@ class _NotesText(HTMLParser):
|
||||
def handle_data(self, data):
|
||||
if not self.hidden:
|
||||
self.parts.append(data)
|
||||
if not self.heading:
|
||||
self.content.append(data)
|
||||
|
||||
|
||||
def release_notes(params, key: str) -> str | None:
|
||||
@@ -69,6 +160,8 @@ def release_notes(params, key: str) -> str | None:
|
||||
parser = _NotesText()
|
||||
parser.feed(raw.decode('utf-8'))
|
||||
parser.close()
|
||||
if not re.sub(r'^\s*#{1,6}\s+.*$', '', ''.join(parser.content), flags=re.MULTILINE).strip():
|
||||
return None
|
||||
return re.sub(r'\n{3,}', '\n\n', ''.join(parser.parts)).strip()
|
||||
except (UnicodeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -15,5 +15,5 @@ def automatic_downloads(params) -> bool | None:
|
||||
return raw != b'0' # Preserve the upstream schedule for existing installations.
|
||||
|
||||
|
||||
def download_permitted(params, *, manual: bool, check_only: bool) -> bool:
|
||||
return not check_only and (manual or automatic_downloads(params) is True)
|
||||
def download_permitted(params, *, manual: bool) -> bool:
|
||||
return manual or automatic_downloads(params) is True
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import Mock
|
||||
|
||||
from openpilot.starpilot.software.auto_install import DEFAULT_CRON, InstallSettings, cron_fields, cron_matches, next_restart, scheduled_restart
|
||||
|
||||
|
||||
class Params:
|
||||
def __init__(self):
|
||||
self.values = {'IsOffroad': True}
|
||||
self.writes = []
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.values.get(key))
|
||||
|
||||
def put_bool(self, key, value, **kwargs):
|
||||
self.values[key] = value
|
||||
self.writes.append((key, value))
|
||||
|
||||
|
||||
class AutoInstallTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
temporary = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(temporary.cleanup)
|
||||
self.settings = InstallSettings(Path(temporary.name) / 'install')
|
||||
self.params = Params()
|
||||
self.now = datetime(2026, 10, 30, 0, 0, tzinfo=UTC)
|
||||
|
||||
def enable(self, expression=DEFAULT_CRON):
|
||||
current = self.settings.read()
|
||||
assert current is not None
|
||||
return self.settings.save(True, expression, current['revision'], lambda: True)
|
||||
|
||||
def test_local_midnight_tracks_daylight_savings(self):
|
||||
current = self.settings.read()
|
||||
assert current is not None
|
||||
self.settings.save(True, DEFAULT_CRON, current['revision'], lambda: True, timezone='America/Los_Angeles')
|
||||
for month, utc_hour in [(7, 7), (12, 8)]:
|
||||
with self.subTest(month=month):
|
||||
now = datetime(2026, month, 15, utc_hour, tzinfo=UTC)
|
||||
self.params.values['DoReboot'] = False
|
||||
self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, now - timedelta(hours=1)))
|
||||
self.assertTrue(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, now))
|
||||
zone = ZoneInfo('America/Los_Angeles')
|
||||
before = datetime(2026, 3, 8, 0, tzinfo=zone)
|
||||
self.assertEqual(next_restart('30 2 * * *', before), datetime(2026, 3, 9, 2, 30, tzinfo=zone))
|
||||
|
||||
def test_next_restart(self):
|
||||
now = datetime(2026, 10, 30, 12, tzinfo=UTC)
|
||||
self.assertEqual(next_restart(DEFAULT_CRON, now), datetime(2026, 10, 31, tzinfo=UTC))
|
||||
self.assertEqual(next_restart('15 2 * * 1-5', now), datetime(2026, 11, 2, 2, 15, tzinfo=UTC))
|
||||
self.assertEqual(next_restart('0 0 29 2 *', now), datetime(2028, 2, 29, tzinfo=UTC))
|
||||
self.assertIsNone(next_restart('0 0 31 2 *', now))
|
||||
|
||||
def test_default_is_disabled_and_daily_at_midnight(self):
|
||||
value = self.settings.read()
|
||||
assert value is not None
|
||||
self.assertFalse(value['enabled'])
|
||||
self.assertEqual(value['cron'], '0 0 * * *')
|
||||
self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now))
|
||||
self.assertEqual(self.params.writes, [])
|
||||
|
||||
def test_numeric_cron_ranges_lists_steps_and_calendar_semantics(self):
|
||||
self.assertTrue(cron_matches(DEFAULT_CRON, self.now))
|
||||
self.assertFalse(cron_matches(DEFAULT_CRON, self.now + timedelta(minutes=1)))
|
||||
self.assertTrue(cron_matches('*/15 1-3 * * 1-5', self.now.replace(hour=2, minute=30)))
|
||||
self.assertFalse(cron_matches('*/15 1-3 * * 1-5', (self.now + timedelta(days=1)).replace(hour=2, minute=30)))
|
||||
sunday = datetime(2026, 11, 1, tzinfo=UTC)
|
||||
self.assertTrue(cron_matches('0 0 * * 0,7', sunday))
|
||||
self.assertTrue(cron_matches('0 0 1 * 5', self.now))
|
||||
self.assertFalse(cron_matches('0 0 1 * */1', self.now))
|
||||
|
||||
def test_invalid_cron_is_rejected(self):
|
||||
for value in ('@midnight', '* * * *', '60 0 * * *', '0 24 * * *', '0 0 0 * *', '0 0 * 13 *',
|
||||
'0 0 * * 8', '*/0 * * * *', '10-2 * * * *', '0, * * * *', '1/100 * * * *', 'x' * 129):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
cron_fields(value)
|
||||
|
||||
def test_settings_revision_and_last_moment_authorization(self):
|
||||
saved = self.enable(' 0 0 * * * ')
|
||||
self.assertEqual(saved['cron'], DEFAULT_CRON)
|
||||
with self.assertRaises(ValueError):
|
||||
self.settings.save(False, DEFAULT_CRON, '0' * 32, lambda: True)
|
||||
with self.assertRaises(PermissionError):
|
||||
self.settings.save(False, DEFAULT_CRON, saved['revision'], lambda: False)
|
||||
self.assertEqual(self.settings.read(), saved)
|
||||
|
||||
def test_corrupt_or_symlink_settings_never_enable_restarts(self):
|
||||
self.enable()
|
||||
for raw in (b'not json', b'{}', b'x' * 4097):
|
||||
self.settings.path.write_bytes(raw)
|
||||
self.assertIsNone(self.settings.read())
|
||||
self.assertFalse(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now))
|
||||
self.settings.path.unlink()
|
||||
self.settings.path.symlink_to(Path(__file__))
|
||||
self.assertIsNone(self.settings.read())
|
||||
|
||||
def test_onroad_disabled_updater_no_update_and_no_park_proof_block_restart(self):
|
||||
self.enable()
|
||||
for flags, ready, parked in (({'IsOffroad': False}, True, True), ({'DisableUpdates': True}, True, True),
|
||||
({'DoReboot': True}, True, True), ({}, False, True), ({}, True, False)):
|
||||
with self.subTest(flags=flags, ready=ready, parked=parked):
|
||||
params = Params()
|
||||
params.values.update(flags)
|
||||
self.assertFalse(scheduled_restart(params, self.settings, lambda ready=ready: ready, lambda parked=parked: parked, self.now))
|
||||
self.assertEqual(params.writes, [])
|
||||
|
||||
def test_restart_once_per_scheduled_minute_is_durable(self):
|
||||
self.enable()
|
||||
self.assertTrue(scheduled_restart(self.params, self.settings, lambda: True, lambda: True, self.now))
|
||||
self.params.values['DoReboot'] = False
|
||||
other = InstallSettings(self.settings.root)
|
||||
self.assertFalse(scheduled_restart(self.params, other, lambda: True, lambda: True, self.now))
|
||||
self.assertEqual(self.params.writes, [('DoReboot', True)])
|
||||
self.assertTrue(scheduled_restart(self.params, other, lambda: True, lambda: True, self.now + timedelta(days=1)))
|
||||
|
||||
def test_offroad_and_prepared_update_are_rechecked_before_reboot(self):
|
||||
for ready, parked in ((Mock(side_effect=[True, False]), Mock(return_value=True)),
|
||||
(Mock(return_value=True), Mock(side_effect=[True, False]))):
|
||||
with self.subTest(ready=ready, parked=parked):
|
||||
self.settings.path.unlink(missing_ok=True)
|
||||
self.enable()
|
||||
self.assertFalse(scheduled_restart(self.params, self.settings, ready, parked, self.now))
|
||||
self.assertEqual(self.params.writes, [])
|
||||
|
||||
def test_no_catchup_restart_after_the_scheduled_minute(self):
|
||||
self.enable()
|
||||
ready, parked = Mock(return_value=True), Mock(return_value=True)
|
||||
self.assertFalse(scheduled_restart(self.params, self.settings, ready, parked, self.now + timedelta(minutes=1)))
|
||||
ready.assert_not_called()
|
||||
parked.assert_not_called()
|
||||
@@ -206,6 +206,7 @@ class TestFastUpdate(unittest.TestCase):
|
||||
self.assertEqual(self.params.calls, [])
|
||||
self.assertEqual(self.invalidations, [])
|
||||
self.assertIn('--depth=20', next(command for command in commands if 'fetch' in command))
|
||||
self.assertIn('--filter=blob:none', next(command for command in commands if 'fetch' in command))
|
||||
|
||||
def test_selected_older_version_uses_same_install_and_rollback_transaction(self):
|
||||
self.update()
|
||||
@@ -225,7 +226,7 @@ class TestFastUpdate(unittest.TestCase):
|
||||
self.assertEqual(self.params.calls, [])
|
||||
self.assertEqual(self.invalidations, [])
|
||||
rejected('f' * 40)
|
||||
for index in range(21):
|
||||
for index in range(owner.MAX_COMMITS + 1):
|
||||
(self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index))
|
||||
self.git(self.remote, 'commit', '-am', 'recent build')
|
||||
rejected(self.target)
|
||||
@@ -235,6 +236,35 @@ class TestFastUpdate(unittest.TestCase):
|
||||
self.git(self.remote, 'commit', '-am', 'replacement')
|
||||
rejected(abandoned)
|
||||
|
||||
def test_installed_parent_remains_selectable_after_leaving_recent_window(self):
|
||||
self.git(self.repo, 'fetch', 'origin')
|
||||
self.git(self.repo, 'reset', '--hard', self.target)
|
||||
for index in range(owner.MAX_COMMITS + 1):
|
||||
(self.remote / 'openpilot/starpilot/ui/source.py').write_text(str(index))
|
||||
self.git(self.remote, 'commit', '-am', 'newer build')
|
||||
self.assertEqual(self.update(selected_commit=self.previous), 'reboot-requested')
|
||||
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), self.previous)
|
||||
|
||||
def test_selected_version_fetches_only_its_artifacts(self):
|
||||
self.git(self.remote, 'config', 'uploadpack.allowFilter', 'true')
|
||||
self.git(self.remote, 'config', 'uploadpack.allowReachableSHA1InWant', 'true')
|
||||
self.git(self.repo, 'remote', 'set-url', 'origin', self.remote.as_uri())
|
||||
artifact = self.remote / 'openpilot/starpilot/galaxy/web/assets/artifact.png'
|
||||
artifact.parent.mkdir(parents=True)
|
||||
artifact.write_bytes(b'chosen artifact' * 10000)
|
||||
self.git(self.remote, 'add', '.')
|
||||
self.git(self.remote, 'commit', '-m', 'Chosen version')
|
||||
selected = self.git(self.remote, 'rev-parse', 'HEAD')
|
||||
selected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png')
|
||||
artifact.write_bytes(b'unselected newer artifact' * 10000)
|
||||
self.git(self.remote, 'commit', '-am', 'Unselected version')
|
||||
unselected_blob = self.git(self.remote, 'rev-parse', 'HEAD:openpilot/starpilot/galaxy/web/assets/artifact.png')
|
||||
self.assertEqual(self.update(selected_commit=selected), 'reboot-requested')
|
||||
objects = set(self.git(self.repo, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
|
||||
self.assertIn(selected_blob, objects)
|
||||
self.assertNotIn(unselected_blob, objects)
|
||||
self.assertEqual(self.git(self.repo, 'rev-parse', 'HEAD'), selected)
|
||||
|
||||
def test_selected_version_rechecks_os_and_native_artifact_contracts(self):
|
||||
(self.remote / 'launch_env.sh').write_text('export AGNOS_VERSION="different"\n')
|
||||
self.git(self.remote, 'commit', '-am', 'different OS')
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
from openpilot.starpilot.software.history import commit_history, release_notes, recent_versions
|
||||
from openpilot.starpilot.software.history import MAX_COMMITS, commit_history, downloaded_versions, release_notes, recent_versions, previous_version
|
||||
|
||||
|
||||
def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
|
||||
@@ -11,12 +12,12 @@ def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
|
||||
'-c', 'user.name=Test', '-c', 'user.email=test@example.com', *args],
|
||||
check=True, capture_output=True, text=True).stdout.strip()
|
||||
git('init', '-q')
|
||||
for index in range(23):
|
||||
for index in range(MAX_COMMITS + 3):
|
||||
git('commit', '--allow-empty', '-qm', f'Change {index:02d}')
|
||||
commit = git('rev-parse', 'HEAD~1')
|
||||
rows = commit_history(tmp_path, commit)
|
||||
assert len(rows) == 20
|
||||
assert rows[0]['hash'] == commit and rows[0]['subject'] == 'Change 21'
|
||||
assert len(rows) == MAX_COMMITS
|
||||
assert rows[0]['hash'] == commit and rows[0]['subject'] == f'Change {MAX_COMMITS + 1}'
|
||||
assert rows[-1]['subject'] == 'Change 02'
|
||||
assert all(row['date'] for row in rows)
|
||||
assert commit_history(tmp_path, '--all') == []
|
||||
@@ -27,6 +28,44 @@ def test_history_is_bounded_and_pinned_to_the_requested_commit(tmp_path):
|
||||
assert recent['head'] == commit and recent['branch'] == 'Dom' and recent['entries'] == rows
|
||||
assert recent_versions(tmp_path, '../escape')['entries'] == []
|
||||
assert recent_versions(tmp_path, 'Dom')['entries'][0]['hash'] == commit
|
||||
assert previous_version(tmp_path) is None
|
||||
git('update-ref', 'refs/starpilot/previous', commit)
|
||||
assert previous_version(tmp_path) == rows[0]
|
||||
git('config', '--local', 'starpilot.previousVersion', f'{commit} old-branch')
|
||||
previous = previous_version(tmp_path)
|
||||
assert previous is not None
|
||||
assert previous['branch'] == 'old-branch'
|
||||
git('update-ref', '-d', 'refs/starpilot/previous')
|
||||
assert previous_version(tmp_path) is None
|
||||
|
||||
|
||||
def test_downloaded_versions_require_all_files_and_accept_a_staging_cache(tmp_path):
|
||||
repo = tmp_path / 'repo'
|
||||
repo.mkdir()
|
||||
def git(*args):
|
||||
return subprocess.check_output(['git', '-C', str(repo), '-c', 'user.name=Test',
|
||||
'-c', 'user.email=test@example.com', *args], text=True).strip()
|
||||
git('init', '-q')
|
||||
(repo / 'file').write_text('old')
|
||||
(repo / 'nested').mkdir()
|
||||
(repo / 'nested/shared').write_text('shared subtree')
|
||||
git('add', '.')
|
||||
git('commit', '-qm', 'Old')
|
||||
old = git('rev-parse', 'HEAD')
|
||||
(repo / 'file').write_text('new')
|
||||
git('commit', '-qam', 'New')
|
||||
new = git('rev-parse', 'HEAD')
|
||||
cache = tmp_path / 'cache'
|
||||
shutil.copytree(repo, cache)
|
||||
assert downloaded_versions((repo,), [old, new, '--all']) == [old, new]
|
||||
blob = git('rev-parse', 'HEAD:file')
|
||||
(repo / '.git/objects' / blob[:2] / blob[2:]).unlink()
|
||||
assert downloaded_versions((repo,), [old, new]) == [old]
|
||||
assert downloaded_versions((repo, cache), [old, new]) == [old, new]
|
||||
(repo / '.git/objects' / blob[:2] / blob[2:]).write_bytes((cache / '.git/objects' / blob[:2] / blob[2:]).read_bytes())
|
||||
tree = git('rev-parse', 'HEAD^{tree}')
|
||||
(repo / '.git/objects' / tree[:2] / tree[2:]).unlink()
|
||||
assert downloaded_versions((repo,), [old, new]) == [old]
|
||||
|
||||
|
||||
def test_release_notes_are_bounded_plain_text(tmp_path):
|
||||
@@ -39,3 +78,8 @@ def test_release_notes_are_bounded_plain_text(tmp_path):
|
||||
assert release_notes(params, path.name) is None
|
||||
path.write_bytes(b'\xff')
|
||||
assert release_notes(params, path.name) is None
|
||||
for empty in ('', ' \n\t', '# StarPilot 7.0 — Domathon development\n', '<h1>StarPilot 7.0</h1>', '<h2>Version</h2><p> </p>'):
|
||||
path.write_text(empty)
|
||||
assert release_notes(params, path.name) is None
|
||||
path.write_text('# Version\n\n* Fixed updates')
|
||||
assert release_notes(params, path.name) == '# Version\n\n* Fixed updates'
|
||||
|
||||
@@ -12,10 +12,8 @@ def test_default_saved_and_invalid_values_preserve_manual_requests(tmp_path, raw
|
||||
if raw is not None:
|
||||
Path(params.get_param_path(AUTOMATIC_DOWNLOADS)).write_bytes(raw)
|
||||
assert automatic_downloads(params) is expected
|
||||
assert download_permitted(params, manual=False, check_only=False) is (expected is True)
|
||||
assert download_permitted(params, manual=True, check_only=False)
|
||||
assert not download_permitted(params, manual=False, check_only=True)
|
||||
assert not download_permitted(params, manual=True, check_only=True)
|
||||
assert download_permitted(params, manual=False) is (expected is True)
|
||||
assert download_permitted(params, manual=True)
|
||||
|
||||
|
||||
def test_nonregular_and_symlink_preferences_do_not_enable_automatic_downloads(tmp_path):
|
||||
|
||||
@@ -52,11 +52,22 @@ class UpdaterControlTests(unittest.TestCase):
|
||||
with self.assertRaises(update_control.UpdaterControlError):
|
||||
update_control._command('fast', 'Dom', expected, selected)
|
||||
|
||||
def test_download_version_pins_both_commits_without_becoming_an_install(self):
|
||||
expected, selected = 'a' * 40, 'b' * 40
|
||||
command = update_control._command('download-version', 'Dom', expected, selected)
|
||||
self.assertEqual(update_control._parse(command + b'\n'), ('download-version', 'Dom', expected, selected))
|
||||
self.assertLessEqual(len(update_control._command('download-version', 'x' * 128, expected, selected)) + 1,
|
||||
update_control.MAX_COMMAND)
|
||||
with self.assertRaises(update_control.UpdaterControlError):
|
||||
update_control._command('download-version', 'Dom', expected, 'HEAD')
|
||||
|
||||
def test_server_delivers_fast_identity_and_legacy_callback_shape(self):
|
||||
for raw, expected in ((b'update Dom ' + b'a' * 40 + b'\n', mock.call('update', branch='Dom', commit='a' * 40)),
|
||||
(b'fast Dom ' + b'a' * 40 + b'\n', mock.call('fast', branch='Dom', commit='a' * 40)),
|
||||
(b'version Dom ' + b'a' * 40 + b' ' + b'b' * 40 + b'\n',
|
||||
mock.call('version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)),
|
||||
(b'download-version Dom ' + b'a' * 40 + b' ' + b'b' * 40 + b'\n',
|
||||
mock.call('download-version', branch='Dom', commit='a' * 40, selected_commit='b' * 40)),
|
||||
(b'versions Dom ' + b'a' * 40 + b'\n', mock.call('versions', branch='Dom', commit='a' * 40)),
|
||||
(b'check\n', mock.call('check')), (b'download\n', mock.call('download'))):
|
||||
with self.subTest(raw=raw):
|
||||
@@ -88,6 +99,16 @@ class UpdaterControlTests(unittest.TestCase):
|
||||
self.assertEqual(request, updated.UserRequest.ROLLBACK)
|
||||
self.assertEqual(target, ('Dom', 'a' * 40))
|
||||
|
||||
def test_check_requests_background_work_but_versions_only_requests_metadata(self):
|
||||
from openpilot.system.updated import updated
|
||||
callback = mock.Mock()
|
||||
helper = updated.WaitTimeHelper.__new__(updated.WaitTimeHelper)
|
||||
helper.metadata = callback
|
||||
helper._control_request('check')
|
||||
callback.assert_called_with(branch=None, commit=None, download_after=True)
|
||||
helper._control_request('versions', branch='Dom', commit='a' * 40)
|
||||
callback.assert_called_with(branch='Dom', commit='a' * 40, download_after=False)
|
||||
|
||||
def test_same_process_control_and_stale_identity(self):
|
||||
actions = []
|
||||
accepted = threading.Event()
|
||||
|
||||
@@ -5,8 +5,9 @@ import sys
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from openpilot.starpilot.software.fast_update import run
|
||||
from openpilot.starpilot.software.fast_update import FastUpdateError, run
|
||||
|
||||
|
||||
class TestUpdateProcess(unittest.TestCase):
|
||||
@@ -56,6 +57,39 @@ finally:
|
||||
self.assertEqual(error.exception.output, b'locked\n')
|
||||
self.assertFalse((self.directory / 'shallow.lock').exists())
|
||||
|
||||
def test_progress_burst_is_coalesced_without_losing_output(self):
|
||||
command = self.command("for i in range(1000): print(f'Receiving objects: {i}\\r', end='')\n")
|
||||
progress = []
|
||||
result = run(command, self.directory, progress.append)
|
||||
self.assertIn('Receiving objects: 500\r', result)
|
||||
self.assertEqual(progress[0], 'Receiving objects: 0')
|
||||
self.assertEqual(progress[-1], 'Receiving objects: 999')
|
||||
self.assertLess(len(progress), 20)
|
||||
|
||||
def test_phone_uplink_interrupts_implicit_downloads(self):
|
||||
command = self.command("from pathlib import Path\nimport time\nPath('started').touch()\ntime.sleep(60)\n")
|
||||
with patch('openpilot.starpilot.software.fast_update.phone_uplink_active',
|
||||
side_effect=lambda: (self.directory / 'started').exists()):
|
||||
with self.assertRaisesRegex(FastUpdateError, 'software download stopped'):
|
||||
run(command, self.directory, download=True, timeout=5)
|
||||
|
||||
def test_pending_progress_is_delivered_while_command_is_quiet(self):
|
||||
command = self.command("""import time
|
||||
from pathlib import Path
|
||||
print('Receiving objects: 1%\\rReceiving objects: 99%', flush=True)
|
||||
deadline = time.monotonic() + 2
|
||||
while not Path('progress_received').exists() and time.monotonic() < deadline:
|
||||
time.sleep(0.01)
|
||||
print('received while running' if Path('progress_received').exists() else 'progress stalled')
|
||||
""")
|
||||
|
||||
def progress(detail):
|
||||
if detail == 'Receiving objects: 99%':
|
||||
(self.directory / 'progress_received').touch()
|
||||
|
||||
result = run(command, self.directory, progress)
|
||||
self.assertIn('received while running', result)
|
||||
|
||||
def test_stubborn_command_is_killed(self):
|
||||
command = self.command("""import os
|
||||
import signal
|
||||
|
||||
@@ -82,9 +82,9 @@ def available(pid: int, start: int) -> bool:
|
||||
|
||||
|
||||
def _command(action: str, branch=None, commit=None, selected_commit=None) -> bytes:
|
||||
if action == 'version' and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit):
|
||||
if action in ('version', 'download-version') and type(selected_commit) is str and _COMMIT.fullmatch(selected_commit):
|
||||
base = _command('fast', branch, commit).decode('ascii').split(' ', 1)[1]
|
||||
return f'version {base} {selected_commit}'.encode('ascii')
|
||||
return f'{action} {base} {selected_commit}'.encode('ascii')
|
||||
if selected_commit is not None:
|
||||
raise UpdaterControlError('Invalid updater version target')
|
||||
if action in ('check', 'download') and branch is None and commit is None:
|
||||
@@ -100,7 +100,7 @@ def _parse(command: bytes):
|
||||
return command[:-1].decode('ascii'), None, None
|
||||
try:
|
||||
parts = command[:-1].decode('ascii').split(' ')
|
||||
if len(parts) == 4 and parts[0] == 'version' and command.endswith(b'\n'):
|
||||
if len(parts) == 4 and parts[0] in ('version', 'download-version') and command.endswith(b'\n'):
|
||||
action, branch, commit, selected = parts
|
||||
if _command(action, branch, commit, selected) + b'\n' == command:
|
||||
return action, branch, commit, selected
|
||||
@@ -160,7 +160,7 @@ class UpdaterControlServer:
|
||||
else:
|
||||
parsed = _parse(command)
|
||||
action, branch, commit = parsed[:3]
|
||||
if action == 'version':
|
||||
if action in ('version', 'download-version'):
|
||||
self.request(action, branch=branch, commit=commit, selected_commit=parsed[3])
|
||||
elif action in ('update', 'fast', 'rollback', 'versions'):
|
||||
self.request(action, branch=branch, commit=commit)
|
||||
|
||||
@@ -177,3 +177,14 @@ class TestExternalPreferencesReset(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
def test_fresh_preferences_disable_scheduled_automatic_installation(tmp_path):
|
||||
from openpilot.starpilot.software.auto_install import InstallSettings
|
||||
settings = InstallSettings(tmp_path / 'software' / 'auto-install')
|
||||
value = settings.read()
|
||||
assert value is not None
|
||||
settings.save(True, '0 0 * * *', value['revision'], lambda: True)
|
||||
assert reset.reset_external_preferences(tmp_path, tmp_path / 'models') == ()
|
||||
value = settings.read()
|
||||
assert value is not None and not value['enabled']
|
||||
|
||||
@@ -801,6 +801,10 @@ class RuntimeSnapshotAdapter:
|
||||
offroad=confirmed_offroad,
|
||||
available_actions=frozenset(device_actions))
|
||||
updater_state = _text(params, "UpdaterState")
|
||||
fast = params.get('UpdaterFastState')
|
||||
fast = fast if isinstance(fast, dict) else {}
|
||||
checking_metadata = fast.get('check', {}).get('state') == 'checking'
|
||||
background = fast.get('background') and updater_state in ('downloading...', 'finalizing update...')
|
||||
available = _flag(params, "UpdaterFetchAvailable")
|
||||
status = "updater status unavailable" if not updater_state else updater_state if updater_state != "idle" else (
|
||||
"update available" if available else "up to date, last checked " + ("previously" if _text(params, "LastUpdateTime") else "never"))
|
||||
@@ -810,6 +814,11 @@ class RuntimeSnapshotAdapter:
|
||||
if updater_state == "idle":
|
||||
software_actions.update((SoftwareRequest.FAST_UPDATE, SoftwareRequest.ROLLBACK))
|
||||
software_actions.add(SoftwareRequest.DOWNLOAD_UPDATE if available else SoftwareRequest.CHECK_FOR_UPDATES)
|
||||
elif background and not checking_metadata:
|
||||
software_actions.add(SoftwareRequest.CHECK_FOR_UPDATES)
|
||||
if checking_metadata:
|
||||
software_actions.discard(SoftwareRequest.CHECK_FOR_UPDATES)
|
||||
software_actions.discard(SoftwareRequest.DOWNLOAD_UPDATE)
|
||||
if not _flag(params, "IsTestedBranch") and _text(params, "UpdaterAvailableBranches"):
|
||||
software_actions.add(SoftwareRequest.OPEN_BRANCH_CHOOSER)
|
||||
software = SoftwareState(current_version=_text(params, "UpdaterCurrentDescription"),
|
||||
|
||||
@@ -423,6 +423,18 @@ class TestRuntimeSnapshot(unittest.TestCase):
|
||||
self.assertEqual(settings.software, first.software)
|
||||
self.assertEqual(adapter.build(ShellMode.ONROAD, now_ns=NOW + 1_000_000_002).software, first.software)
|
||||
|
||||
def test_background_finalization_allows_metadata_check(self):
|
||||
from openpilot.starpilot.ui.software_state import SoftwareRequest
|
||||
ui = ui_fake()
|
||||
ui.params.values['UpdaterState'] = 'finalizing update...'
|
||||
ui.params.values['UpdaterFastState'] = {'background': True}
|
||||
with patch.object(RuntimeSnapshotAdapter, '_parked', return_value=True):
|
||||
state = RuntimeSnapshotAdapter(ui).build(ShellMode.SETTINGS, now_ns=NOW).software
|
||||
self.assertIn(SoftwareRequest.CHECK_FOR_UPDATES, state.available_actions)
|
||||
ui.params.values['UpdaterFastState']['check'] = {'state': 'checking'}
|
||||
state = RuntimeSnapshotAdapter(ui).build(ShellMode.SETTINGS, now_ns=NOW).software
|
||||
self.assertNotIn(SoftwareRequest.CHECK_FOR_UPDATES, state.available_actions)
|
||||
|
||||
def test_customization_uses_existing_refresh_and_invalidation(self):
|
||||
adapter = RuntimeSnapshotAdapter(ui_fake())
|
||||
first = default_document()
|
||||
|
||||
@@ -12,6 +12,8 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
@@ -19,6 +21,7 @@ from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
from openpilot.common.vendor_manifest import DEPENDENCIES
|
||||
from openpilot.starpilot.software.auto_install import InstallSettings
|
||||
|
||||
BRANCH = "test-vendored"
|
||||
|
||||
@@ -30,6 +33,9 @@ class MemoryParams:
|
||||
def get(self, key):
|
||||
return self.values.get(key)
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.values.get(key))
|
||||
|
||||
def put(self, key, value, **kwargs):
|
||||
self.values[key] = value
|
||||
|
||||
@@ -100,14 +106,16 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
|
||||
self.updated = load_updater()
|
||||
self.updated.OVERLAY_MERGED = str(self.merged)
|
||||
self.updated.METADATA_ROOT = self.root / 'metadata.git'
|
||||
self.updated.FINALIZED = str(self.finalized)
|
||||
self.updated.BASEDIR = str(self.merged)
|
||||
self.updated.set_consistent_flag = self.set_consistent_flag
|
||||
self.updated.handle_agnos_update = Mock()
|
||||
self.updated.init_overlay = Mock()
|
||||
self.commands = []
|
||||
real_run = self.updated.run
|
||||
|
||||
def bounded_run(command, cwd=None):
|
||||
def bounded_run(command, cwd=None, **kwargs):
|
||||
if cwd is None:
|
||||
raise AssertionError("Every updater command must name its isolated working directory")
|
||||
self.assertTrue(Path(cwd).resolve().is_relative_to(self.root.resolve()))
|
||||
@@ -115,10 +123,11 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
self.commands.append(command)
|
||||
if command in (["git", "gc"], ["git", "lfs", "prune"]):
|
||||
return ""
|
||||
return real_run(command, cwd)
|
||||
return real_run(command, cwd, **kwargs)
|
||||
|
||||
self.updated.run = bounded_run
|
||||
self.updater = self.updated.Updater()
|
||||
self.updater = self.updated.Updater(install_settings=InstallSettings(self.root / 'install-settings'))
|
||||
self.addCleanup(self.updater.close)
|
||||
|
||||
@staticmethod
|
||||
def git(repo, *args):
|
||||
@@ -166,10 +175,89 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
self.updater.fast_update(BRANCH, self.initial_commit)
|
||||
normal.assert_not_called() # Explicit fast/rollback/version operations keep their contract.
|
||||
|
||||
@patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True)))
|
||||
def test_download_selected_version_stages_exact_commit_without_installing(self, _physical):
|
||||
installed = self.root / 'installed'
|
||||
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
(self.source / 'version.txt').write_text('selected')
|
||||
selected = self.commit_source('Selected version')
|
||||
(self.source / 'version.txt').write_text('newest')
|
||||
latest = self.commit_source('Newer version')
|
||||
with patch.object(self.updater, 'set_params') as publish:
|
||||
self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, selected, download_only=True), 'downloaded')
|
||||
publish.assert_called_once_with(True, 0, None)
|
||||
self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit)
|
||||
self.assertEqual(self.git(self.finalized, 'rev-parse', 'HEAD'), selected)
|
||||
self.assertEqual(self.git(self.finalized, 'rev-parse', 'refs/starpilot/previous'), self.initial_commit)
|
||||
self.assertEqual(self.updater.branches[BRANCH], latest)
|
||||
self.assertTrue(self.updater.update_ready)
|
||||
self.assertFalse(self.updater.latest_update_ready)
|
||||
self.assertFalse(self.updater.params.get_bool('DoReboot'))
|
||||
|
||||
@patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True)))
|
||||
def test_download_selected_version_rejects_changed_source_and_unlisted_target(self, _physical):
|
||||
for expected, selected in (('f' * 40, self.initial_commit), (self.initial_commit, 'f' * 40)):
|
||||
with self.subTest(expected=expected), self.assertRaises(self.updated.FastUpdateError):
|
||||
self.updater.fast_update(BRANCH, expected, selected, download_only=True)
|
||||
self.updated.init_overlay.assert_not_called()
|
||||
self.assertEqual(self.git(self.merged, 'rev-parse', 'HEAD'), self.initial_commit)
|
||||
self.assertFalse(self.updater.params.get_bool('DoReboot'))
|
||||
|
||||
def test_metadata_refresh_does_not_wake_background_but_check_does(self):
|
||||
(self.source / 'new.txt').write_text('available')
|
||||
self.commit_source('New update')
|
||||
self.updater.wake_automatic = Mock()
|
||||
with patch.object(self.updated, 'download_permitted', return_value=True):
|
||||
self.updater.request_check(branch=BRANCH)
|
||||
worker = self.updater._check_thread
|
||||
if worker is not None:
|
||||
worker.join(timeout=5)
|
||||
self.updater.wake_automatic.assert_not_called()
|
||||
self.updated.init_overlay.assert_not_called()
|
||||
self.updater.request_check(branch=BRANCH, download_after=True)
|
||||
worker = self.updater._check_thread
|
||||
if worker is not None:
|
||||
worker.join(timeout=5)
|
||||
self.updater.wake_automatic.assert_called_once_with()
|
||||
|
||||
def test_scheduled_install_requires_clock_idle_and_fresh_offroad_evidence(self):
|
||||
installed = self.root / 'installed'
|
||||
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
(self.source / 'scheduled.txt').write_text('prepared')
|
||||
self.commit_source('Prepared update for scheduling')
|
||||
self.assertTrue(self.updater.fetch_update())
|
||||
self.updater.params.put('UpdaterState', 'idle')
|
||||
self.updater.params.put_bool('IsOffroad', True)
|
||||
settings = self.updater.install_settings
|
||||
current = settings.read()
|
||||
assert current is not None
|
||||
settings.save(True, '* * * * *', current['revision'], lambda: True)
|
||||
physical = Mock(update_ready=Mock(return_value=True))
|
||||
with patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=physical), \
|
||||
patch.object(self.updated, 'system_time_valid', return_value=False):
|
||||
self.assertFalse(self.updater.maybe_install())
|
||||
with patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=physical), \
|
||||
patch.object(self.updated, 'system_time_valid', return_value=True):
|
||||
self.assertFalse(self.updater.maybe_install(lambda: False))
|
||||
self.updater.params.put_bool('IsOffroad', False)
|
||||
self.assertFalse(self.updater.maybe_install())
|
||||
self.assertFalse(self.updater.params.get_bool('DoReboot'))
|
||||
physical.update_ready.assert_not_called()
|
||||
self.updater.params.put_bool('IsOffroad', True)
|
||||
self.assertTrue(self.updater.maybe_install())
|
||||
self.assertTrue(self.updater.params.get_bool('DoReboot'))
|
||||
self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit)
|
||||
self.assertEqual(physical.update_ready.call_count, 2)
|
||||
|
||||
def test_normal_fallback_stages_flashes_and_requests_reboot_with_fresh_parked_state(self):
|
||||
installed = self.root / "installed"
|
||||
self.git(self.root, "clone", "--no-local", str(self.merged), str(installed))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
(self.source / "panda/README.md").write_text("New native revision")
|
||||
target = self.commit_source("Full update")
|
||||
self.updated.AGNOS = True
|
||||
@@ -187,6 +275,7 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
installed = self.root / "installed"
|
||||
self.git(self.root, "clone", "--no-local", str(self.merged), str(installed))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
(self.source / "panda/README.md").write_text("New native revision")
|
||||
self.commit_source("Full update")
|
||||
parked = iter((True, False))
|
||||
@@ -215,6 +304,7 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), target)
|
||||
self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target)
|
||||
self.assertEqual((self.finalized / "panda" / "README.md").read_text(), "Updated ordinary tracked source\n")
|
||||
self.assertEqual(self.git(self.finalized, "rev-parse", f"refs/starpilot/recent/{BRANCH}"), target)
|
||||
self.assertTrue((self.finalized / ".overlay_consistent").is_file())
|
||||
for name in DEPENDENCIES:
|
||||
self.assertFalse((self.finalized / name / ".git").exists())
|
||||
@@ -223,6 +313,176 @@ class TestVendoredUpdate(unittest.TestCase):
|
||||
self.assertEqual(self.git(self.merged, "config", "fetch.recurseSubmodules"), "false")
|
||||
self.updated.handle_agnos_update.assert_not_called()
|
||||
|
||||
def test_filtered_fetch_preserves_history_without_obsolete_artifact_contents(self):
|
||||
self.git(self.source, 'config', 'uploadpack.allowFilter', 'true')
|
||||
self.git(self.source, 'config', 'uploadpack.allowReachableSHA1InWant', 'true')
|
||||
self.git(self.merged, 'remote', 'set-url', 'origin', self.source.as_uri())
|
||||
artifact = self.source / 'artifact.bin'
|
||||
artifact.write_bytes(os.urandom(128 * 1024))
|
||||
middle = self.commit_source('Superseded large artifact')
|
||||
obsolete_blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin')
|
||||
artifact.write_bytes(os.urandom(128 * 1024))
|
||||
target = self.commit_source('Chosen large artifact')
|
||||
target_blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin')
|
||||
self.updater.check_for_update()
|
||||
objects = set(self.git(self.updated.METADATA_ROOT, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
|
||||
self.assertIn(target, objects)
|
||||
self.assertIn(middle, objects)
|
||||
self.assertNotIn(target_blob, objects)
|
||||
self.assertNotIn(obsolete_blob, objects)
|
||||
metadata_fetches = sum('fetch' in command for command in self.commands)
|
||||
self.assertTrue(self.updater.fetch_update())
|
||||
self.assertEqual(sum('fetch' in command for command in self.commands), metadata_fetches + 2)
|
||||
objects = set(self.git(self.merged, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
|
||||
self.assertIn(target, objects)
|
||||
self.assertNotIn(middle, objects)
|
||||
self.assertIn(target_blob, objects)
|
||||
self.assertNotIn(obsolete_blob, objects)
|
||||
self.assertIn(middle, self.git(self.updated.METADATA_ROOT, 'log', '--first-parent', '--format=%H', f'refs/starpilot/recent/{BRANCH}').splitlines())
|
||||
self.assertEqual((self.finalized / 'artifact.bin').read_bytes(), artifact.read_bytes())
|
||||
|
||||
def test_refresh_versions_fetches_only_metadata_in_staging(self):
|
||||
self.git(self.source, 'config', 'uploadpack.allowFilter', 'true')
|
||||
self.git(self.merged, 'remote', 'set-url', 'origin', self.source.as_uri())
|
||||
artifact = self.source / 'artifact.bin'
|
||||
artifact.write_bytes(os.urandom(128 * 1024))
|
||||
target = self.commit_source('Version metadata to refresh')
|
||||
blob = self.git(self.source, 'rev-parse', 'HEAD:artifact.bin')
|
||||
installed = self.root / 'installed'
|
||||
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
self.git(self.root, 'clone', '--no-local', str(self.merged), str(self.finalized))
|
||||
marker = self.finalized / '.overlay_consistent'
|
||||
marker.touch()
|
||||
self.updater.params.values['IsOffroad'] = True
|
||||
with patch.object(self.updated, 'init_overlay'), \
|
||||
patch('openpilot.starpilot.drive_state.evidence.PhysicalSource', return_value=Mock(update_ready=Mock(return_value=True))):
|
||||
self.assertEqual(self.updater.fast_update(BRANCH, self.initial_commit, versions_only=True), 'versions-listed')
|
||||
objects = set(self.git(self.updated.METADATA_ROOT, 'cat-file', '--batch-all-objects', '--batch-check=%(objectname)').splitlines())
|
||||
self.assertIn(target, objects)
|
||||
self.assertNotIn(blob, objects)
|
||||
self.assertEqual(self.git(installed, 'rev-parse', 'HEAD'), self.initial_commit)
|
||||
self.assertFalse((installed / '.git/FETCH_HEAD').exists())
|
||||
self.assertTrue(marker.exists())
|
||||
self.assertNotIn('DoReboot', self.updater.params.values)
|
||||
self.assertTrue(self.updater.params.values['UpdaterFetchAvailable'])
|
||||
self.assertFalse(self.updater.params.values['UpdateAvailable'])
|
||||
|
||||
def test_scheduled_check_stages_new_commit_when_automatic_downloads_are_enabled(self):
|
||||
(self.source / "panda/README.md").write_text("New automatic download\n")
|
||||
target = self.commit_source("Automatic download after checking")
|
||||
params = self.updater.params
|
||||
params.values["InstallDate"] = "already installed"
|
||||
preference = self.root / "UpdaterAutomaticDownloads"
|
||||
params.get_param_path = lambda key: str(self.root / key)
|
||||
for enabled in (False, True):
|
||||
with self.subTest(enabled=enabled):
|
||||
preference.write_bytes(b"1" if enabled else b"0")
|
||||
helper = Mock()
|
||||
helper.current_request.side_effect = [
|
||||
(self.updated.UserRequest.NONE, 0, None),
|
||||
(self.updated.UserRequest.NONE, 1, None),
|
||||
RuntimeError("test loop complete"),
|
||||
]
|
||||
with patch.object(self.updated, "Params", return_value=params), \
|
||||
patch.object(self.updated, "Updater", return_value=self.updater), \
|
||||
patch.object(self.updated, "WaitTimeHelper", return_value=helper), \
|
||||
patch.object(self.updated, "init_overlay"), \
|
||||
patch.object(self.updated, "LOCK_FILE", str(self.root / "updater.lock")), \
|
||||
patch.object(self.updater, "set_params"), \
|
||||
self.assertRaisesRegex(RuntimeError, "test loop complete"):
|
||||
self.updated.main()
|
||||
self.assertEqual((self.finalized / ".overlay_consistent").exists(), enabled)
|
||||
self.assertEqual("UpdaterLastFetchTime" in params.values, enabled)
|
||||
self.assertNotIn("DoReboot", params.values)
|
||||
if enabled:
|
||||
self.assertEqual(self.git(self.finalized, "rev-parse", "HEAD"), target)
|
||||
else:
|
||||
self.assertEqual(self.git(self.merged, "rev-parse", "HEAD"), self.initial_commit)
|
||||
|
||||
def test_up_to_date_check_does_not_repeat_automatic_staging(self):
|
||||
params = self.updater.params
|
||||
params.values['InstallDate'] = 'already installed'
|
||||
params.get_param_path = lambda key: str(self.root / key)
|
||||
helper = Mock()
|
||||
helper.current_request.side_effect = [
|
||||
(self.updated.UserRequest.NONE, 0, None),
|
||||
(self.updated.UserRequest.CHECK, 1, None),
|
||||
RuntimeError('test loop complete'),
|
||||
]
|
||||
with patch.object(self.updated, 'Params', return_value=params), \
|
||||
patch.object(self.updated, 'Updater', return_value=self.updater), \
|
||||
patch.object(self.updated, 'WaitTimeHelper', return_value=helper), \
|
||||
patch.object(self.updated, 'init_overlay'), \
|
||||
patch.object(self.updated, 'LOCK_FILE', str(self.root / 'updater.lock')), \
|
||||
patch.object(self.updater, 'set_params'), \
|
||||
patch.object(self.updater, 'fetch_update') as download, \
|
||||
self.assertRaisesRegex(RuntimeError, 'test loop complete'):
|
||||
self.updated.main()
|
||||
download.assert_not_called()
|
||||
self.assertNotIn('UpdaterLastFetchTime', params.values)
|
||||
self.assertFalse(self.finalized.exists())
|
||||
|
||||
def test_metadata_refresh_completes_while_background_finalization_is_blocked(self):
|
||||
installed = self.root / 'installed'
|
||||
self.git(self.root, 'clone', '--no-local', str(self.merged), str(installed))
|
||||
self.git(installed, 'remote', 'set-url', 'origin', str(self.source))
|
||||
self.updated.BASEDIR = str(installed)
|
||||
(self.source / 'panda/README.md').write_text('New background version')
|
||||
self.commit_source('Background download')
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
finalize = self.updated.finalize_update
|
||||
failures = []
|
||||
def blocked_finalize(*args, **kwargs):
|
||||
entered.set()
|
||||
if not release.wait(8):
|
||||
raise RuntimeError('Test did not release finalization')
|
||||
return finalize(*args, **kwargs)
|
||||
def download():
|
||||
try:
|
||||
self.updater.fetch_update(background=True)
|
||||
except Exception as error:
|
||||
failures.append(error)
|
||||
with patch.object(self.updated, 'finalize_update', side_effect=blocked_finalize):
|
||||
worker = threading.Thread(target=download)
|
||||
worker.start()
|
||||
try:
|
||||
self.assertTrue(entered.wait(4))
|
||||
started = time.monotonic()
|
||||
self.updater.request_check(branch=BRANCH, commit=self.initial_commit)
|
||||
deadline = started + 4
|
||||
while time.monotonic() < deadline:
|
||||
check = self.updater.params.values['UpdaterFastState'].get('check', {})
|
||||
if check.get('completedAt', 0) >= started:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
self.assertEqual(check['state'], 'complete')
|
||||
self.assertGreaterEqual(check['completedAt'], started)
|
||||
self.assertEqual(self.updater.params.values['UpdaterState'], 'finalizing update...')
|
||||
self.assertTrue(self.updater.params.values['UpdaterFastState']['background'])
|
||||
finally:
|
||||
release.set()
|
||||
worker.join(5)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertEqual(failures, [])
|
||||
|
||||
def test_finalized_runtime_files_are_independent_and_git_objects_are_shared(self):
|
||||
(self.source / 'panda/README.md').write_text('Prepared source')
|
||||
self.commit_source('New release')
|
||||
self.updater.fetch_update()
|
||||
source = self.merged / 'panda/README.md'
|
||||
copied = self.finalized / 'panda/README.md'
|
||||
self.assertNotEqual(source.stat().st_ino, copied.stat().st_ino)
|
||||
copied.write_text('Local edit in prepared tree')
|
||||
self.assertEqual(source.read_text(), 'Prepared source')
|
||||
objects = list((self.merged / '.git/objects/pack').glob('*.pack'))
|
||||
self.assertTrue(objects)
|
||||
for obj in objects:
|
||||
final = self.finalized / obj.relative_to(self.merged)
|
||||
self.assertEqual(obj.stat().st_ino, final.stat().st_ino)
|
||||
self.assertFalse(any(command[:2] == ['git', 'gc'] for command in self.commands))
|
||||
|
||||
def test_invalid_manifest_rejected_before_checkout(self):
|
||||
self.manifest["schema_version"] = 999
|
||||
self.write_manifest()
|
||||
|
||||
@@ -24,7 +24,11 @@ from openpilot.common.version import get_build_metadata
|
||||
from openpilot.common.vendor_manifest import validate_revision
|
||||
from openpilot.starpilot.software.update_control import UpdaterControlError, UpdaterControlServer
|
||||
from openpilot.starpilot.software.preferences import AUTOMATIC_DOWNLOADS, download_permitted
|
||||
from openpilot.starpilot.software.fast_update import FastUpdateError, FastUpdateIncompatible, required_os_version, run as run_git
|
||||
from openpilot.starpilot.software.fast_update import (
|
||||
FastUpdateError, FastUpdateIncompatible, fetch_files, required_os_version, run as run_git,
|
||||
)
|
||||
from openpilot.starpilot.software.history import MAX_COMMITS, METADATA_ROOT, commit_history
|
||||
from openpilot.starpilot.software.auto_install import InstallSettings, cron_matches, scheduled_restart
|
||||
|
||||
LOCK_FILE = os.getenv("UPDATER_LOCK_FILE", "/tmp/safe_staging_overlay.lock")
|
||||
STAGING_ROOT = os.getenv("UPDATER_STAGING_ROOT", "/data/safe_staging")
|
||||
@@ -51,16 +55,20 @@ class UserRequest:
|
||||
FAST = 3
|
||||
ROLLBACK = 4
|
||||
VERSIONS = 5
|
||||
DOWNLOAD_VERSION = 8
|
||||
VERSION = 6
|
||||
UPDATE = 7
|
||||
|
||||
class WaitTimeHelper:
|
||||
def __init__(self):
|
||||
def __init__(self, metadata=None, idle_tick=None):
|
||||
self.ready_event = threading.Event()
|
||||
self.user_request = UserRequest.NONE
|
||||
self.request_lock = threading.RLock()
|
||||
self.request_generation = 0
|
||||
self.fast_target: tuple[str, str] | tuple[str, str, str] | None = None
|
||||
self.metadata = metadata
|
||||
self.idle_tick = idle_tick
|
||||
self.automatic_pending = False
|
||||
signal.signal(signal.SIGHUP, self.update_now)
|
||||
signal.signal(signal.SIGUSR1, self.check_now)
|
||||
self.control = UpdaterControlServer(self._control_request)
|
||||
@@ -74,10 +82,10 @@ class WaitTimeHelper:
|
||||
def _request(self, request: int, *, branch=None, commit=None, selected_commit=None) -> None:
|
||||
with self.request_lock:
|
||||
self.user_request = request
|
||||
if request in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION):
|
||||
if request in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION):
|
||||
if not isinstance(branch, str) or not isinstance(commit, str):
|
||||
raise ValueError("Update source identity is required")
|
||||
if request == UserRequest.VERSION:
|
||||
if request in (UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION):
|
||||
if not isinstance(selected_commit, str):
|
||||
raise ValueError("Selected version is required")
|
||||
self.fast_target = (branch, commit, selected_commit)
|
||||
@@ -89,9 +97,12 @@ class WaitTimeHelper:
|
||||
self.ready_event.set()
|
||||
|
||||
def _control_request(self, action: str, *, branch=None, commit=None, selected_commit=None) -> None:
|
||||
if self.metadata is not None and action in ('check', 'versions'):
|
||||
self.metadata(branch=branch, commit=commit, download_after=action == 'check')
|
||||
return
|
||||
request = {'update': UserRequest.UPDATE, 'check': UserRequest.CHECK, 'download': UserRequest.FETCH,
|
||||
'fast': UserRequest.FAST, 'rollback': UserRequest.ROLLBACK,
|
||||
'versions': UserRequest.VERSIONS, 'version': UserRequest.VERSION}.get(action)
|
||||
'versions': UserRequest.VERSIONS, 'version': UserRequest.VERSION, 'download-version': UserRequest.DOWNLOAD_VERSION}.get(action)
|
||||
if request is not None:
|
||||
self._request(request, branch=branch, commit=commit, selected_commit=selected_commit)
|
||||
|
||||
@@ -101,32 +112,48 @@ class WaitTimeHelper:
|
||||
|
||||
def check_now(self, signum: int, frame) -> None:
|
||||
cloudlog.info("caught SIGUSR1, checking for updates")
|
||||
self._request(UserRequest.CHECK)
|
||||
self._control_request('check')
|
||||
|
||||
def current_request(self) -> tuple[int, int, tuple[str, str] | tuple[str, str, str] | None]:
|
||||
with self.request_lock:
|
||||
self.ready_event.clear()
|
||||
self.automatic_pending = False
|
||||
return self.user_request, self.request_generation, self.fast_target
|
||||
|
||||
def wake_automatic(self):
|
||||
with self.request_lock:
|
||||
self.automatic_pending = True
|
||||
self.ready_event.set()
|
||||
|
||||
def finish_request(self, generation: int) -> bool:
|
||||
with self.request_lock:
|
||||
if self.request_generation != generation:
|
||||
return False
|
||||
self.user_request = UserRequest.NONE
|
||||
self.fast_target = None
|
||||
if self.automatic_pending:
|
||||
return False
|
||||
self.ready_event.clear()
|
||||
return True
|
||||
|
||||
def sleep(self, t: float) -> None:
|
||||
self.ready_event.wait(timeout=t)
|
||||
deadline = time.monotonic() + t
|
||||
while not self.ready_event.is_set():
|
||||
tick = getattr(self, 'idle_tick', None)
|
||||
if tick is not None:
|
||||
tick()
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or self.ready_event.wait(timeout=min(30, remaining)):
|
||||
return
|
||||
|
||||
def write_time_to_param(params, param) -> None:
|
||||
t = datetime.datetime.now(datetime.UTC).replace(tzinfo=None)
|
||||
params.put(param, t, block=True)
|
||||
|
||||
def run(cmd: list[str], cwd: str | None = None) -> str:
|
||||
def run(cmd: list[str], cwd: str | None = None, *, download: bool = False, progress=None, cancelled=None, stdin=None, timeout=None) -> str:
|
||||
if cmd and cmd[0] == 'git':
|
||||
return run_git(cmd, cwd, timeout=300 if 'fetch' in cmd or 'gc' in cmd else 120)
|
||||
return run_git(cmd, cwd, timeout=timeout or (300 if download or 'fetch' in cmd or 'gc' in cmd else 120),
|
||||
download=download, progress=progress, cancelled=cancelled, stdin=stdin)
|
||||
return subprocess.check_output(cmd, cwd=cwd, stderr=subprocess.STDOUT, encoding='utf8')
|
||||
|
||||
|
||||
@@ -234,7 +261,7 @@ def init_overlay() -> None:
|
||||
cloudlog.info(f"git diff output:\n{git_diff}")
|
||||
|
||||
|
||||
def finalize_update() -> None:
|
||||
def finalize_update(progress=None) -> None:
|
||||
"""Take the current OverlayFS merged view and finalize a copy outside of
|
||||
OverlayFS, ready to be swapped-in at BASEDIR. Copy using shutil.copytree"""
|
||||
|
||||
@@ -246,19 +273,45 @@ def finalize_update() -> None:
|
||||
# Copy the merged overlay view and set the update ready flag
|
||||
if os.path.exists(FINALIZED):
|
||||
shutil.rmtree(FINALIZED)
|
||||
shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True)
|
||||
total = sum(not os.path.islink(os.path.join(root, name)) for root, _, files in os.walk(OVERLAY_MERGED) for name in files)
|
||||
copied = 0
|
||||
reported = 0.0
|
||||
roots = (Path(OVERLAY_UPPER), Path(BASEDIR)) if os.path.ismount(OVERLAY_MERGED) else (Path(OVERLAY_MERGED),)
|
||||
def copy_file(source, destination):
|
||||
nonlocal copied, reported
|
||||
relative = Path(source).relative_to(OVERLAY_MERGED)
|
||||
immutable = re.fullmatch(r'\.git/objects/(?:[0-9a-f]{2}/[0-9a-f]{38}|pack/pack-[0-9a-f]{40}\.(?:pack|idx))', relative.as_posix())
|
||||
linked = False
|
||||
if immutable:
|
||||
# Git objects are immutable; worktree files, refs and indexes remain independent copies.
|
||||
for root in roots:
|
||||
candidate = root / relative
|
||||
try:
|
||||
if not candidate.is_symlink():
|
||||
os.link(candidate, destination)
|
||||
linked = True
|
||||
break
|
||||
except OSError:
|
||||
pass
|
||||
if not linked:
|
||||
shutil.copy2(source, destination)
|
||||
copied += 1
|
||||
now = time.monotonic()
|
||||
if progress is not None and (now - reported >= 0.25 or copied == total):
|
||||
progress(f'Copying update files: {copied}/{total} ({copied * 100 // max(1, total)}%)', copied * 100 / max(1, total))
|
||||
reported = now
|
||||
return destination
|
||||
started = time.monotonic()
|
||||
shutil.copytree(OVERLAY_MERGED, FINALIZED, symlinks=True, copy_function=copy_file)
|
||||
cloudlog.event('update copy complete', duration=time.monotonic() - started, files=copied)
|
||||
|
||||
if progress is not None:
|
||||
progress('Verifying the prepared update...', None)
|
||||
run(["git", "reset", "--hard", "--no-recurse-submodules"], FINALIZED)
|
||||
validate_revision(FINALIZED, "HEAD")
|
||||
|
||||
cloudlog.info("Starting git cleanup in finalized update")
|
||||
t = time.monotonic()
|
||||
try:
|
||||
run(["git", "gc"], FINALIZED)
|
||||
cloudlog.event("Done git cleanup", duration=time.monotonic() - t)
|
||||
except subprocess.CalledProcessError:
|
||||
cloudlog.exception(f"Failed git cleanup, took {time.monotonic() - t:.3f} s")
|
||||
|
||||
if progress is not None:
|
||||
progress('Syncing the prepared update to storage...', None)
|
||||
set_consistent_flag(True)
|
||||
cloudlog.info("done finalizing overlay")
|
||||
|
||||
@@ -292,11 +345,96 @@ def handle_agnos_update() -> None:
|
||||
|
||||
|
||||
class Updater:
|
||||
def __init__(self):
|
||||
def __init__(self, install_settings=None):
|
||||
self.params = Params()
|
||||
self.branches: dict[str, str] = {}
|
||||
self._branches_checked = False
|
||||
self._has_internet: bool = False
|
||||
self._progress_lock = threading.RLock()
|
||||
self._metadata_lock = threading.Lock()
|
||||
self._request_lock = threading.Lock()
|
||||
self._check_thread = None
|
||||
self._pending_check = None
|
||||
self._closed = threading.Event()
|
||||
self._checked_at = 0.0
|
||||
self._checked_branch = None
|
||||
self.wake_automatic = None
|
||||
self.install_settings = install_settings if install_settings is not None else InstallSettings()
|
||||
|
||||
def maybe_install(self, idle=lambda: True) -> bool:
|
||||
from zoneinfo import ZoneInfo
|
||||
value = self.install_settings.read()
|
||||
now = datetime.datetime.now(ZoneInfo(value.get('timezone', 'UTC') if value else 'UTC'))
|
||||
if not value or not value['enabled'] or not system_time_valid() or not cron_matches(value['cron'], now):
|
||||
return False
|
||||
from openpilot.starpilot.drive_state.evidence import PhysicalSource
|
||||
source = PhysicalSource()
|
||||
def parked():
|
||||
deadline = time.monotonic() + 1.5
|
||||
while True:
|
||||
if source.update_ready(self.params.get_bool('IsOffroad')):
|
||||
return True
|
||||
if time.monotonic() >= deadline:
|
||||
return False
|
||||
time.sleep(0.02)
|
||||
def ready():
|
||||
if (not idle() or self.params.get('UpdaterState') != 'idle' or
|
||||
self._check_thread is not None or not self.update_ready or Path(FINALIZED, '.overlay_consistent').is_symlink()):
|
||||
return False
|
||||
validate_revision(FINALIZED, 'HEAD')
|
||||
return True
|
||||
try:
|
||||
return scheduled_restart(self.params, self.install_settings, ready, parked, now)
|
||||
except (OSError, ValueError, subprocess.SubprocessError):
|
||||
cloudlog.exception('Scheduled update restart was not admitted')
|
||||
return False
|
||||
finally:
|
||||
source.close()
|
||||
|
||||
def _write_progress(self, **values):
|
||||
if not hasattr(self, '_progress_lock'):
|
||||
self._progress_lock = threading.RLock()
|
||||
with self._progress_lock:
|
||||
previous = self.params.get('UpdaterFastState')
|
||||
state = dict(previous) if isinstance(previous, dict) else {'version': 1, 'stage': 'complete', 'detail': ''}
|
||||
state.update(values)
|
||||
self.params.put('UpdaterFastState', state, block=True)
|
||||
|
||||
def progress(self, stage, detail, *, background=False, percent=None):
|
||||
if percent is None and (match := re.search(r'(\d+)%', detail)):
|
||||
percent = min(100, int(match[1]))
|
||||
self._write_progress(version=1, stage=stage, detail=detail[:512], background=background, percent=percent)
|
||||
|
||||
def request_check(self, *, branch=None, commit=None, download_after=False):
|
||||
with self._request_lock:
|
||||
download_after = download_after or bool(self._pending_check and self._pending_check[2])
|
||||
self._pending_check = (branch or self.target_branch, commit, download_after)
|
||||
if self._check_thread is None:
|
||||
self._check_thread = threading.Thread(target=self._run_checks, name='updater-metadata', daemon=True)
|
||||
self._check_thread.start()
|
||||
|
||||
def _run_checks(self):
|
||||
while not self._closed.is_set():
|
||||
with self._request_lock:
|
||||
request, self._pending_check = self._pending_check, None
|
||||
if request is None:
|
||||
self._check_thread = None
|
||||
return
|
||||
branch, commit, download_after = request
|
||||
try:
|
||||
if commit is not None and self.get_commit_hash(BASEDIR) != commit:
|
||||
raise FastUpdateError('Installed revision changed; refresh and try again')
|
||||
self.check_for_update(branch)
|
||||
if (download_after and self.wake_automatic is not None and not self.params.get_bool('DoReboot') and
|
||||
download_permitted(self.params, manual=False) and self.update_available and not self.latest_update_ready):
|
||||
self.wake_automatic()
|
||||
except Exception as error:
|
||||
self._write_progress(check={'state': 'error', 'branch': branch, 'detail': str(error)[:512], 'completedAt': time.monotonic()})
|
||||
|
||||
def close(self):
|
||||
self._closed.set()
|
||||
if self._check_thread is not None:
|
||||
self._check_thread.join(timeout=6)
|
||||
|
||||
@property
|
||||
def has_internet(self) -> bool:
|
||||
@@ -318,19 +456,28 @@ class Updater:
|
||||
@property
|
||||
def update_ready(self) -> bool:
|
||||
consistent_file = Path(os.path.join(FINALIZED, ".overlay_consistent"))
|
||||
if self._branches_checked and consistent_file.is_file() and self.target_branch in self.branches:
|
||||
hash_mismatch = self.get_commit_hash(BASEDIR) != self.branches[self.target_branch]
|
||||
branch_mismatch = self.get_branch(BASEDIR) != self.target_branch
|
||||
on_target_branch = self.get_branch(FINALIZED) == self.target_branch
|
||||
finalized_at_remote_head = self.get_commit_hash(FINALIZED) == self.branches[self.target_branch]
|
||||
return ((hash_mismatch or branch_mismatch) and on_target_branch and finalized_at_remote_head)
|
||||
branch, branches = self.target_branch, self.branches
|
||||
if self._branches_checked and consistent_file.is_file() and branch in branches:
|
||||
branch_mismatch = self.get_branch(BASEDIR) != branch
|
||||
on_target_branch = self.get_branch(FINALIZED) == branch
|
||||
staged = getattr(self, '_staged_target', None)
|
||||
expected = staged[1] if staged is not None and staged[0] == branch else branches[branch]
|
||||
hash_mismatch = self.get_commit_hash(BASEDIR) != expected
|
||||
finalized_at_target = self.get_commit_hash(FINALIZED) == expected
|
||||
return ((hash_mismatch or branch_mismatch) and on_target_branch and finalized_at_target)
|
||||
return False
|
||||
|
||||
@property
|
||||
def latest_update_ready(self) -> bool:
|
||||
staged = getattr(self, '_staged_target', None)
|
||||
return self.update_ready and (staged is None or staged == (self.target_branch, self.branches.get(self.target_branch)))
|
||||
|
||||
@property
|
||||
def update_available(self) -> bool:
|
||||
if self._branches_checked and os.path.isdir(OVERLAY_MERGED) and self.target_branch in self.branches:
|
||||
hash_mismatch = self.get_commit_hash(OVERLAY_MERGED) != self.branches[self.target_branch]
|
||||
branch_mismatch = self.get_branch(OVERLAY_MERGED) != self.target_branch
|
||||
branch, branches = self.target_branch, self.branches
|
||||
if self._branches_checked and branch in branches:
|
||||
hash_mismatch = self.get_commit_hash(BASEDIR) != branches[branch]
|
||||
branch_mismatch = self.get_branch(BASEDIR) != branch
|
||||
return hash_mismatch or branch_mismatch
|
||||
return False
|
||||
|
||||
@@ -410,41 +557,70 @@ class Updater:
|
||||
remaining = max(HOURS_NO_CONNECTIVITY_MAX - dt_uptime_onroad, 1)
|
||||
set_offroad_alert("Offroad_ConnectivityNeededPrompt", True, extra_text=f"{remaining} hour{'' if remaining == 1 else 's'}.")
|
||||
|
||||
def check_for_update(self) -> None:
|
||||
def check_for_update(self, branch: str | None = None) -> None:
|
||||
branch = branch or self.target_branch
|
||||
with self._metadata_lock:
|
||||
self._write_progress(check={'state': 'checking', 'branch': branch, 'detail': 'Fetching version metadata...'})
|
||||
try:
|
||||
self._check_for_update(branch)
|
||||
self._checked_at, self._checked_branch = time.monotonic(), branch
|
||||
self._write_progress(check={'state': 'complete', 'branch': branch, 'detail': 'Version metadata refreshed.',
|
||||
'completedAt': time.monotonic()})
|
||||
except Exception as error:
|
||||
self._write_progress(check={'state': 'error', 'branch': branch, 'detail': str(error)[:512], 'completedAt': time.monotonic()})
|
||||
raise
|
||||
|
||||
def _check_for_update(self, branch) -> None:
|
||||
cloudlog.info("checking for updates")
|
||||
|
||||
excluded_branches = ('release2', 'release2-staging')
|
||||
|
||||
self._branches_checked = False
|
||||
self.params.put("UpdaterOSUpdate", {}, block=True)
|
||||
setup_git_options(OVERLAY_MERGED)
|
||||
metadata = Path(METADATA_ROOT)
|
||||
metadata.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not (metadata / 'HEAD').is_file():
|
||||
run(['git', 'init', '--bare', str(metadata)], str(metadata.parent))
|
||||
remote = run(['git', 'remote', 'get-url', 'origin'], BASEDIR).strip()
|
||||
run(['git', 'config', 'remote.origin.url', remote], str(metadata))
|
||||
setup_git_options(str(metadata))
|
||||
try:
|
||||
output = run(["git", "ls-remote", "--heads", "origin"], OVERLAY_MERGED)
|
||||
output = run(["git", "ls-remote", "--heads", "origin"], str(metadata), cancelled=self._closed.is_set, timeout=30)
|
||||
except subprocess.CalledProcessError:
|
||||
self._has_internet = False
|
||||
raise
|
||||
self._has_internet = True
|
||||
|
||||
self.branches.clear()
|
||||
branches = {}
|
||||
for line in output.split('\n'):
|
||||
ls_remotes_re = r'(?P<commit_sha>\b[0-9a-f]{5,40}\b)(\s+)(refs\/heads\/)(?P<branch_name>.*$)'
|
||||
x = re.fullmatch(ls_remotes_re, line.strip())
|
||||
if x is not None and x.group('branch_name') not in excluded_branches:
|
||||
self.branches[x.group('branch_name')] = x.group('commit_sha')
|
||||
cur_branch = self.get_branch(OVERLAY_MERGED)
|
||||
cur_commit = self.get_commit_hash(OVERLAY_MERGED)
|
||||
branches[x.group('branch_name')] = x.group('commit_sha')
|
||||
self.branches = branches
|
||||
cur_branch = self.get_branch(BASEDIR)
|
||||
cur_commit = self.get_commit_hash(BASEDIR)
|
||||
self._branches_checked = True
|
||||
write_time_to_param(self.params, "UpdaterLastCheckTime")
|
||||
new_branch = self.target_branch
|
||||
new_branch = branch or self.target_branch
|
||||
new_commit = self.branches.get(new_branch)
|
||||
if new_commit is None:
|
||||
cloudlog.info("target branch %s is not published on origin; no update available", new_branch)
|
||||
self.params.put('UpdaterAvailableBranches', ','.join(self.branches), block=True)
|
||||
self.params.put_bool('UpdaterFetchAvailable', False, block=True)
|
||||
self.params.put_bool('UpdateAvailable', False, block=True)
|
||||
write_time_to_param(self.params, 'UpdaterLastCheckTime')
|
||||
return
|
||||
# Fetch history and trees, not artifact contents, for the version list.
|
||||
def metadata_progress(detail):
|
||||
self._write_progress(check={'state': 'checking', 'branch': branch, 'detail': detail[:512]})
|
||||
run(["git", "fetch", '--progress', f"--depth={MAX_COMMITS}", "--filter=blob:none", "--no-tags", "--no-recurse-submodules",
|
||||
"origin", f"+{new_commit}:refs/remotes/origin/{new_branch}"], str(metadata),
|
||||
progress=metadata_progress, cancelled=self._closed.is_set, timeout=30)
|
||||
run(["git", "update-ref", f"refs/starpilot/recent/{new_branch}", new_commit], str(metadata))
|
||||
if AGNOS:
|
||||
try:
|
||||
if cur_commit != new_commit:
|
||||
run(["git", "fetch", "--depth=1", "--filter=blob:none", "--no-tags", "--no-recurse-submodules", "origin", new_commit], OVERLAY_MERGED)
|
||||
version = required_os_version(run(["git", "show", f"{new_commit}:launch_env.sh"], OVERLAY_MERGED))
|
||||
version = required_os_version(run(["git", "show", f"{new_commit}:launch_env.sh"], str(metadata),
|
||||
download=True, cancelled=self._closed.is_set, timeout=30))
|
||||
current_os = HARDWARE.get_os_version()
|
||||
if version and current_os:
|
||||
self.params.put("UpdaterOSUpdate", {"branch": new_branch, "required": version != current_os}, block=True)
|
||||
@@ -454,8 +630,12 @@ class Updater:
|
||||
cloudlog.info(f"update available, {cur_branch} ({str(cur_commit)[:7]}) -> {new_branch} ({str(new_commit)[:7]})")
|
||||
else:
|
||||
cloudlog.info(f"up to date on {cur_branch} ({str(cur_commit)[:7]})")
|
||||
self.params.put("UpdaterAvailableBranches", ','.join(self.branches), block=True)
|
||||
self.params.put_bool("UpdaterFetchAvailable", self.update_available, block=True)
|
||||
self.params.put_bool("UpdateAvailable", self.update_ready, block=True)
|
||||
write_time_to_param(self.params, 'UpdaterLastCheckTime')
|
||||
|
||||
def fast_update(self, branch: str, commit: str, selected_commit=None, *, rollback=False, versions_only=False, automatic=False) -> str:
|
||||
def fast_update(self, branch: str, commit: str, selected_commit=None, *, rollback=False, versions_only=False, automatic=False, download_only=False) -> str:
|
||||
from openpilot.starpilot.drive_state.evidence import PhysicalSource
|
||||
from openpilot.starpilot.software.fast_update import fast_update
|
||||
|
||||
@@ -478,11 +658,47 @@ class Updater:
|
||||
self._branches_checked = False
|
||||
|
||||
def progress(stage, detail):
|
||||
self.params.put("UpdaterFastState", {"version": 1, "stage": stage, "detail": detail[:512], "branch": branch}, block=True)
|
||||
self.progress(stage, detail)
|
||||
|
||||
try:
|
||||
if versions_only:
|
||||
initial_branch = self.get_branch(BASEDIR)
|
||||
def admitted():
|
||||
if not parked() or self.get_commit_hash(BASEDIR) != commit or self.get_branch(BASEDIR) != initial_branch:
|
||||
raise FastUpdateError("Update source or parked state changed")
|
||||
admitted()
|
||||
run(["git", "check-ref-format", "--branch", branch], BASEDIR)
|
||||
progress('fetching', 'Fetching version metadata...')
|
||||
self.check_for_update(branch)
|
||||
admitted()
|
||||
if branch not in self.branches:
|
||||
raise FastUpdateError("Target branch is no longer available")
|
||||
progress('complete', 'Recent versions are ready to review.')
|
||||
return 'versions-listed'
|
||||
if download_only:
|
||||
def admitted():
|
||||
if not parked() or self.get_commit_hash(BASEDIR) != commit:
|
||||
raise FastUpdateError('Installed revision or parked state changed; refresh and try again')
|
||||
admitted()
|
||||
self.check_for_update(branch)
|
||||
members = commit_history(Path(METADATA_ROOT), self.branches.get(branch, ''), first_parent=True)
|
||||
if branch == self.get_branch(BASEDIR):
|
||||
members += commit_history(Path(BASEDIR), commit, first_parent=True)
|
||||
if not any(row['hash'] == selected_commit for row in members):
|
||||
raise FastUpdateError('Selected version is no longer in branch history; refresh and try again')
|
||||
admitted()
|
||||
self.params.put('UpdaterTargetBranch', branch, block=True)
|
||||
if not self.fetch_update(selected_commit=selected_commit, admitted=admitted):
|
||||
raise FastUpdateError('Selected version could not be downloaded')
|
||||
admitted()
|
||||
self.set_params(True, 0, None)
|
||||
return 'downloaded'
|
||||
def installed_progress(stage, detail):
|
||||
if stage == 'complete':
|
||||
self.params.put('UpdaterTargetBranch', self.get_branch(BASEDIR), block=True)
|
||||
progress(stage, detail)
|
||||
return fast_update(Path(BASEDIR), branch, expected_commit=commit, params=self.params, parked=parked,
|
||||
current_os=HARDWARE.get_os_version(), invalidate=invalidate, rollback=rollback, progress=progress,
|
||||
current_os=HARDWARE.get_os_version(), invalidate=invalidate, rollback=rollback, progress=installed_progress,
|
||||
selected_commit=selected_commit, versions_only=versions_only)
|
||||
except FastUpdateIncompatible:
|
||||
if not automatic:
|
||||
@@ -499,7 +715,6 @@ class Updater:
|
||||
admitted()
|
||||
progress("preparing", "Preparing full software update")
|
||||
self.params.put("UpdaterTargetBranch", branch, block=True)
|
||||
init_overlay()
|
||||
self._branches_checked = False
|
||||
self.params.put("UpdaterState", "checking...", block=True)
|
||||
if phone_uplink_active():
|
||||
@@ -513,10 +728,10 @@ class Updater:
|
||||
self.params.put_bool("DoReboot", True, block=True)
|
||||
return "reboot-requested"
|
||||
|
||||
def fetch_update(self) -> bool:
|
||||
def fetch_update(self, *, background=False, selected_commit=None, admitted=None) -> bool:
|
||||
if phone_uplink_active():
|
||||
return False
|
||||
if not self._branches_checked:
|
||||
if not self._branches_checked or self._checked_branch != self.target_branch:
|
||||
self.check_for_update()
|
||||
if self.target_branch not in self.branches:
|
||||
cloudlog.info("skipping fetch of unpublished target branch %s", self.target_branch)
|
||||
@@ -525,6 +740,11 @@ class Updater:
|
||||
cloudlog.info("attempting git fetch inside staging overlay")
|
||||
|
||||
self.params.put("UpdaterState", "downloading...", block=True)
|
||||
self.progress('preparing', 'Preparing download staging...', background=background)
|
||||
init_overlay()
|
||||
self.progress('fetching', 'Downloading the selected version...', background=background)
|
||||
def git_progress(detail):
|
||||
self.progress('fetching', detail, background=background)
|
||||
|
||||
# TODO: cleanly interrupt this and invalidate old update
|
||||
set_consistent_flag(False)
|
||||
@@ -535,22 +755,37 @@ class Updater:
|
||||
run(["git", "config", "--replace-all", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*"], OVERLAY_MERGED)
|
||||
|
||||
branch = self.target_branch
|
||||
git_fetch_output = run(["git", "fetch", "--no-recurse-submodules", "origin", branch], OVERLAY_MERGED)
|
||||
cloudlog.info("git fetch success: %s", git_fetch_output)
|
||||
|
||||
# Validate using the running updater before checking out or executing any update.
|
||||
# A branch containing gitlinks or an invalid dependency manifest is not installable.
|
||||
fetched_commit = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], OVERLAY_MERGED).strip()
|
||||
fetched_commit = selected_commit or self.branches[branch]
|
||||
run(['git', 'fetch', '--progress', '--depth=1', '--filter=blob:none', '--no-tags', '--no-recurse-submodules',
|
||||
'origin', f'+{fetched_commit}:refs/remotes/origin/{branch}'], OVERLAY_MERGED, progress=git_progress)
|
||||
# Fetch the manifest under the download guard before read-only validation.
|
||||
if run(["git", "ls-tree", fetched_commit, "--", "upstream-sync.json"], OVERLAY_MERGED).split()[:1] == ['100644']:
|
||||
run(["git", "show", f"{fetched_commit}:upstream-sync.json"], OVERLAY_MERGED, download=True)
|
||||
validate_revision(OVERLAY_MERGED, fetched_commit)
|
||||
def git(*args, stdin=None):
|
||||
return run(['git', *args], OVERLAY_MERGED, progress=git_progress, stdin=stdin)
|
||||
fetch_files(git, fetched_commit)
|
||||
run(["git", "update-ref", f"refs/starpilot/recent/{branch}", fetched_commit], OVERLAY_MERGED)
|
||||
|
||||
if admitted is not None:
|
||||
admitted()
|
||||
cloudlog.info("git reset in progress")
|
||||
self.progress('applying', 'Writing downloaded files...', background=background)
|
||||
def apply_progress(detail):
|
||||
self.progress('applying', detail, background=background)
|
||||
installed_commit = self.get_commit_hash(BASEDIR)
|
||||
installed_branch = self.get_branch(BASEDIR)
|
||||
run(['git', 'update-ref', 'refs/starpilot/previous', installed_commit], OVERLAY_MERGED)
|
||||
run(['git', 'config', '--local', 'starpilot.previousVersion', f'{installed_commit} {installed_branch}'], OVERLAY_MERGED)
|
||||
cmds = [
|
||||
["git", "checkout", "--force", "--no-recurse-submodules", "-B", branch, fetched_commit],
|
||||
["git", "checkout", '--progress', "--force", "--no-recurse-submodules", "-B", branch, fetched_commit],
|
||||
["git", "branch", "--set-upstream-to", f"origin/{branch}"],
|
||||
["git", "reset", "--hard", "--no-recurse-submodules"],
|
||||
["git", "clean", "-xdff"],
|
||||
]
|
||||
r = [run(cmd, OVERLAY_MERGED) for cmd in cmds]
|
||||
r = [run(cmd, OVERLAY_MERGED, download=cmd[1] in ('checkout', 'reset'), progress=apply_progress) for cmd in cmds]
|
||||
validate_revision(OVERLAY_MERGED, "HEAD")
|
||||
cloudlog.info("git reset success: %s", '\n'.join(r))
|
||||
|
||||
@@ -560,18 +795,27 @@ class Updater:
|
||||
|
||||
# Create the finalized, ready-to-swap update
|
||||
self.params.put("UpdaterState", "finalizing update...", block=True)
|
||||
finalize_update()
|
||||
self.progress('finalizing', 'Preparing the downloaded update...', background=background)
|
||||
finalize_update(progress=lambda detail, percent: self.progress('finalizing', detail, background=background, percent=percent))
|
||||
self._staged_target = (branch, fetched_commit)
|
||||
self.progress('complete', 'Downloaded update is ready to install.', background=background)
|
||||
cloudlog.info("finalize success!")
|
||||
return True
|
||||
|
||||
|
||||
def recover_fast_state(params):
|
||||
previous = params.get("UpdaterFastState")
|
||||
if isinstance(previous, dict) and previous.get("stage") not in ("complete", "error"):
|
||||
params.put("UpdaterFastState", {
|
||||
"version": 1, "stage": "error",
|
||||
"detail": "Update process restarted before completion; check the installed version and retry.",
|
||||
}, block=True)
|
||||
if not isinstance(previous, dict):
|
||||
return
|
||||
state = dict(previous)
|
||||
if state.get('check', {}).get('state') == 'checking':
|
||||
state['check'] = {**state['check'], 'state': 'error', 'completedAt': time.monotonic(),
|
||||
'detail': 'Metadata check was interrupted when the updater restarted. Retry the check.'}
|
||||
if state.get('stage') not in ('complete', 'error'):
|
||||
state.update(version=1, stage='error',
|
||||
detail='Update process restarted before completion; check the installed version and retry.')
|
||||
if state != previous:
|
||||
params.put('UpdaterFastState', state, block=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -597,7 +841,10 @@ def main() -> None:
|
||||
|
||||
updater = Updater()
|
||||
update_failed_count = max(0, params.get("UpdateFailedCount") or 0)
|
||||
wait_helper = WaitTimeHelper()
|
||||
wait_helper = WaitTimeHelper(metadata=updater.request_check)
|
||||
wait_helper.idle_tick = lambda: updater.maybe_install(lambda: not wait_helper.ready_event.is_set())
|
||||
updater.wake_automatic = wait_helper.wake_automatic
|
||||
atexit.register(updater.close)
|
||||
|
||||
# invalidate old finalized update
|
||||
set_consistent_flag(False)
|
||||
@@ -613,14 +860,18 @@ def main() -> None:
|
||||
|
||||
# Attempt an update
|
||||
exception = None
|
||||
fast_requested = requested in (UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION)
|
||||
fast_requested = requested in (
|
||||
UserRequest.UPDATE, UserRequest.FAST, UserRequest.ROLLBACK, UserRequest.VERSIONS, UserRequest.VERSION, UserRequest.DOWNLOAD_VERSION,
|
||||
)
|
||||
try:
|
||||
if fast_requested:
|
||||
update_failed_count += 1
|
||||
params.put("UpdaterState", "updating...", block=True)
|
||||
params.put("UpdaterState", "downloading..." if requested == UserRequest.DOWNLOAD_VERSION else "updating...", block=True)
|
||||
if fast_target is None:
|
||||
raise ValueError("Update source identity is required")
|
||||
if requested == UserRequest.VERSIONS:
|
||||
if requested == UserRequest.DOWNLOAD_VERSION:
|
||||
updater.fast_update(*fast_target, download_only=True)
|
||||
elif requested == UserRequest.VERSIONS:
|
||||
updater.fast_update(*fast_target, versions_only=True)
|
||||
elif requested == UserRequest.UPDATE:
|
||||
updater.fast_update(*fast_target, automatic=True)
|
||||
@@ -632,9 +883,6 @@ def main() -> None:
|
||||
if requested != UserRequest.VERSIONS:
|
||||
write_time_to_param(params, "LastUpdateTime")
|
||||
else:
|
||||
# TODO: reuse overlay from previous updated instance if it looks clean
|
||||
init_overlay()
|
||||
|
||||
# ensure we have some params written soon after startup
|
||||
updater.set_params(False, update_failed_count, exception)
|
||||
|
||||
@@ -646,21 +894,27 @@ def main() -> None:
|
||||
update_failed_count += 1
|
||||
|
||||
# check for update
|
||||
params.put("UpdaterState", "checking...", block=True)
|
||||
updater.check_for_update()
|
||||
if requested == UserRequest.CHECK or updater._checked_branch != updater.target_branch or time.monotonic() - updater._checked_at > 60:
|
||||
updater.check_for_update()
|
||||
|
||||
# download update
|
||||
last_fetch = params.get("UpdaterLastFetchTime")
|
||||
timed_out = last_fetch is None or (datetime.datetime.now(datetime.UTC).replace(tzinfo=None) - last_fetch > datetime.timedelta(days=3))
|
||||
user_requested_fetch = requested == UserRequest.FETCH
|
||||
if phone_uplink_active():
|
||||
if params.get_bool('DoReboot'):
|
||||
cloudlog.info('skipping fetch, restart pending')
|
||||
elif requested == UserRequest.CHECK:
|
||||
cloudlog.info('metadata-only check complete')
|
||||
elif phone_uplink_active():
|
||||
cloudlog.info("skipping fetch, phone uplink forbids updates")
|
||||
elif params.get_bool("NetworkMetered") and not timed_out and not user_requested_fetch:
|
||||
cloudlog.info("skipping fetch, connection metered")
|
||||
elif not download_permitted(params, manual=user_requested_fetch, check_only=requested == UserRequest.CHECK):
|
||||
cloudlog.info("skipping fetch, check-only request or automatic downloads disabled")
|
||||
elif not download_permitted(params, manual=user_requested_fetch):
|
||||
cloudlog.info("skipping fetch, automatic downloads disabled")
|
||||
elif not user_requested_fetch and (updater.latest_update_ready or not updater.update_available):
|
||||
cloudlog.info("skipping fetch, checked version is already installed or staged")
|
||||
else:
|
||||
if updater.fetch_update():
|
||||
if updater.fetch_update(background=not user_requested_fetch):
|
||||
write_time_to_param(params, "UpdaterLastFetchTime")
|
||||
update_failed_count = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -683,7 +937,7 @@ def main() -> None:
|
||||
update_successful = (update_failed_count == 0)
|
||||
if fast_requested:
|
||||
if exception is not None:
|
||||
params.put("UpdaterFastState", {"version": 1, "stage": "error", "detail": exception[:512]}, block=True)
|
||||
updater.progress('error', exception)
|
||||
params.put("UpdateFailedCount", update_failed_count, block=True)
|
||||
if exception is None:
|
||||
params.remove("LastUpdateException")
|
||||
|
||||
Reference in New Issue
Block a user