Files
2026-07-13 12:46:20 +08:00

343 lines
11 KiB
JavaScript

/**
* common functions for build
*/
const { exec } = require('child_process')
const { resolve } = require('path')
const { writeFileSync, readFileSync } = require('fs')
const replace = require('replace-in-file')
const { rm, mv } = require('shelljs')
exports.run = function (cmd) {
return new Promise((resolve, reject) => {
console.log('Executing command:', cmd)
const childProcess = exec(cmd, {
env: {
...process.env,
DEBUG: 'electron-builder:*',
ELECTRON_BUILDER_CACHE: process.env.ELECTRON_BUILDER_CACHE || ''
},
maxBuffer: 1024 * 1024 * 50 // 50MB buffer for large debug output
}, (err, stdout, stderr) => {
// Always log stdout and stderr regardless of success/failure
if (stdout) {
console.log('=== STDOUT ===')
console.log(stdout)
}
if (stderr) {
console.log('=== STDERR ===')
console.log(stderr)
}
if (err) {
console.error('=== COMMAND FAILED ===')
console.error('Command:', cmd)
console.error('Exit code:', err.code)
console.error('Signal:', err.signal)
console.error('Error message:', err.message)
// Create a more detailed error message
const detailedError = new Error(`Command failed with exit code ${err.code}: ${cmd}`)
detailedError.originalError = err
detailedError.stdout = stdout
detailedError.stderr = stderr
detailedError.command = cmd
return reject(detailedError)
}
resolve(stdout)
})
// Also pipe output in real-time for long-running commands
childProcess.stdout.on('data', (data) => {
process.stdout.write(data)
})
childProcess.stderr.on('data', (data) => {
process.stderr.write(data)
})
})
}
exports.writeSrc = function (src) {
const p = resolve(__dirname, '../../work/app/lib/install-src.js')
writeFileSync(p, `module.exports = '${src}'`)
if (src.includes('AppImage')) {
exports.patchAppImage()
}
}
/**
* Patch work/app to include AppImage desktop integration.
* Copies the integration module and wires it into create-app.js
* so the .desktop file + icon are installed on first run.
* Only called for AppImage builds — zero overhead for other targets.
*/
exports.patchAppImage = function patchAppImage () {
const fs = require('fs')
const workLib = resolve(__dirname, '../../work/app/lib')
const workAssets = resolve(__dirname, '../../work/app/assets/images')
// Copy integration module to work/app/lib/
const modSrc = resolve(__dirname, 'appimage-integration.js')
const modDst = resolve(workLib, 'appimage-integration.js')
fs.copyFileSync(modSrc, modDst)
// Copy icon for desktop integration (outside asar, accessible at runtime)
const iconSrc = resolve(
__dirname, '../../node_modules/@electerm/electerm-resource/res/imgs/electerm-round-128x128.png'
)
const iconDst = resolve(workAssets, 'electerm-round-128x128.png')
if (fs.existsSync(iconSrc)) {
fs.copyFileSync(iconSrc, iconDst)
}
// Patch create-app.js to import and call installDesktopFile
const createAppPath = resolve(workLib, 'create-app.js')
let code = fs.readFileSync(createAppPath, 'utf8')
if (!code.includes('installDesktopFile')) {
code = code.replace(
"const log = require('../common/log')",
"const log = require('../common/log')\n" +
"const { installDesktopFile } = require('./appimage-integration')"
)
code = code.replace(
'app.setName(packInfo.name)',
'app.setName(packInfo.name)\n installDesktopFile()'
)
fs.writeFileSync(createAppPath, code)
}
console.log('[appimage] Patched work/app for desktop integration')
}
exports.builder = resolve(
__dirname, '../../node_modules/.bin/electron-builder'
)
exports.reBuild = resolve(
__dirname, '../../node_modules/.bin/electron-rebuild'
)
exports.replaceArr = function (froms, tos) {
const pth = resolve(__dirname, '../../electron-builder.json')
console.log('electron-builder', pth)
let str = readFileSync(pth, 'utf8')
for (let i = 0; i < froms.length; i++) {
str = str.replace(froms[i], tos[i])
}
writeFileSync(pth, str)
}
exports.replaceJSON = function (func) {
const pth = resolve(__dirname, '../../electron-builder.json')
const js = require(pth)
func(js)
writeFileSync(pth, JSON.stringify(js, null, 2))
}
exports.setChannel = function (suffix) {
exports.replaceJSON((data) => {
if (!data.linux) { data.linux = {} }
if (!data.linux.publish) { data.linux.publish = {} }
data.linux.publish.channel = '${env.WORKFLOW_NAME}-' + suffix // eslint-disable-line
})
}
const options = {
files: require('path').resolve(__dirname, '../../electron-builder.json'),
from: ['"asar": true', '${productName}-${version}-${os}-${arch}.${ext}', ', "appx", "nsis"'], // eslint-disable-line
to: ['"asar": false', '${productName}-${version}-${os}-${arch}-loose.${ext}', ''] // eslint-disable-line
}
exports.replaceRun = function () {
return new Promise((resolve, reject) => {
replace(options, (err) => {
if (err) {
return reject(err)
}
console.log('start build loose file(no asar)')
resolve()
})
})
}
const shouldKeepFile = !!process.env.KEEP_FILE
exports.renameDist = function renameDist () {
if (!shouldKeepFile) {
return rm('-rf', 'dist')
}
mv('dist', 'dist' + new Date().getTime())
}
exports.uploadToR2 = async function uploadToR2 (src) {
// Must be running in CI
if (!process.env.CI) {
console.log('[r2] Skipping upload: not in CI environment')
return
}
// Check commit message contains "[r2]"
const { execSync } = require('child_process')
let commitMsg = ''
try {
commitMsg = execSync('git log -1 --format=%s', { encoding: 'utf8' }).trim()
} catch (e) {
console.log('[r2] Skipping upload: could not read commit message')
return
}
if (!commitMsg.includes('[r2]')) {
console.log('[r2] Skipping upload: commit message does not contain "[r2]"')
return
}
// Check required env vars
const accountId = process.env.CF_R2_ACCOUNT_ID
const bucket = process.env.CF_R2_BUCKET
const accessKeyId = process.env.CF_R2_ACCESS_KEY_ID
const secretAccessKey = process.env.CF_R2_SECRET_ACCESS_KEY
if (!accountId || !bucket || !accessKeyId || !secretAccessKey) {
console.error('[r2] Missing required env vars: CF_R2_ACCOUNT_ID, CF_R2_BUCKET, CF_R2_ACCESS_KEY_ID, CF_R2_SECRET_ACCESS_KEY')
return
}
// Find the built file in dist/
const fs = require('fs')
const path = require('path')
const distDir = resolve(__dirname, '../../dist')
let filePath = null
if (fs.existsSync(distDir)) {
const files = fs.readdirSync(distDir)
const match = files.find(f => f.endsWith(src))
if (match) {
filePath = path.join(distDir, match)
}
}
if (!filePath) {
// Try to find recursively one level deeper
if (fs.existsSync(distDir)) {
const entries = fs.readdirSync(distDir, { withFileTypes: true })
for (const entry of entries) {
if (entry.isDirectory()) {
const subDir = path.join(distDir, entry.name)
const subFiles = fs.readdirSync(subDir)
const match = subFiles.find(f => f.endsWith(src))
if (match) {
filePath = path.join(subDir, match)
break
}
}
}
}
}
if (!filePath) {
console.error(`[r2] Could not find built file matching "${src}" in dist/`)
return
}
console.log(`[r2] Uploading ${filePath} to R2 bucket "${bucket}" as "${path.basename(filePath)}"`)
// Polyfill crypto.getRandomValues for Node < 19.
// @smithy/core reads getRandomValues from require('node:crypto') directly
// (line: const _getRandomValues = node_crypto.getRandomValues), so we must
// patch the crypto module itself, not just globalThis.crypto.
const nodeCrypto = require('crypto')
if (!nodeCrypto.getRandomValues) {
nodeCrypto.getRandomValues = (buf) => nodeCrypto.randomFillSync(buf)
}
if (!globalThis.crypto) {
globalThis.crypto = nodeCrypto
}
let S3Client, PutObjectCommand
try {
const s3Module = require('@aws-sdk/client-s3')
S3Client = s3Module.S3Client
PutObjectCommand = s3Module.PutObjectCommand
} catch (e) {
console.error('[r2] @aws-sdk/client-s3 is not installed. Run: npm install --save-dev @aws-sdk/client-s3')
throw e
}
const client = new S3Client({
region: 'auto',
endpoint: `https://${accountId}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId,
secretAccessKey
}
})
const fileStream = fs.createReadStream(filePath)
const key = path.basename(filePath)
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: fileStream,
ContentLength: fs.statSync(filePath).size
})
await client.send(command)
console.log(`[r2] Successfully uploaded ${key} to R2`)
}
/**
* Patch the electron-builder NSIS template so that the installer always tries
* to keep shortcuts (desktop / start-menu / taskbar pins) when upgrading.
*
* By default, when `allowToChangeInstallationDirectory` is set, the template
* sets `isTryToKeepShortcuts = "false"` for manual (non-auto-update) installs,
* which causes `WinShell::UninstShortcut` / `UninstAppUserModelId` to be called
* and the taskbar pin to be silently removed. Removing the guard makes the
* installer always pass `--keep-shortcuts` to the old uninstaller when the
* previous install wrote `KeepShortcuts = "true"` to the registry.
*/
exports.patchNsisKeepShortcuts = function patchNsisKeepShortcuts () {
const fs = require('fs')
const path = require('path')
const templatePath = path.join(
require.resolve('app-builder-lib/package.json'),
'../templates/nsis/include/installUtil.nsh'
)
const original = fs.readFileSync(templatePath, 'utf-8')
const patched = original.replace(
/(!macro setIsTryToKeepShortcuts\s+StrCpy \$isTryToKeepShortcuts "true"\s*)!ifdef allowToChangeInstallationDirectory[\s\S]*?!endif(\s*!macroend)/,
'$1$2'
)
if (patched === original) {
console.log('NSIS keep-shortcuts patch: already applied or pattern not found, skipping')
} else {
fs.writeFileSync(templatePath, patched, 'utf-8')
console.log('NSIS keep-shortcuts patch: applied successfully')
}
}
/**
* Patch electron-builder's Snap target so classic confinement builds keep the
* Chromium sandbox and do not force --no-sandbox into the generated launcher.
*
* electerm's packaged entrypoint rejects the --no-sandbox CLI flag, and classic
* snaps do not need the browser-support plug path used by strict confinement.
*/
exports.patchSnapClassicSandbox = function patchSnapClassicSandbox () {
const fs = require('fs')
const path = require('path')
const targetPath = path.join(
require.resolve('app-builder-lib/package.json'),
'../out/targets/snap.js'
)
const original = fs.readFileSync(targetPath, 'utf-8')
const patched = original.replace(
' if (this.isElectronVersionGreaterOrEqualThan("5.0.0") && !isBrowserSandboxAllowed(snap)) {',
' if (this.isElectronVersionGreaterOrEqualThan("5.0.0") && snap.confinement !== "classic" && !isBrowserSandboxAllowed(snap)) {'
)
if (patched === original) {
console.log('Snap classic sandbox patch: already applied or pattern not found, skipping')
} else {
fs.writeFileSync(targetPath, patched, 'utf-8')
console.log('Snap classic sandbox patch: applied successfully')
}
}