mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-04 13:02:09 +08:00
Updater
This commit is contained in:
@@ -75,4 +75,4 @@
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603
|
||||
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
|
||||
import { TmuxLog } from "/assets/components/tools/tmux.js"
|
||||
import { ToggleControl } from "/assets/components/tools/toggles.js"
|
||||
import { UpdateManager } from "/assets/components/tools/update_manager.js"
|
||||
|
||||
let router, routerState
|
||||
|
||||
@@ -45,6 +46,7 @@ function Root() {
|
||||
createRoute("thememaker", "/theme_maker", ThemeMaker),
|
||||
createRoute("tmux", "/manage_tmux", TmuxLog),
|
||||
createRoute("toggles", "/manage_toggles", ToggleControl),
|
||||
createRoute("updates", "/manage_updates", UpdateManager),
|
||||
createRoute("vehicle_features", "/vehicle_features", VehicleFeatures),
|
||||
]
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const MenuItems = {
|
||||
{ name: "Theme Maker", link: "/theme_maker", icon: "bi-palette-fill" },
|
||||
{ name: "Tmux Log", link: "/manage_tmux", icon: "bi-terminal" },
|
||||
{ name: "Toggles", link: "/manage_toggles", icon: "bi-toggle-on" },
|
||||
{ name: "Software", link: "/manage_updates", icon: "bi-arrow-up-circle" },
|
||||
{ name: "Vehicle Features", link: "/vehicle_features", icon: "bi-car-front" },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
.updateManager {
|
||||
max-width: var(--width-xxl);
|
||||
}
|
||||
|
||||
.updateActions {
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateButton {
|
||||
background: var(--main-fg);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
padding: var(--padding-sm) var(--padding-base);
|
||||
}
|
||||
|
||||
.updateButton.danger {
|
||||
background: var(--danger-bg);
|
||||
}
|
||||
|
||||
.updateButton.danger:hover {
|
||||
background: var(--danger-hover-bg);
|
||||
}
|
||||
|
||||
.updateButton:hover {
|
||||
transform: var(--hover-scale-sm);
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.updateButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.updateCard {
|
||||
background-color: var(--secondary-bg);
|
||||
border-radius: var(--border-radius-md);
|
||||
color: var(--text-color);
|
||||
max-width: 80%;
|
||||
padding: var(--border-radius-xl);
|
||||
}
|
||||
|
||||
.updateError {
|
||||
color: var(--danger-fg);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateGrid {
|
||||
display: grid;
|
||||
gap: 0.5em 1em;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.updateGrid p {
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.updateLink {
|
||||
color: var(--main-fg);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.updateMessage {
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateProgressCard {
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateProgressDetail {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateProgressDetail.error {
|
||||
color: var(--danger-fg);
|
||||
}
|
||||
|
||||
.updateProgressFill {
|
||||
background: linear-gradient(90deg, #5ec8c8 0%, #8b6cc5 100%);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 100%;
|
||||
transition: width var(--transition-base);
|
||||
}
|
||||
|
||||
.updateProgressFill.error {
|
||||
background: linear-gradient(90deg, #b43a3a 0%, #de5656 100%);
|
||||
}
|
||||
|
||||
.updateProgressHeader {
|
||||
display: flex;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--margin-xs);
|
||||
}
|
||||
|
||||
.updateProgressTrack {
|
||||
background: var(--track-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.updateProgressTrack.error {
|
||||
border: 1px solid var(--danger-bg);
|
||||
}
|
||||
|
||||
.updateToggleRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateWarning {
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateHint {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateRebootNotice {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.updateFooter {
|
||||
border-top: 1px solid var(--track-color);
|
||||
margin-top: var(--margin-base);
|
||||
padding-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateBranchSection {
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.updateAdvancedNote {
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateBranchHeader {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: var(--font-size-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.updateBranchRow {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.updateSelect {
|
||||
background-color: var(--main-bg);
|
||||
border: 1px solid var(--track-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
font-size: var(--font-size-sm);
|
||||
min-width: 0;
|
||||
padding: var(--padding-sm);
|
||||
}
|
||||
|
||||
.updateSelect:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.updateToggleRow input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 768px) and (orientation: portrait) {
|
||||
.updateActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.updateBranchRow {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.updateCard {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.updateGrid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
import { html, reactive } from "https://esm.sh/@arrow-js/core"
|
||||
|
||||
const state = reactive({
|
||||
loading: true,
|
||||
error: "",
|
||||
status: null,
|
||||
checkedForUpdates: false,
|
||||
checkBusy: false,
|
||||
updateBusy: false,
|
||||
toggleBusy: false,
|
||||
showAdvancedOptions: false,
|
||||
branches: [],
|
||||
selectedBranch: "",
|
||||
hasManualBranchSelection: false,
|
||||
branchesError: "",
|
||||
branchesBusy: false,
|
||||
switchBusy: false,
|
||||
})
|
||||
|
||||
let initialized = false
|
||||
let pollHandle = null
|
||||
let reconnectPending = false
|
||||
let rebootNoticeShown = false
|
||||
const POLL_INTERVAL_MS = 1000
|
||||
const ADVANCED_OPTIONS_KEY = "softwareShowAdvancedOptions"
|
||||
|
||||
function shortHash(value) {
|
||||
const text = String(value || "").trim()
|
||||
return text ? text.slice(0, 10) : "Unknown"
|
||||
}
|
||||
|
||||
function toPercent(value) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return 0
|
||||
return Math.max(0, Math.min(100, n))
|
||||
}
|
||||
|
||||
function findBranchInList(branches, target) {
|
||||
const wanted = String(target || "").trim()
|
||||
if (!wanted || !Array.isArray(branches) || branches.length === 0) return ""
|
||||
if (branches.includes(wanted)) return wanted
|
||||
|
||||
const wantedLower = wanted.toLowerCase()
|
||||
const caseInsensitive = branches.find((branch) => String(branch || "").toLowerCase() === wantedLower)
|
||||
return caseInsensitive || ""
|
||||
}
|
||||
|
||||
function isSelectedBranchDifferent() {
|
||||
const currentBranch = String(state.status?.branch || "").trim()
|
||||
const selectedBranch = String(state.selectedBranch || "").trim()
|
||||
return !!currentBranch && !!selectedBranch && currentBranch !== selectedBranch
|
||||
}
|
||||
|
||||
function normalizeGithubRemote(remoteValue, commitsUrlValue = "") {
|
||||
let remote = String(remoteValue || "").trim()
|
||||
if (!remote) {
|
||||
const commitsUrl = String(commitsUrlValue || "").trim()
|
||||
const marker = "/commits/"
|
||||
const markerIndex = commitsUrl.indexOf(marker)
|
||||
if (commitsUrl.startsWith("https://github.com/") && markerIndex > 0) {
|
||||
remote = commitsUrl.slice(0, markerIndex)
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
if (remote.startsWith("git@github.com:")) {
|
||||
remote = `https://github.com/${remote.split(":", 2)[1] || ""}`
|
||||
} else if (remote.startsWith("ssh://git@github.com/")) {
|
||||
remote = `https://github.com/${remote.split("ssh://git@github.com/", 2)[1] || ""}`
|
||||
} else if (remote.startsWith("http://github.com/")) {
|
||||
remote = `https://github.com/${remote.split("http://github.com/", 2)[1] || ""}`
|
||||
}
|
||||
|
||||
if (!remote.startsWith("https://github.com/")) return ""
|
||||
remote = remote.replace(/\/+$/, "")
|
||||
if (remote.endsWith(".git")) remote = remote.slice(0, -4)
|
||||
return remote
|
||||
}
|
||||
|
||||
function currentOrSelectedBranchForCommits() {
|
||||
const selected = String(state.selectedBranch || "").trim()
|
||||
if (selected) return selected
|
||||
return String(state.status?.branch || "").trim()
|
||||
}
|
||||
|
||||
function activeCommitsUrl() {
|
||||
const branch = currentOrSelectedBranchForCommits()
|
||||
if (!branch) return ""
|
||||
|
||||
const remote = normalizeGithubRemote(state.status?.originRemote, state.status?.commitsUrl)
|
||||
if (!remote) return ""
|
||||
return `${remote}/commits/${encodeURIComponent(branch)}/`
|
||||
}
|
||||
|
||||
function activeCommitsLabel() {
|
||||
const branch = currentOrSelectedBranchForCommits()
|
||||
return branch
|
||||
? `View latest commit for the "${branch}" branch`
|
||||
: "View latest commit for this branch"
|
||||
}
|
||||
|
||||
function shouldShowPrimaryUpdateAction() {
|
||||
if (state.status?.running) return true
|
||||
if (isSelectedBranchDifferent()) return true
|
||||
return !!state.checkedForUpdates && !!state.status?.updateAvailable
|
||||
}
|
||||
|
||||
function shouldContinuePolling() {
|
||||
return !!state.status?.running || state.status?.stage === "rebooting" || reconnectPending
|
||||
}
|
||||
|
||||
function shouldShowRebootNotice() {
|
||||
if (state.status?.stage === "rebooting") return true
|
||||
if (reconnectPending) return true
|
||||
const message = String(state.status?.message || "").toLowerCase()
|
||||
return message.includes("reboot")
|
||||
}
|
||||
|
||||
function isHtmlLike(text) {
|
||||
const value = String(text || "").trim()
|
||||
return value.startsWith("<!DOCTYPE") || value.startsWith("<html") || value.startsWith("<")
|
||||
}
|
||||
|
||||
async function readJsonPayload(response) {
|
||||
const bodyText = await response.text()
|
||||
if (!bodyText) return {}
|
||||
|
||||
try {
|
||||
return JSON.parse(bodyText)
|
||||
} catch (parseError) {
|
||||
const error = new Error("Invalid JSON response")
|
||||
error.cause = parseError
|
||||
error.bodyText = bodyText
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function setRebootingUiState(detail = "Reboot in progress...") {
|
||||
state.status = {
|
||||
...(state.status || {}),
|
||||
running: false,
|
||||
stage: "rebooting",
|
||||
progressStep: 5,
|
||||
progressTotalSteps: state.status?.progressTotalSteps || 5,
|
||||
progressStepPercent: 100,
|
||||
progressPercent: 100,
|
||||
progressLabel: "Rebooting device",
|
||||
progressDetail: detail,
|
||||
message: "Update complete. Device is rebooting. Please wait...",
|
||||
}
|
||||
state.error = ""
|
||||
}
|
||||
|
||||
function isRebootRelatedConnectionError(error) {
|
||||
const message = String(error?.message || "")
|
||||
return /Invalid JSON response|Unexpected token '<'|Failed to fetch|NetworkError|Load failed/i.test(message)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (!pollHandle) return
|
||||
clearTimeout(pollHandle)
|
||||
pollHandle = null
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (pollHandle) return
|
||||
|
||||
const poll = async () => {
|
||||
await fetchStatus(false)
|
||||
if (shouldContinuePolling()) {
|
||||
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
} else {
|
||||
pollHandle = null
|
||||
}
|
||||
}
|
||||
|
||||
pollHandle = setTimeout(poll, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
async function fetchStatus(showToast) {
|
||||
if (showToast) state.checkBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/update/fast/status")
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to load update status")
|
||||
}
|
||||
|
||||
state.status = payload
|
||||
state.error = ""
|
||||
|
||||
if (reconnectPending && !payload.running && payload.stage !== "rebooting") {
|
||||
reconnectPending = false
|
||||
rebootNoticeShown = false
|
||||
showSnackbar("Connection reestablished.")
|
||||
}
|
||||
|
||||
if (payload.stage === "rebooting" && !rebootNoticeShown) {
|
||||
rebootNoticeShown = true
|
||||
showSnackbar("Device rebooting, please wait for reconnection...")
|
||||
}
|
||||
|
||||
if (!state.selectedBranch && payload.branch) {
|
||||
state.selectedBranch = payload.branch
|
||||
}
|
||||
|
||||
if (shouldContinuePolling()) {
|
||||
ensurePolling()
|
||||
} else {
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
state.checkedForUpdates = true
|
||||
showSnackbar(payload.updateAvailable ? "Update available." : "No update available.")
|
||||
}
|
||||
} catch (error) {
|
||||
const isRebootTransitionError = !showToast
|
||||
&& (state.status?.running || state.status?.stage === "rebooting" || reconnectPending)
|
||||
&& (isHtmlLike(error?.bodyText) || isRebootRelatedConnectionError(error))
|
||||
|
||||
if (isRebootTransitionError) {
|
||||
reconnectPending = true
|
||||
setRebootingUiState("Device rebooting, please wait for reconnection...")
|
||||
if (!rebootNoticeShown) {
|
||||
rebootNoticeShown = true
|
||||
showSnackbar("Device rebooting, please wait for reconnection...")
|
||||
}
|
||||
ensurePolling()
|
||||
return
|
||||
}
|
||||
|
||||
const message = error?.message || String(error)
|
||||
state.error = message
|
||||
if (showToast) {
|
||||
showSnackbar(message, "error")
|
||||
}
|
||||
} finally {
|
||||
if (showToast) state.checkBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBranches(showToast = false) {
|
||||
state.branchesBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/update/branches")
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to load branches")
|
||||
}
|
||||
|
||||
const branches = Array.isArray(payload.branches) ? payload.branches : []
|
||||
const currentBranchRaw = String(payload.currentBranch || state.status?.branch || "").trim()
|
||||
const matchedCurrentBranch = findBranchInList(branches, currentBranchRaw)
|
||||
state.branches = branches
|
||||
state.branchesError = payload.remoteError || ""
|
||||
|
||||
if (branches.length === 0) {
|
||||
state.selectedBranch = currentBranchRaw
|
||||
state.hasManualBranchSelection = false
|
||||
} else if (matchedCurrentBranch && !state.hasManualBranchSelection) {
|
||||
// Default to the branch currently installed unless the user picked one explicitly.
|
||||
state.selectedBranch = matchedCurrentBranch
|
||||
} else if (!state.selectedBranch || !branches.includes(state.selectedBranch)) {
|
||||
state.selectedBranch = matchedCurrentBranch || branches[0]
|
||||
state.hasManualBranchSelection = false
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
showSnackbar(branches.length ? `Loaded ${branches.length} branches.` : "No branches found.")
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.message || "Failed to load branches"
|
||||
state.branchesError = message
|
||||
if (showToast) {
|
||||
showSnackbar(message, "error")
|
||||
}
|
||||
} finally {
|
||||
state.branchesBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function setAdvancedOptions(enabled) {
|
||||
const next = !!enabled
|
||||
state.showAdvancedOptions = next
|
||||
|
||||
if (!next) {
|
||||
const currentBranch = String(state.status?.branch || "").trim()
|
||||
if (currentBranch) {
|
||||
state.selectedBranch = currentBranch
|
||||
}
|
||||
state.hasManualBranchSelection = false
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(ADVANCED_OPTIONS_KEY, next ? "1" : "0")
|
||||
} catch (error) {
|
||||
console.warn("Failed to persist advanced options preference", error)
|
||||
}
|
||||
|
||||
if (next && state.branches.length === 0 && !state.branchesBusy) {
|
||||
fetchBranches(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function setAutomaticUpdates(enabled) {
|
||||
if (state.toggleBusy) return
|
||||
if (state.status?.isOnroad) {
|
||||
showSnackbar("Actions are blocked while onroad.", "error")
|
||||
return
|
||||
}
|
||||
state.toggleBusy = true
|
||||
try {
|
||||
const response = await fetch("/api/params", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key: "AutomaticUpdates", value: !!enabled }),
|
||||
})
|
||||
const payload = await response.json()
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to update Automatic Updates")
|
||||
}
|
||||
|
||||
state.status = { ...(state.status || {}), automaticUpdates: !!enabled }
|
||||
showSnackbar(payload.message || "Automatic updates updated.")
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to update Automatic Updates", "error")
|
||||
await fetchStatus(false)
|
||||
} finally {
|
||||
state.toggleBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runFastUpdate() {
|
||||
if (state.updateBusy) return
|
||||
if (state.status?.running) {
|
||||
showSnackbar("Fast update is already running.")
|
||||
return
|
||||
}
|
||||
if (state.status?.isOnroad) {
|
||||
showSnackbar("Actions are blocked while onroad.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"Fast update warning:\n\n" +
|
||||
"- This update method skips backup creation.\n" +
|
||||
"- Your device will reboot when the update is done.\n\n" +
|
||||
"Continue with fast update?"
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
state.updateBusy = true
|
||||
try {
|
||||
reconnectPending = false
|
||||
rebootNoticeShown = false
|
||||
const response = await fetch("/api/update/fast", { method: "POST" })
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to start fast update")
|
||||
}
|
||||
|
||||
state.status = {
|
||||
...(state.status || {}),
|
||||
running: true,
|
||||
stage: "starting",
|
||||
progressStep: 1,
|
||||
progressTotalSteps: state.status?.progressTotalSteps || 5,
|
||||
progressStepPercent: 0,
|
||||
progressPercent: 0,
|
||||
progressLabel: "Preparing update",
|
||||
progressDetail: "Initializing update process...",
|
||||
message: payload.message || "Fast update started. Device will reboot when complete.",
|
||||
lastError: "",
|
||||
}
|
||||
state.error = ""
|
||||
showSnackbar(payload.message || "Fast update started.")
|
||||
await fetchStatus(false)
|
||||
ensurePolling()
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to start fast update", "error")
|
||||
} finally {
|
||||
state.updateBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runBranchSwitch() {
|
||||
if (state.switchBusy) return
|
||||
if (state.status?.running) {
|
||||
showSnackbar("An update action is already running.")
|
||||
return
|
||||
}
|
||||
if (state.status?.isOnroad) {
|
||||
showSnackbar("Actions are blocked while onroad.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const targetBranch = String(state.selectedBranch || "").trim()
|
||||
if (!targetBranch) {
|
||||
showSnackbar("Select a target branch first.", "error")
|
||||
return
|
||||
}
|
||||
|
||||
const currentBranch = String(state.status?.branch || "").trim()
|
||||
const actionLabel = currentBranch && currentBranch === targetBranch ? "update" : "switch and update"
|
||||
const confirmed = window.confirm(
|
||||
`This will ${actionLabel} to the '${targetBranch}' branch.\n\n` +
|
||||
"- This update method skips backup creation.\n" +
|
||||
"- Your device will reboot when the update is done.\n\n" +
|
||||
"Continue?"
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
state.switchBusy = true
|
||||
try {
|
||||
reconnectPending = false
|
||||
rebootNoticeShown = false
|
||||
const response = await fetch("/api/update/branch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ branch: targetBranch }),
|
||||
})
|
||||
const payload = await readJsonPayload(response)
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || response.statusText || "Failed to start branch switch")
|
||||
}
|
||||
|
||||
state.status = {
|
||||
...(state.status || {}),
|
||||
running: true,
|
||||
stage: "starting",
|
||||
progressStep: 1,
|
||||
progressTotalSteps: state.status?.progressTotalSteps || 5,
|
||||
progressStepPercent: 0,
|
||||
progressPercent: 0,
|
||||
progressLabel: "Preparing branch switch",
|
||||
progressDetail: "Initializing branch switch...",
|
||||
message: payload.message || `Branch switch started for '${targetBranch}'. Device will reboot when complete.`,
|
||||
lastError: "",
|
||||
}
|
||||
state.error = ""
|
||||
showSnackbar(payload.message || "Branch switch started.")
|
||||
await fetchStatus(false)
|
||||
ensurePolling()
|
||||
} catch (error) {
|
||||
showSnackbar(error?.message || "Failed to start branch switch", "error")
|
||||
} finally {
|
||||
state.switchBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
try {
|
||||
state.showAdvancedOptions = localStorage.getItem(ADVANCED_OPTIONS_KEY) === "1"
|
||||
} catch (error) {
|
||||
state.showAdvancedOptions = false
|
||||
}
|
||||
|
||||
const initTasks = [fetchStatus(false)]
|
||||
if (state.showAdvancedOptions) {
|
||||
initTasks.push(fetchBranches(false))
|
||||
}
|
||||
|
||||
Promise.all(initTasks).finally(() => {
|
||||
state.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
export function UpdateManager() {
|
||||
initialize()
|
||||
|
||||
return html`
|
||||
<div class="updateManager">
|
||||
<h2>Software</h2>
|
||||
|
||||
${() => state.loading ? html`<div class="updateCard">Loading update status...</div>` : ""}
|
||||
${() => !state.loading ? html`
|
||||
<div class="updateCard">
|
||||
<div class="updateGrid">
|
||||
<p><strong>Current Branch:</strong> ${state.status?.branch || "Unknown"}</p>
|
||||
<p><strong>Installed Commit:</strong> ${shortHash(state.status?.localCommit)}</p>
|
||||
<p><strong>Latest Commit:</strong> ${shortHash(state.status?.remoteCommit)}</p>
|
||||
<p><strong>Update Available:</strong> ${state.status?.updateAvailable ? "Yes" : "No"}</p>
|
||||
<p><strong>Status:</strong> ${state.status?.stage || "idle"}</p>
|
||||
<p><strong>Onroad:</strong> ${state.status?.isOnroad ? "Yes" : "No"}</p>
|
||||
</div>
|
||||
|
||||
<div class="updateProgressCard">
|
||||
<div class="updateProgressHeader">
|
||||
<span>
|
||||
Step ${state.status?.progressStep || 0}/${state.status?.progressTotalSteps || 5}:
|
||||
${state.status?.progressLabel || "Idle"}
|
||||
</span>
|
||||
<span>${Math.round(toPercent(state.status?.progressPercent))}%</span>
|
||||
</div>
|
||||
<div class="updateProgressTrack ${state.status?.stage === "error" ? "error" : ""}">
|
||||
<div class="updateProgressFill ${state.status?.stage === "error" ? "error" : ""}" style="width: ${toPercent(state.status?.progressPercent)}%;"></div>
|
||||
</div>
|
||||
${() => state.status?.progressDetail ? html`<p class="updateProgressDetail ${state.status?.stage === "error" ? "error" : ""}">${state.status.progressDetail}</p>` : ""}
|
||||
</div>
|
||||
|
||||
${() => state.status?.isOnroad ? html`<p class="updateWarning"><strong>Onroad: actions disabled</strong></p>` : ""}
|
||||
|
||||
<label class="updateToggleRow">
|
||||
<span>Automatically install updates</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ds-toggle"
|
||||
?checked="${!!state.status?.automaticUpdates}"
|
||||
?disabled="${!!state.status?.isOnroad || state.toggleBusy || !!state.status?.running}"
|
||||
@change="${(event) => setAutomaticUpdates(!!event.target.checked)}"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="updateToggleRow updateAdvancedToggle">
|
||||
<span>Show advanced options</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ds-toggle"
|
||||
?checked="${() => state.showAdvancedOptions}"
|
||||
@change="${(event) => setAdvancedOptions(!!event.target.checked)}"
|
||||
/>
|
||||
</label>
|
||||
|
||||
${() => state.showAdvancedOptions ? html`
|
||||
<div class="updateBranchSection">
|
||||
<div class="updateBranchHeader">
|
||||
<strong>Branch switching</strong>
|
||||
${state.branchesBusy ? html`<span>Loading...</span>` : ""}
|
||||
</div>
|
||||
<p class="updateAdvancedNote">
|
||||
For advanced users only. Switching to test/dev branches can introduce instability.
|
||||
</p>
|
||||
<div class="updateBranchRow">
|
||||
<select
|
||||
class="updateSelect"
|
||||
?disabled="${!!state.status?.isOnroad || !!state.status?.running || state.switchBusy || state.branchesBusy || state.branches.length === 0}"
|
||||
@change="${(event) => {
|
||||
state.selectedBranch = String(event.target.value || "")
|
||||
state.hasManualBranchSelection = true
|
||||
}}">
|
||||
${() => state.branches.length
|
||||
? state.branches.map((branch) => html`<option value="${branch}" ${branch === state.selectedBranch ? "selected" : ""}>${branch}${branch === state.status?.branch ? " (current)" : ""}</option>`)
|
||||
: html`<option value="">No branches found</option>`
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
class="updateButton"
|
||||
?disabled="${state.branchesBusy || !!state.status?.running}"
|
||||
@click="${() => fetchBranches(true)}">
|
||||
${state.branchesBusy ? "Refreshing..." : "Refresh Branches"}
|
||||
</button>
|
||||
</div>
|
||||
${() => state.branchesError ? html`<p class="updateError"><strong>Branch List:</strong> ${state.branchesError}</p>` : ""}
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${() => state.status?.message && state.status?.stage !== "rebooting" ? html`<p class="updateMessage">${state.status.message}</p>` : ""}
|
||||
${() => state.status?.remoteError ? html`<p class="updateError"><strong>Remote Check:</strong> ${state.status.remoteError}</p>` : ""}
|
||||
${() => state.status?.lastError ? html`<p class="updateError"><strong>Last Error:</strong> ${state.status.lastError}</p>` : ""}
|
||||
${() => state.error ? html`<p class="updateError"><strong>Error:</strong> ${state.error}</p>` : ""}
|
||||
|
||||
<div class="updateActions">
|
||||
${() => !isSelectedBranchDifferent() ? html`
|
||||
<button class="updateButton" @click="${() => fetchStatus(true)}">
|
||||
${state.checkBusy ? "Checking..." : "Check for Updates"}
|
||||
</button>
|
||||
` : ""}
|
||||
${() => shouldShowPrimaryUpdateAction() ? html`
|
||||
<button
|
||||
class="updateButton danger"
|
||||
?disabled="${!!state.status?.isOnroad || !!state.status?.running || (isSelectedBranchDifferent() ? state.switchBusy : state.updateBusy)}"
|
||||
@click="${() => isSelectedBranchDifferent() ? runBranchSwitch() : runFastUpdate()}">
|
||||
${state.status?.running
|
||||
? "Update Running..."
|
||||
: (isSelectedBranchDifferent()
|
||||
? (state.switchBusy ? "Starting..." : "Switch + Update")
|
||||
: (state.updateBusy ? "Starting..." : "Fast Update (No Backup)"))}
|
||||
</button>
|
||||
` : ""}
|
||||
</div>
|
||||
${() => !state.status?.running && !shouldShowPrimaryUpdateAction()
|
||||
? html`<p class="updateHint">Run <strong>Check for Updates</strong> first, or select a different branch in advanced options.</p>`
|
||||
: ""}
|
||||
${() => shouldShowRebootNotice()
|
||||
? html`<p class="updateHint updateRebootNotice">Device rebooting, please wait for reconnection...</p>`
|
||||
: ""}
|
||||
${() => activeCommitsUrl() ? html`
|
||||
<div class="updateFooter">
|
||||
<a class="updateLink" href="${activeCommitsUrl()}" target="_blank" rel="noopener noreferrer">
|
||||
${activeCommitsLabel()}
|
||||
</a>
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
` : ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/device_settings.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
|
||||
@@ -14,11 +14,14 @@ import os
|
||||
import re
|
||||
import requests
|
||||
import secrets
|
||||
import selectors
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from urllib.parse import quote
|
||||
|
||||
from cereal import car, messaging
|
||||
from opendbc.can.parser import CANParser
|
||||
@@ -134,9 +137,558 @@ _FINGERPRINT_VALID_NAME_RE = re.compile(r'^[A-Za-z0-9 \u0160.()\-]+$')
|
||||
_openpilot_root_cache = None
|
||||
_fingerprint_catalog_cache = None
|
||||
|
||||
_fast_update_lock = threading.Lock()
|
||||
_FAST_UPDATE_TOTAL_STEPS = 5
|
||||
_FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S = 5.0
|
||||
_FAST_UPDATE_REBOOT_NOTICE_SECONDS = 6.0
|
||||
_FAST_UPDATE_FETCH_TIMEOUT_S = 60
|
||||
_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S = 60
|
||||
_GIT_PROGRESS_PERCENT_RE = re.compile(r'([A-Za-z][A-Za-z /_-]+):\s*([0-9]{1,3})%')
|
||||
_GIT_SUBMODULE_SECTION_RE = re.compile(r'^\s*\[submodule\s+"[^"]+"\]\s*$', re.MULTILINE)
|
||||
_fast_update_state = {
|
||||
"running": False,
|
||||
"stage": "idle",
|
||||
"message": "",
|
||||
"lastError": "",
|
||||
"lastBranch": "",
|
||||
"lastMode": "",
|
||||
"startedAt": 0.0,
|
||||
"finishedAt": 0.0,
|
||||
"progressStep": 0,
|
||||
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
||||
"progressStepPercent": 0.0,
|
||||
"progressPercent": 0.0,
|
||||
"progressLabel": "Idle",
|
||||
"progressDetail": "",
|
||||
}
|
||||
|
||||
def _normalize_fingerprint_make_key(make_value):
|
||||
return str(make_value or "").strip().lower()
|
||||
|
||||
def _set_fast_update_state(**kwargs):
|
||||
with _fast_update_lock:
|
||||
_fast_update_state.update(kwargs)
|
||||
|
||||
def _get_fast_update_state():
|
||||
with _fast_update_lock:
|
||||
return dict(_fast_update_state)
|
||||
|
||||
def _set_fast_update_progress(step, label, step_percent=0.0, detail=""):
|
||||
safe_step = max(1, min(_FAST_UPDATE_TOTAL_STEPS, int(step)))
|
||||
safe_step_percent = float(max(0.0, min(100.0, step_percent)))
|
||||
overall_percent = (((safe_step - 1) + (safe_step_percent / 100.0)) / _FAST_UPDATE_TOTAL_STEPS) * 100.0
|
||||
|
||||
_set_fast_update_state(
|
||||
progressStep=safe_step,
|
||||
progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS,
|
||||
progressStepPercent=round(safe_step_percent, 1),
|
||||
progressPercent=round(overall_percent, 1),
|
||||
progressLabel=label,
|
||||
progressDetail=detail,
|
||||
)
|
||||
|
||||
def _parse_git_progress_line(raw_line):
|
||||
text = str(raw_line or "").replace("\x1b", "").strip()
|
||||
while text.startswith("remote:"):
|
||||
text = text[len("remote:"):].strip()
|
||||
|
||||
if not text:
|
||||
return None, "", ""
|
||||
|
||||
match = _GIT_PROGRESS_PERCENT_RE.search(text)
|
||||
if not match:
|
||||
return None, text, ""
|
||||
|
||||
try:
|
||||
percent = float(match.group(2))
|
||||
except Exception:
|
||||
percent = None
|
||||
|
||||
phase = str(match.group(1) or "").strip().lower()
|
||||
return percent, text, phase
|
||||
|
||||
def _normalize_git_phase_percent(phase, percent):
|
||||
safe_percent = max(0.0, min(100.0, float(percent)))
|
||||
phase_text = str(phase or "").strip().lower()
|
||||
|
||||
# Git progress lines are per-phase and can hit 100% multiple times before the
|
||||
# command actually exits. Map known phases to a monotonic 0..99% envelope.
|
||||
if "counting objects" in phase_text:
|
||||
return min(20.0, safe_percent * 0.20)
|
||||
if "compressing objects" in phase_text:
|
||||
return min(45.0, 20.0 + (safe_percent * 0.25))
|
||||
if "receiving objects" in phase_text:
|
||||
return min(85.0, 45.0 + (safe_percent * 0.40))
|
||||
if "resolving deltas" in phase_text:
|
||||
return min(99.0, 85.0 + (safe_percent * 0.14))
|
||||
|
||||
# Unknown phase: keep below 100 until the process exits.
|
||||
return min(99.0, safe_percent)
|
||||
|
||||
def _git_command_env():
|
||||
env = os.environ.copy()
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
env["GIT_ASKPASS"] = "/bin/false"
|
||||
env["SSH_ASKPASS"] = "/bin/false"
|
||||
env["GCM_INTERACTIVE"] = "Never"
|
||||
if not env.get("GIT_SSH_COMMAND"):
|
||||
env["GIT_SSH_COMMAND"] = "ssh -oBatchMode=yes"
|
||||
return env
|
||||
|
||||
def _build_shallow_fetch_args(branch):
|
||||
return [
|
||||
"-c", "gc.auto=0",
|
||||
"-c", "maintenance.auto=false",
|
||||
"fetch",
|
||||
"--progress",
|
||||
"--depth=1",
|
||||
"--no-recurse-submodules",
|
||||
"origin",
|
||||
branch,
|
||||
]
|
||||
|
||||
def _run_git_with_progress(repo_path, args, timeout, step, label):
|
||||
cmd = ["git", *args]
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=repo_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env=_git_command_env(),
|
||||
)
|
||||
|
||||
if process.stdout is None:
|
||||
raise RuntimeError("Failed to open git output stream")
|
||||
|
||||
fd = process.stdout.fileno()
|
||||
os.set_blocking(fd, False)
|
||||
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(fd, selectors.EVENT_READ)
|
||||
|
||||
started_at = time.monotonic()
|
||||
last_activity_at = started_at
|
||||
last_emit_at = 0.0
|
||||
last_percent = None
|
||||
last_detail = ""
|
||||
output_tail = []
|
||||
buffer = ""
|
||||
|
||||
def consume_text(text):
|
||||
nonlocal buffer
|
||||
for char in text:
|
||||
if char in ("\r", "\n"):
|
||||
if buffer:
|
||||
handle_line(buffer)
|
||||
buffer = ""
|
||||
else:
|
||||
buffer += char
|
||||
|
||||
def append_tail(text):
|
||||
if not text:
|
||||
return
|
||||
output_tail.append(text)
|
||||
if len(output_tail) > 180:
|
||||
del output_tail[:-180]
|
||||
|
||||
def handle_line(text):
|
||||
nonlocal last_activity_at, last_emit_at, last_percent, last_detail
|
||||
|
||||
percent, detail, phase = _parse_git_progress_line(text)
|
||||
append_tail(detail or text)
|
||||
|
||||
now = time.monotonic()
|
||||
last_activity_at = now
|
||||
should_emit = False
|
||||
|
||||
if percent is not None:
|
||||
safe_percent = _normalize_git_phase_percent(phase, percent)
|
||||
if safe_percent in (0.0, 99.0):
|
||||
should_emit = True
|
||||
elif now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S:
|
||||
should_emit = last_percent is None or abs(safe_percent - last_percent) >= 1.0
|
||||
|
||||
if should_emit:
|
||||
_set_fast_update_progress(step, label, safe_percent, detail or label)
|
||||
last_emit_at = now
|
||||
last_percent = safe_percent
|
||||
last_detail = detail or label
|
||||
else:
|
||||
if last_percent is None:
|
||||
last_percent = safe_percent
|
||||
else:
|
||||
if detail and now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S and detail != last_detail:
|
||||
fallback_percent = last_percent if last_percent is not None else 0.0
|
||||
_set_fast_update_progress(step, label, fallback_percent, detail)
|
||||
last_emit_at = now
|
||||
last_detail = detail
|
||||
|
||||
try:
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if timeout and (now - last_activity_at) > timeout:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
tail = output_tail[-1] if output_tail else ""
|
||||
suffix = f" (last output: {tail})" if tail else ""
|
||||
raise TimeoutError(f"git {' '.join(args)} stalled for {int(timeout)}s without output{suffix}")
|
||||
|
||||
events = selector.select(timeout=0.5)
|
||||
if not events:
|
||||
if process.poll() is not None:
|
||||
try:
|
||||
trailing = os.read(fd, 4096)
|
||||
except BlockingIOError:
|
||||
trailing = b""
|
||||
if trailing:
|
||||
last_activity_at = time.monotonic()
|
||||
consume_text(trailing.decode("utf-8", errors="replace"))
|
||||
continue
|
||||
break
|
||||
|
||||
# Heartbeat: if git is quiet (no progress lines), still surface activity.
|
||||
now = time.monotonic()
|
||||
if now - last_emit_at >= _FAST_UPDATE_PROGRESS_UPDATE_INTERVAL_S:
|
||||
if timeout:
|
||||
inferred_percent = min(95.0, max(0.0, ((now - started_at) / timeout) * 95.0))
|
||||
else:
|
||||
inferred_percent = min(95.0, (last_percent or 0.0) + 1.0)
|
||||
if last_percent is None or inferred_percent > last_percent:
|
||||
last_percent = inferred_percent
|
||||
heartbeat_detail = last_detail or f"{label}..."
|
||||
_set_fast_update_progress(step, label, last_percent or 0.0, heartbeat_detail)
|
||||
last_emit_at = now
|
||||
continue
|
||||
|
||||
reached_eof = False
|
||||
for _, _ in events:
|
||||
try:
|
||||
chunk = os.read(fd, 4096)
|
||||
except BlockingIOError:
|
||||
chunk = b""
|
||||
|
||||
if not chunk:
|
||||
# Selector can keep reporting readability on EOF; exit once process ended.
|
||||
if process.poll() is not None:
|
||||
reached_eof = True
|
||||
break
|
||||
continue
|
||||
|
||||
last_activity_at = time.monotonic()
|
||||
consume_text(chunk.decode("utf-8", errors="replace"))
|
||||
|
||||
if reached_eof:
|
||||
break
|
||||
|
||||
if buffer:
|
||||
handle_line(buffer)
|
||||
|
||||
return_code = process.wait(timeout=2)
|
||||
finally:
|
||||
try:
|
||||
selector.unregister(fd)
|
||||
except Exception:
|
||||
pass
|
||||
selector.close()
|
||||
try:
|
||||
process.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if return_code == 0:
|
||||
_set_fast_update_progress(step, label, 100.0, last_detail or "Done")
|
||||
|
||||
return return_code, "\n".join(output_tail[-40:])
|
||||
|
||||
def _run_git(repo_path, args, timeout=30):
|
||||
return subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
env=_git_command_env(),
|
||||
)
|
||||
|
||||
def _git_stdout(repo_path, args, timeout=15):
|
||||
result = _run_git(repo_path, args, timeout=timeout)
|
||||
if result.returncode != 0:
|
||||
stderr = (result.stderr or "").strip() or (result.stdout or "").strip() or "git command failed"
|
||||
raise RuntimeError(stderr)
|
||||
return (result.stdout or "").strip()
|
||||
|
||||
def _is_valid_git_branch_name(repo_path, branch_name):
|
||||
branch = str(branch_name or "").strip()
|
||||
if not branch or branch.startswith("-") or "\x00" in branch:
|
||||
return False
|
||||
|
||||
result = _run_git(repo_path, ["check-ref-format", "--branch", branch], timeout=10)
|
||||
return result.returncode == 0
|
||||
|
||||
def _list_origin_branches(repo_path, include_remote=True):
|
||||
branches = set()
|
||||
remote_error = ""
|
||||
|
||||
if include_remote:
|
||||
try:
|
||||
remote_heads = _git_stdout(repo_path, ["ls-remote", "--heads", "origin"], timeout=25)
|
||||
for line in remote_heads.splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
|
||||
ref = parts[1].strip()
|
||||
if not ref.startswith("refs/heads/"):
|
||||
continue
|
||||
|
||||
branch = ref[len("refs/heads/"):].strip()
|
||||
if branch:
|
||||
branches.add(branch)
|
||||
except Exception as exception:
|
||||
remote_error = str(exception)
|
||||
|
||||
if not branches:
|
||||
try:
|
||||
local_refs = _git_stdout(
|
||||
repo_path,
|
||||
["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"],
|
||||
timeout=15,
|
||||
)
|
||||
for line in local_refs.splitlines():
|
||||
ref = line.strip()
|
||||
if not ref or ref in ("origin/HEAD",):
|
||||
continue
|
||||
if ref.startswith("origin/"):
|
||||
ref = ref[len("origin/"):]
|
||||
if ref.endswith("/HEAD"):
|
||||
continue
|
||||
if ref:
|
||||
branches.add(ref)
|
||||
except Exception as exception:
|
||||
if not remote_error:
|
||||
remote_error = str(exception)
|
||||
|
||||
return sorted(branches, key=lambda branch: branch.lower()), remote_error
|
||||
|
||||
def _repo_has_submodule_entries(repo_path):
|
||||
gitmodules_path = Path(repo_path) / ".gitmodules"
|
||||
if not gitmodules_path.is_file():
|
||||
return False
|
||||
|
||||
try:
|
||||
content = gitmodules_path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
# If we cannot inspect .gitmodules, stay conservative and try syncing.
|
||||
return True
|
||||
|
||||
return bool(_GIT_SUBMODULE_SECTION_RE.search(content))
|
||||
|
||||
def _run_submodule_update_if_needed(repo_path, step=4):
|
||||
if not _repo_has_submodule_entries(repo_path):
|
||||
_set_fast_update_progress(step, "Updating submodules", 100.0, "No submodules configured.")
|
||||
return
|
||||
|
||||
_set_fast_update_progress(step, "Updating submodules", 0.0, "Syncing submodules...")
|
||||
submodule_rc, submodule_output = _run_git_with_progress(
|
||||
repo_path,
|
||||
["submodule", "update", "--init", "--recursive", "--depth=1", "--progress"],
|
||||
timeout=240,
|
||||
step=step,
|
||||
label="Updating submodules",
|
||||
)
|
||||
if submodule_rc != 0:
|
||||
raise RuntimeError(submodule_output.strip() or "git submodule update failed")
|
||||
|
||||
def _finish_update_and_reboot(message):
|
||||
_set_fast_update_progress(5, "Rebooting device", 100.0, "Update complete. Please wait for device to reboot.")
|
||||
_set_fast_update_state(
|
||||
running=False,
|
||||
stage="rebooting",
|
||||
message=message,
|
||||
finishedAt=time.time(),
|
||||
)
|
||||
# Keep the service online briefly so the UI can fetch and render the reboot notice.
|
||||
time.sleep(_FAST_UPDATE_REBOOT_NOTICE_SECONDS)
|
||||
HARDWARE.reboot()
|
||||
|
||||
def _set_fast_update_error_state(message, exception):
|
||||
error_text = str(exception).strip() or "Unknown error"
|
||||
_set_fast_update_state(
|
||||
running=False,
|
||||
stage="error",
|
||||
message=message,
|
||||
lastError=error_text,
|
||||
finishedAt=time.time(),
|
||||
progressStep=0,
|
||||
progressTotalSteps=_FAST_UPDATE_TOTAL_STEPS,
|
||||
progressStepPercent=0.0,
|
||||
progressPercent=0.0,
|
||||
progressLabel="Failed",
|
||||
progressDetail="Update failed. See Last Error below.",
|
||||
)
|
||||
|
||||
def _collect_fast_update_info(include_remote=True):
|
||||
repo_path = str(_get_openpilot_root())
|
||||
|
||||
branch = ""
|
||||
local_commit = ""
|
||||
remote_commit = ""
|
||||
update_available = False
|
||||
remote_error = ""
|
||||
origin_remote = ""
|
||||
commits_url = ""
|
||||
|
||||
try:
|
||||
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
local_commit = _git_stdout(repo_path, ["rev-parse", "HEAD"])
|
||||
try:
|
||||
origin_remote = _git_stdout(repo_path, ["config", "--get", "remote.origin.url"])
|
||||
except Exception:
|
||||
origin_remote = ""
|
||||
except Exception as exception:
|
||||
return {
|
||||
"repoPath": repo_path,
|
||||
"branch": branch,
|
||||
"localCommit": local_commit,
|
||||
"remoteCommit": remote_commit,
|
||||
"updateAvailable": False,
|
||||
"remoteError": str(exception),
|
||||
"originRemote": origin_remote,
|
||||
"commitsUrl": commits_url,
|
||||
}
|
||||
|
||||
if origin_remote:
|
||||
remote = origin_remote.strip()
|
||||
if remote.startswith("git@github.com:"):
|
||||
remote = "https://github.com/" + remote.split(":", 1)[1]
|
||||
elif remote.startswith("ssh://git@github.com/"):
|
||||
remote = "https://github.com/" + remote.split("ssh://git@github.com/", 1)[1]
|
||||
elif remote.startswith("http://github.com/"):
|
||||
remote = "https://github.com/" + remote.split("http://github.com/", 1)[1]
|
||||
|
||||
if remote.startswith("https://github.com/"):
|
||||
remote = remote.rstrip("/")
|
||||
if remote.endswith(".git"):
|
||||
remote = remote[:-4]
|
||||
if branch:
|
||||
commits_url = f"{remote}/commits/{quote(branch, safe='')}/"
|
||||
|
||||
if branch and include_remote:
|
||||
try:
|
||||
remote_raw = _git_stdout(repo_path, ["ls-remote", "--heads", "origin", branch], timeout=20)
|
||||
if remote_raw:
|
||||
remote_commit = remote_raw.split()[0]
|
||||
update_available = bool(local_commit and remote_commit and local_commit != remote_commit)
|
||||
except Exception as exception:
|
||||
remote_error = str(exception)
|
||||
|
||||
return {
|
||||
"repoPath": repo_path,
|
||||
"branch": branch,
|
||||
"localCommit": local_commit,
|
||||
"remoteCommit": remote_commit,
|
||||
"updateAvailable": update_available,
|
||||
"remoteError": remote_error,
|
||||
"originRemote": origin_remote,
|
||||
"commitsUrl": commits_url,
|
||||
}
|
||||
|
||||
def _fast_update_worker():
|
||||
started_at = time.time()
|
||||
repo_path = str(_get_openpilot_root())
|
||||
|
||||
try:
|
||||
_set_fast_update_progress(1, "Preparing update", 10.0, "Resolving active branch...")
|
||||
branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
_set_fast_update_progress(1, "Preparing update", 100.0, f"Branch: {branch}")
|
||||
_set_fast_update_state(
|
||||
running=True,
|
||||
stage="updating",
|
||||
message=f"Applying shallow update on '{branch}'...",
|
||||
lastError="",
|
||||
lastBranch=branch,
|
||||
lastMode="fetch-reset",
|
||||
startedAt=started_at,
|
||||
finishedAt=0.0,
|
||||
)
|
||||
|
||||
_set_fast_update_progress(2, "Fetching branch snapshot", 0.0, "Fetching latest shallow commit...")
|
||||
fetch_rc, fetch_output = _run_git_with_progress(
|
||||
repo_path,
|
||||
_build_shallow_fetch_args(branch),
|
||||
timeout=_FAST_UPDATE_FETCH_TIMEOUT_S,
|
||||
step=2,
|
||||
label="Fetching branch snapshot",
|
||||
)
|
||||
if fetch_rc != 0:
|
||||
raise RuntimeError(fetch_output.strip() or "git fetch failed")
|
||||
|
||||
_set_fast_update_progress(3, "Applying fetched commit", 20.0, "Resetting repository to fetched head...")
|
||||
reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120)
|
||||
if reset.returncode != 0:
|
||||
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
|
||||
_set_fast_update_progress(3, "Applying fetched commit", 100.0, "Repository reset complete.")
|
||||
|
||||
_run_submodule_update_if_needed(repo_path, step=4)
|
||||
_finish_update_and_reboot(
|
||||
"Update successful. Device is rebooting now. Please wait for reconnection."
|
||||
)
|
||||
except Exception as exception:
|
||||
_set_fast_update_error_state("Fast update failed.", exception)
|
||||
|
||||
def _branch_switch_worker(target_branch):
|
||||
started_at = time.time()
|
||||
repo_path = str(_get_openpilot_root())
|
||||
|
||||
try:
|
||||
_set_fast_update_progress(1, "Preparing branch switch", 10.0, f"Target: {target_branch}")
|
||||
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
_set_fast_update_progress(1, "Preparing branch switch", 100.0, f"{current_branch} -> {target_branch}")
|
||||
_set_fast_update_state(
|
||||
running=True,
|
||||
stage="switching",
|
||||
message=f"Switching to '{target_branch}' with shallow fetch...",
|
||||
lastError="",
|
||||
lastBranch=target_branch,
|
||||
lastMode="branch-switch",
|
||||
startedAt=started_at,
|
||||
finishedAt=0.0,
|
||||
)
|
||||
|
||||
_set_fast_update_progress(2, "Fetching branch snapshot", 0.0, f"Fetching '{target_branch}' from origin...")
|
||||
fetch_rc, fetch_output = _run_git_with_progress(
|
||||
repo_path,
|
||||
_build_shallow_fetch_args(target_branch),
|
||||
timeout=_FAST_BRANCH_SWITCH_FETCH_TIMEOUT_S,
|
||||
step=2,
|
||||
label="Fetching branch snapshot",
|
||||
)
|
||||
if fetch_rc != 0:
|
||||
raise RuntimeError(fetch_output.strip() or f"git fetch failed for '{target_branch}'")
|
||||
|
||||
_set_fast_update_progress(3, "Switching branch", 20.0, f"Checking out '{target_branch}'...")
|
||||
checkout = _run_git(repo_path, ["checkout", "--force", "-B", target_branch, "FETCH_HEAD"], timeout=120)
|
||||
if checkout.returncode != 0:
|
||||
raise RuntimeError((checkout.stderr or checkout.stdout or "git checkout failed").strip())
|
||||
|
||||
reset = _run_git(repo_path, ["reset", "--hard", "FETCH_HEAD"], timeout=120)
|
||||
if reset.returncode != 0:
|
||||
raise RuntimeError((reset.stderr or reset.stdout or "git reset failed").strip())
|
||||
|
||||
_run_git(repo_path, ["branch", "--set-upstream-to", f"origin/{target_branch}", target_branch], timeout=30)
|
||||
_set_fast_update_progress(3, "Switching branch", 100.0, f"Now on '{target_branch}'.")
|
||||
|
||||
_run_submodule_update_if_needed(repo_path, step=4)
|
||||
_finish_update_and_reboot(
|
||||
f"Switched to '{target_branch}'. Device is rebooting now. Please wait for reconnection."
|
||||
)
|
||||
except Exception as exception:
|
||||
_set_fast_update_error_state("Fast branch switch failed.", exception)
|
||||
|
||||
def _get_openpilot_root():
|
||||
global _openpilot_root_cache
|
||||
if _openpilot_root_cache is not None:
|
||||
@@ -639,6 +1191,9 @@ def setup(app):
|
||||
name = friendly_names.get(key, key)
|
||||
return jsonify({"error": f"Cannot change {name} while the car is driving. A reboot is required."}), 403
|
||||
|
||||
if key == "AutomaticUpdates" and params.get_bool("IsOnroad"):
|
||||
return jsonify({"error": "Cannot change Automatic Updates while driving."}), 403
|
||||
|
||||
if key == "CarMake":
|
||||
catalog = _get_fingerprint_catalog()
|
||||
normalized_make = _normalize_fingerprint_make_key(str_val)
|
||||
@@ -1381,6 +1936,111 @@ def setup(app):
|
||||
},
|
||||
}
|
||||
|
||||
@app.route("/api/update/fast/status", methods=["GET"])
|
||||
def get_fast_update_status():
|
||||
state_data = _get_fast_update_state()
|
||||
git_data = _collect_fast_update_info(include_remote=not state_data.get("running", False))
|
||||
return jsonify({
|
||||
**state_data,
|
||||
**git_data,
|
||||
"isOnroad": params.get_bool("IsOnroad"),
|
||||
"automaticUpdates": params.get_bool("AutomaticUpdates"),
|
||||
"warning": "Fast update skips backup creation and finalization safeguards.",
|
||||
}), 200
|
||||
|
||||
@app.route("/api/update/branches", methods=["GET"])
|
||||
def get_update_branches():
|
||||
state_data = _get_fast_update_state()
|
||||
repo_path = str(_get_openpilot_root())
|
||||
|
||||
try:
|
||||
current_branch = _git_stdout(repo_path, ["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
except Exception as exception:
|
||||
return jsonify({"error": str(exception)}), 500
|
||||
|
||||
branches, remote_error = _list_origin_branches(repo_path, include_remote=not state_data.get("running", False))
|
||||
if current_branch and current_branch not in branches:
|
||||
branches = sorted([*branches, current_branch], key=lambda branch: branch.lower())
|
||||
|
||||
return jsonify({
|
||||
"currentBranch": current_branch,
|
||||
"branches": branches,
|
||||
"remoteError": remote_error,
|
||||
"isOnroad": params.get_bool("IsOnroad"),
|
||||
"running": state_data.get("running", False),
|
||||
}), 200
|
||||
|
||||
@app.route("/api/update/fast", methods=["POST"])
|
||||
def run_fast_update():
|
||||
if params.get_bool("IsOnroad"):
|
||||
return jsonify({"error": "Cannot run a fast update while driving."}), 409
|
||||
|
||||
with _fast_update_lock:
|
||||
if _fast_update_state.get("running"):
|
||||
return jsonify({"error": "Fast update already in progress."}), 409
|
||||
_fast_update_state.update({
|
||||
"running": True,
|
||||
"stage": "starting",
|
||||
"message": "Starting fast update...",
|
||||
"lastError": "",
|
||||
"startedAt": time.time(),
|
||||
"finishedAt": 0.0,
|
||||
"progressStep": 1,
|
||||
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
||||
"progressStepPercent": 0.0,
|
||||
"progressPercent": 0.0,
|
||||
"progressLabel": "Preparing update",
|
||||
"progressDetail": "Initializing update process...",
|
||||
})
|
||||
|
||||
threading.Thread(target=_fast_update_worker, daemon=True).start()
|
||||
|
||||
return jsonify({
|
||||
"message": "Fast update started. Device will reboot when complete.",
|
||||
"warning": "Fast update skips backup creation and finalization safeguards.",
|
||||
}), 202
|
||||
|
||||
@app.route("/api/update/branch", methods=["POST"])
|
||||
def run_branch_switch():
|
||||
if params.get_bool("IsOnroad"):
|
||||
return jsonify({"error": "Cannot switch branches while driving."}), 409
|
||||
|
||||
request_data = request.get_json() or {}
|
||||
target_branch = str(request_data.get("branch") or "").strip()
|
||||
if not target_branch:
|
||||
return jsonify({"error": "Missing 'branch' in request body."}), 400
|
||||
|
||||
repo_path = str(_get_openpilot_root())
|
||||
if not _is_valid_git_branch_name(repo_path, target_branch):
|
||||
return jsonify({"error": "Invalid branch name."}), 400
|
||||
|
||||
with _fast_update_lock:
|
||||
if _fast_update_state.get("running"):
|
||||
return jsonify({"error": "Another update action is already in progress."}), 409
|
||||
_fast_update_state.update({
|
||||
"running": True,
|
||||
"stage": "starting",
|
||||
"message": f"Starting branch switch to '{target_branch}'...",
|
||||
"lastError": "",
|
||||
"lastBranch": target_branch,
|
||||
"lastMode": "branch-switch",
|
||||
"startedAt": time.time(),
|
||||
"finishedAt": 0.0,
|
||||
"progressStep": 1,
|
||||
"progressTotalSteps": _FAST_UPDATE_TOTAL_STEPS,
|
||||
"progressStepPercent": 0.0,
|
||||
"progressPercent": 0.0,
|
||||
"progressLabel": "Preparing branch switch",
|
||||
"progressDetail": "Initializing branch switch...",
|
||||
})
|
||||
|
||||
threading.Thread(target=_branch_switch_worker, args=(target_branch,), daemon=True).start()
|
||||
|
||||
return jsonify({
|
||||
"message": f"Branch switch started for '{target_branch}'. Device will reboot when complete.",
|
||||
"warning": "Fast update skips backup creation and finalization safeguards.",
|
||||
}), 202
|
||||
|
||||
# ── Galaxy pairing (mirrors settings.cc L262-282) ──────────────────
|
||||
GALAXY_DIR = Path("/data/galaxy")
|
||||
GALAXY_AUTH_FILE = GALAXY_DIR / "glxyauth"
|
||||
|
||||
Reference in New Issue
Block a user