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

559 lines
17 KiB
JavaScript

/**
* install electerm from binary
* After npm i -g electerm, running `electerm` command will:
* 1. Download the appropriate binary for the platform
* 2. Extract it to the package directory (electerm/)
* 3. The bash script (npm/electerm) then launches the extracted binary
*
* This script only downloads and extracts. Launching is handled by the bash script.
*/
const os = require('os')
const { resolve, join } = require('path')
const { execSync, rm, mv } = require('shelljs')
const { execFile } = require('child_process')
const fs = require('fs')
const { phin, download, extractTarGz, GITHUB_PROXY, applyProxy } = require('./utils')
const plat = os.platform()
const arch = os.arch()
const { homepage } = require('../package.json')
const releaseInfoUrl = `${homepage}/data/electerm-github-release.json?_=${+new Date()}`
const versionUrl = `${homepage}/version.html?_=${+new Date()}`
// Directory where electerm package is installed
const packageRoot = resolve(__dirname, '..')
// Directory where the extracted binary will live
const extractDir = join(packageRoot, 'electerm')
// ---------------------------------------------------------------------------
// Security helpers
// ---------------------------------------------------------------------------
function sanitizeVersion (ver) {
const clean = String(ver).trim().replace(/^v/, '')
if (!/^\d+\.\d+\.\d+$/.test(clean)) {
throw new Error(
`Refusing to continue: remote version string failed validation: "${ver}"`
)
}
return clean
}
function sanitizeFilename (name) {
const clean = String(name).trim()
if (!/^[\w.-]+$/.test(clean)) {
throw new Error(
`Refusing to continue: remote filename failed validation: "${name}"`
)
}
return clean
}
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
function getVer () {
return phin({
url: versionUrl,
timeout: 15000
})
.then(res => res.body.toString())
}
function getReleaseInfo (filter) {
return phin({
url: releaseInfoUrl,
timeout: 15000
})
.then((res) => {
return JSON.parse(res.body.toString())
.release
.assets
.filter(filter)[0]
})
}
function showFinalMessage () {
console.log('')
console.log('========================================')
console.log('electerm installation complete!')
console.log('========================================')
console.log('')
console.log('For more information, documentation, and updates, please visit:')
console.log('https://electerm.org')
console.log('')
console.log('Thank you for using electerm!')
console.log('========================================')
console.log('')
}
// ---------------------------------------------------------------------------
// Platform detection helpers
// ---------------------------------------------------------------------------
function isWindows7OrEarlier (platform, release) {
if (platform !== 'win32') return false
const [major, minor] = release.split('.').map(Number)
return major < 10 && (major < 6 || (major === 6 && minor <= 1))
}
function isMacOS10 (platform, release) {
if (platform !== 'darwin') return false
const majorVersion = parseInt(release.split('.')[0], 10)
return majorVersion < 20
}
function isLinuxLegacy (platform) {
if (platform !== 'linux') return false
try {
const result = execSync('ldd --version 2>&1 | head -n1', { encoding: 'utf8' })
const match = result.match(/(\d+\.\d+)\s*$/)
if (match) {
return parseFloat(match[1]) < 2.34
}
return false
} catch (e) {
return false
}
}
// ---------------------------------------------------------------------------
// Launch the extracted binary
// ---------------------------------------------------------------------------
/**
* Get the path to the extracted electerm executable
*/
function getElectermExePath () {
if (plat === 'win32') {
return join(extractDir, 'electerm.exe')
}
// Linux and macOS (if extracted)
return join(extractDir, 'electerm')
}
/**
* Check if the electerm binary has been extracted already
*/
function isElectermExtracted () {
const exePath = getElectermExePath()
return fs.existsSync(exePath)
}
// ---------------------------------------------------------------------------
// Platform installers
// ---------------------------------------------------------------------------
async function runLinux (folderName, filePattern) {
const rawVer = await getVer()
const ver = sanitizeVersion(rawVer)
console.log(` Version: ${ver}`)
console.log(` Target: ${folderName}`)
const target = join(packageRoot, `electerm-${ver}-${folderName}`)
// Clean up old installations
rm('-rf', [target, extractDir])
console.log(' Fetching release info...')
const releaseInfo = await getReleaseInfo(r => r.name.includes(filePattern))
if (!releaseInfo) {
throw new Error(`No release found for pattern: ${filePattern}`)
}
// Download without extracting to packageRoot directly
// We'll extract to a temp location first
const tmpDir = join(packageRoot, '.electerm-tmp')
rm('-rf', tmpDir)
fs.mkdirSync(tmpDir, { recursive: true })
const proxyUrl = applyProxy(releaseInfo.browser_download_url)
console.log(` URL: ${proxyUrl}`)
const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name })
// Extract to tmpDir (keeps top-level folder name)
await extractTarGz(filepath, tmpDir)
// Find the extracted folder (should be the only directory)
const entries = fs.readdirSync(tmpDir)
const extractedFolder = entries.find(e => fs.statSync(join(tmpDir, e)).isDirectory())
if (!extractedFolder) {
throw new Error('No folder found in extracted archive')
}
// Move to extractDir
console.log(` Installing to: ${extractDir}`)
mv(join(tmpDir, extractedFolder), extractDir)
// Fix chrome-sandbox permissions on Linux (Electron requires specific permissions)
// Note: setting the setuid bit requires root ownership, which npm install cannot provide.
// The launcher handles this by passing --no-sandbox when the sandbox is not root-owned.
if (plat === 'linux') {
const chromeSandboxPath = join(extractDir, 'chrome-sandbox')
if (fs.existsSync(chromeSandboxPath)) {
console.log(' Note: To enable the Electron sandbox, run:')
console.log(` sudo chown root:root "${chromeSandboxPath}"`)
console.log(` sudo chmod 4755 "${chromeSandboxPath}"`)
console.log(' Otherwise, electerm will launch with --no-sandbox automatically.')
}
}
// Clean up temp files
rm('-rf', tmpDir)
showFinalMessage()
}
async function runWin (archName) {
const rawVer = await getVer()
const ver = sanitizeVersion(rawVer)
console.log(` Version: ${ver}`)
console.log(` Target: win-${archName}`)
const target = join(packageRoot, `electerm-${ver}-win-${archName}`)
rm('-rf', [target, extractDir])
fs.mkdirSync(extractDir, { recursive: true })
const pattern = new RegExp(`electerm-\\d+\\.\\d+\\.\\d+-win-${archName}\\.tar\\.gz$`)
console.log(' Fetching release info...')
const releaseInfo = await getReleaseInfo(r => pattern.test(r.name))
if (!releaseInfo) {
throw new Error(`No release found for Windows ${archName}`)
}
// Download to a temp file, then extract directly to extractDir with strip:1
// (avoids a rename which can fail on Windows when AV has file locks)
const tmpDir = join(packageRoot, '.electerm-tmp')
rm('-rf', tmpDir)
fs.mkdirSync(tmpDir, { recursive: true })
const proxyUrl = applyProxy(releaseInfo.browser_download_url)
console.log(` URL: ${proxyUrl}`)
const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name })
console.log(` Installing to: ${extractDir}`)
await extractTarGz(filepath, extractDir, 1)
rm('-rf', tmpDir)
const exePath = getElectermExePath()
if (!fs.existsSync(exePath)) {
throw new Error(`electerm.exe not found at ${exePath} after extraction. Archive may have an unexpected structure.`)
}
showFinalMessage()
}
async function runWin7 () {
const rawVer = await getVer()
const ver = sanitizeVersion(rawVer)
console.log(` Version: ${ver}`)
console.log(' Target: win7')
const target = join(packageRoot, `electerm-${ver}-win7`)
rm('-rf', [target, extractDir])
fs.mkdirSync(extractDir, { recursive: true })
console.log(' Fetching release info...')
const releaseInfo = await getReleaseInfo(r => /electerm-\d+\.\d+\.\d+-win7\.tar\.gz$/.test(r.name))
if (!releaseInfo) {
throw new Error('No release found for Windows 7')
}
const tmpDir = join(packageRoot, '.electerm-tmp')
rm('-rf', tmpDir)
fs.mkdirSync(tmpDir, { recursive: true })
const proxyUrl = applyProxy(releaseInfo.browser_download_url)
console.log(` URL: ${proxyUrl}`)
const { filepath } = await download(releaseInfo.browser_download_url, tmpDir, { extract: false, displayName: releaseInfo.name })
console.log(` Installing to: ${extractDir}`)
await extractTarGz(filepath, extractDir, 1)
rm('-rf', tmpDir)
const exePath = getElectermExePath()
if (!fs.existsSync(exePath)) {
throw new Error(`electerm.exe not found at ${exePath} after extraction. Archive may have an unexpected structure.`)
}
showFinalMessage()
}
/**
* Mount a DMG, copy the .app to /Applications, then detach
* @param {string} dmgPath - Path to the DMG file
* @returns {Promise<string>} - Path to the installed app
*/
function installFromDmg (dmgPath) {
return new Promise((resolve, reject) => {
// Step 1: Mount the DMG
console.log(' Mounting DMG...')
execFile('hdiutil', ['attach', dmgPath, '-nobrowse', '-readonly'], (err, stdout) => {
if (err) {
reject(new Error(`Failed to mount DMG: ${err.message}`))
return
}
// Parse mount point
const mountMatch = stdout.match(/(\/Volumes\/[^\n]+)/)
if (!mountMatch) {
reject(new Error('Could not find mount point'))
return
}
const mountPoint = mountMatch[1].trim()
console.log(` Mounted at: ${mountPoint}`)
// Step 2: Find the .app bundle
try {
const entries = fs.readdirSync(mountPoint)
const appFile = entries.find(e => e.endsWith('.app'))
if (!appFile) {
// Try to detach before rejecting
execFileSyncIgnore('hdiutil', ['detach', mountPoint])
reject(new Error('No .app bundle found in DMG'))
return
}
const appSource = join(mountPoint, appFile)
const appDest = `/Applications/${appFile}`
// Check if app already exists
if (fs.existsSync(appDest)) {
console.log(` Existing app found at ${appDest}, replacing...`)
// Remove existing app
rm('-rf', appDest)
}
// Step 3: Copy the app to /Applications
console.log(` Installing ${appFile} to /Applications...`)
execFile('cp', ['-R', appSource, appDest], (cpErr) => {
// Step 4: Detach the DMG (always, regardless of copy result)
console.log(' Detaching DMG...')
execFile('hdiutil', ['detach', mountPoint], (detachErr) => {
if (detachErr) {
console.log(' Warning: Failed to detach DMG:', detachErr.message)
} else {
console.log(' DMG detached')
}
if (cpErr) {
reject(new Error(`Failed to copy app: ${cpErr.message}`))
return
}
console.log(` App installed to: ${appDest}`)
resolve(appDest)
})
})
} catch (e) {
// Try to detach before rejecting
execFileSyncIgnore('hdiutil', ['detach', mountPoint])
reject(e)
}
})
})
}
/**
* Execute a file synchronously, ignoring errors
*/
function execFileSyncIgnore (cmd, args) {
try {
execSync(cmd, args, { stdio: 'ignore' })
} catch (e) {
// Ignore
}
}
async function runMac (archName) {
const pattern = new RegExp(`mac-${archName}\\.dmg$`)
console.log(' Fetching release info...')
const releaseInfo = await getReleaseInfo(r => pattern.test(r.name))
if (!releaseInfo) {
throw new Error(`No release found for Mac ${archName}`)
}
const safeName = sanitizeFilename(releaseInfo.name)
const proxyUrl = applyProxy(releaseInfo.browser_download_url)
console.log(` URL: ${proxyUrl}`)
await download(releaseInfo.browser_download_url, packageRoot, { extract: false, displayName: releaseInfo.name })
const dmgPath = join(packageRoot, safeName)
showFinalMessage()
// Install from DMG automatically
try {
await installFromDmg(dmgPath)
// Clean up DMG
try {
fs.unlinkSync(dmgPath)
console.log(' Cleaned up DMG file')
} catch (e) {
// Ignore cleanup errors
}
console.log('')
console.log(' Installation complete! You can now launch electerm from /Applications')
} catch (err) {
console.error('')
console.error(' Warning: Automatic installation failed:', err.message)
console.error(' Please manually copy the app from the DMG to /Applications')
console.error('')
console.log(' Opening DMG for manual installation...')
execFile('open', [dmgPath])
}
}
async function runMac10 () {
console.log(' Fetching release info...')
const releaseInfo = await getReleaseInfo(r => /mac10-x64\.dmg$/.test(r.name))
if (!releaseInfo) {
throw new Error('No release found for macOS 10.x')
}
const safeName = sanitizeFilename(releaseInfo.name)
const proxyUrl = applyProxy(releaseInfo.browser_download_url)
console.log(` URL: ${proxyUrl}`)
await download(releaseInfo.browser_download_url, packageRoot, { extract: false, displayName: releaseInfo.name })
const dmgPath = join(packageRoot, safeName)
showFinalMessage()
// Install from DMG automatically
try {
await installFromDmg(dmgPath)
// Clean up DMG
try {
fs.unlinkSync(dmgPath)
console.log(' Cleaned up DMG file')
} catch (e) {
// Ignore cleanup errors
}
console.log('')
console.log(' Installation complete! You can now launch electerm from /Applications')
} catch (err) {
console.error('')
console.error(' Warning: Automatic installation failed:', err.message)
console.error(' Please manually copy the app from the DMG to /Applications')
console.error('')
console.log(' Opening DMG for manual installation...')
execFile('open', [dmgPath])
}
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main () {
console.log('')
console.log('========================================')
console.log('electerm binary installer')
console.log('========================================')
console.log(`Platform: ${plat}, Architecture: ${arch}`)
if (GITHUB_PROXY) {
console.log(`GitHub Proxy: ${GITHUB_PROXY}`)
}
console.log('')
// Check for legacy systems
const win7 = isWindows7OrEarlier(plat, os.release())
const mac10 = isMacOS10(plat, os.release())
const linuxLegacy = isLinuxLegacy(plat)
if (win7) console.log(' Detected: Windows 7 or earlier')
if (mac10) console.log(' Detected: macOS 10.x')
if (linuxLegacy) console.log(' Detected: Linux with glibc < 2.34 (legacy)')
console.log(' Fetching release information...')
try {
if (plat === 'win32') {
if (win7) {
await runWin7()
} else if (arch === 'arm64') {
await runWin('arm64')
} else {
await runWin('x64')
}
} else if (plat === 'darwin') {
if (mac10) {
await runMac10()
} else if (arch === 'arm64') {
await runMac('arm64')
} else {
await runMac('x64')
}
} else if (plat === 'linux') {
const suffix = linuxLegacy ? '-legacy' : ''
if (arch === 'arm64' || arch === 'aarch64') {
await runLinux(`linux-arm64${suffix}`, `linux-arm64${suffix}.tar.gz`)
} else if (arch === 'arm') {
await runLinux(`linux-armv7l${suffix}`, `linux-armv7l${suffix}.tar.gz`)
} else if (arch.includes('loong')) {
await runLinux(`linux-loong64${suffix}`, `linux-loong64${suffix}.tar.gz`)
} else {
await runLinux(`linux-x64${suffix}`, `linux-x64${suffix}.tar.gz`)
}
} else {
throw new Error(`Platform "${plat}" is not supported.`)
}
} catch (err) {
console.error('')
console.error('========================================')
console.error('Installation failed!')
console.error('========================================')
console.error(`Error: ${err.message}`)
console.error(`Platform: ${plat}, Architecture: ${arch}`)
console.error('')
console.error('Please visit https://electerm.org for manual download options.')
console.error('========================================')
console.error('')
process.exit(1)
}
}
// ---------------------------------------------------------------------------
// Exports for testing
// ---------------------------------------------------------------------------
module.exports = {
isWindows7OrEarlier,
isMacOS10,
isLinuxLegacy,
sanitizeVersion,
sanitizeFilename,
getElectermExePath,
isElectermExtracted,
// Expose for test injection
_packageRoot: packageRoot,
_extractDir: extractDir
}
if (require.main === module) {
main()
}