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
+41 -2
View File
@@ -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);
+127 -7
View File
@@ -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);