diff --git a/tools/packaging/verify-electron-package-layout.mjs b/tools/packaging/verify-electron-package-layout.mjs new file mode 100644 index 000000000..8526578d3 --- /dev/null +++ b/tools/packaging/verify-electron-package-layout.mjs @@ -0,0 +1,169 @@ +import fs from 'fs'; +import path from 'path'; + +const args = process.argv.slice(2); +const normalizedArgs = args[0] === '--' ? args.slice(1) : args; +const [platform, arch = ''] = normalizedArgs; + +if (!platform) { + console.error( + 'Usage: node tools/packaging/verify-electron-package-layout.mjs [arch]' + ); + process.exit(1); +} + +const workspaceRoot = process.cwd(); +const executablesRoot = path.join(workspaceRoot, 'dist', 'executables'); +const workerRelativeDir = path.join( + 'dist', + 'apps', + 'electron-backend', + 'workers' +); +const workerFiles = ['epg-parser.worker.js', 'database.worker.js']; +const nativeModuleRelativeDirs = [ + path.join('app.asar.unpacked', 'node_modules'), + path.join('app.asar.unpacked', 'electron-backend', 'node_modules'), + path.join( + 'app.asar.unpacked', + 'dist', + 'apps', + 'electron-backend', + 'node_modules' + ), +]; + +function directoryExists(directoryPath) { + return fs.existsSync(directoryPath) && fs.statSync(directoryPath).isDirectory(); +} + +function fileExists(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); +} + +function getMacResourceDirs() { + const candidates = [ + { + arch: 'x64', + directory: path.join( + executablesRoot, + 'mac', + 'IPTVnator.app', + 'Contents', + 'Resources' + ), + }, + { + arch: 'arm64', + directory: path.join( + executablesRoot, + 'mac-arm64', + 'IPTVnator.app', + 'Contents', + 'Resources' + ), + }, + ]; + + return candidates + .filter((candidate) => !arch || candidate.arch === arch) + .map((candidate) => candidate.directory) + .filter(directoryExists); +} + +function getUnpackedResourceDirs(prefix) { + if (!directoryExists(executablesRoot)) { + return []; + } + + return fs + .readdirSync(executablesRoot, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith(prefix) && + entry.name.endsWith('-unpacked') + ) + .map((entry) => path.join(executablesRoot, entry.name, 'resources')) + .filter(directoryExists); +} + +function getResourceDirs() { + switch (platform) { + case 'macos': + return getMacResourceDirs(); + case 'linux': + return getUnpackedResourceDirs('linux'); + case 'windows': + return getUnpackedResourceDirs('win'); + default: + console.error(`Unsupported platform "${platform}"`); + process.exit(1); + } +} + +function verifyResourceDir(resourceDir) { + const missingWorkers = workerFiles.filter( + (workerFile) => + !fileExists(path.join(resourceDir, workerRelativeDir, workerFile)) + ); + + const nativeModuleDirs = nativeModuleRelativeDirs.map((relativeDir) => + path.join(resourceDir, relativeDir) + ); + const matchingNativeModuleDir = nativeModuleDirs.find((nativeDir) => + fileExists(path.join(nativeDir, 'better-sqlite3', 'package.json')) + ); + + const errors = []; + + if (missingWorkers.length > 0) { + errors.push( + `Missing worker artifacts in ${resourceDir}: ${missingWorkers.join(', ')}` + ); + } + + if (!matchingNativeModuleDir) { + errors.push( + [ + `Unable to find unpacked better-sqlite3 in ${resourceDir}.`, + 'Checked:', + ...nativeModuleDirs.map((nativeDir) => `- ${nativeDir}`), + ].join('\n') + ); + } + + return { + resourceDir, + errors, + matchingNativeModuleDir, + }; +} + +const resourceDirs = getResourceDirs(); + +if (resourceDirs.length === 0) { + console.error( + `No packaged resource directories found for ${platform}${arch ? ` (${arch})` : ''}.` + ); + process.exit(1); +} + +const results = resourceDirs.map(verifyResourceDir); +const failures = results.filter((result) => result.errors.length > 0); + +for (const result of results) { + console.log(`Verified packaged resources: ${result.resourceDir}`); + if (result.matchingNativeModuleDir) { + console.log( + `Resolved unpacked better-sqlite3 at: ${result.matchingNativeModuleDir}` + ); + } +} + +if (failures.length > 0) { + for (const failure of failures) { + console.error(failure.errors.join('\n')); + } + process.exit(1); +} diff --git a/tools/testing/launch-packaged-electron.mjs b/tools/testing/launch-packaged-electron.mjs new file mode 100644 index 000000000..68ffb4f33 --- /dev/null +++ b/tools/testing/launch-packaged-electron.mjs @@ -0,0 +1,116 @@ +import fs from 'fs'; +import path from 'path'; +import { spawn } from 'child_process'; + +const args = process.argv.slice(2); +const normalizedArgs = args[0] === '--' ? args.slice(1) : args; +const [platformArg, arch = ''] = normalizedArgs; +const currentPlatform = + platformArg ?? + (process.platform === 'darwin' + ? 'macos' + : process.platform === 'win32' + ? 'windows' + : 'linux'); + +const workspaceRoot = process.cwd(); +const executablesRoot = path.join(workspaceRoot, 'dist', 'executables'); +const remoteDebuggingPort = '9222'; + +function ensureFile(filePath) { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); +} + +function findUnpackedExecutable(prefix, executableNames) { + if (!fs.existsSync(executablesRoot)) { + return undefined; + } + + const unpackedDirs = fs + .readdirSync(executablesRoot, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + entry.name.startsWith(prefix) && + entry.name.endsWith('-unpacked') + ) + .map((entry) => path.join(executablesRoot, entry.name)); + + for (const unpackedDir of unpackedDirs) { + for (const executableName of executableNames) { + const executablePath = path.join(unpackedDir, executableName); + if (ensureFile(executablePath)) { + return executablePath; + } + } + } + + return undefined; +} + +function resolvePackagedExecutable() { + if (currentPlatform === 'macos') { + const candidates = [ + { + arch: 'x64', + executable: path.join( + executablesRoot, + 'mac', + 'IPTVnator.app', + 'Contents', + 'MacOS', + 'IPTVnator' + ), + }, + { + arch: 'arm64', + executable: path.join( + executablesRoot, + 'mac-arm64', + 'IPTVnator.app', + 'Contents', + 'MacOS', + 'IPTVnator' + ), + }, + ]; + + const match = candidates.find( + (candidate) => (!arch || candidate.arch === arch) && ensureFile(candidate.executable) + ); + return match?.executable; + } + + if (currentPlatform === 'windows') { + return findUnpackedExecutable('win', ['IPTVnator.exe']); + } + + if (currentPlatform === 'linux') { + return findUnpackedExecutable('linux', ['IPTVnator', 'iptvnator']); + } + + return undefined; +} + +const executable = resolvePackagedExecutable(); + +if (!executable) { + console.error( + `Unable to resolve a packaged executable for ${currentPlatform}${arch ? ` (${arch})` : ''}.` + ); + process.exit(1); +} + +console.log(`Launching packaged app: ${executable}`); +console.log( + `After the window opens, connect with: agent-browser --cdp ${remoteDebuggingPort} tab list` +); + +const child = spawn(executable, [`--remote-debugging-port=${remoteDebuggingPort}`], { + stdio: 'inherit', + detached: false, +}); + +child.on('exit', (code) => { + process.exit(code ?? 0); +});