UNPKG

browser-extension-manager

Version:
641 lines (530 loc) 20.1 kB
// Libraries const Manager = new (require('../../build.js')); const logger = Manager.logger('package'); const path = require('path'); const jetpack = require('fs-jetpack'); const { series, parallel, watch } = require('gulp'); const { execute, getKeys } = require('node-powertools'); const JSON5 = require('json5'); // Load package const package = Manager.getPackage('main'); const project = Manager.getPackage('project'); const config = Manager.getConfig('project'); const rootPathPackage = Manager.getRootPath('main'); const rootPathProject = Manager.getRootPath('project'); // Glob const input = [ // Files to include 'dist/**/*', // Files to exclude // '!dist/**', ]; const output = 'dist'; const delay = 250; // Set index let index = -1; // JSONP Template for build.js const JSONP_TEMPLATE = ` (function() { // Create a global variable for the config const config = { config }; // Assign to various global scopes for compatibility if (typeof self !== 'undefined') self.BEM_BUILD_JSON = config; if (typeof window !== 'undefined') window.BEM_BUILD_JSON = config; if (typeof globalThis !== 'undefined') globalThis.BEM_BUILD_JSON = config; })(); `.trim(); // Generate build.js file async function generateBuildJs(outputDir) { try { // Get git info const gitInfo = getGitInfo(); // Build config object matching web-manager's expected structure const buildConfig = { timestamp: new Date().toISOString(), repo: gitInfo, environment: Manager.getEnvironment(), packages: { 'browser-extension-manager': package.version, 'web-manager': getPackageVersion('web-manager'), }, config: { // Core metadata runtime: 'browser-extension', version: project.version, environment: Manager.getEnvironment(), buildTime: Date.now(), // Brand configuration (from browser-extension-manager.json or manifest) brand: config.brand || {}, // BEM-specific config bem: { environment: Manager.getEnvironment(), cache_breaker: Math.round(new Date().getTime() / 1000), liveReloadPort: config.liveReloadPort || 35729, }, // Web-manager features (matching expected structure) auth: { enabled: true, config: {} }, firebase: { app: { enabled: !!(config.firebaseConfig?.apiKey), config: config.firebaseConfig || {}, }, appCheck: { enabled: false, config: {} }, }, cookieConsent: { enabled: true, config: {} }, chatsy: { enabled: true, config: {} }, sentry: { enabled: !!(config.sentry?.dsn), config: config.sentry || {} }, exitPopup: { enabled: false, config: {} }, lazyLoading: { enabled: true, config: {} }, socialSharing: { enabled: false, config: {} }, pushNotifications: { enabled: false, config: {} }, validRedirectHosts: ['itwcreativeworks.com'], refreshNewVersion: { enabled: true, config: {} }, serviceWorker: { enabled: false, config: {} }, // Tracking tracking: { 'google-analytics': config.google_analytics?.id || '', 'google-analytics-secret': config.google_analytics?.secret || '', }, // Theme config theme: config.theme || {}, } }; // Write JSON version const jsonPath = path.join(outputDir, 'build.json'); jetpack.write(jsonPath, JSON.stringify(buildConfig, null, 2)); // Write JSONP version for service worker const jsonpContent = JSONP_TEMPLATE.replace('{ config }', JSON.stringify(buildConfig, null, 2)); const jsonpPath = path.join(outputDir, 'build.js'); jetpack.write(jsonpPath, jsonpContent); logger.log(`Generated build.js and build.json`); } catch (e) { logger.error(`Error generating build.js`, e); } } // Get git info function getGitInfo() { try { const { execSync } = require('child_process'); const user = execSync('git config user.name', { encoding: 'utf8' }).trim(); const repo = execSync('git config --get remote.origin.url', { encoding: 'utf8' }) .trim() .replace(/.*[\/:]([\w-]+)\/([\w-]+)(\.git)?$/, '$2'); return { user, name: repo }; } catch (e) { return { user: 'unknown', name: 'unknown' }; } } // Get package version function getPackageVersion(packageName) { try { const pkgPath = require.resolve(`${packageName}/package.json`, { paths: [process.cwd()] }); const pkg = require(pkgPath); return pkg.version; } catch (e) { return 'unknown'; } } // Build targets with browser-specific configurations const TARGETS = { chromium: { // Chrome, Edge, Brave, etc. - uses service_worker adjustManifest: (manifest) => { if (manifest.background) { delete manifest.background.scripts; } return manifest; }, }, firefox: { // Firefox - uses scripts array, no service_worker adjustManifest: (manifest) => { if (manifest.background) { if (manifest.background.service_worker) { if (!manifest.background.scripts) { manifest.background.scripts = [manifest.background.service_worker]; } delete manifest.background.service_worker; } } return manifest; }, }, opera: { // Opera - like chromium but with stricter requirements // Opera enforces 12-char limit on short_name INCLUDING placeholder text adjustManifest: (manifest) => { // Same as chromium for background if (manifest.background) { delete manifest.background.scripts; } // Opera requires static short_name (12 char limit includes placeholder text) // __MSG_appNameShort__ is 19 chars, so we must use actual value if (manifest.short_name && manifest.short_name.startsWith('__MSG_')) { // Try to get the value from default locale const localesDir = path.join('dist', '_locales'); const defaultLocale = manifest.default_locale || 'en'; const messagesPath = path.join(localesDir, defaultLocale, 'messages.json'); if (jetpack.exists(messagesPath)) { try { const messages = JSON5.parse(jetpack.read(messagesPath)); // Extract key from __MSG_keyName__ const key = manifest.short_name.replace(/^__MSG_/, '').replace(/__$/, ''); if (messages[key]?.message) { manifest.short_name = messages[key].message; logger.log(`[opera] Resolved short_name to "${manifest.short_name}"`); } } catch (e) { logger.warn(`[opera] Could not resolve short_name from locale: ${e.message}`); } } } return manifest; }, }, }; // Recursively remove empty arrays and objects from an object function removeEmptyValues(obj) { if (Array.isArray(obj)) { return obj; } if (obj && typeof obj === 'object') { const result = {}; for (const [key, value] of Object.entries(obj)) { // Recursively clean nested objects const cleaned = removeEmptyValues(value); // Skip empty arrays if (Array.isArray(cleaned) && cleaned.length === 0) { continue; } // Skip empty objects (after cleaning) if (cleaned && typeof cleaned === 'object' && !Array.isArray(cleaned) && Object.keys(cleaned).length === 0) { continue; } result[key] = cleaned; } return result; } return obj; } // Special Compilation Task for manifest.json with default settings async function compileManifest(outputDir, target) { try { const manifestPath = path.join('dist', 'manifest.json'); const outputPath = path.join(outputDir, 'manifest.json'); const configPath = path.join(rootPathPackage, 'dist', 'config', 'manifest.json'); // Read and parse using JSON5 let manifest = JSON5.parse(jetpack.read(manifestPath)); const defaultConfig = JSON5.parse(jetpack.read(configPath)); // ═══════════════════════════════════════════════════════════════════════════ // STEP 1: Apply defaults (shared across all targets) // ═══════════════════════════════════════════════════════════════════════════ getKeys(defaultConfig).forEach(key => { const defaultValue = key.split('.').reduce((o, k) => (o || {})[k], defaultConfig); const userValue = key.split('.').reduce((o, k) => (o || {})[k], manifest); if (Array.isArray(defaultValue) && Array.isArray(userValue)) { // Merge arrays const mergedArray = Array.from(new Set([...defaultValue, ...userValue])); key.split('.').reduce((o, k, i, arr) => { if (i === arr.length - 1) o[k] = mergedArray; else o[k] = o[k] || {}; return o[k]; }, manifest); } else if (userValue === undefined) { // Apply default if user value doesn't exist key.split('.').reduce((o, k, i, arr) => { if (i === arr.length - 1) o[k] = defaultValue; else o[k] = o[k] || {}; return o[k]; }, manifest); } }); // Add package version to manifest manifest.version = project.version; // ═══════════════════════════════════════════════════════════════════════════ // STEP 2: Apply target-specific adjustments // ═══════════════════════════════════════════════════════════════════════════ const targetConfig = TARGETS[target]; if (targetConfig?.adjustManifest) { manifest = targetConfig.adjustManifest(manifest); } // ═══════════════════════════════════════════════════════════════════════════ // STEP 3: Clean up (shared across all targets) // ═══════════════════════════════════════════════════════════════════════════ // Remove empty arrays and objects from manifest const cleanedManifest = removeEmptyValues(manifest); // Save as regular JSON jetpack.write(outputPath, JSON.stringify(cleanedManifest, null, 2)); logger.log(`[${target}] Manifest compiled with defaults`); } catch (e) { logger.error(`Error compiling manifest`, e); } } // Special Compilation Task for _locales async function compileLocales(outputDir) { try { const localesDir = path.join('dist', '_locales'); const outputLocalesDir = path.join(outputDir, '_locales'); // Ensure the directory exists jetpack.dir(outputLocalesDir); // Process each locale file jetpack.find(localesDir, { matching: '**/*.json' }).forEach(filePath => { const relativePath = path.relative(localesDir, filePath); const outputPath = path.join(outputLocalesDir, relativePath); // Read and parse using JSON5 const localeData = JSON5.parse(jetpack.read(filePath)); // Save as regular JSON jetpack.write(outputPath, JSON.stringify(localeData, null, 2)); logger.log(`Locale compiled and saved: ${outputPath}`); }); } catch (e) { logger.error(`Error compiling locales`, e); } } // Package Task for raw - creates builds for all browser targets async function packageRaw() { // Log logger.log(`Starting raw packaging...`); try { // Build for each target for (const target of Object.keys(TARGETS)) { await packageRawForTarget(target); } // Log completion logger.log(`Finished raw packaging for all targets`); } catch (e) { logger.error(`Error during raw packaging`, e); } } // Files to exclude from packaged output (development/source files) const PACKAGE_EXCLUDE_PATTERNS = [ '**/*.scss', '**/*.sass', '**/*.ts', '**/.DS_Store', ]; // Package raw for a specific target (chromium or firefox) async function packageRawForTarget(target) { logger.log(`[${target}] Starting raw packaging...`); const outputDir = `packaged/${target}/raw`; // Ensure the directory exists (this also cleans it) jetpack.dir(outputDir, { empty: true }); // Copy files to raw package directory await execute(`cp -r dist/* ${outputDir}`); // Remove development/source files that shouldn't be in the package const filesToRemove = jetpack.find(outputDir, { matching: PACKAGE_EXCLUDE_PATTERNS }); filesToRemove.forEach(file => { jetpack.remove(file); logger.log(`[${target}] Removed dev file: ${path.relative(outputDir, file)}`); }); // Loop thru outputDir/assets/js all JS files for redactions const jsFiles = jetpack.find(path.join(outputDir, 'assets', 'js'), { matching: '*.js' }); const redactions = getRedactions(); jsFiles.forEach(filePath => { // Load the content let content = jetpack.read(filePath); // Replace keys with their corresponding values Object.keys(redactions).forEach(key => { const value = redactions[key]; const regex = new RegExp(key, 'g'); // Create a global regex for the key content = content.replace(regex, value); // Log replacement logger.log(`[${target}] Redacted ${key} in ${filePath}`); }); // Write the new content to the file jetpack.write(filePath, content); }); // Generate build.js and build.json await generateBuildJs(outputDir); // Compile manifest (target-specific) and locales await compileManifest(outputDir, target); await compileLocales(outputDir); logger.log(`[${target}] Finished raw packaging`); } // Create zipped version of raw packages for each target async function packageZip() { // Log logger.log(`Zipping raw packages...`); // Skip if not in build mode if (!Manager.isBuildMode()) { logger.log(`Skipping zip (not in build mode)`); return; } try { // Create zip for each target for (const target of Object.keys(TARGETS)) { const inputDir = `packaged/${target}/raw`; const zipPath = `packaged/${target}/extension.zip`; // Remove existing zip if it exists jetpack.remove(zipPath); // Zip contents of raw directory (not the directory itself) // This ensures manifest.json is at the root of the zip await execute(`cd ${inputDir} && zip -r ../extension.zip .`); logger.log(`[${target}] Zipped package created at ${zipPath}`); } } catch (e) { logger.error(`Error zipping package`, e); } } // Create source code zip for Firefox review async function packageSource() { const { template } = require('node-powertools'); const os = require('os'); // Log logger.log(`Zipping source code...`); try { const sourceZipPath = 'packaged/source.zip'; // Only in build mode if (!Manager.isBuildMode()) { logger.log(`Skipping source zip (not in build mode)`); return; } // Remove existing zip if it exists jetpack.remove(sourceZipPath); // Get system info const platform = os.platform(); const platformName = { darwin: 'macOS', win32: 'Windows', linux: 'Linux', }[platform] || platform; // Read template and replace variables const templatePath = path.join(rootPathPackage, 'dist', 'gulp', 'templates', 'BUILD_INSTRUCTIONS.md'); const templateContent = jetpack.read(templatePath); const instructions = template(templateContent, { system: { platform: platformName, release: os.release(), arch: os.arch(), nodeVersion: process.version, }, }); // Create source zip using git archive (respects .gitignore) await execute(`git archive --format=zip --output=${sourceZipPath} HEAD`); // Write build instructions to .temp/README.md const tempReadmePath = path.join(process.cwd(), '.temp', 'README.md'); jetpack.write(tempReadmePath, instructions); // Update the zip - replace README.md with build instructions await execute(`cd .temp && zip -u ../${sourceZipPath} README.md`); // Clean up temp file jetpack.remove(tempReadmePath); logger.log(`Source code zip created at ${sourceZipPath}`); } catch (e) { logger.error(`Error zipping source code`, e); } } function liveReload() { // Log logger.log('Reloading live server clients...'); // Quit if in build mode if (Manager.isBuildMode()) { return logger.log('Skipping live reload in non-build mode'); } // Quit if no websocket server if (!global.websocket) { return logger.log('No live reload server found'); } // Reload each client global.websocket.clients.forEach((client) => { // Get client IP const clientIp = client._socket?.remoteAddress || 'Unknown IP'; // Log logger.log(`Sending to client at IP: ${clientIp}`); // Send client.send(JSON.stringify({ command: 'reload' })) }) // Complete return; } // Package Task async function packageFn(complete) { try { // Log logger.log('Starting...'); // Increment index index++; // Run build:pre hook await hook('build:pre', index); // Run packageRaw await packageRaw(); // Run packageZip await packageZip(); // Run packageSource await packageSource(); // Run build:post hook await hook('build:post', index); // Run liveReload liveReload(); // Log logger.log('Finished!'); // Complete return complete(); } catch (error) { // Handle any errors that occur during package build return Manager.reportBuildError(Object.assign(error, { plugin: 'Package' }), complete); } } // Watcher Task function packageFnWatcher(complete) { // Quit if in build mode if (Manager.isBuildMode()) { logger.log('[watcher] Skipping watcher in build mode'); return complete(); } // Log logger.log('[watcher] Watching for changes...'); // Watch for changes in the dist folder watch(input, { delay: delay, dot: true }, packageFn) .on('change', function (path) { logger.log(`[watcher] File ${path} was changed`); }); // Complete return complete(); } // Export tasks module.exports = series(packageFn, packageFnWatcher); // Run hooks async function hook(file, index) { // Full path const fullPath = path.join(process.cwd(), 'hooks', `${file}.js`); // Log // logger.log(`Loading hook: ${fullPath}`); // Check if it exists if (!jetpack.exists(fullPath)) { return console.warn(`Hook not found: ${fullPath}`); } // Log logger.log(`Running hook: ${fullPath}`); // Load hook let hook; try { // Load the hook hook = require(fullPath); } catch (e) { throw new Error(`Error loading hook: ${fullPath} ${e.stack}`); } // Execute hook try { return await hook(index); } catch (e) { throw new Error(`Error running hook: ${fullPath} ${e.stack}`); } } // Get redactions function getRedactions() { const REDACTED = './REDACTED_REMOTE_CODE'; return { 'https://app.chatsy.ai/resources/script.js': REDACTED + 1, // '/https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js\\?[^"\'\\s]*/g': REDACTED + 2, 'https://cdnjs.cloudflare.com/polyfill/v3/polyfill.min.js': REDACTED + 2, 'https://www.google.com/recaptcha/enterprise.js': REDACTED + 3, 'https://apis.google.com/js/api.js': REDACTED + 4, 'https://www.google.com/recaptcha/api.js': REDACTED + 5, 'https://browser.sentry-cdn.com': REDACTED + 6, } }