// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause import { useCallback, useEffect, useState } from "react" import { apiFetch } from "src/api" import { VersionInfo } from "src/types" // see ipnstate.UpdateProgress export type UpdateProgress = { status: "UpdateFinished" | "UpdateInProgress" | "UpdateFailed" message: string version: string } export enum UpdateState { UpToDate, Available, InProgress, Complete, Failed, } // useInstallUpdate initiates and tracks a Tailscale self-update via the LocalAPI, // and returns state messages showing the progress of the update. export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) { const [updateState, setUpdateState] = useState( cv?.RunningLatest ? UpdateState.UpToDate : UpdateState.Available ) const [updateLog, setUpdateLog] = useState("") const appendUpdateLog = useCallback( (msg: string) => { setUpdateLog(updateLog + msg + "\n") }, [updateLog, setUpdateLog] ) useEffect(() => { if (updateState !== UpdateState.Available) { // useEffect cleanup function return () => {} } setUpdateState(UpdateState.InProgress) apiFetch("/local/v0/update/install", "POST").catch((err) => { console.error(err) setUpdateState(UpdateState.Failed) }) let tsAwayForPolls = 0 let updateMessagesRead = 0 let timer: NodeJS.Timeout | undefined function poll() { apiFetch("/local/v0/update/progress", "GET") .then((res) => { // res contains a list of UpdateProgresses that is strictly increasing // in size, so updateMessagesRead keeps track (across calls of poll()) // of how many of those we have already read. This is why it is not // initialized to zero here and we don't just use res.forEach() for (; updateMessagesRead < res.length; ++updateMessagesRead) { const up = res[updateMessagesRead] if (up.status === "UpdateFailed") { setUpdateState(UpdateState.Failed) if (up.message) appendUpdateLog("ERROR: " + up.message) return } if (up.status === "UpdateFinished") { // if update finished and tailscaled did not go away (ie. did not restart), // then the version being the same might not be an error, it might just require // the user to restart Tailscale manually (this is required in some cases in the // clientupdate package). if (up.version === currentVersion && tsAwayForPolls > 0) { setUpdateState(UpdateState.Failed) appendUpdateLog( "ERROR: Update failed, still running Tailscale " + up.version ) if (up.message) appendUpdateLog("ERROR: " + up.message) } else { setUpdateState(UpdateState.Complete) if (up.message) appendUpdateLog("INFO: " + up.message) } return } setUpdateState(UpdateState.InProgress) if (up.message) appendUpdateLog("INFO: " + up.message) } // If we have gone through the entire loop without returning out of the function, // the update is still in progress. So we want to poll again for further status // updates. timer = setTimeout(poll, 1000) }) .catch((err) => { ++tsAwayForPolls if (tsAwayForPolls >= 5 * 60) { setUpdateState(UpdateState.Failed) appendUpdateLog( "ERROR: tailscaled went away but did not come back!" ) appendUpdateLog("ERROR: last error received:") appendUpdateLog(err.toString()) } else { timer = setTimeout(poll, 1000) } }) } poll() // useEffect cleanup function return () => { if (timer) clearTimeout(timer) timer = undefined } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) return !cv ? { updateState: UpdateState.UpToDate, updateLog: "" } : { updateState, updateLog } }