feat: reconcile WebAVP desktop parity and releases

This commit is contained in:
2026-08-19 12:14:29 +00:00
parent 407892ed9a
commit 2a275fc536
26 changed files with 2155 additions and 1508 deletions
+140
View File
@@ -0,0 +1,140 @@
(function initWebAVPUtils(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
if (root) root.WebAVPUtils = api;
}(typeof globalThis !== 'undefined' ? globalThis : this, function createWebAVPUtils() {
'use strict';
const GITPEJI_ORIGIN = 'https://git.pejicorp.com';
const RELEASES_URL = `${GITPEJI_ORIGIN}/peji/WebAVP/releases`;
const LATEST_RELEASE_API = `${GITPEJI_ORIGIN}/api/v1/repos/peji/WebAVP/releases/latest`;
function parseVersion(value) {
const match = String(value || '').trim().match(/^v?(\d+)\.(\d+)(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/);
if (!match) throw new Error(`Invalid semantic version: ${value}`);
return {
parts: [Number(match[1]), Number(match[2]), Number(match[3] || 0)],
prerelease: match[4] ? match[4].split('.') : [],
};
}
function compareIdentifiers(left, right) {
const leftNumber = /^\d+$/.test(left) ? Number(left) : null;
const rightNumber = /^\d+$/.test(right) ? Number(right) : null;
if (leftNumber !== null && rightNumber !== null) return Math.sign(leftNumber - rightNumber);
if (leftNumber !== null) return -1;
if (rightNumber !== null) return 1;
return left === right ? 0 : (left > right ? 1 : -1);
}
function compareVersions(leftValue, rightValue) {
const left = parseVersion(leftValue);
const right = parseVersion(rightValue);
for (let index = 0; index < 3; index += 1) {
if (left.parts[index] !== right.parts[index]) return Math.sign(left.parts[index] - right.parts[index]);
}
if (!left.prerelease.length && !right.prerelease.length) return 0;
if (!left.prerelease.length) return 1;
if (!right.prerelease.length) return -1;
const length = Math.max(left.prerelease.length, right.prerelease.length);
for (let index = 0; index < length; index += 1) {
if (left.prerelease[index] === undefined) return -1;
if (right.prerelease[index] === undefined) return 1;
const result = compareIdentifiers(left.prerelease[index], right.prerelease[index]);
if (result) return result;
}
return 0;
}
function getAutoGrid(countValue) {
const count = Math.max(1, Math.min(16, Number(countValue) || 1));
if (count === 1) return { cols: 1, rows: 1 };
if (count === 2) return { cols: 2, rows: 1 };
if (count <= 4) return { cols: 2, rows: 2 };
if (count <= 6) return { cols: 3, rows: 2 };
if (count <= 9) return { cols: 3, rows: 3 };
if (count <= 12) return { cols: 4, rows: 3 };
return { cols: 4, rows: 4 };
}
function assertGitPejiUrl(rawUrl, label) {
let url;
try {
url = new URL(rawUrl);
} catch {
throw new Error(`Invalid ${label} URL`);
}
if (url.origin !== GITPEJI_ORIGIN || url.username || url.password) {
throw new Error(`Invalid ${label} host`);
}
return url.href;
}
function normalizeRelease(rawRelease) {
if (!rawRelease || typeof rawRelease !== 'object') throw new Error('Invalid release response');
const version = String(rawRelease.tag_name || '').replace(/^v/, '');
parseVersion(version);
const pageUrl = assertGitPejiUrl(rawRelease.html_url || RELEASES_URL, 'release page');
const assets = Array.isArray(rawRelease.assets) ? rawRelease.assets.map((asset) => {
if (!asset || typeof asset.name !== 'string' || !asset.name.trim()) throw new Error('Invalid release asset');
const url = assertGitPejiUrl(asset.browser_download_url, 'release asset');
if (!new URL(url).pathname.startsWith('/peji/WebAVP/releases/download/')) throw new Error('Invalid release asset path');
return { name: asset.name.trim(), url };
}) : [];
return { version, name: String(rawRelease.name || rawRelease.tag_name), pageUrl, assets };
}
function normalizedPlatform(platform) {
if (platform === 'darwin' || platform === 'mac' || platform === 'macos') return 'mac';
if (platform === 'win32' || platform === 'windows' || platform === 'win') return 'win';
return platform === 'linux' ? 'linux' : '';
}
function assetScore(asset, platformValue, archValue) {
const platform = normalizedPlatform(platformValue);
const arch = String(archValue || '').toLowerCase();
const name = asset.name.toLowerCase();
const platformMatches = platform === 'linux'
? name.endsWith('.appimage') || name.endsWith('.deb') || /(^|[-_.])linux([-_.]|$)/.test(name)
: platform === 'mac'
? name.endsWith('.dmg') || /(^|[-_.])(mac|macos|darwin)([-_.]|$)/.test(name)
: platform === 'win'
? name.endsWith('.exe') || name.endsWith('.msi') || /(^|[-_.])(win|windows)([-_.]|$)/.test(name)
: false;
if (!platformMatches) return -1;
const hasArm64 = /(^|[-_.])(arm64|aarch64)([-_.]|$)/.test(name);
const hasX64 = /(^|[-_.])(x64|amd64|x86_64)([-_.]|$)/.test(name);
if ((arch === 'arm64' || arch === 'aarch64') && !hasArm64) return -1;
if ((arch === 'x64' || arch === 'amd64' || arch === 'x86_64') && !hasX64) return -1;
if (!['arm64', 'aarch64', 'x64', 'amd64', 'x86_64'].includes(arch)) return -1;
if (platform === 'linux' && name.endsWith('.appimage')) return 30;
if (platform === 'win' && name.endsWith('.exe')) return 30;
if (platform === 'mac' && name.endsWith('.dmg')) return 30;
return 10;
}
function selectReleaseAsset(release, platform, arch) {
if (!release || !Array.isArray(release.assets)) return null;
return release.assets
.map((asset) => ({ asset, score: assetScore(asset, platform, arch) }))
.filter((candidate) => candidate.score >= 0)
.sort((left, right) => right.score - left.score || left.asset.name.localeCompare(right.asset.name))[0]?.asset || null;
}
function evaluateUpdate(release, currentVersion, platform, arch) {
if (compareVersions(release.version, currentVersion) <= 0) return { status: 'current', asset: null };
const asset = selectReleaseAsset(release, platform, arch);
return asset ? { status: 'available', asset } : { status: 'unavailable-platform', asset: null };
}
return {
GITPEJI_ORIGIN,
LATEST_RELEASE_API,
RELEASES_URL,
compareVersions,
evaluateUpdate,
getAutoGrid,
normalizeRelease,
selectReleaseAsset,
};
}));