feat: reconcile WebAVP desktop parity and releases
This commit is contained in:
+184
-26
@@ -2,11 +2,21 @@ const { app, BrowserWindow, ipcMain, protocol, shell } = require('electron');
|
||||
const fs = require('node:fs/promises');
|
||||
const path = require('node:path');
|
||||
const https = require('node:https');
|
||||
const {
|
||||
LATEST_RELEASE_API,
|
||||
evaluateUpdate,
|
||||
normalizeRelease,
|
||||
} = require('../static/webavp-utils.js');
|
||||
|
||||
const APP_SCHEME = 'webavp';
|
||||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||||
const TEMPLATE_DIR = path.join(ROOT_DIR, 'templates');
|
||||
const STATIC_DIR = path.join(ROOT_DIR, 'static');
|
||||
const APP_ICON = path.join(ROOT_DIR, 'build', 'icon.png');
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
||||
const MAX_SERIAL_LENGTH = 128;
|
||||
const MAX_CERT_HASH_LENGTH = 256;
|
||||
|
||||
if (process.env.WEBAVP_DISABLE_GPU === '1') {
|
||||
app.disableHardwareAcceleration();
|
||||
@@ -45,6 +55,7 @@ function contentTypeFor(filePath) {
|
||||
|
||||
function safeJoin(baseDir, requestPath) {
|
||||
const decoded = decodeURIComponent(requestPath);
|
||||
if (decoded.includes('\0')) throw new Error('Null byte blocked');
|
||||
const normalized = path.normalize(decoded).replace(/^([/\\])+/, '');
|
||||
const resolved = path.resolve(baseDir, normalized);
|
||||
const base = path.resolve(baseDir);
|
||||
@@ -54,6 +65,41 @@ function safeJoin(baseDir, requestPath) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isAppUrl(rawUrl) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
return url.protocol === `${APP_SCHEME}:` && url.hostname === 'app';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeExternalUrl(rawUrl) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
return url.protocol === 'https:' || url.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isGitPejiReleaseAssetUrl(rawUrl) {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
return url.origin === 'https://git.pejicorp.com'
|
||||
&& url.pathname.startsWith('/peji/WebAVP/releases/download/')
|
||||
&& !url.username
|
||||
&& !url.password;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isAuthorizedRenderer(event) {
|
||||
const senderUrl = event.senderFrame?.url || event.sender?.getURL?.() || '';
|
||||
return isAppUrl(senderUrl);
|
||||
}
|
||||
|
||||
async function serveFile(filePath) {
|
||||
try {
|
||||
const data = await fs.readFile(filePath);
|
||||
@@ -93,16 +139,30 @@ function registerAppProtocol() {
|
||||
});
|
||||
}
|
||||
|
||||
// Issue the GET via Node's https stack (not Electron's net.fetch). The Alta
|
||||
// endpoint requests an optional TLS client certificate; Chromium's net stack
|
||||
// reacts by failing the handshake with ERR_SSL_CLIENT_AUTH_CERT_NEEDED, which
|
||||
// made cloud verification appear "offline" in the desktop build. Node's TLS
|
||||
// (like curl and the Python app.py reference) simply proceeds without one.
|
||||
function httpsGetStatus(url, timeoutMs = 10000) {
|
||||
// Node's TLS proceeds when Alta requests an optional client certificate, unlike
|
||||
// Chromium's net stack. It also gives update checks one bounded request path.
|
||||
function httpsGetResponse(url, timeoutMs = REQUEST_TIMEOUT_MS) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, (res) => {
|
||||
res.resume(); // drain so the socket is released
|
||||
resolve(res.statusCode);
|
||||
const req = https.get(url, {
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain;q=0.9, */*;q=0.8',
|
||||
'User-Agent': `WebAVP/${app.getVersion()}`,
|
||||
},
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
res.on('data', (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
req.destroy(new Error('Response exceeded size limit'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
res.on('end', () => resolve({
|
||||
status: res.statusCode || 0,
|
||||
body: Buffer.concat(chunks).toString('utf8'),
|
||||
}));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(timeoutMs, () => {
|
||||
@@ -111,28 +171,105 @@ function httpsGetStatus(url, timeoutMs = 10000) {
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyCertificateOnline(_event, { serial, certificateHash } = {}) {
|
||||
if (!serial || !certificateHash) {
|
||||
function interpretVerifyBody(body) {
|
||||
const text = String(body || '').trim();
|
||||
if (!text) return null;
|
||||
const lowered = text.toLowerCase();
|
||||
if (lowered === 'true' || lowered === 'false') return lowered === 'true';
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (typeof data === 'boolean') return data;
|
||||
if (data && typeof data === 'object') {
|
||||
for (const field of ['verified', 'valid', 'isValid', 'success', 'result']) {
|
||||
if (typeof data[field] === 'boolean') return data[field];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function verifyCertificateOnline(event, { serial, certificateHash } = {}) {
|
||||
if (!isAuthorizedRenderer(event)) {
|
||||
return { verified: false, error: 'Unauthorized certificate verification request' };
|
||||
}
|
||||
const serialValue = typeof serial === 'string' ? serial.trim() : '';
|
||||
const hashValue = typeof certificateHash === 'string' ? certificateHash.trim() : '';
|
||||
if (!serialValue || !hashValue) {
|
||||
return { verified: false, error: 'Missing parameters' };
|
||||
}
|
||||
if (serialValue.length > MAX_SERIAL_LENGTH || hashValue.length > MAX_CERT_HASH_LENGTH) {
|
||||
return { verified: false, error: 'Invalid parameter length' };
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
serial: String(serial).toLowerCase(),
|
||||
certificateHash: String(certificateHash),
|
||||
serial: serialValue.toLowerCase(),
|
||||
certificateHash: hashValue,
|
||||
});
|
||||
const url = `https://aware.avasecurity.com/api/v1/public/verifyServerCertificate?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const status = await httpsGetStatus(url);
|
||||
if (status >= 200 && status < 300) {
|
||||
return { verified: true };
|
||||
const response = await httpsGetResponse(url);
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return { verified: false, error: `HTTP ${response.status}` };
|
||||
}
|
||||
return { verified: false, error: `HTTP ${status}` };
|
||||
if (interpretVerifyBody(response.body) === false) {
|
||||
return { verified: false, error: 'Certificate not recognized by Alta/Ava' };
|
||||
}
|
||||
return { verified: true };
|
||||
} catch (err) {
|
||||
return { verified: false, error: err.message || 'Certificate verification request failed' };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkForUpdates(event) {
|
||||
if (!isAuthorizedRenderer(event)) return { status: 'error', message: 'Unauthorized update request' };
|
||||
const currentVersion = app.getVersion();
|
||||
try {
|
||||
const response = await httpsGetResponse(LATEST_RELEASE_API);
|
||||
if (response.status === 404) return { status: 'error', message: 'No public GitPeji release is published yet.' };
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
return { status: 'error', message: `GitPeji release check failed (HTTP ${response.status}).` };
|
||||
}
|
||||
const release = normalizeRelease(JSON.parse(response.body));
|
||||
const update = evaluateUpdate(release, currentVersion, process.platform, process.arch);
|
||||
if (update.status === 'current') {
|
||||
return { status: 'current', currentVersion, releaseVersion: release.version, pageUrl: release.pageUrl };
|
||||
}
|
||||
if (update.status === 'unavailable-platform') {
|
||||
return {
|
||||
status: 'unavailable-platform',
|
||||
currentVersion,
|
||||
releaseVersion: release.version,
|
||||
pageUrl: release.pageUrl,
|
||||
message: `Version ${release.version} is available, but no ${process.platform}/${process.arch} installer was published.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: 'available',
|
||||
currentVersion,
|
||||
releaseVersion: release.version,
|
||||
pageUrl: release.pageUrl,
|
||||
asset: update.asset,
|
||||
};
|
||||
} catch (err) {
|
||||
return { status: 'error', message: err.message || 'GitPeji release check failed.' };
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUpdate(event, { url } = {}) {
|
||||
if (!isAuthorizedRenderer(event) || !isGitPejiReleaseAssetUrl(url)) {
|
||||
return { opened: false, error: 'Invalid update download request' };
|
||||
}
|
||||
try {
|
||||
await shell.openExternal(url);
|
||||
return { opened: true };
|
||||
} catch (err) {
|
||||
return { opened: false, error: err.message || 'Could not open the update download' };
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1440,
|
||||
@@ -141,26 +278,50 @@ function createWindow() {
|
||||
minHeight: 720,
|
||||
backgroundColor: '#121826',
|
||||
title: 'Alta Video Player',
|
||||
icon: APP_ICON,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webviewTag: false,
|
||||
navigateOnDragDrop: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
if (!isAppUrl(url)) {
|
||||
event.preventDefault();
|
||||
console.warn('[WebAVP] Blocked navigation to:', url);
|
||||
}
|
||||
});
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (isSafeExternalUrl(url)) {
|
||||
shell.openExternal(url).catch((err) => console.warn('[WebAVP] Failed to open external URL:', err.message || err));
|
||||
} else {
|
||||
console.warn('[WebAVP] Blocked external URL:', url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
win.webContents.session.setPermissionRequestHandler((_webContents, _permission, callback) => callback(false));
|
||||
|
||||
win.loadURL(`${APP_SCHEME}://app/index.html`);
|
||||
|
||||
if (process.env.WEBAVP_SMOKE_QUIT_AFTER_MS) {
|
||||
const quitAfterMs = Number(process.env.WEBAVP_SMOKE_QUIT_AFTER_MS);
|
||||
win.webContents.once('did-finish-load', async () => {
|
||||
try {
|
||||
const result = await win.webContents.executeJavaScript(`({
|
||||
const result = await win.webContents.executeJavaScript(`(async () => ({
|
||||
title: document.title,
|
||||
hasJsZip: typeof window.JSZip === 'function',
|
||||
hasNativeBridge: !!window.webavpNative?.verifyCertificateOnline
|
||||
})`);
|
||||
if (result.title !== 'Alta Video Player' || !result.hasJsZip || !result.hasNativeBridge) {
|
||||
hasNativeBridge: !!window.webavpNative?.verifyCertificateOnline,
|
||||
hasUpdateBridge: !!window.webavpNative?.checkForUpdates,
|
||||
hasFourByFourLayout: !!document.querySelector('[data-layout="4x4"]'),
|
||||
bridgeResult: await window.webavpNative?.verifyCertificateOnline?.({ serial: '', certificateHash: '' })
|
||||
}))()`);
|
||||
if (result.title !== 'Alta Video Player' || !result.hasJsZip || !result.hasNativeBridge || !result.hasUpdateBridge || !result.hasFourByFourLayout || result.bridgeResult?.error !== 'Missing parameters') {
|
||||
console.error('[WebAVP] Electron smoke checks failed:', JSON.stringify(result));
|
||||
app.exit(1);
|
||||
return;
|
||||
@@ -178,17 +339,14 @@ function createWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerAppProtocol();
|
||||
ipcMain.handle('certificate:verify-online', verifyCertificateOnline);
|
||||
ipcMain.handle('release:check-update', checkForUpdates);
|
||||
ipcMain.handle('release:download-update', downloadUpdate);
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
|
||||
@@ -5,4 +5,10 @@ contextBridge.exposeInMainWorld('webavpNative', {
|
||||
verifyCertificateOnline({ serial, certificateHash }) {
|
||||
return ipcRenderer.invoke('certificate:verify-online', { serial, certificateHash });
|
||||
},
|
||||
checkForUpdates() {
|
||||
return ipcRenderer.invoke('release:check-update');
|
||||
},
|
||||
downloadUpdate(url) {
|
||||
return ipcRenderer.invoke('release:download-update', { url });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user