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')
|
2026-06-01 15:32:47 +00:00
|
|
|
.option('path', {
|
|
|
|
|
type: 'string',
|
|
|
|
|
describe: 'Path to the HTTP Toolkit installation (the install dir or its resources folder)'
|
|
|
|
|
})
|
2026-01-12 13:40:16 +01:00
|
|
|
.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
|
2026-06-01 15:32:47 +00:00
|
|
|
const getAppPath = () => {
|
|
|
|
|
if (argv.path) {
|
|
|
|
|
return /resources$/i.test(argv.path) ? argv.path : path.join(argv.path, isMac ? 'Contents/Resources' : 'resources')
|
|
|
|
|
}
|
|
|
|
|
const localAppData = process.env.LOCALAPPDATA ?? ''
|
|
|
|
|
const candidates = isWin
|
|
|
|
|
? [
|
|
|
|
|
path.join(localAppData, 'Programs', 'httptoolkit', 'HTTP Toolkit', 'resources'), // current default
|
|
|
|
|
path.join(localAppData, 'Programs', 'httptoolkit', 'resources'), // older default
|
|
|
|
|
path.join(process.env.PROGRAMFILES ?? '', 'HTTP Toolkit', 'resources'),
|
|
|
|
|
path.join(process.env['PROGRAMFILES(X86)'] ?? '', 'HTTP Toolkit', 'resources')
|
|
|
|
|
]
|
|
|
|
|
: isMac
|
|
|
|
|
? ['/Applications/HTTP Toolkit.app/Contents/Resources']
|
|
|
|
|
: [
|
|
|
|
|
'/opt/HTTP Toolkit/resources',
|
|
|
|
|
'/opt/httptoolkit/resources',
|
|
|
|
|
'/usr/lib/httptoolkit'
|
|
|
|
|
]
|
|
|
|
|
for (const p of candidates) {
|
|
|
|
|
if (p && fs.existsSync(path.join(p, 'app.asar'))) return p
|
|
|
|
|
}
|
|
|
|
|
return candidates[0]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const appPath = getAppPath()
|
2026-01-12 13:40:16 +01:00
|
|
|
|
|
|
|
|
const exePath =
|
2026-06-01 15:32:47 +00:00
|
|
|
isWin ? path.join(path.dirname(appPath), 'HTTP Toolkit.exe')
|
|
|
|
|
: isMac ? path.join(path.dirname(appPath), 'MacOS', 'HTTP Toolkit')
|
|
|
|
|
: path.join(path.dirname(appPath), 'httptoolkit')
|
2026-01-12 13:40:16 +01:00
|
|
|
|
|
|
|
|
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-03-19 15:15:20 +01:00
|
|
|
// Replace @prisma/instrumentation with a stub - it has a broken nested node_modules
|
|
|
|
|
// reference to @opentelemetry/instrumentation that causes a crash after repacking.
|
|
|
|
|
// @sentry/node's prisma integration imports from it, so we can't just delete it —
|
|
|
|
|
// we need a stub that exports the expected symbols as noops.
|
2026-02-19 00:06:37 +01:00
|
|
|
const prismaInstrPath = path.join(tempPath, 'node_modules', '@prisma', 'instrumentation')
|
|
|
|
|
if (fs.existsSync(prismaInstrPath)) {
|
|
|
|
|
rm(prismaInstrPath)
|
2026-03-19 15:15:20 +01:00
|
|
|
fs.mkdirSync(prismaInstrPath, { recursive: true })
|
|
|
|
|
fs.writeFileSync(path.join(prismaInstrPath, 'package.json'), JSON.stringify({
|
|
|
|
|
name: '@prisma/instrumentation',
|
|
|
|
|
version: '0.0.0-stub',
|
|
|
|
|
main: 'index.js',
|
|
|
|
|
type: 'module'
|
|
|
|
|
}, null, 2))
|
|
|
|
|
fs.writeFileSync(path.join(prismaInstrPath, 'index.js'),
|
|
|
|
|
[
|
|
|
|
|
'export class PrismaInstrumentation {',
|
|
|
|
|
' constructor(config) { this._config = config; }',
|
|
|
|
|
' instrumentationName = "@prisma/instrumentation";',
|
|
|
|
|
' instrumentationVersion = "0.0.0";',
|
|
|
|
|
' enable() {}',
|
|
|
|
|
' disable() {}',
|
|
|
|
|
' setTracerProvider() {}',
|
|
|
|
|
' setMeterProvider() {}',
|
|
|
|
|
' getConfig() { return this._config || {}; }',
|
|
|
|
|
'}',
|
|
|
|
|
'export default PrismaInstrumentation;',
|
|
|
|
|
''
|
|
|
|
|
].join('\n')
|
|
|
|
|
)
|
|
|
|
|
console.log(chalk.yellowBright`Replaced broken @prisma/instrumentation with stub`)
|
2026-02-19 00:06:37 +01:00
|
|
|
}
|
|
|
|
|
|
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!`)
|