2026-01-12 13:40:16 +01:00
|
|
|
// @ts-check
|
|
|
|
|
import { spawn } from 'child_process'
|
|
|
|
|
import { flipFuses, FuseVersion, FuseV1Options } from '@electron/fuses'
|
|
|
|
|
import asar from '@electron/asar'
|
|
|
|
|
import prompts from 'prompts'
|
|
|
|
|
import yargs from 'yargs'
|
|
|
|
|
import chalk from 'chalk'
|
|
|
|
|
import path from 'path'
|
|
|
|
|
import fs from 'fs'
|
|
|
|
|
import os from 'os'
|
|
|
|
|
|
|
|
|
|
const argv = await yargs(process.argv.slice(2))
|
|
|
|
|
.usage(`Usage: ${process.argv0} . <command> [options]`)
|
|
|
|
|
.command('patch', 'Patch HTTP Toolkit using the specified script')
|
|
|
|
|
.command('restore', 'Restore HTTP Toolkit files to their original state')
|
|
|
|
|
.command('start', 'Start HTTP Toolkit')
|
|
|
|
|
.demandCommand(1, 'You need at least one command before moving on')
|
|
|
|
|
.alias('h', 'help')
|
|
|
|
|
.parse()
|
|
|
|
|
|
|
|
|
|
const isWin = process.platform === 'win32'
|
|
|
|
|
const isMac = process.platform === 'darwin'
|
|
|
|
|
|
2026-01-21 03:33:00 +01:00
|
|
|
const userAgent = process.env.npm_config_user_agent
|
|
|
|
|
const pm = userAgent ? userAgent.split('/')[0] : 'npm'
|
|
|
|
|
const installCmd = pm === 'npm' ? 'install' : 'add'
|
|
|
|
|
|
2026-01-12 13:40:16 +01:00
|
|
|
// Try to find where HTTP Toolkit is installed
|
|
|
|
|
const appPath =
|
|
|
|
|
isWin ? path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'HTTP Toolkit', 'resources')
|
|
|
|
|
: isMac ? '/Applications/HTTP Toolkit.app/Contents/Resources'
|
|
|
|
|
: fs.existsSync('/opt/HTTP Toolkit/resources') ? '/opt/HTTP Toolkit/resources'
|
|
|
|
|
: '/opt/httptoolkit/resources'
|
|
|
|
|
|
|
|
|
|
const exePath =
|
|
|
|
|
isWin ? path.join(process.env.LOCALAPPDATA ?? '', 'Programs', 'HTTP Toolkit', 'HTTP Toolkit.exe')
|
|
|
|
|
: isMac ? '/Applications/HTTP Toolkit.app/Contents/MacOS/HTTP Toolkit'
|
|
|
|
|
: fs.existsSync('/opt/HTTP Toolkit/httptoolkit') ? '/opt/HTTP Toolkit/httptoolkit'
|
|
|
|
|
: '/opt/httptoolkit/httptoolkit'
|
|
|
|
|
|
|
|
|
|
const isSudo = !isWin && (process.getuid || (() => process.env.SUDO_UID ? 0 : null))() === 0
|
|
|
|
|
|
|
|
|
|
if (+(process.versions.node.split('.')[0]) < 15) {
|
|
|
|
|
console.error(chalk.redBright`Node.js version 15 or higher is recommended, you are using version {bold ${process.versions.node}}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(path.join(appPath, 'app.asar'))) {
|
|
|
|
|
console.error(chalk.redBright`Couldn't find HTTP Toolkit! Make sure it's installed.`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.blueBright`Found HTTP Toolkit at {bold ${path.dirname(appPath)}}`)
|
|
|
|
|
|
|
|
|
|
// Helper to recursively delete directories
|
|
|
|
|
const rm = dirPath => {
|
|
|
|
|
if (!fs.existsSync(dirPath)) return
|
|
|
|
|
if (!fs.lstatSync(dirPath).isDirectory()) return fs.rmSync(dirPath, { force: true })
|
|
|
|
|
for (const entry of fs.readdirSync(dirPath)) {
|
|
|
|
|
const entryPath = path.join(dirPath, entry)
|
|
|
|
|
if (fs.lstatSync(entryPath).isDirectory()) rm(entryPath)
|
|
|
|
|
else fs.rmSync(entryPath, { force: true })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @type {Array<import('child_process').ChildProcess>} */
|
|
|
|
|
const activeProcesses = []
|
|
|
|
|
let isCancelled = false
|
|
|
|
|
|
|
|
|
|
const cleanUp = async () => {
|
|
|
|
|
isCancelled = true
|
|
|
|
|
console.log(chalk.redBright`Cancelled! Cleaning up...`)
|
|
|
|
|
if (activeProcesses.length) {
|
|
|
|
|
console.log(chalk.yellowBright`Stopping active processes...`)
|
|
|
|
|
for (const proc of activeProcesses) {
|
|
|
|
|
proc.kill('SIGINT')
|
|
|
|
|
console.log(chalk.yellowBright`Process {bold ${proc.pid ? process.pid + ' ' : ''}}stopped`)
|
|
|
|
|
}
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
|
|
|
}
|
|
|
|
|
const paths = [
|
|
|
|
|
path.resolve(os.tmpdir(), 'httptoolkit-patch'),
|
|
|
|
|
path.resolve(appPath, 'app')
|
|
|
|
|
]
|
|
|
|
|
try {
|
|
|
|
|
for (const p of paths) {
|
|
|
|
|
if (fs.existsSync(p)) {
|
|
|
|
|
console.log(chalk.yellowBright`Removing {bold ${p}}`)
|
|
|
|
|
rm(p)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(chalk.redBright`Error while cleaning up`, e)
|
|
|
|
|
}
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const patchApp = async () => {
|
|
|
|
|
const filePath = path.join(appPath, 'app.asar')
|
|
|
|
|
const tempPath = path.join(appPath, 'app')
|
|
|
|
|
|
|
|
|
|
if (fs.readFileSync(filePath).includes('Injected by HTTP Toolkit Patcher')) {
|
|
|
|
|
console.log(chalk.greenBright`App is already patched!`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const globalProxy = process.env.PROXY
|
|
|
|
|
|
|
|
|
|
console.log(chalk.blueBright`Starting the patch process...`)
|
|
|
|
|
|
|
|
|
|
if (globalProxy) {
|
|
|
|
|
if (!globalProxy.match(/^https?:/)) {
|
|
|
|
|
console.error(chalk.redBright`Global proxy must start with http:// or https://`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
console.log(chalk.yellowBright`Using custom proxy: {bold ${globalProxy}}`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.log(chalk.yellowBright`Extracting app files...`)
|
|
|
|
|
|
|
|
|
|
;['SIGINT', 'SIGTERM'].forEach(signal => process.on(signal, cleanUp))
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
rm(tempPath)
|
|
|
|
|
asar.extractAll(filePath, tempPath)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!isSudo && /** @type {any} */ (e).errno === -13) { // Permission denied
|
|
|
|
|
console.error(chalk.redBright`Permission denied${!isWin ? ', try running with sudo' : ', try running node as administrator'}`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
console.error(chalk.redBright`Error extracting app`, e)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 00:06:37 +01:00
|
|
|
// Remove @prisma/instrumentation - it has a broken nested node_modules reference
|
|
|
|
|
// to @opentelemetry/instrumentation that causes a crash after repacking.
|
|
|
|
|
// It's only telemetry and not needed for the app to function.
|
|
|
|
|
const prismaInstrPath = path.join(tempPath, 'node_modules', '@prisma', 'instrumentation')
|
|
|
|
|
if (fs.existsSync(prismaInstrPath)) {
|
|
|
|
|
rm(prismaInstrPath)
|
|
|
|
|
console.log(chalk.yellowBright`Removed broken @prisma/instrumentation package`)
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-12 13:40:16 +01:00
|
|
|
const indexPath = path.join(tempPath, 'build', 'index.js')
|
|
|
|
|
if (!fs.existsSync(indexPath)) {
|
|
|
|
|
console.error(chalk.redBright`Couldn't find index.js file`)
|
|
|
|
|
cleanUp()
|
|
|
|
|
}
|
|
|
|
|
const data = fs.readFileSync(indexPath, 'utf-8')
|
|
|
|
|
;['SIGINT', 'SIGTERM'].forEach(signal => process.off(signal, cleanUp))
|
|
|
|
|
|
|
|
|
|
const { email } = await prompts({
|
|
|
|
|
type: 'text',
|
|
|
|
|
name: 'email',
|
|
|
|
|
message: 'Enter an email for the pro plan',
|
|
|
|
|
validate: value => value.includes('@') || 'Invalid email'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!email || typeof email !== 'string') {
|
|
|
|
|
console.error(chalk.redBright`No email provided`)
|
|
|
|
|
cleanUp()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
;['SIGINT', 'SIGTERM'].forEach(signal => process.on(signal, cleanUp))
|
|
|
|
|
const patchContent = fs.readFileSync('patch.js', 'utf-8')
|
|
|
|
|
|
|
|
|
|
// Separate imports from the rest of the patch code
|
|
|
|
|
const patchImportsMatch = patchContent.match(/\/\/ --- Patcher Imports[\s\S]*?\/\/ --- End Patcher Imports ---\n?/)
|
|
|
|
|
const patchImports = patchImportsMatch ? patchImportsMatch[0] : ''
|
|
|
|
|
const patchCode = patchContent.replace(patchImports, '')
|
|
|
|
|
|
|
|
|
|
// Find where to put our imports (right after the last existing import)
|
|
|
|
|
const lastImportMatch = data.match(/^import .+$/gm)
|
|
|
|
|
const lastImport = lastImportMatch ? lastImportMatch[lastImportMatch.length - 1] : null
|
|
|
|
|
|
|
|
|
|
let patchedData = data
|
|
|
|
|
|
|
|
|
|
// Inject imports at the top
|
|
|
|
|
if (patchImports && lastImport) {
|
|
|
|
|
patchedData = patchedData.replace(lastImport, `${lastImport}\n// ------- Patcher Imports -------\n${patchImports.trim()}\n// ------- End Patcher Imports -------`)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Inject the main patch code where APP_URL is defined
|
|
|
|
|
patchedData = patchedData
|
|
|
|
|
.replace('const APP_URL =', `// ------- Injected by HTTP Toolkit Patcher -------\nconst email = \`${email.replace(/`/g, '\\`')}\`\nconst globalProxy = process.env.PROXY ?? \`${globalProxy ? globalProxy.replace(/`/g, '\\`') : ''}\`\n${patchCode}\n// ------- End patched content -------\nconst APP_URL =`)
|
|
|
|
|
|
|
|
|
|
if (data === patchedData || !patchedData) {
|
|
|
|
|
console.error(chalk.redBright`Patch failed - couldn't find injection point`)
|
|
|
|
|
cleanUp()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fs.writeFileSync(indexPath, patchedData, 'utf-8')
|
|
|
|
|
console.log(chalk.greenBright`Patched index.js successfully`)
|
|
|
|
|
console.log(chalk.yellowBright`Installing dependencies...`)
|
|
|
|
|
|
|
|
|
|
try {
|
2026-01-21 03:33:00 +01:00
|
|
|
const proc = spawn(`${pm} ${installCmd} express https-proxy-agent`, { cwd: tempPath, stdio: 'inherit', shell: true })
|
2026-01-12 13:40:16 +01:00
|
|
|
activeProcesses.push(proc)
|
|
|
|
|
await new Promise(resolve =>
|
|
|
|
|
proc.on('close', resolve)
|
|
|
|
|
)
|
|
|
|
|
activeProcesses.splice(activeProcesses.indexOf(proc), 1)
|
|
|
|
|
if (isCancelled) return
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(chalk.redBright`Error installing dependencies`, e)
|
|
|
|
|
cleanUp()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rm(path.join(tempPath, 'package-lock.json'))
|
2026-01-21 03:33:00 +01:00
|
|
|
rm(path.join(tempPath, 'yarn.lock'))
|
|
|
|
|
rm(path.join(tempPath, 'pnpm-lock.yaml'))
|
|
|
|
|
rm(path.join(tempPath, 'bun.lockb'))
|
2026-01-12 13:40:16 +01:00
|
|
|
fs.copyFileSync(filePath, `${filePath}.bak`)
|
|
|
|
|
console.log(chalk.greenBright`Created backup at {bold ${filePath}.bak}`)
|
|
|
|
|
|
|
|
|
|
console.log(chalk.yellowBright`Repacking the app...`)
|
|
|
|
|
await asar.createPackage(tempPath, filePath)
|
|
|
|
|
rm(tempPath)
|
|
|
|
|
console.log(chalk.greenBright`App patched!`)
|
|
|
|
|
|
|
|
|
|
// Disable ASAR integrity checks so Electron doesn't complain
|
|
|
|
|
await flipElectronFuses()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const flipElectronFuses = async () => {
|
|
|
|
|
console.log(chalk.yellowBright`Disabling ASAR integrity checks...`)
|
|
|
|
|
|
|
|
|
|
if (!fs.existsSync(exePath)) {
|
|
|
|
|
console.log(chalk.yellowBright`Executable not found at ${exePath}, skipping fuse flipping`)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await flipFuses(exePath, {
|
|
|
|
|
version: FuseVersion.V1,
|
|
|
|
|
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: false,
|
|
|
|
|
[FuseV1Options.OnlyLoadAppFromAsar]: false,
|
|
|
|
|
})
|
|
|
|
|
console.log(chalk.greenBright`Integrity checks disabled successfully`)
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(chalk.yellowBright`Failed to flip fuses (might already be done or not supported): ${/** @type {Error} */ (e).message}`)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch (argv._[0]) {
|
|
|
|
|
case 'patch':
|
|
|
|
|
await patchApp()
|
|
|
|
|
break
|
|
|
|
|
case 'restore':
|
|
|
|
|
try {
|
|
|
|
|
console.log(chalk.blueBright`Restoring app...`)
|
|
|
|
|
if (!fs.existsSync(path.join(appPath, 'app.asar.bak')))
|
|
|
|
|
console.error(chalk.redBright`Backup file not found - maybe the app wasn't patched?`)
|
|
|
|
|
else {
|
|
|
|
|
fs.copyFileSync(path.join(appPath, 'app.asar.bak'), path.join(appPath, 'app.asar'))
|
|
|
|
|
console.log(chalk.greenBright`App restored successfully`)
|
|
|
|
|
}
|
|
|
|
|
rm(path.join(os.tmpdir(), 'httptoolkit-patch'))
|
|
|
|
|
} catch (e) {
|
|
|
|
|
if (!isSudo && /** @type {any} */ (e).errno === -13) { // Permission denied
|
|
|
|
|
console.error(chalk.redBright`Permission denied${!isWin ? ', try running with sudo' : ', try running node as administrator'}`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
console.error(chalk.redBright`Error restoring app`, e)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
case 'start':
|
|
|
|
|
console.log(chalk.blueBright`Starting HTTP Toolkit...`)
|
|
|
|
|
try {
|
|
|
|
|
const command =
|
|
|
|
|
isWin ? `"${path.resolve(appPath, '..', 'HTTP Toolkit.exe')}"`
|
|
|
|
|
: isMac ? 'open -a "HTTP Toolkit"'
|
|
|
|
|
: 'httptoolkit'
|
|
|
|
|
const proc = spawn(command, { stdio: 'inherit', shell: true })
|
|
|
|
|
proc.on('close', code => process.exit(code))
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(chalk.redBright`Error starting app`, e)
|
|
|
|
|
if (isSudo) console.error(chalk.redBright`Try running without sudo`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
break
|
|
|
|
|
default:
|
|
|
|
|
console.error(chalk.redBright`Unknown command`)
|
|
|
|
|
process.exit(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isCancelled) console.log(chalk.greenBright`All done!`)
|