From 1bca0c5e264c3cb2b2e0735285dfb313bde443c0 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 12:20:45 +0000 Subject: [PATCH] refactor: simplify version check and downgrade --- internal/ota/ota.go | 38 ++++++--- internal/ota/state.go | 30 ++++--- jsonrpc.go | 3 +- ota.go | 73 +++++++---------- .../routes/devices.$id.settings.advanced.tsx | 81 ++++++++++++------- .../devices.$id.settings.general.update.tsx | 76 ++++++++++------- ui/src/utils/jsonrpc.ts | 18 +++++ 7 files changed, 193 insertions(+), 126 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index d3f637bf..0459c3f2 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -239,10 +239,29 @@ func (s *State) getUpdateStatus( systemUpdate = ¤tSystemUpdate } + err = s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate) + if err != nil { + return nil, nil, err + } + + s.componentUpdateStatuses["app"] = *appUpdate + s.componentUpdateStatuses["system"] = *systemUpdate + + return appUpdate, systemUpdate, nil +} + +// doGetUpdateStatus is the internal function that gets the update status +// it WON'T change the state of the OTA state +func (s *State) doGetUpdateStatus( + ctx context.Context, + params UpdateParams, + appUpdate *componentUpdateStatus, + systemUpdate *componentUpdateStatus, +) error { // Get local versions systemVersionLocal, appVersionLocal, err := s.getLocalVersion() if err != nil { - return nil, nil, fmt.Errorf("error getting local version: %w", err) + return fmt.Errorf("error getting local version: %w", err) } appUpdate.localVersion = appVersionLocal.String() systemUpdate.localVersion = systemVersionLocal.String() @@ -255,7 +274,7 @@ func (s *State) getUpdateStatus( } else { err = fmt.Errorf("error checking for updates: %w", err) } - return + return err } appUpdate.url = remoteMetadata.AppURL appUpdate.hash = remoteMetadata.AppHash @@ -269,7 +288,7 @@ func (s *State) getUpdateStatus( systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) if err != nil { err = fmt.Errorf("error parsing remote system version: %w", err) - return + return err } systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal) systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal) @@ -277,7 +296,7 @@ func (s *State) getUpdateStatus( appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) if err != nil { err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) - return + return err } appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal) appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal) @@ -293,18 +312,17 @@ func (s *State) getUpdateStatus( appUpdate.available = false } - s.componentUpdateStatuses["app"] = *appUpdate - s.componentUpdateStatuses["system"] = *systemUpdate - - return + return nil } // GetUpdateStatus returns the current update status (for backwards compatibility) func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) { - _, _, err := s.getUpdateStatus(ctx, params) + appUpdate := &componentUpdateStatus{} + systemUpdate := &componentUpdateStatus{} + err := s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate) if err != nil { return nil, fmt.Errorf("error getting update status: %w", err) } - return s.ToUpdateStatus(), nil + return toUpdateStatus(appUpdate, systemUpdate, ""), nil } diff --git a/internal/ota/state.go b/internal/ota/state.go index 862e76a6..f6a35e40 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -156,18 +156,7 @@ func (s *State) GetTargetVersion(component string) string { return componentUpdate.targetVersion } -// ToUpdateStatus converts the State to the UpdateStatus -func (s *State) ToUpdateStatus() *UpdateStatus { - appUpdate, ok := s.componentUpdateStatuses["app"] - if !ok { - return nil - } - - systemUpdate, ok := s.componentUpdateStatuses["system"] - if !ok { - return nil - } - +func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, error string) *UpdateStatus { return &UpdateStatus{ Local: &LocalMetadata{ AppVersion: appUpdate.localVersion, @@ -185,10 +174,25 @@ func (s *State) ToUpdateStatus() *UpdateStatus { SystemDowngradeAvailable: systemUpdate.downgradeAvailable, AppUpdateAvailable: appUpdate.available, AppDowngradeAvailable: appUpdate.downgradeAvailable, - Error: s.error, + Error: error, } } +// ToUpdateStatus converts the State to the UpdateStatus +func (s *State) ToUpdateStatus() *UpdateStatus { + appUpdate, ok := s.componentUpdateStatuses["app"] + if !ok { + return nil + } + + systemUpdate, ok := s.componentUpdateStatuses["system"] + if !ok { + return nil + } + + return toUpdateStatus(&appUpdate, &systemUpdate, s.error) +} + // IsUpdatePending returns true if an update is pending func (s *State) IsUpdatePending() bool { return s.updating diff --git a/jsonrpc.go b/jsonrpc.go index 7e2540ce..2810e4f9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1151,9 +1151,10 @@ var rpcHandlers = map[string]RPCHandler{ "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "getLocalVersion": {Func: rpcGetLocalVersion}, "getUpdateStatus": {Func: rpcGetUpdateStatus}, + "checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}}, "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "tryUpdate": {Func: rpcTryUpdate}, - "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"components", "includePreRelease", "checkOnly", "resetConfig"}}, + "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}}, "cancelDowngrade": {Func: rpcCancelDowngrade}, "getDevModeState": {Func: rpcGetDevModeState}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, diff --git a/ota.go b/ota.go index f4a56415..b9d454d6 100644 --- a/ota.go +++ b/ota.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "strings" - "time" "github.com/Masterminds/semver/v3" "github.com/jetkvm/kvm/internal/ota" @@ -135,72 +134,56 @@ func rpcGetLocalVersion() (*ota.LocalMetadata, error) { }, nil } -// ComponentName represents the name of a component -type tryUpdateComponents struct { +type updateParams struct { AppTargetVersion string `json:"app"` SystemTargetVersion string `json:"system"` Components string `json:"components,omitempty"` // components is a comma-separated list of components to update } func rpcTryUpdate() error { - return rpcTryUpdateComponents(tryUpdateComponents{ + return rpcTryUpdateComponents(updateParams{ AppTargetVersion: "", SystemTargetVersion: "", - }, config.IncludePreRelease, false, false) + }, config.IncludePreRelease, false) } -func rpcTryUpdateComponents(components tryUpdateComponents, includePreRelease bool, checkOnly bool, resetConfig bool) error { +// rpcCheckUpdateComponents checks the update status for the given components +func rpcCheckUpdateComponents(params updateParams, includePreRelease bool) (*ota.UpdateStatus, error) { + updateParams := ota.UpdateParams{ + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + AppTargetVersion: params.AppTargetVersion, + SystemTargetVersion: params.SystemTargetVersion, + } + if params.Components != "" { + updateParams.Components = strings.Split(params.Components, ",") + } + info, err := otaState.GetUpdateStatus(context.Background(), updateParams) + if err != nil { + return nil, fmt.Errorf("failed to check update: %w", err) + } + return info, nil +} + +func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetConfig bool) error { updateParams := ota.UpdateParams{ DeviceID: GetDeviceID(), IncludePreRelease: includePreRelease, - CheckOnly: checkOnly, ResetConfig: resetConfig, } - logger.Info().Interface("components", components).Msg("components") - - currentAppTargetVersion := otaState.GetTargetVersion("app") - appTargetVersionChanged := currentAppTargetVersion != components.AppTargetVersion - updateParams.AppTargetVersion = components.AppTargetVersion - if err := otaState.SetTargetVersion("app", components.AppTargetVersion); err != nil { + updateParams.AppTargetVersion = params.AppTargetVersion + if err := otaState.SetTargetVersion("app", params.AppTargetVersion); err != nil { return fmt.Errorf("failed to set app target version: %w", err) } - currentSystemTargetVersion := otaState.GetTargetVersion("system") - systemTargetVersionChanged := currentSystemTargetVersion != components.SystemTargetVersion - updateParams.SystemTargetVersion = components.SystemTargetVersion - if err := otaState.SetTargetVersion("system", components.SystemTargetVersion); err != nil { + updateParams.SystemTargetVersion = params.SystemTargetVersion + if err := otaState.SetTargetVersion("system", params.SystemTargetVersion); err != nil { return fmt.Errorf("failed to set system target version: %w", err) } - if components.Components != "" { - updateParams.Components = strings.Split(components.Components, ",") - } - - // if it's a check only update, we don't need to try to update, we just need to check if the version is available - // and return the error immediately then revert the previous target versions - if checkOnly { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - - _, err := otaState.GetUpdateStatus(ctx, updateParams) - if err == nil { - return nil - } - - // revert the previous target versions - if appTargetVersionChanged { - if err := otaState.SetTargetVersion("app", currentAppTargetVersion); err != nil { - return fmt.Errorf("failed to revert app target version: %w", err) - } - } - if systemTargetVersionChanged { - if err := otaState.SetTargetVersion("system", currentSystemTargetVersion); err != nil { - return fmt.Errorf("failed to revert system target version: %w", err) - } - } - - return err + if params.Components != "" { + updateParams.Components = strings.Split(params.Components, ",") } go func() { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index c90cba64..9b0b8fd1 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsStore } from "@hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { Button } from "@components/Button"; import Checkbox, { CheckboxWithLabel } from "@components/Checkbox"; @@ -17,6 +17,8 @@ import { isOnDevice } from "@/main"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; import { sleep } from "@/utils"; +import { checkUpdateComponents } from "@/utils/jsonrpc"; +import { SystemVersionInfo } from "@hooks/useVersion"; export default function SettingsAdvancedRoute() { const { send } = useJsonRpc(); @@ -33,7 +35,7 @@ export default function SettingsAdvancedRoute() { const [systemVersion, setSystemVersion] = useState(""); const [resetConfig, setResetConfig] = useState(false); const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false); - + const [versionUpdateLoading, setVersionUpdateLoading] = useState(false); const settings = useSettingsStore(); useEffect(() => { @@ -183,34 +185,57 @@ export default function SettingsAdvancedRoute() { setShowLoopbackWarning(false); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); - const handleVersionUpdate = useCallback(() => { - const params = { - components: { + const handleVersionUpdateError = useCallback((error?: JsonRpcError) => { + notifications.error( + m.advanced_error_version_update({ + error: error?.data ?? error?.message ?? m.unknown_error() + }), + { duration: 1000 * 15 } // 15 seconds + ); + setVersionUpdateLoading(false); + }, []); + + const handleVersionUpdate = useCallback(async () => { + const components = updateTarget === "both" ? ["app", "system"] : [updateTarget]; + let versionInfo: SystemVersionInfo | undefined; + try { + // we do not need to set it to false if check succeeds, + // because it will be redirected to the update page later + setVersionUpdateLoading(true); + versionInfo = await checkUpdateComponents({ + components: components.join(","), app: appVersion, system: systemVersion, - }, - includePreRelease: devChannel, - checkOnly: true, - // no need to reset config for a check only update - resetConfig: false, - }; + }, devChannel); + console.log("versionInfo", versionInfo); + } catch (error: unknown) { + const jsonRpcError = error as JsonRpcError; + handleVersionUpdateError(jsonRpcError); + return ; + } - send("tryUpdateComponents", params, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error( - m.advanced_error_version_update({ error: resp.error.data || m.unknown_error() }) - ); - return; - } - const pageParams = new URLSearchParams(); - pageParams.set("downgrade", "true"); - pageParams.set("resetConfig", resetConfig.toString()); - pageParams.set("components", updateTarget === "both" ? "app,system" : updateTarget); + if (!versionInfo) { + handleVersionUpdateError(); + return; + } - // Navigate to update page - navigateTo(`/settings/general/update?${pageParams.toString()}`); - }); - }, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo, resetConfig]); + const pageParams = new URLSearchParams(); + pageParams.set("downgrade", "true"); + if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appDowngradeAvailable) { + pageParams.set("app", versionInfo.remote?.appVersion); + } + if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemDowngradeAvailable) { + pageParams.set("system", versionInfo.remote?.systemVersion); + } + pageParams.set("resetConfig", resetConfig.toString()); + + // Navigate to update page + navigateTo(`/settings/general/update?${pageParams.toString()}`); + }, [ + updateTarget, appVersion, systemVersion, devChannel, + navigateTo, resetConfig, handleVersionUpdateError, + setVersionUpdateLoading + ]); return (
@@ -374,8 +399,10 @@ export default function SettingsAdvancedRoute() { (updateTarget === "app" && !appVersion) || (updateTarget === "system" && !systemVersion) || (updateTarget === "both" && (!appVersion || !systemVersion)) || - !versionChangeAcknowledged + !versionChangeAcknowledged || + versionUpdateLoading } + loading={versionUpdateLoading} onClick={handleVersionUpdate} />
diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 3222038b..d9f0b8a9 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -23,7 +23,8 @@ export default function SettingsGeneralUpdateRoute() { const { send } = useJsonRpc(); const downgrade = useMemo(() => searchParams.get("downgrade") === "true", [searchParams]); - const updateComponents = useMemo(() => searchParams.get("components") || "", [searchParams]); + const customAppVersion = useMemo(() => searchParams.get("app") || "", [searchParams]); + const customSystemVersion = useMemo(() => searchParams.get("system") || "", [searchParams]); const resetConfig = useMemo(() => searchParams.get("resetConfig") === "true", [searchParams]); const onClose = useCallback(async () => { @@ -38,18 +39,28 @@ export default function SettingsGeneralUpdateRoute() { setModalView("updating"); }, [send, setModalView]); - const onConfirmDowngrade = useCallback((system?: string, app?: string) => { + const onConfirmDowngrade = useCallback(() => { + const components = []; + if (customSystemVersion) { + components.push("system"); + } + if (customAppVersion) { + components.push("app"); + } + send("tryUpdateComponents", { - components: { - system, app, - components: updateComponents + params: { + components: components.join(","), + app: customAppVersion, + system: customSystemVersion, }, - includePreRelease: true, - checkOnly: false, - resetConfig: resetConfig, + includePreRelease: false, + resetConfig, + }, (resp) => { + if ("error" in resp) return; + setModalView("updating"); }); - setModalView("updating"); - }, [send, setModalView, updateComponents, resetConfig]); + }, [send, setModalView, customAppVersion, customSystemVersion, resetConfig]); useEffect(() => { if (otaState.updating) { @@ -64,10 +75,12 @@ export default function SettingsGeneralUpdateRoute() { }, [otaState.error, otaState.updating, setModalView, updateSuccess]); return ; } @@ -76,11 +89,15 @@ export function Dialog({ onConfirmUpdate, onConfirmDowngrade, downgrade, + customAppVersion, + customSystemVersion, }: Readonly<{ downgrade: boolean; onClose: () => void; onConfirmUpdate: () => void; onConfirmDowngrade: () => void; + customAppVersion?: string; + customSystemVersion?: string; }>) { const { navigateTo } = useDeviceUiNavigation(); @@ -92,8 +109,7 @@ export function Dialog({ (versionInfo: SystemVersionInfo) => { const hasUpdate = versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable; - const hasDowngrade = - versionInfo?.systemDowngradeAvailable || versionInfo?.appDowngradeAvailable; + const hasDowngrade = customSystemVersion !== undefined || customAppVersion !== undefined; setVersionInfo(versionInfo); @@ -105,7 +121,7 @@ export function Dialog({ setModalView("upToDate"); } }, - [setModalView, downgrade], + [setModalView, downgrade, customAppVersion, customSystemVersion], ); const onCancelDowngrade = useCallback(() => { @@ -137,9 +153,10 @@ export function Dialog({ )} {modalView === "updateDowngradeAvailable" && ( )} @@ -455,20 +472,19 @@ function UpdateAvailableState({ } function UpdateDowngradeAvailableState({ - versionInfo, + appVersion, + systemVersion, onConfirmDowngrade, onCancelDowngrade, }: { - versionInfo: SystemVersionInfo; - onConfirmDowngrade: (system?: string, app?: string) => void; + appVersion?: string; + systemVersion?: string; + onConfirmDowngrade: () => void; onCancelDowngrade: () => void; }) { const confirmDowngrade = useCallback(() => { - onConfirmDowngrade( - versionInfo?.remote?.systemVersion || undefined, - versionInfo?.remote?.appVersion || undefined, - ); - }, [versionInfo, onConfirmDowngrade]); + onConfirmDowngrade(); + }, [onConfirmDowngrade]); return (
@@ -479,15 +495,15 @@ function UpdateDowngradeAvailableState({ {m.general_update_downgrade_available_description()}

- {versionInfo?.systemDowngradeAvailable ? ( + {systemVersion ? ( <> - {m.general_update_system_type()}: {versionInfo?.remote?.systemVersion} + {m.general_update_system_type()}: {systemVersion}
) : null} - {versionInfo?.appDowngradeAvailable ? ( + {appVersion ? ( <> - {m.general_update_application_type()}: {versionInfo?.remote?.appVersion} + {m.general_update_application_type()}: {appVersion} ) : null}

diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index 0ad4b5df..68ef0d47 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -244,3 +244,21 @@ export async function getLocalVersion() { if (response.error) throw response.error; return response.result; } + +export interface updateParams { + app?: string; + system?: string; + components?: string; +} + +export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) { + const response = await callJsonRpc({ + method: "checkUpdateComponents", + params: { + params, + includePreRelease, + }, + }); + if (response.error) throw response.error; + return response.result; +} \ No newline at end of file