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(); });