import { DriveStatePanel } from "./drive-state.js" import { GxDialog } from "./dialog.js" import { GxState } from "./state.js" import { GxNotice } from "./notice.js" import { ToggleBackup } from "./toggle-backup.js" 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 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.", Dom: "Latest features and fixes under development. Updates regularly and may introduce bugs.", } const unavailableOperations = () => ({ parked: false, availableBranches: [], selectedTarget: null, canCheck: false, canFastUpdate: false, canRollback: false, canDownload: false, canSelect: false, canInstall: false, reason: "Update controls are unavailable", request: null }) export function validSoftwareSnapshot(data) { const text = (value) => value === null || typeof value === "string" const flag = (value) => value === null || typeof value === "boolean" const updater = data?.updater, operations = data?.operations, request = operations?.request return data?.schemaVersion === 1 && !!data.installed && !!updater && [data.installed.version, data.installed.branch, data.installed.commit, updater.state, updater.targetBranch, updater.lastSuccessAt, updater.lastFetchAt].every(text) && (updater.lastCheckedAt === undefined || text(updater.lastCheckedAt)) && (data.installed.displayVersion === undefined || text(data.installed.displayVersion)) && [updater.targetChangeFound, updater.finalizedUpdateReady].every(flag) && (updater.failedCount === null || Number.isSafeInteger(updater.failedCount) && updater.failedCount >= 0) && (operations === undefined || !!operations && typeof operations.parked === "boolean" && Array.isArray(operations.availableBranches) && operations.availableBranches.length <= 256 && operations.availableBranches.every((branch) => 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" && 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) && operations.progress.percent >= 0 && operations.progress.percent <= 100)) && (operations.canFastUpdate === undefined || typeof operations.canFastUpdate === "boolean") && (request?.selectedCommit === undefined || /^[0-9a-f]{40}$/.test(request.selectedCommit)) && (operations.canRollback === undefined || typeof operations.canRollback === "boolean") && (operations.automaticDownloads === undefined || flag(operations.automaticDownloads)) && (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") && (request === null || !!request && typeof request.id === "string" && request.id.length <= 100 && ACTIONS.has(request.action) && text(request.target) && REQUEST_STATES.has(request.state) && text(request.error) && (request.outcome === undefined || ["up_to_date", "restarting"].includes(request.outcome)))) } export function softwareProgress(updater, operations = {}) { if (!updater) return null const progress = operations.progress const fast = updater.state === "updating..." ? updater.fast : null 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", 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" } if (!active && (!stage || ["idle", "unknown"].includes(stage))) return null const percent = Number.isFinite(progress?.percent) && progress.percent >= 0 && progress.percent <= 100 ? progress.percent : null return { active, failed: ["error", "unavailable", "timed_out"].includes(stage), percent, label: labels[progress?.stage || fast?.stage] || progress?.stage || fast?.stage || labels[updater.state] || "Update status", detail: progress?.detail || fast?.detail || "" } } export function validHistory(value) { const rows = (items) => Array.isArray(items) && items.length <= 20 && 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.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) } export class SoftwareStatusFeed { constructor({ includeHistory = true, publish, unauthorized = () => {}, restarting = () => {}, preparing = () => {}, cancelPreparation = () => {}, fetcher = (...args) => fetch(...args), later = (fn, ms) => setTimeout(fn, ms), cancelTimer = (id) => clearTimeout(id) }) { Object.assign(this, { includeHistory, publish, unauthorized, restarting, preparing, cancelPreparation, fetcher, later, cancelTimer }) this.active = false this.generation = 0 this.controller = null this.timeout = null this.pollTimer = null this.data = null this.attempted = false this.busy = false this.mutating = false this.blocked = false this.connectionLost = false this.uncertain = false this.uncertainAction = null this.actionPriorRequestId = null this.installBaseline = null this.notice = "" this.error = "" this.startupFailures = 0 this.waitingForStatus = false } 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 }) } stop() { this.active = false this.generation++ this.controller?.abort() if (this.timeout !== null) this.cancelTimer(this.timeout) if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) this.controller = this.timeout = this.pollTimer = null this.data = this.installBaseline = this.uncertainAction = this.actionPriorRequestId = null this.attempted = false this.busy = this.mutating = this.blocked = this.connectionLost = this.uncertain = false this.notice = this.error = "" this.startupFailures = 0 this.waitingForStatus = false this.emit("idle") } start() { this.stop(); this.active = true; return this.load() } needsRapidPoll() { return this.uncertain || !!this.installBaseline || this.data?.operations?.parked === false || this.data?.operations?.request?.state === "pending" || UPDATER_ACTIVE.has(this.data?.updater?.state) } schedulePoll(delay = null) { if (!this.active || this.pollTimer !== null || this.controller) return const generation = this.generation this.pollTimer = this.later(() => { this.pollTimer = null if (this.active && generation === this.generation) this.load() }, delay ?? (this.needsRapidPoll() ? 1000 : 5000)) } async run(body = null) { if (!this.active || this.mutating) return null const generation = ++this.generation this.controller?.abort() if (this.timeout !== null) this.cancelTimer(this.timeout) if (this.pollTimer !== null) this.cancelTimer(this.pollTimer) this.pollTimer = null const controller = new AbortController() this.controller = controller this.busy = body !== null || this.data === null this.mutating = body !== null if (body !== null) { this.error = ""; this.notice = "" } this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading") this.timeout = this.later(() => { if (!this.active || this.generation !== generation || this.controller !== controller) return controller.abort() this.generation++ this.controller = this.timeout = null this.busy = this.mutating = false this.attempted = true this.blocked = true this.connectionLost = true if (body !== null) { this.uncertain = true; this.uncertainAction = body } this.error = body !== null ? "The request timed out. Its result is unknown; wait for a fresh status before trying another action." : "Software status timed out. Refresh to try again." this.emit(this.data ? "ready" : "unavailable") this.schedulePoll(2000) }, 5000) let reachedServer = false try { const response = await this.fetcher(body === null ? "./api/software/status" + (this.includeHistory ? "" : "?history=0") : "./api/software/action", { credentials: "same-origin", cache: "no-store", signal: controller.signal, ...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }), }) if (!this.active || generation !== this.generation || controller.signal.aborted) return null reachedServer = true this.connectionLost = false const payload = typeof response.json === "function" ? await response.json().catch(() => null) : null if (!this.active || generation !== this.generation || controller.signal.aborted) return null if (response.status === 401 || response.status === 503 && ["access_unavailable", "setup_required"].includes(payload?.code)) { this.stop() this.unauthorized() return null } // A tunnel may return its own JSON error page with a successful HTTP status. const deviceUnreachable = !payload?.code && [payload?.error, payload?.message].some(message => typeof message === "string" && /^Your comma cannot be reached(?:\. Check your connection and try again\.?)?$/i.test(message.trim())) if (!response.ok || deviceUnreachable) { const error = new Error(payload?.error || payload?.message || "Software request failed. Refresh to try again.") error.connectionLost = deviceUnreachable || [502, 504].includes(response.status) || response.status === 503 && !payload?.code && !["Software status is unavailable", "Updater status is unavailable"].includes(payload?.error) error.rejected = !error.connectionLost && response.status >= 400 && response.status < 500 error.startupUnavailable = body === null && response.status === 503 && ["Software status is unavailable", "Updater status is unavailable"].includes(payload?.error) throw error } if (!validSoftwareSnapshot(payload)) { const error = new Error("Software response is unavailable. Refresh to try again.") error.connectionLost = payload === null throw error } this.data = payload.operations === undefined ? { ...payload, operations: unavailableOperations() } : payload this.attempted = true this.blocked = false this.startupFailures = 0 this.waitingForStatus = false if (body !== null) { this.uncertain = false this.uncertainAction = null this.actionPriorRequestId = null if (["update", "fast", "rollback", "version"].includes(body.action)) { this.notice = `Updating ${body.branch}. Waiting for completion or reconnect.` } else if (body.action === "install") { 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." : "" } else { const observed = payload.operations.request const requestObserved = !!observed && !!this.uncertainAction && observed.action === this.uncertainAction.action && observed.id !== this.actionPriorRequestId && (!this.uncertainAction?.branch || observed.target === this.uncertainAction.branch) && (!this.uncertainAction?.selectedCommit || observed.selectedCommit === this.uncertainAction.selectedCommit) const selectionObserved = this.uncertainAction?.action === "select" && payload.operations.selectedTarget === this.uncertainAction.branch const preferenceObserved = this.uncertainAction?.action === "preferences" && payload.operations.automaticDownloads === this.uncertainAction.automaticDownloads if (this.uncertain && (requestObserved || selectionObserved || preferenceObserved)) { this.uncertain = false this.uncertainAction = null this.actionPriorRequestId = null } if (["update", "fast", "version"].includes(this.installBaseline?.action) && observed?.action === this.installBaseline.action && observed.target === this.installBaseline.target && (!this.installBaseline.selectedCommit || observed.selectedCommit === this.installBaseline.selectedCommit) && ["complete", "failed"].includes(observed.state)) { if (observed.state === "failed" || observed.outcome === "up_to_date") { this.installBaseline = null this.cancelPreparation() this.notice = observed.state === "failed" ? "" : "Installed branch is already up to date. No restart needed." } else this.notice = "Update installed. Waiting to reconnect and verify the installed build." } if (this.installBaseline && payload.installed.branch === this.installBaseline.target && (!this.installBaseline.selectedCommit || payload.installed.commit === this.installBaseline.selectedCommit) && (payload.installed.commit && payload.installed.commit !== this.installBaseline.commit || payload.installed.branch !== this.installBaseline.branch)) { this.installBaseline = null this.uncertain = false this.uncertainAction = null this.actionPriorRequestId = null this.cancelPreparation() this.notice = "Installed build verified after reconnect." } if (this.installBaseline && payload.operations.updaterAvailable === false && payload.operations.progress?.stage === "unavailable") this.restarting(this.installBaseline) } this.error = "" this.emit("ready") return payload } catch (error) { if (this.active && generation === this.generation && !controller.signal.aborted) { if (error?.startupUnavailable && !this.data && ++this.startupFailures < 3) { this.waitingForStatus = true this.error = "" return null } this.attempted = true this.blocked = true this.waitingForStatus = false this.connectionLost = !reachedServer || error?.connectionLost === true if (body !== null) { this.uncertain = !error?.rejected this.uncertainAction = this.uncertain ? body : null if (error?.rejected && ["install", "update", "fast", "rollback", "version"].includes(body.action)) { this.installBaseline = null this.cancelPreparation() } } this.error = error?.message || "Software request failed. Refresh to try again." this.emit(this.data ? "ready" : this.attempted ? "unavailable" : "loading") } return null } finally { if (this.controller === controller) { if (this.timeout !== null) this.cancelTimer(this.timeout) this.controller = this.timeout = null this.busy = this.mutating = false this.emit(this.data ? "ready" : this.waitingForStatus ? "loading" : "unavailable") this.schedulePoll(this.error ? 2000 : null) } } } load() { return this.run() } configureAutomaticDownloads(enabled) { if (!this.active || this.mutating || this.blocked || this.uncertain || this.data?.operations?.canConfigure !== true || typeof enabled !== "boolean") return null return this.run({ action: "preferences", automaticDownloads: enabled, expectedAutomaticDownloads: this.data.operations.automaticDownloads }) } 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] if (!this.active || !capability || this.mutating || this.blocked || this.uncertain || !operations?.parked || operations[capability] !== true || operations.request?.state === "pending") 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 (action === "rollback" && branch !== this.data.installed.branch) return null if (action === "version") { 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 } this.actionPriorRequestId = operations.request?.id ?? null if (action === "install") this.installBaseline = { commit: this.data.installed.commit, branch: this.data.installed.branch, target: branch, action } 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 }) return this.run(action === "check" ? { action } : { action, branch }) } } export const SoftwarePage = { components: { DriveStatePanel, GxDialog, GxState, GxNotice, GalaxySelect, GalaxySettingRow, ToggleBackup, RouteCleanup }, 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: "" }), created() { this.feed = new SoftwareStatusFeed({ includeHistory: false, publish: (update) => { Object.assign(this.$data, update) if (update.data && !this.draftTouched) this.syncDraftBranch(update.data.operations.selectedTarget) }, unauthorized: this.unauthorized, restarting: this.restarting, preparing: this.preparing, cancelPreparation: this.cancelPreparation }) }, async mounted() { 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") } finally { this.initialChecking = false } }, beforeUnmount() { this.feed.stop() }, computed: { operations() { return this.data?.operations }, reportedProgress() { return softwareProgress(this.data?.updater, this.operations) }, updateProgress() { if (this.error || this.operations?.request?.state === "failed") return null const progress = this.reportedProgress if (progress?.active) return progress if (progress?.failed) return null if (this.initialChecking || this.busy || this.pending || this.uncertain) return { active: true, percent: null, label: this.initialChecking || this.operations?.request?.action === "check" ? "Checking for updates" : this.requestMessage(this.operations?.request) || "Reading update status", detail: "" } return null }, updateAvailable() { return this.data?.updater.targetChangeFound === true || this.data?.updater?.finalizedUpdateReady === true }, osUpdateRequired() { return this.data?.updater.osUpdate?.required === true && this.data.updater.osUpdate.branch === (this.draftBranch || this.operations?.selectedTarget) && this.data.updater.osUpdate.branch === this.data.updater.targetBranch }, canUpdate() { if (this.data?.updater?.finalizedUpdateReady && this.draftBranch === this.operations?.selectedTarget) return this.operations.canInstall return this.operations?.canFastUpdate === true && this.branchOptions.some(branch => branch.name === this.draftBranch && branch.available) }, updateBanner() { const request = this.operations?.request const progress = this.reportedProgress const error = this.error || (request?.state === "failed" ? this.requestMessage(request) : "") || (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.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.notice) return { tone: "info", message: this.notice } if (request && request.action !== "check") 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." } }, repositoryUrl() { const repository = this.operations?.repository return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository || "") ? `https://github.com/${repository}` : "" }, commitUrl() { return this.repositoryUrl && /^[0-9a-f]{7,40}$/.test(this.data?.installed.commit || "") ? `${this.repositoryUrl}/commit/${this.data.installed.commit}` : "" }, pending() { return this.operations?.request?.state === "pending" }, actionUnavailable() { return this.uncertain || this.pending || !!this.error || !this.operations?.parked }, actionDisabled() { return this.busy || this.actionUnavailable }, branchOptions() { const available = this.operations?.availableBranches || [] return [...new Set([this.operations?.selectedTarget, this.data?.installed.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 })) }, 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] || [] }, commitsUrl() { return this.data?.installed.branch ? `https://github.com/StarPilotHQ/StarPilot/commits/${encodeURIComponent(this.data.installed.branch)}/` : "" }, }, methods: { switchRow, 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:" : "" }, changeBranch() { this.draftTouched = true; this.version = ""; this.primaryChoice = PRIMARY_BRANCHES.includes(this.draftBranch) ? this.draftBranch : this.draftBranch ? "other:" : "" }, onPrimaryBranchChange() { 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 }, 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" }, 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 === "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" } }, askUpdate() { const branch = this.draftBranch || this.operations?.selectedTarget || this.data?.installed?.branch if (this.actionDisabled || !branch) return if (this.data?.updater?.finalizedUpdateReady && branch === this.operations?.selectedTarget) return this.askInstall() if (this.operations?.canFastUpdate !== true) return this.dialog = { action: "update", branch, title: "Update software", message: `Install the latest version of ${branch} and restart?`, label: "Update & restart" } }, loadHistory(event) { if (!event.target.open || this.operations?.history || this.busy) return this.feed.includeHistory = true this.feed.load() }, 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 } 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" } }, askRollback() { const branch = this.data?.installed?.branch if (this.actionDisabled || this.operations?.canRollback !== true || !branch) return this.dialog = { action: "rollback", branch, title: "Previous version", message: "Restore the previous installed version and restart? Automatic downloads will be turned off.", label: "Restore previous version" } }, askInstall() { if (this.actionDisabled || !this.operations?.canInstall || !this.operations.selectedTarget) return this.dialog = { action: "install", branch: this.operations.selectedTarget, title: "Restart and install update", message: `Restart the device to install the finalized update for ${this.operations.selectedTarget}? Keep the vehicle parked and the device accessible.`, label: "Restart & install" } }, closeDialog() { this.dialog = null }, async confirmDialog() { const choice = this.dialog if (!choice) return this.dialog = null const result = await this.feed.action(choice.action, choice.branch, choice) if (result && choice.action === "select") { this.draftTouched = false this.syncDraftBranch(result.operations.selectedTarget) } }, }, template: `
{{ updateProgress.detail }}
Checked {{ reported(data.updater.lastCheckedAt) }} · Downloaded {{ reported(data.updater.lastFetchAt) }}
The download preference could not be read. Choose a setting while parked to repair it.
{{ dialog.message }}