diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 0494bca2..ad79085d 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -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]); diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index f9e7c628..811a92ed 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -11,6 +11,7 @@ export type UpdateInfo = { currentVersion: string; body?: string; date?: string; + nextSuggestedCheckInSec?: number; // Web-specific fields packageManager?: string; updateCommand?: string; diff --git a/packages/ui/src/stores/useUpdateStore.ts b/packages/ui/src/stores/useUpdateStore.ts index 7f5a7931..2ce066e8 100644 --- a/packages/ui/src/stores/useUpdateStore.ts +++ b/packages/ui/src/stores/useUpdateStore.ts @@ -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; + checkForUpdates: () => Promise; downloadUpdate: () => Promise; restartToUpdate: () => Promise; dismiss: () => void; reset: () => void; } -async function checkForWebUpdates(): Promise { +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 { + 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 { 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()((set, get) => ({ @@ -82,30 +148,40 @@ export const useUpdateStore = create()((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; } }, diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index cfaa94c0..a78e5aa6 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; +import { randomUUID } from 'crypto'; import { spawn, execFile } from 'child_process'; import { promisify } from 'util'; import { type OpenCodeManager } from './opencode'; @@ -112,6 +113,37 @@ const execFileAsync = promisify(execFile); const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf']; const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); +const VSCODE_INSTALL_ID_KEY = 'openchamber.installId.vscode'; +const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; + +const mapNodePlatformToApiPlatform = (value: string): 'macos' | 'windows' | 'linux' | 'web' => { + if (value === 'darwin') return 'macos'; + if (value === 'win32') return 'windows'; + if (value === 'linux') return 'linux'; + return 'web'; +}; + +const mapNodeArchToApiArch = (value: string): 'arm64' | 'x64' | 'unknown' => { + if (value === 'arm64' || value === 'aarch64') return 'arm64'; + if (value === 'x64' || value === 'amd64') return 'x64'; + return 'unknown'; +}; + +const getOrCreateVSCodeInstallId = async (ctx?: BridgeContext): Promise => { + const state = ctx?.context?.globalState; + if (state) { + const existing = state.get(VSCODE_INSTALL_ID_KEY); + if (typeof existing === 'string' && existing.trim().length > 0) { + return existing.trim(); + } + } + + const generated = randomUUID(); + if (state) { + await state.update(VSCODE_INSTALL_ID_KEY, generated); + } + return generated; +}; const guessMimeTypeFromExtension = (ext: string) => { switch (ext) { @@ -2890,6 +2922,56 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } + case 'api:openchamber:update-check': { + try { + const body = (payload && typeof payload === 'object' ? payload : {}) as Record; + const currentVersion = typeof body.currentVersion === 'string' && body.currentVersion.trim().length > 0 + ? body.currentVersion.trim() + : 'unknown'; + const instanceMode = typeof body.instanceMode === 'string' && body.instanceMode.trim().length > 0 + ? body.instanceMode.trim() + : 'local'; + const deviceClass = typeof body.deviceClass === 'string' && body.deviceClass.trim().length > 0 + ? body.deviceClass.trim() + : 'desktop'; + const reportUsage = body.reportUsage !== false; + + const installId = await getOrCreateVSCodeInstallId(ctx); + const requestBody = { + appType: 'vscode', + deviceClass, + platform: mapNodePlatformToApiPlatform(os.platform()), + arch: mapNodeArchToApiArch(os.arch()), + channel: 'stable', + currentVersion, + installId, + instanceMode, + reportUsage, + }; + + const response = await fetch(UPDATE_CHECK_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(10_000), + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'update check failed'); + return { id, type, success: false, error: text || `Update check failed with ${response.status}` }; + } + + const data = await response.json(); + return { id, type, success: true, data }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + case 'editor:openFile': { const { path: filePath, line, column } = payload as { path: string; line?: number; column?: number }; try { diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 5df6fed6..8670ede5 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -746,6 +746,26 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { } } + if (pathname.startsWith('/api/openchamber/update-check')) { + try { + const currentVersion = url.searchParams.get('currentVersion') || undefined; + const instanceMode = url.searchParams.get('instanceMode') || 'local'; + const deviceClass = url.searchParams.get('deviceClass') || 'desktop'; + const reportUsageRaw = (url.searchParams.get('reportUsage') || 'true').toLowerCase(); + const reportUsage = !(reportUsageRaw === 'false' || reportUsageRaw === '0' || reportUsageRaw === 'no'); + const data = await sendBridgeMessage('api:openchamber:update-check', { + currentVersion, + instanceMode, + deviceClass, + reportUsage, + }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ available: false, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } }); + } + } + if (pathname === '/auth/session') { // VS Code host is trusted; mirror web server shape to keep UI logic happy const body = { diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 42634344..fb730830 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -7556,10 +7556,49 @@ async function main(options = {}) { }); }); - app.get('/api/openchamber/update-check', async (_req, res) => { + app.get('/api/openchamber/update-check', async (req, res) => { try { const { checkForUpdates } = await import('./lib/package-manager.js'); - const updateInfo = await checkForUpdates(); + const parseString = (value) => (typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined); + const parseReportUsage = (value) => { + if (typeof value !== 'string') return true; + const normalized = value.trim().toLowerCase(); + if (normalized === 'false' || normalized === '0' || normalized === 'no') return false; + return true; + }; + const inferDeviceClass = (ua) => { + const value = (ua || '').toLowerCase(); + if (!value) return 'unknown'; + if (value.includes('ipad') || value.includes('tablet')) return 'tablet'; + if (value.includes('mobi') || value.includes('android') || value.includes('iphone')) return 'mobile'; + return 'desktop'; + }; + const inferArch = (ua) => { + const value = (ua || '').toLowerCase(); + if (!value) return 'unknown'; + if (value.includes('aarch64') || value.includes('arm64') || value.includes(' arm;') || value.includes('armv')) return 'arm64'; + if (value.includes('x86_64') || value.includes('x64') || value.includes('amd64') || value.includes('win64') || value.includes('x86-64')) return 'x64'; + return 'unknown'; + }; + const inferPlatform = (ua) => { + const value = (ua || '').toLowerCase(); + if (!value) return undefined; + if (value.includes('mac os') || value.includes('macintosh') || value.includes('darwin')) return 'macos'; + if (value.includes('windows') || value.includes('win32') || value.includes('win64')) return 'windows'; + if (value.includes('linux') || value.includes('x11')) return 'linux'; + return 'web'; + }; + const userAgent = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''; + + const updateInfo = await checkForUpdates({ + appType: parseString(req.query.appType), + deviceClass: parseString(req.query.deviceClass) || inferDeviceClass(userAgent), + platform: parseString(req.query.platform) || inferPlatform(userAgent), + arch: parseString(req.query.arch) || inferArch(userAgent), + instanceMode: parseString(req.query.instanceMode), + currentVersion: parseString(req.query.currentVersion), + reportUsage: parseReportUsage(parseString(req.query.reportUsage)), + }); res.json(updateInfo); } catch (error) { console.error('Failed to check for updates:', error); diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index 932cedf2..cc4b21c3 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -1,5 +1,7 @@ import { spawnSync } from 'child_process'; +import crypto from 'crypto'; import fs from 'fs'; +import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -9,6 +11,116 @@ const __dirname = path.dirname(__filename); const PACKAGE_NAME = '@openchamber/web'; const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; +const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; + +function getOpenChamberConfigDir() { + if (process.platform === 'win32') { + const appData = process.env.APPDATA; + if (appData) return path.join(appData, 'openchamber'); + } + + return path.join(os.homedir(), '.config', 'openchamber'); +} + +function sanitizeInstallScope(scope) { + if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; + return 'web'; +} + +function getOrCreateInstallId(scope = 'web') { + const configDir = getOpenChamberConfigDir(); + const normalizedScope = sanitizeInstallScope(scope); + const idPath = path.join(configDir, `install-id-${normalizedScope}`); + + try { + const existing = fs.readFileSync(idPath, 'utf8').trim(); + if (existing) return existing; + } catch { + // Generate new id. + } + + const installId = crypto.randomUUID(); + fs.mkdirSync(configDir, { recursive: true }); + fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); + return installId; +} + +function mapPlatform(value) { + if (value === 'darwin') return 'macos'; + if (value === 'win32') return 'windows'; + if (value === 'linux') return 'linux'; + return 'web'; +} + +function mapArch(value) { + if (value === 'arm64' || value === 'aarch64') return 'arm64'; + if (value === 'x64' || value === 'amd64') return 'x64'; + return 'unknown'; +} + +function normalizeAppType(value) { + if (value === 'web' || value === 'desktop-tauri' || value === 'vscode') return value; + return 'web'; +} + +function normalizeDeviceClass(value) { + if (value === 'mobile' || value === 'tablet' || value === 'desktop' || value === 'unknown') return value; + return 'unknown'; +} + +function normalizePlatform(value) { + if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'web') return value; + return mapPlatform(process.platform); +} + +function normalizeArch(value) { + if (value === 'arm64' || value === 'x64' || value === 'unknown') return value; + return mapArch(process.arch); +} + +async function checkForUpdatesFromApi(currentVersion, options = {}) { + try { + const appType = normalizeAppType(options.appType); + const payload = { + appType, + deviceClass: normalizeDeviceClass(options.deviceClass), + platform: normalizePlatform(options.platform), + arch: normalizeArch(options.arch), + channel: 'stable', + currentVersion, + installId: getOrCreateInstallId(appType), + instanceMode: options.instanceMode || 'unknown', + reportUsage: options.reportUsage !== false, + }; + + const response = await fetch(UPDATE_CHECK_URL, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(10000), + }); + + if (!response.ok) return null; + const data = await response.json(); + if (typeof data?.latestVersion !== 'string') return null; + + return { + available: Boolean(data.updateAvailable), + version: data.latestVersion, + currentVersion, + body: typeof data.releaseNotes === 'string' ? data.releaseNotes : undefined, + nextSuggestedCheckInSec: + typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec) + ? data.nextSuggestedCheckInSec + : undefined, + }; + } catch { + return null; + } +} /** * Detect which package manager was used to install this package. @@ -304,11 +416,21 @@ export async function fetchChangelogNotes(fromVersion, toVersion) { } } -/** - * Check for updates and return update info - */ -export async function checkForUpdates() { - const currentVersion = getCurrentVersion(); +export async function checkForUpdates(options = {}) { + const currentVersion = options.currentVersion || getCurrentVersion(); + const pm = detectPackageManager(); + + if (currentVersion !== 'unknown') { + const remote = await checkForUpdatesFromApi(currentVersion, options); + if (remote) { + return { + ...remote, + packageManager: pm, + updateCommand: 'openchamber update', + }; + } + } + const latestVersion = await getLatestVersion(); if (!latestVersion || currentVersion === 'unknown') { @@ -323,8 +445,6 @@ export async function checkForUpdates() { const latestNum = parseVersion(latestVersion); const available = latestNum > currentNum; - const pm = detectPackageManager(); - let changelog; if (available) { changelog = await fetchChangelogNotes(currentVersion, latestVersion);