refactor: simplify version check and downgrade

This commit is contained in:
Siyuan 2025-11-07 12:20:45 +00:00
parent 329ad025bf
commit 1bca0c5e26
7 changed files with 193 additions and 126 deletions

View File

@ -239,10 +239,29 @@ func (s *State) getUpdateStatus(
systemUpdate = &currentSystemUpdate
}
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
}

View File

@ -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

View File

@ -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"}},

73
ota.go
View File

@ -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() {

View File

@ -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<string>("");
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,
};
send("tryUpdateComponents", params, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
m.advanced_error_version_update({ error: resp.error.data || m.unknown_error() })
);
}, devChannel);
console.log("versionInfo", versionInfo);
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
handleVersionUpdateError(jsonRpcError);
return ;
}
if (!versionInfo) {
handleVersionUpdateError();
return;
}
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());
pageParams.set("components", updateTarget === "both" ? "app,system" : updateTarget);
// Navigate to update page
navigateTo(`/settings/general/update?${pageParams.toString()}`);
});
}, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo, resetConfig]);
}, [
updateTarget, appVersion, systemVersion, devChannel,
navigateTo, resetConfig, handleVersionUpdateError,
setVersionUpdateLoading
]);
return (
<div className="space-y-4">
@ -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}
/>
</div>

View File

@ -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");
}, [send, setModalView, updateComponents, resetConfig]);
});
}, [send, setModalView, customAppVersion, customSystemVersion, resetConfig]);
useEffect(() => {
if (otaState.updating) {
@ -68,6 +79,8 @@ export default function SettingsGeneralUpdateRoute() {
onConfirmUpdate={onConfirmUpdate}
onConfirmDowngrade={onConfirmDowngrade}
downgrade={downgrade}
customAppVersion={customAppVersion}
customSystemVersion={customSystemVersion}
/>;
}
@ -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" && (
<UpdateDowngradeAvailableState
appVersion={customAppVersion}
systemVersion={customSystemVersion}
onConfirmDowngrade={onConfirmDowngrade}
onCancelDowngrade={onCancelDowngrade}
versionInfo={versionInfo!}
/>
)}
@ -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 (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
@ -479,15 +495,15 @@ function UpdateDowngradeAvailableState({
{m.general_update_downgrade_available_description()}
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemDowngradeAvailable ? (
{systemVersion ? (
<>
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.remote?.systemVersion}
<span className="font-semibold">{m.general_update_system_type()}</span>: {systemVersion}
<br />
</>
) : null}
{versionInfo?.appDowngradeAvailable ? (
{appVersion ? (
<>
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.remote?.appVersion}
<span className="font-semibold">{m.general_update_application_type()}</span>: {appVersion}
</>
) : null}
</p>

View File

@ -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<SystemVersionInfo>({
method: "checkUpdateComponents",
params: {
params,
includePreRelease,
},
});
if (response.error) throw response.error;
return response.result;
}