Files
WebAVP/electron/main.js
T

360 lines
12 KiB
JavaScript

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();
}
protocol.registerSchemesAsPrivileged([
{
scheme: APP_SCHEME,
privileges: {
standard: true,
secure: true,
supportFetchAPI: true,
corsEnabled: true,
stream: true,
},
},
]);
function contentTypeFor(filePath) {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.html': return 'text/html; charset=utf-8';
case '.js': return 'text/javascript; charset=utf-8';
case '.css': return 'text/css; charset=utf-8';
case '.json': return 'application/json; charset=utf-8';
case '.svg': return 'image/svg+xml';
case '.png': return 'image/png';
case '.jpg':
case '.jpeg': return 'image/jpeg';
case '.webp': return 'image/webp';
case '.mp4': return 'video/mp4';
case '.webm': return 'video/webm';
default: return 'application/octet-stream';
}
}
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);
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
throw new Error('Path traversal blocked');
}
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);
return new Response(data, {
headers: { 'content-type': contentTypeFor(filePath) },
});
} catch (err) {
if (err && err.code === 'ENOENT') {
return new Response('Not found', { status: 404 });
}
console.error('[WebAVP] app protocol error:', err);
return new Response('Internal server error', { status: 500 });
}
}
function registerAppProtocol() {
protocol.handle(APP_SCHEME, async (request) => {
const url = new URL(request.url);
if (url.hostname !== 'app') {
return new Response('Unknown host', { status: 404 });
}
if (url.pathname === '/' || url.pathname === '/index.html') {
return serveFile(path.join(TEMPLATE_DIR, 'index.html'));
}
if (url.pathname.startsWith('/static/')) {
try {
const relativeStaticPath = url.pathname.slice('/static/'.length);
return serveFile(safeJoin(STATIC_DIR, relativeStaticPath));
} catch (err) {
return new Response('Forbidden', { status: 403 });
}
}
return new Response('Not found', { status: 404 });
});
}
// 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, {
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, () => {
req.destroy(new Error('Certificate verification request timed out'));
});
});
}
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: serialValue.toLowerCase(),
certificateHash: hashValue,
});
const url = `https://aware.avasecurity.com/api/v1/public/verifyServerCertificate?${params.toString()}`;
try {
const response = await httpsGetResponse(url);
if (response.status < 200 || response.status >= 300) {
return { verified: false, error: `HTTP ${response.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,
height: 960,
minWidth: 1024,
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(`(async () => ({
title: document.title,
hasJsZip: typeof window.JSZip === 'function',
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;
}
console.log('[WebAVP] Electron smoke loaded webavp://app/index.html with JSZip and native bridge');
setTimeout(() => app.quit(), Number.isFinite(quitAfterMs) ? quitAfterMs : 250);
} catch (err) {
console.error('[WebAVP] Electron smoke failed:', err);
app.exit(1);
}
});
win.webContents.once('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
console.error(`[WebAVP] Electron smoke failed to load ${validatedURL}: ${errorCode} ${errorDescription}`);
app.exit(1);
});
}
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', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});