Files
openchamber/packages/ui/src/stores/useUpdateStore.ts
T
Bohdan Triapitsyn 03cec8d507 fix: restore reliable update flow across sidebar and mobile
Show Update button in sidebar only when an update is available
Support update button on mobile sidebar and remove duplicate mobile Settings entry
Force desktop Tauri recheck and preflight before download to avoid missing pending updates
2026-03-20 02:04:33 +02:00

290 lines
8.7 KiB
TypeScript

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';
export type UpdateState = {
checking: boolean;
available: boolean;
downloading: boolean;
downloaded: boolean;
info: UpdateInfo | null;
progress: UpdateProgress | null;
error: string | null;
runtimeType: 'desktop' | 'web' | 'vscode' | null;
lastChecked: number | null;
nextCheckInSec: number | null;
};
interface UpdateStore extends UpdateState {
checkForUpdates: () => Promise<number | null>;
downloadUpdate: () => Promise<void>;
restartToUpdate: () => Promise<void>;
dismiss: () => void;
reset: () => void;
}
type ClientRuntime = 'desktop' | 'web' | 'vscode';
function detectDeviceClass(): 'mobile' | 'tablet' | 'desktop' | 'unknown' {
if (typeof window === 'undefined') return 'unknown';
try {
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' },
});
if (!response.ok) {
throw new Error(`Server responded with ${response.status}`);
}
const data = await response.json();
return {
available: data.available ?? false,
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 updates:', error);
return null;
}
}
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
if (isTauriShell()) {
// Only use Tauri updater when we're on the local instance.
// 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;
}
const initialState: UpdateState = {
checking: false,
available: false,
downloading: false,
downloaded: false,
info: null,
progress: null,
error: null,
runtimeType: null,
lastChecked: null,
nextCheckInSec: null,
};
export const useUpdateStore = create<UpdateStore>()((set, get) => ({
...initialState,
checkForUpdates: async () => {
const runtime = detectRuntimeType();
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') {
let desktopInfo = await checkForDesktopUpdates();
set({
checking: false,
available: desktopInfo?.available ?? false,
info: desktopInfo,
lastChecked: Date.now(),
nextCheckInSec: null,
});
const sidecarInfo = await checkForWebUpdates('desktop', desktopInfo?.currentVersion);
suggestedSec = sidecarInfo?.nextSuggestedCheckInSec ?? null;
if (sidecarInfo?.available && !desktopInfo?.available) {
const forcedDesktopInfo = await checkForDesktopUpdates();
if (forcedDesktopInfo) {
desktopInfo = forcedDesktopInfo;
}
}
if (sidecarInfo) {
const mergedInfo: UpdateInfo = {
...(desktopInfo ?? { available: false, currentVersion: sidecarInfo.currentVersion ?? 'unknown' }),
...sidecarInfo,
currentVersion: desktopInfo?.currentVersion ?? sidecarInfo.currentVersion ?? 'unknown',
available: sidecarInfo.available,
};
set({
available: mergedInfo.available,
info: mergedInfo,
nextCheckInSec: suggestedSec,
});
} else {
set({ nextCheckInSec: suggestedSec });
}
return suggestedSec;
} else if (runtime === 'web') {
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: 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;
}
},
downloadUpdate: async () => {
const { available, runtimeType } = get();
// For web runtime, there's no download - user uses in-app update or CLI
if (runtimeType !== 'desktop' || !available) {
return;
}
set({ downloading: true, error: null, progress: null });
try {
const desktopInfo = await checkForDesktopUpdates();
if (!desktopInfo?.available) {
throw new Error('Update detected, but desktop package is not ready yet. Retry in a moment.');
}
set((state) => ({
info: state.info
? {
...state.info,
...desktopInfo,
available: state.info.available,
}
: desktopInfo,
}));
const ok = await downloadDesktopUpdate((progress) => {
set({ progress });
});
if (!ok) {
throw new Error('Desktop update only works on Local instance');
}
set({ downloading: false, downloaded: true });
} catch (error) {
set({
downloading: false,
error: error instanceof Error ? error.message : 'Failed to download update',
});
}
},
restartToUpdate: async () => {
const { downloaded, runtimeType } = get();
if (runtimeType !== 'desktop' || !downloaded) {
return;
}
try {
const ok = await restartToApplyUpdate();
if (!ok) {
throw new Error('Desktop restart only works on Local instance');
}
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to restart',
});
}
},
dismiss: () => {
set({ available: false, downloaded: false, info: null });
},
reset: () => {
set(initialState);
},
}));