* perf: optimize session loading and startup * fix(chat): stabilize history prepend virtualization * perf: unblock first session open from startup network contention Opening the first session after app start waited seconds for its message fetch. Three independent contributors, each measured via CDP network capture and Chromium net-log against the packaged desktop app: - The active-session watchdog fired an uncapped per-directory status poll and child-session discovery burst at startup, and other subsystems (git checks, global session pages, command/skill discovery) fanned out alongside it, saturating the browser's ~6 HTTP/1.1 sockets per origin. Add a shared background-network gate (concurrency 3) and route the watchdog, poll-shaped git reads (also priority: low), global session pages, command/skill loads, and the background update check through it. - The packaged renderer is cross-origin to the loopback backend, so every API call needs a CORS preflight; a few slow OpenCode-proxied requests held the whole pool while preflights and interactive traffic queued behind them. Lift Chromium's per-host connection cap for loopback via ignore-connections-limit in the Electron shell. - OpenCode initializes each directory lazily on its first request, so the first click paid that cost interactively. Warm the last-used directory and the three most recently opened projects right after OpenCode readiness, sequentially and best-effort, overlapping UI startup. Validation: new background-network tests, lifecycle warmup test, focused store/sync tests, UI type-check and lint, dead-code report, node --check plus electron type-check/lint, and CDP first-open measurements on the packaged app (message fetch socket queue 5.4s -> 0.03s). * fix(ui): keep interactive git reads out of background queue --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import { create } from 'zustand';
|
|
import type { UpdateInfo, UpdateProgress } from '@/lib/desktop';
|
|
import { getDeviceInfo } from '@/lib/device';
|
|
import { useUIStore } from './useUIStore';
|
|
import {
|
|
checkForDesktopUpdates,
|
|
downloadDesktopUpdate,
|
|
restartToApplyUpdate,
|
|
isDesktopLocalOriginActive,
|
|
isElectronShell,
|
|
isVSCodeRuntime,
|
|
isWebRuntime,
|
|
} from '@/lib/desktop';
|
|
import { runtimeFetch } from '@/lib/runtime-fetch';
|
|
import { getClientPlatform, isCapacitorApp } from '@/lib/platform';
|
|
|
|
declare const __APP_VERSION__: string | undefined;
|
|
|
|
type UpdateState = {
|
|
checking: boolean;
|
|
available: boolean;
|
|
downloading: boolean;
|
|
downloaded: boolean;
|
|
info: UpdateInfo | null;
|
|
progress: UpdateProgress | null;
|
|
error: string | null;
|
|
runtimeType: 'desktop' | 'web' | 'vscode' | 'mobile' | 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' | 'mobile';
|
|
|
|
const CLIENT_INSTALL_ID_KEY = 'openchamber.update-install-id';
|
|
|
|
function getClientInstallId(): string | undefined {
|
|
if (typeof window === 'undefined' || typeof crypto.randomUUID !== 'function') return undefined;
|
|
|
|
try {
|
|
const existing = window.localStorage.getItem(CLIENT_INSTALL_ID_KEY)?.trim();
|
|
if (existing) return existing;
|
|
|
|
const installId = crypto.randomUUID();
|
|
window.localStorage.setItem(CLIENT_INSTALL_ID_KEY, installId);
|
|
return installId;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
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 electronArch = typeof window !== 'undefined'
|
|
? window.__OPENCHAMBER_ELECTRON__?.arch?.toLowerCase?.()
|
|
: undefined;
|
|
if (electronArch === 'arm64' || electronArch === 'aarch64') return 'arm64';
|
|
if (electronArch === 'x64' || electronArch === 'amd64' || electronArch === 'x86_64') return 'x64';
|
|
|
|
const vscodeArch = typeof window !== 'undefined'
|
|
? (window as { __VSCODE_CONFIG__?: { arch?: string } }).__VSCODE_CONFIG__?.arch?.toLowerCase?.()
|
|
: undefined;
|
|
if (vscodeArch === 'arm64' || vscodeArch === 'aarch64') return 'arm64';
|
|
if (vscodeArch === 'x64' || vscodeArch === 'amd64' || vscodeArch === 'x86_64') return 'x64';
|
|
|
|
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' | 'android' | 'ios' {
|
|
const clientPlatform = getClientPlatform();
|
|
if (clientPlatform === 'android' || clientPlatform === 'ios') return clientPlatform;
|
|
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 {
|
|
// Check if user has opted out of usage reporting (default: true/enabled from UI store)
|
|
const shouldReportUsage = useUIStore.getState().reportUsage;
|
|
|
|
const params = new URLSearchParams({ reportUsage: shouldReportUsage ? 'true' : 'false' });
|
|
params.set('deviceClass', detectDeviceClass());
|
|
params.set('arch', detectArch());
|
|
params.set('platform', detectPlatform());
|
|
if (shouldReportUsage && (runtime === 'desktop' || runtime === 'mobile')) {
|
|
const installId = getClientInstallId();
|
|
if (installId) params.set('installId', installId);
|
|
}
|
|
if (runtime === 'desktop') {
|
|
params.set('appType', 'desktop-electron');
|
|
params.set('instanceMode', isDesktopLocalOriginActive() ? 'local' : 'remote');
|
|
return params;
|
|
}
|
|
|
|
if (runtime === 'vscode') {
|
|
params.set('appType', 'vscode');
|
|
params.set('instanceMode', 'local');
|
|
return params;
|
|
}
|
|
|
|
if (runtime === 'mobile') {
|
|
params.set('appType', 'mobile-capacitor');
|
|
params.set('instanceMode', 'remote');
|
|
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);
|
|
const vscodeVersion = typeof window !== 'undefined'
|
|
? (window as { __VSCODE_CONFIG__?: { extensionVersion?: string } }).__VSCODE_CONFIG__?.extensionVersion
|
|
: undefined;
|
|
if (currentVersion) params.set('currentVersion', currentVersion);
|
|
else if (runtime === 'vscode' && vscodeVersion) params.set('currentVersion', vscodeVersion);
|
|
const response = await runtimeFetch(`/api/openchamber/update-check?${params.toString()}`, {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json' },
|
|
// Background check — keep sockets free for interactive traffic at startup.
|
|
priority: 'low',
|
|
});
|
|
|
|
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,
|
|
releaseUrl: data.releaseUrl,
|
|
downloadUrl: data.downloadUrl,
|
|
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' | 'mobile' | null {
|
|
if (isCapacitorApp()) {
|
|
return 'mobile';
|
|
}
|
|
if (isElectronShell()) {
|
|
return 'desktop';
|
|
}
|
|
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') {
|
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
|
|
const [desktopResult, apiResult] = await Promise.allSettled([
|
|
checkForDesktopUpdates(),
|
|
checkForWebUpdates('desktop', appVersion),
|
|
]);
|
|
const desktopInfo = desktopResult.status === 'fulfilled' ? desktopResult.value : null;
|
|
suggestedSec = apiResult.status === 'fulfilled'
|
|
? (apiResult.value?.nextSuggestedCheckInSec ?? null)
|
|
: null;
|
|
set({
|
|
checking: false,
|
|
available: desktopInfo?.available ?? false,
|
|
info: desktopInfo,
|
|
lastChecked: Date.now(),
|
|
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;
|
|
} else if (runtime === 'mobile') {
|
|
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : undefined;
|
|
info = await checkForWebUpdates('mobile', appVersion);
|
|
suggestedSec = info?.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,
|
|
// Keep the richer sidecar-sourced changelog; desktopInfo.body is
|
|
// often the bare "See release notes at..." fallback from the
|
|
// updater and would otherwise clobber the nice changelog.
|
|
body: state.info.body || desktopInfo.body,
|
|
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);
|
|
},
|
|
}));
|