fix: unify update-check API flow across runtimes (#672)

* fix: align update flow across web, desktop, and vscode

Unify runtime update handling in shared UI update state
Adjust desktop and VS Code bridge integration for consistent behavior
Improve server package-manager update plumbing for safer checks

* fix: sync update checks between vscode and server

Align VS Code bridge behavior with server package-manager logic
Reduce mismatches in update detection across runtimes

* fix: stabilize update checks across ui and server
This commit is contained in:
Bohdan Triapitsyn
2026-03-15 23:14:37 +02:00
committed by GitHub
parent d81b34575a
commit 7f37256e96
7 changed files with 381 additions and 27 deletions
@@ -144,23 +144,39 @@ export const MainLayout: React.FC = () => {
}
}, [isRightSidebarOpen, isMobile]);
// Trigger initial update check shortly after mount, then every hour.
// Trigger initial update check shortly after mount, then repeat using server-suggested cadence.
const checkForUpdates = useUpdateStore((state) => state.checkForUpdates);
React.useEffect(() => {
const initialDelayMs = 3000;
const periodicIntervalMs = 60 * 60 * 1000;
const defaultIntervalMs = 60 * 60 * 1000;
const minIntervalMs = 5 * 60 * 1000;
const maxIntervalMs = 24 * 60 * 60 * 1000;
let disposed = false;
let timer: number | null = null;
const timer = window.setTimeout(() => {
checkForUpdates();
}, initialDelayMs);
const clampIntervalMs = (seconds: number): number => {
const ms = Math.round(seconds * 1000);
return Math.max(minIntervalMs, Math.min(maxIntervalMs, ms));
};
const interval = window.setInterval(() => {
checkForUpdates();
}, periodicIntervalMs);
const scheduleNext = (delayMs: number) => {
if (disposed) return;
timer = window.setTimeout(async () => {
const suggestedSec = await checkForUpdates();
const nextDelay = typeof suggestedSec === 'number' && Number.isFinite(suggestedSec)
? clampIntervalMs(suggestedSec)
: defaultIntervalMs;
scheduleNext(nextDelay);
}, delayMs);
};
scheduleNext(initialDelayMs);
return () => {
window.clearTimeout(timer);
window.clearInterval(interval);
disposed = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [checkForUpdates]);
+1
View File
@@ -11,6 +11,7 @@ export type UpdateInfo = {
currentVersion: string;
body?: string;
date?: string;
nextSuggestedCheckInSec?: number;
// Web-specific fields
packageManager?: string;
updateCommand?: string;
+84 -8
View File
@@ -1,11 +1,13 @@
import { create } from 'zustand';
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
import { getDeviceInfo } from '@/lib/device';
import {
checkForDesktopUpdates,
downloadDesktopUpdate,
restartToApplyUpdate,
isDesktopLocalOriginActive,
isTauriShell,
isVSCodeRuntime,
isWebRuntime,
} from '@/lib/desktop';
@@ -19,19 +21,77 @@ export type UpdateState = {
error: string | null;
runtimeType: 'desktop' | 'web' | 'vscode' | null;
lastChecked: number | null;
nextCheckInSec: number | null;
};
interface UpdateStore extends UpdateState {
checkForUpdates: () => Promise<void>;
checkForUpdates: () => Promise<number | null>;
downloadUpdate: () => Promise<void>;
restartToUpdate: () => Promise<void>;
dismiss: () => void;
reset: () => void;
}
async function checkForWebUpdates(): Promise<UpdateInfo | null> {
type ClientRuntime = 'desktop' | 'web' | 'vscode';
function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | 'unknown' {
if (typeof window === 'undefined') return 'unknown';
try {
const response = await fetch('/api/openchamber/update-check', {
const { deviceType } = getDeviceInfo();
return deviceType;
} catch {
return 'unknown';
}
}
function detectArch(): 'arm64' | 'x64' | 'unknown' {
const nav = typeof navigator !== 'undefined' ? (navigator as Navigator & { userAgentData?: { architecture?: string } }).userAgentData : undefined;
const fromUAData = nav?.architecture?.toLowerCase?.();
if (fromUAData === 'arm' || fromUAData === 'arm64' || fromUAData === 'aarch64') return 'arm64';
if (fromUAData === 'x86' || fromUAData === 'x64' || fromUAData === 'amd64') return 'x64';
const ua = typeof navigator !== 'undefined' ? navigator.userAgent.toLowerCase() : '';
if (ua.includes('aarch64') || ua.includes('arm64') || ua.includes('armv')) return 'arm64';
if (ua.includes('x86_64') || ua.includes('x64') || ua.includes('amd64') || ua.includes('win64')) return 'x64';
return 'unknown';
}
function detectPlatform(): 'macos' | 'windows' | 'linux' | 'web' {
if (typeof navigator === 'undefined') return 'web';
const platform = (navigator.platform || '').toLowerCase();
if (platform.includes('mac')) return 'macos';
if (platform.includes('win')) return 'windows';
if (platform.includes('linux')) return 'linux';
return 'web';
}
function mapRuntimeParams(runtime: ClientRuntime): URLSearchParams {
const params = new URLSearchParams({ reportUsage: 'true' });
params.set('deviceClass', detectDeviceClass());
params.set('arch', detectArch());
params.set('platform', detectPlatform());
if (runtime === 'desktop') {
params.set('appType', 'desktop-tauri');
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
return params;
}
if (runtime === 'vscode') {
params.set('appType', 'vscode');
params.set('instanceMode', 'local');
return params;
}
params.set('appType', 'web');
params.set('instanceMode', 'unknown');
return params;
}
async function checkForWebUpdates(runtime: ClientRuntime, currentVersion?: string): Promise<UpdateInfo | null> {
try {
const params = mapRuntimeParams(runtime);
if (currentVersion) params.set('currentVersion', currentVersion);
const response = await fetch(`/api/openchamber/update-check?${params.toString()}`, {
method: 'GET',
headers: { Accept: 'application/json' },
});
@@ -46,11 +106,15 @@ async function checkForWebUpdates(): Promise<UpdateInfo | null> {
version: data.version,
currentVersion: data.currentVersion ?? 'unknown',
body: data.body,
nextSuggestedCheckInSec:
typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec)
? data.nextSuggestedCheckInSec
: undefined,
packageManager: data.packageManager,
updateCommand: data.updateCommand,
};
} catch (error) {
console.warn('Failed to check for web updates:', error);
console.warn('Failed to check for updates:', error);
return null;
}
}
@@ -61,6 +125,7 @@ function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
// When viewing a remote host inside the desktop shell, treat update as web update.
return isDesktopLocalOriginActive() ? 'desktop' : 'web';
}
if (isVSCodeRuntime()) return 'vscode';
if (isWebRuntime()) return 'web';
return null;
}
@@ -75,6 +140,7 @@ const initialState: UpdateState = {
error: null,
runtimeType: null,
lastChecked: null,
nextCheckInSec: null,
};
export const useUpdateStore = create<UpdateStore>()((set, get) => ({
@@ -82,30 +148,40 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
checkForUpdates: async () => {
const runtime = detectRuntimeType();
if (!runtime) return;
if (!runtime) return null;
set({ checking: true, error: null, runtimeType: runtime });
try {
let info: UpdateInfo | null = null;
let suggestedSec: number | null = null;
if (runtime === 'desktop') {
info = await checkForDesktopUpdates();
const sidecarInfo = await checkForWebUpdates('desktop', info?.currentVersion);
suggestedSec = sidecarInfo?.nextSuggestedCheckInSec ?? null;
} else if (runtime === 'web') {
info = await checkForWebUpdates();
info = await checkForWebUpdates('web');
suggestedSec = info?.nextSuggestedCheckInSec ?? null;
} else if (runtime === 'vscode') {
const vscodeInfo = await checkForWebUpdates('vscode');
suggestedSec = vscodeInfo?.nextSuggestedCheckInSec ?? null;
}
set({
checking: false,
available: info?.available ?? false,
info,
available: runtime === 'vscode' ? false : (info?.available ?? false),
info: runtime === 'vscode' ? null : info,
lastChecked: Date.now(),
nextCheckInSec: suggestedSec,
});
return suggestedSec;
} catch (error) {
set({
checking: false,
error: error instanceof Error ? error.message : 'Failed to check for updates',
});
return null;
}
},