From 3cc1225b18c994349c8e0eaf7fc924ff25324b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jim=20Liu=20=E5=AE=9D=E7=8E=89?= Date: Sun, 24 May 2026 17:00:31 -0500 Subject: [PATCH] feat(baoyu-electron-extract): add new skill to extract Electron app sources Extracts resources and JavaScript from any installed Electron app's app.asar. When .js.map files embed sourcesContent, restores the original source tree (TypeScript/JSX included); otherwise formats minified JS/CSS with Prettier in place. Always skips node_modules and webpack/runtime/* entries. Source-map paths are resolved relative to each .js.map file first, so bundler-relative entries like ../../src/main.ts restore to readable paths under restored/ instead of hashed placeholders. Auto-discovers apps on macOS (/Applications, ~/Applications) and Windows (%LOCALAPPDATA%\Programs, %PROGRAMFILES%, %PROGRAMFILES(X86)%, %APPDATA%). On other platforms pass --asar explicitly. Safety: refuses to write to /, the user home, or the current working directory, and refuses non-empty existing output dirs without --force. --- .claude-plugin/marketplace.json | 1 + README.md | 37 + README.zh.md | 37 + skills/baoyu-electron-extract/SKILL.md | 140 +++ .../scripts/main.test.ts | 123 +++ skills/baoyu-electron-extract/scripts/main.ts | 928 ++++++++++++++++++ 6 files changed, 1266 insertions(+) create mode 100644 skills/baoyu-electron-extract/SKILL.md create mode 100644 skills/baoyu-electron-extract/scripts/main.test.ts create mode 100644 skills/baoyu-electron-extract/scripts/main.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e604a84..f71e338 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -22,6 +22,7 @@ "./skills/baoyu-danger-gemini-web", "./skills/baoyu-danger-x-to-markdown", "./skills/baoyu-diagram", + "./skills/baoyu-electron-extract", "./skills/baoyu-format-markdown", "./skills/baoyu-imagine", "./skills/baoyu-infographic", diff --git a/README.md b/README.md index bd5fbd3..1c0eca5 100644 --- a/README.md +++ b/README.md @@ -1154,6 +1154,43 @@ Summarize WeChat group chat highlights into a structured digest. Extracts topics - Normal and roast (毒舌) digest versions - Profile backfill from historical digests +#### baoyu-electron-extract + +Extract resources and JavaScript from any installed Electron app's `app.asar`. When `.js.map` files embed `sourcesContent`, restores the original source tree (TypeScript/JSX included); otherwise formats the minified JS/CSS with Prettier in place. Always skips `node_modules`. Works on macOS and Windows; pass `--asar ` on other platforms. + +```bash +# Extract by app name (default output: ~/Downloads/Codex-electron-extract/) +/baoyu-electron-extract Codex + +# Extract by absolute path (.app bundle, install dir, or .asar file) +/baoyu-electron-extract "/Applications/Visual Studio Code.app" +/baoyu-electron-extract --asar /Applications/Codex.app/Contents/Resources/app.asar Codex + +# Custom output directory +/baoyu-electron-extract Codex --output ~/work/codex-source + +# Preview discovery without writing anything +/baoyu-electron-extract Codex --dry-run + +# Overwrite an existing output directory +/baoyu-electron-extract Codex --force +``` + +**Options**: +| Option | Description | Default | +|--------|-------------|---------| +| `` | App name or absolute path (required unless `--asar`) | — | +| `--output`, `-o` | Output directory | `~/Downloads/-electron-extract` | +| `--asar` | Override the resolved `.asar` path | auto-discovered | +| `--force`, `-f` | Allow writing into a non-empty existing output dir | false | +| `--skip-format` | Skip Prettier formatting | false | +| `--skip-restore` | Skip source-map restoration | false | +| `--no-unpacked` | Don't copy `app.asar.unpacked/` alongside | false | +| `--dry-run` | Print resolved paths and exit without writing | false | +| `--json` | Emit one JSON-line summary on stdout | false | + +**Output layout**: `extract-report.json` (counts, warnings, paths), `extracted/` (raw asar, formatted in place when no map), `extracted.unpacked/` (native modules if present), and `restored/` (rebuilt source tree from `.js.map` files). + ## Environment Configuration Some skills require API keys or custom configuration. Environment variables can be set in `.env` files: diff --git a/README.zh.md b/README.zh.md index e6ee038..8a938e0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1145,6 +1145,43 @@ AI 驱动的生成后端。 - 正常版和毒舌版两种风格 - 支持从历史摘要回溯初始化画像 +#### baoyu-electron-extract + +从任意已安装的 Electron 应用的 `app.asar` 中提取资源和 JavaScript。当 `.js.map` 内嵌 `sourcesContent` 时,还原原始源码树(含 TypeScript/JSX);否则用 Prettier 原地美化压缩后的 JS/CSS。始终跳过 `node_modules`。支持 macOS 和 Windows,其他平台请用 `--asar ` 指定 asar 文件。 + +```bash +# 按应用名提取(默认输出:~/Downloads/-electron-extract/) +/baoyu-electron-extract Codex + +# 按绝对路径提取(.app 包、安装目录或 .asar 文件均可) +/baoyu-electron-extract "/Applications/Visual Studio Code.app" +/baoyu-electron-extract --asar /Applications/Codex.app/Contents/Resources/app.asar Codex + +# 自定义输出目录 +/baoyu-electron-extract Codex --output ~/work/codex-source + +# 仅预览发现的路径,不写入任何文件 +/baoyu-electron-extract Codex --dry-run + +# 覆盖已存在的输出目录 +/baoyu-electron-extract Codex --force +``` + +**选项**: +| 选项 | 说明 | 默认值 | +|------|------|--------| +| `` | 应用名或绝对路径(未给 `--asar` 时必填) | — | +| `--output`, `-o` | 输出目录 | `~/Downloads/-electron-extract` | +| `--asar` | 覆盖解析得到的 `.asar` 路径 | 自动发现 | +| `--force`, `-f` | 允许写入非空的已有输出目录 | false | +| `--skip-format` | 跳过 Prettier 格式化 | false | +| `--skip-restore` | 跳过 source-map 还原 | false | +| `--no-unpacked` | 不复制同级的 `app.asar.unpacked/` | false | +| `--dry-run` | 打印解析路径后退出,不写文件 | false | +| `--json` | 在 stdout 输出一行 JSON 概要 | false | + +**输出结构**:`extract-report.json`(计数、警告、路径),`extracted/`(asar 原始内容,无 map 时原地美化),`extracted.unpacked/`(存在时复制的原生模块),以及 `restored/`(基于 `.js.map` 重建的源码树)。 + ## 环境配置 部分技能需要 API 密钥或自定义配置。环境变量可以在 `.env` 文件中设置: diff --git a/skills/baoyu-electron-extract/SKILL.md b/skills/baoyu-electron-extract/SKILL.md new file mode 100644 index 0000000..212445f --- /dev/null +++ b/skills/baoyu-electron-extract/SKILL.md @@ -0,0 +1,140 @@ +--- +name: baoyu-electron-extract +description: Extracts resources and JavaScript from any installed Electron app (`.asar` bundle), restoring original sources from `.js.map` files when available or formatting minified code with Prettier otherwise. Use when user wants to "extract Electron app", "decompile Electron", "get the source code of ", "inspect app.asar", "看 Electron 应用源码", "提取 .asar", or asks how a desktop Electron app is built. Skips `node_modules` and supports both macOS and Windows. +version: 1.119.0 +metadata: + openclaw: + homepage: https://github.com/JimLiu/baoyu-skills#baoyu-electron-extract + requires: + anyBins: + - bun + - npx +--- + +# Electron App Extract + +Extracts resources and code from an installed Electron app's `app.asar`. When a `.js.map` is present, restores the original source files from the embedded `sourcesContent`; otherwise formats the minified code with Prettier. Source-map paths are resolved relative to the `.js.map` file first, so bundled paths like `../../src/main.ts` restore to readable paths such as `restored/src/main.ts` instead of hashed placeholders. Always skips `node_modules`. Works on macOS and Windows. + +## User Input Tools + +When this skill prompts the user, follow this tool-selection rule (priority order): + +1. **Prefer built-in user-input tools** exposed by the current agent runtime — e.g., `AskUserQuestion`, `request_user_input`, `clarify`, `ask_user`, or any equivalent. +2. **Fallback**: if no such tool exists, emit a numbered plain-text message and ask the user to reply with the chosen number/answer for each question. +3. **Batching**: if the tool supports multiple questions per call, combine all applicable questions into a single call; if only single-question, ask them one at a time in priority order. + +Concrete `AskUserQuestion` references below are examples — substitute the local equivalent in other runtimes. + +## Script Directory + +Scripts in `scripts/` subdirectory. `{baseDir}` = this SKILL.md's directory path. Resolve `${BUN_X}` runtime: if `bun` installed → `bun`; if `npx` available → `npx -y bun`; else suggest installing bun. Replace `{baseDir}` and `${BUN_X}` with actual values. + +| Script | Purpose | +| ----------------- | ------------------------------------------------------------------------------ | +| `scripts/main.ts` | App discovery + asar extraction + source-map restoration + Prettier formatting | + +## When to use + +Use this skill whenever the user wants to look inside an installed Electron application or inspect its bundled code. Trigger phrases include: + +- "extract Electron app", "decompile this Electron app", "unpack app.asar" +- "show me the source of ", "look inside ", "how is built" +- "get the source code of Codex / Cursor / Discord / Slack / VS Code / Notion / Obsidian / ChatGPT desktop" +- "提取 Electron 应用", "看 的源码", "反编译 Electron", "解包 app.asar", "还原 source map" + +Both **app name** (e.g., `Codex`) and **absolute path** (e.g., `/Applications/Codex.app`, a `.asar` file, or a Windows install dir) are accepted. The script handles discovery for both platforms. + +## Workflow + +**1. Determine the input.** Ask the user for the app name or path if they haven't given one. If they want a custom output directory, ask for that too. + +**2. Run the script.** + +```bash +${BUN_X} {baseDir}/scripts/main.ts "" [--output ] [--asar ] [--force] +``` + +Start with `--dry-run` first if you're unsure whether discovery will find the right bundle — it prints the resolved paths and exits without touching the filesystem. + +**3. Handle the result.** + +- **Success** → report the output paths and the counts (extracted / restored / formatted). +- **Multiple matches** → the script lists candidates and exits non-zero. Show the user the candidates, ask which one to use (via `AskUserQuestion` or the runtime equivalent), then re-run with the chosen absolute path. +- **Existing non-empty output dir** → the script refuses without `--force`. Ask the user whether to overwrite (`--force`) or pick a new `--output` path. +- **Unsupported platform / no match** → suggest passing `--asar /full/path/to/app.asar` if the user knows where the bundle lives. + +**4. Point the user at the result.** The default output dir is `~/Downloads/-electron-extract/`. The most interesting subdirectory depends on what was found: + +- `restored/` exists → the original source tree was reconstructed from `.js.map` files; this is what to read first. +- Only `extracted/` exists (no maps) → the JS/CSS in `extracted/` was Prettier-formatted in place; read from there. + +## Source-map path restoration + +The script should preserve original source names and directory structure as much as the source map allows: + +- Resolve each `sources[]` entry with `sourceRoot` when present, then relative to the `.js.map` file's directory inside `extracted/`. +- Collapse normal bundler-relative paths into the restored project tree. For example, `.vite/main/index.js.map` + `../../src/main.ts` becomes `restored/src/main.ts`. +- If a source path climbs above `extracted/`, keep the readable remaining path under `restored/` instead of hashing it. For example, `.vite/main/index.js.map` + `../../../shared/src/lib/foo.ts` becomes `restored/shared/src/lib/foo.ts`. +- Strip URL/query decorations from source names, including common `webpack://`, `file://`, and `?loader` suffixes. +- Use `restored/__unknown/.` only when the source name is empty or cannot be reduced to a safe file path. +- Continue skipping `node_modules` and `webpack/runtime/*` entries; these are bundler/runtime noise, not app sources. + +## Usage + +```bash +# Extract by app name (default output: ~/Downloads/Codex-electron-extract/) +${BUN_X} {baseDir}/scripts/main.ts Codex + +# Extract by absolute path (works for .app bundles, install dirs, or .asar files) +${BUN_X} {baseDir}/scripts/main.ts "/Applications/Visual Studio Code.app" +${BUN_X} {baseDir}/scripts/main.ts "C:\Users\you\AppData\Local\Programs\codex" +${BUN_X} {baseDir}/scripts/main.ts --asar /Applications/Codex.app/Contents/Resources/app.asar Codex + +# Custom output +${BUN_X} {baseDir}/scripts/main.ts Codex --output ~/work/codex-source + +# Preview discovery without writing anything +${BUN_X} {baseDir}/scripts/main.ts Codex --dry-run + +# Overwrite an existing output dir +${BUN_X} {baseDir}/scripts/main.ts Codex --force + +# Machine-readable result (one JSON line on stdout) +${BUN_X} {baseDir}/scripts/main.ts Codex --json +``` + +## Options + +| Option | Short | Description | Default | +| ---------------- | ----- | --------------------------------------------------------------- | ---------------------------------------- | +| `` | | App name or absolute path. Required unless `--asar` is given. | — | +| `--output` | `-o` | Output directory | `~/Downloads/-electron-extract` | +| `--asar` | | Override the resolved `.asar` path | auto-discovered | +| `--force` | `-f` | Allow writing into a non-empty existing output dir | false | +| `--skip-format` | | Skip Prettier formatting | false | +| `--skip-restore` | | Skip source-map restoration | false | +| `--no-unpacked` | | Don't copy `app.asar.unpacked/` alongside | false | +| `--dry-run` | | Print resolved paths and exit without writing | false | +| `--json` | | Emit one JSON-line summary on stdout (suppresses normal output) | false | + +## Output layout + +``` +~/Downloads/-electron-extract/ +├── extract-report.json # JSON summary: counts, warnings, resolved paths +├── extracted/ # raw asar contents (JS/CSS Prettier-formatted when no map) +│ └── ... # node_modules left untouched (skipped from format) +├── extracted.unpacked/ # copied from .unpacked/ if present +│ └── ... # native modules (.node), large assets +└── restored/ # only present if at least one .js.map was usable + └── # rebuilt from sourcesContent in each .js.map +``` + +## Notes + +- **node_modules** is always skipped — both for source-map restoration and Prettier formatting — because vendored dependencies are noise when inspecting an app. +- **Source-map restoration** only works when the `.js.map` embeds `sourcesContent`. This is the common case for modern bundlers (webpack, esbuild, Vite, rollup). If a map references external `.ts`/`.js` files without embedding them, that map is skipped and the corresponding `.js` is Prettier-formatted instead. Skipped maps are listed in `extract-report.json` under `warnings`. +- **Readable paths over hashes** — don't treat `../` segments in source-map paths as automatically unsafe. First resolve them from the map location and then sanitize the final output path so it still stays under `restored/`. Hash fallback is only for unusable source names. +- **App discovery** searches `/Applications` + `~/Applications` on macOS, and `%LOCALAPPDATA%\Programs`, `%PROGRAMFILES%`, `%PROGRAMFILES(X86)%`, `%APPDATA%` on Windows. If discovery finds multiple matches, the script exits and lists them — re-run with an absolute path. On Linux or other platforms, pass `--asar /path/to/app.asar` explicitly. +- **Safety** — the script refuses to write to `/`, the user home directly, or the current working directory, and refuses to populate an existing non-empty output dir without `--force`. +- **No global installs** — `@electron/asar` and `prettier` are resolved on-the-fly via `npx -y`. First run will be slower while npx caches them. diff --git a/skills/baoyu-electron-extract/scripts/main.test.ts b/skills/baoyu-electron-extract/scripts/main.test.ts new file mode 100644 index 0000000..b58b7dd --- /dev/null +++ b/skills/baoyu-electron-extract/scripts/main.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { normalizeSourcePath, restoreFromMap } from "./main.ts"; + +test("normalizes Vite source map paths into readable project paths", () => { + const extractedRoot = path.join(os.tmpdir(), "app", "extracted"); + const mapPath = path.join(extractedRoot, ".vite", "main", "index.js.map"); + + assert.equal( + normalizeSourcePath( + "../../src/main/agent/claude-agent.ts", + mapPath, + extractedRoot, + undefined + ), + "src/main/agent/claude-agent.ts" + ); + + assert.equal( + normalizeSourcePath( + "../../../shared/src/lib/prompt-classification.ts", + mapPath, + extractedRoot, + undefined + ), + "shared/src/lib/prompt-classification.ts" + ); + + assert.equal( + normalizeSourcePath( + "./src/renderer/app.tsx", + mapPath, + extractedRoot, + "webpack://moss/" + ), + "moss/src/renderer/app.tsx" + ); +}); + +test("restores source map sources without hashing usable relative paths", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "baoyu-electron-extract-")); + try { + const extractedRoot = path.join(root, "extracted"); + const restoredRoot = path.join(root, "restored"); + const mapDir = path.join(extractedRoot, ".vite", "main"); + const mapPath = path.join(mapDir, "index.js.map"); + await mkdir(mapDir, { recursive: true }); + await writeFile( + mapPath, + JSON.stringify({ + version: 3, + file: "index.js", + sources: [ + "../../src/main.ts", + "../../src/common/ipcChannels.ts", + "../../../shared/src/lib/prompt-classification.ts", + "webpack://moss/./src/renderer/app.tsx", + "../../../../node_modules/pkg/index.js", + "..\\..\\node_modules\\windows-pkg\\index.js", + "webpack/runtime/chunk loading", + ], + sourcesContent: [ + "export const main = true;\n", + "export const ipc = true;\n", + "export const shared = true;\n", + "export const renderer = true;\n", + "module.exports = {};\n", + "module.exports = {};\n", + "runtime();\n", + ], + }), + "utf8" + ); + + const warnings: string[] = []; + const restored = restoreFromMap( + mapPath, + extractedRoot, + restoredRoot, + warnings + ); + + assert.equal(restored, 4); + assert.deepEqual(warnings, []); + assert.equal( + await readFile(path.join(restoredRoot, "src", "main.ts"), "utf8"), + "export const main = true;\n" + ); + assert.equal( + await readFile( + path.join(restoredRoot, "src", "common", "ipcChannels.ts"), + "utf8" + ), + "export const ipc = true;\n" + ); + assert.equal( + await readFile( + path.join( + restoredRoot, + "shared", + "src", + "lib", + "prompt-classification.ts" + ), + "utf8" + ), + "export const shared = true;\n" + ); + assert.equal( + await readFile( + path.join(restoredRoot, "moss", "src", "renderer", "app.tsx"), + "utf8" + ), + "export const renderer = true;\n" + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/skills/baoyu-electron-extract/scripts/main.ts b/skills/baoyu-electron-extract/scripts/main.ts new file mode 100644 index 0000000..b747ea9 --- /dev/null +++ b/skills/baoyu-electron-extract/scripts/main.ts @@ -0,0 +1,928 @@ +#!/usr/bin/env bun +import { + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from "fs"; +import { + basename, + dirname, + extname, + isAbsolute, + join, + posix as posixPath, + relative, + resolve, + sep, +} from "path"; +import { createHash } from "crypto"; +import { spawn, spawnSync } from "child_process"; +import { homedir } from "os"; +import { pathToFileURL } from "url"; + +interface Options { + app: string; + output?: string; + asar?: string; + force: boolean; + skipFormat: boolean; + skipRestore: boolean; + noUnpacked: boolean; + dryRun: boolean; + json: boolean; +} + +interface ResolvedApp { + appName: string; + asarPath: string; + unpackedDir: string | null; + installRoot: string | null; +} + +interface Counts { + extractedFiles: number; + restoredFiles: number; + formattedFiles: number; + skippedNodeModules: number; + unpackedFiles: number; +} + +interface Report { + appName: string; + source: { + input: string; + asar: string; + platform: string; + installRoot: string | null; + }; + output: { + root: string; + extracted: string; + unpacked: string | null; + restored: string | null; + }; + counts: Counts; + warnings: string[]; + durationMs: number; +} + +function usage(): string { + return `Usage: main.ts [options] + +Extract resources & JavaScript from an installed Electron app. + +Arguments: + App name (e.g. "Codex", "Visual Studio Code") or absolute + path to a .app bundle, install directory, or .asar file. + +Options: + -o, --output PATH Output directory (default: ~/Downloads/-electron-extract) + --asar PATH Override the resolved .asar path + -f, --force Allow writing into a non-empty existing output dir + --skip-format Skip Prettier formatting (extract only) + --skip-restore Skip source-map restoration + --no-unpacked Don't copy app.asar.unpacked/ alongside + --dry-run Print resolved paths and exit without writing + --json Emit a single JSON line summary to stdout + -h, --help Show this help +`; +} + +function fail(message: string, json: boolean): never { + if (json) { + process.stdout.write( + JSON.stringify({ status: "error", error: message }) + "\n" + ); + } else { + process.stderr.write(`Error: ${message}\n`); + } + process.exit(1); +} + +function parseArgs(argv: string[]): Options { + const opts: Options = { + app: "", + force: false, + skipFormat: false, + skipRestore: false, + noUnpacked: false, + dryRun: false, + json: false, + }; + const positional: string[] = []; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "-h" || a === "--help") { + process.stdout.write(usage()); + process.exit(0); + } else if (a === "-o" || a === "--output") { + opts.output = argv[++i]; + } else if (a === "--asar") { + opts.asar = argv[++i]; + } else if (a === "-f" || a === "--force") { + opts.force = true; + } else if (a === "--skip-format") { + opts.skipFormat = true; + } else if (a === "--skip-restore") { + opts.skipRestore = true; + } else if (a === "--no-unpacked") { + opts.noUnpacked = true; + } else if (a === "--dry-run") { + opts.dryRun = true; + } else if (a === "--json") { + opts.json = true; + } else if (a.startsWith("-")) { + throw new Error(`unknown option: ${a}\n\n${usage()}`); + } else { + positional.push(a); + } + } + if (positional.length === 0 && !opts.asar) { + throw new Error( + `missing argument (or pass --asar PATH)\n\n${usage()}` + ); + } + if (positional.length > 1) { + throw new Error( + `too many positional arguments: ${positional.join(", ")}\n\n${usage()}` + ); + } + opts.app = positional[0] ?? ""; + return opts; +} + +function sanitizeAppName(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "-"); +} + +function appNameFromPath(p: string): string { + const parts = resolve(p).split(sep); + for (let i = parts.length - 1; i >= 0; i--) { + if (parts[i].endsWith(".app")) return parts[i].slice(0, -4); + } + const n = basename(p); + if (n === "app.asar") { + if ( + parts.length >= 3 && + parts[parts.length - 2].toLowerCase() === "resources" + ) { + return parts[parts.length - 3]; + } + if (parts.length >= 2) return parts[parts.length - 2]; + } + if (n.endsWith(".asar")) return n.slice(0, -5); + if (n.endsWith(".app")) return n.slice(0, -4); + return n; +} + +function existsAsFile(p: string): boolean { + try { + return statSync(p).isFile(); + } catch { + return false; + } +} + +function existsAsDir(p: string): boolean { + try { + return statSync(p).isDirectory(); + } catch { + return false; + } +} + +function findAsarUnderDir(dir: string): string | null { + const candidates = [ + join(dir, "resources", "app.asar"), + join(dir, "Resources", "app.asar"), + join(dir, "app.asar"), + ]; + for (const c of candidates) { + if (existsAsFile(c)) return c; + } + if (!existsAsDir(dir)) return null; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const nested = join(dir, entry.name, "resources", "app.asar"); + if (existsAsFile(nested)) return nested; + } + return null; +} + +function resolveByPath(input: string): ResolvedApp | null { + if (!existsSync(input)) return null; + const st = statSync(input); + if (st.isFile() && input.endsWith(".asar")) { + return { + appName: appNameFromPath(input), + asarPath: input, + unpackedDir: existsAsDir(input + ".unpacked") + ? input + ".unpacked" + : null, + installRoot: dirname(input), + }; + } + if (st.isDirectory()) { + if (input.endsWith(".app")) { + const asar = join(input, "Contents", "Resources", "app.asar"); + if (existsAsFile(asar)) { + return { + appName: appNameFromPath(input), + asarPath: asar, + unpackedDir: existsAsDir(asar + ".unpacked") + ? asar + ".unpacked" + : null, + installRoot: input, + }; + } + return null; + } + const asar = findAsarUnderDir(input); + if (asar) { + return { + appName: appNameFromPath(input), + asarPath: asar, + unpackedDir: existsAsDir(asar + ".unpacked") + ? asar + ".unpacked" + : null, + installRoot: input, + }; + } + } + return null; +} + +function listAppCandidates(name: string): string[] { + const lower = name.toLowerCase(); + const stripped = lower.endsWith(".app") ? lower.slice(0, -4) : lower; + const matches: string[] = []; + + if (process.platform === "darwin") { + const roots = ["/Applications", join(homedir(), "Applications")]; + for (const root of roots) { + if (!existsAsDir(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const en = entry.name.toLowerCase(); + const enStripped = en.endsWith(".app") ? en.slice(0, -4) : en; + if ( + en === lower || + en === `${stripped}.app` || + enStripped === stripped + ) { + matches.push(join(root, entry.name)); + } + } + } + } else if (process.platform === "win32") { + const roots = [ + process.env.LOCALAPPDATA + ? join(process.env.LOCALAPPDATA, "Programs") + : null, + process.env.PROGRAMFILES || null, + process.env["PROGRAMFILES(X86)"] || null, + process.env.APPDATA || null, + ].filter((x): x is string => Boolean(x)); + for (const root of roots) { + if (!existsAsDir(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (entry.name.toLowerCase() === stripped) { + matches.push(join(root, entry.name)); + } + } + } + } + return matches; +} + +function resolveApp(input: string): ResolvedApp { + if (isAbsolute(input)) { + const r = resolveByPath(input); + if (r) return r; + throw new Error(`path exists but no app.asar found at or under: ${input}`); + } + + if (process.platform !== "darwin" && process.platform !== "win32") { + throw new Error( + `platform ${process.platform} is not auto-supported. Pass --asar /path/to/app.asar to override.` + ); + } + + const candidates = listAppCandidates(input); + if (candidates.length === 0) { + throw new Error( + `could not find an installed app matching "${input}". Try passing an absolute path or --asar.` + ); + } + + const resolved = candidates + .map((c) => resolveByPath(c)) + .filter((r): r is ResolvedApp => r !== null); + + if (resolved.length === 0) { + throw new Error( + `found app directories for "${input}" but none contained app.asar:\n ${candidates.join( + "\n " + )}` + ); + } + if (resolved.length > 1) { + throw new Error( + `multiple apps match "${input}". Re-run with an absolute path:\n ${resolved + .map((r) => r.installRoot ?? r.asarPath) + .join("\n ")}` + ); + } + return resolved[0]; +} + +function downloadsDir(): string { + return join(homedir(), "Downloads"); +} + +function assertSafeOutputDir(outputDir: string, appName: string): void { + const root = + process.platform === "win32" ? outputDir.split(sep)[0] + sep : "/"; + const home = resolve(homedir()); + const cwd = resolve(process.cwd()); + const forbidden = [root, home, cwd]; + if (forbidden.includes(outputDir)) { + throw new Error(`output directory is unsafe to write into: ${outputDir}`); + } + const base = basename(outputDir).toLowerCase(); + const tag = sanitizeAppName(appName).toLowerCase(); + if (!base.includes(tag.toLowerCase()) && !base.includes("electron-extract")) { + throw new Error( + `output directory basename must contain the app name (or "electron-extract"): ${outputDir}` + ); + } +} + +function dirIsNonEmpty(p: string): boolean { + try { + return readdirSync(p).length > 0; + } catch { + return false; + } +} + +function runQuiet( + cmd: string, + args: string[] +): { code: number; stderr: string; stdout: string } { + const r = spawnSync(cmd, args, { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + if (r.error) return { code: 1, stderr: r.error.message, stdout: "" }; + return { + code: r.status ?? 1, + stderr: r.stderr ?? "", + stdout: r.stdout ?? "", + }; +} + +function runForwarded(cmd: string, args: string[]): Promise { + return new Promise((res) => { + const proc = spawn(cmd, args, { stdio: ["ignore", "inherit", "inherit"] }); + proc.on("close", (code) => res(code ?? 1)); + proc.on("error", () => res(1)); + }); +} + +async function extractAsar( + asarPath: string, + dest: string, + json: boolean +): Promise { + mkdirSync(dest, { recursive: true }); + const args = ["-y", "@electron/asar", "extract", asarPath, dest]; + const code = json + ? await new Promise((res) => { + const p = spawn("npx", args, { stdio: ["ignore", "ignore", "pipe"] }); + let err = ""; + p.stderr?.on("data", (d) => (err += d.toString())); + p.on("close", (c) => { + if ((c ?? 1) !== 0) process.stderr.write(err); + res(c ?? 1); + }); + p.on("error", (e) => { + process.stderr.write(e.message); + res(1); + }); + }) + : await runForwarded("npx", args); + if (code !== 0) + throw new Error(`@electron/asar extract failed (code ${code})`); +} + +type FileKind = "js" | "css" | "other"; + +function classifyExt(p: string): FileKind { + const e = extname(p).toLowerCase(); + if (e === ".js" || e === ".mjs" || e === ".cjs") return "js"; + if (e === ".css") return "css"; + return "other"; +} + +function walk( + root: string, + out: { jsFiles: string[]; cssFiles: string[] }, + counts: Counts +): void { + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.name === "node_modules") { + counts.skippedNodeModules += 1; + continue; + } + const full = join(root, entry.name); + if (entry.isDirectory()) { + walk(full, out, counts); + } else if (entry.isFile()) { + counts.extractedFiles += 1; + const k = classifyExt(full); + if (k === "js") out.jsFiles.push(full); + else if (k === "css") out.cssFiles.push(full); + } + } +} + +function countFiles(dir: string): number { + let total = 0; + if (!existsAsDir(dir)) return 0; + const stack = [dir]; + while (stack.length) { + const cur = stack.pop()!; + for (const entry of readdirSync(cur, { withFileTypes: true })) { + const full = join(cur, entry.name); + if (entry.isDirectory()) stack.push(full); + else if (entry.isFile()) total += 1; + } + } + return total; +} + +function toPosix(p: string): string { + return p.split(sep).join("/"); +} + +function sourcePathExt(src: string): string { + return extname(src.split(/[?#]/)[0]) || ".txt"; +} + +function stripSourceDecorations(src: string): { + path: string; + hadProtocol: boolean; +} { + let s = src.trim(); + let hadProtocol = false; + if (/^webpack:\/\/\/?/.test(s)) { + hadProtocol = true; + s = s.replace(/^webpack:\/\/\/?/, ""); + } else if (/^file:\/\//.test(s)) { + hadProtocol = true; + s = s.replace(/^file:\/\//, ""); + } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(s)) { + hadProtocol = true; + s = s.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, ""); + } + return { path: s.split(/[?#]/)[0].replace(/\\/g, "/"), hadProtocol }; +} + +function isSafeRelativePath(p: string): boolean { + return ( + p !== "" && + p !== "." && + p !== ".." && + !p.startsWith("../") && + !p.includes("/../") + ); +} + +function fallbackUnknownPath(src: string): string { + const h = createHash("sha1").update(src).digest("hex").slice(0, 10); + return posixPath.join("__unknown", `${h}${sourcePathExt(src)}`); +} + +function normalizeForOutputPath(p: string): string { + let s = p.replace(/\\/g, "/").replace(/^[a-zA-Z]:\//, ""); + if (s.startsWith("/")) s = s.replace(/^\/+/, ""); + return posixPath.normalize(s); +} + +function sanitizeEscapedSourcePath( + normalized: string, + original: string +): string { + let s = normalized + .replace(/\\/g, "/") + .replace(/^[a-zA-Z]:\//, "") + .replace(/^\/+/, ""); + while (s.startsWith("../")) s = s.slice(3); + s = posixPath.normalize(s); + if (!isSafeRelativePath(s)) return fallbackUnknownPath(original); + return s; +} + +function shouldSkipRestoredSource(src: string): boolean { + const s = src.replace(/\\/g, "/"); + return s.includes("node_modules/") || s.startsWith("webpack/runtime/"); +} + +function sourceWithRoot( + src: string, + sourceRoot: unknown +): { path: string; hadProtocol: boolean } { + const source = stripSourceDecorations(src); + if (typeof sourceRoot !== "string" || sourceRoot.trim() === "") return source; + + const root = stripSourceDecorations(sourceRoot); + const sourceIsAbsolute = + source.path.startsWith("/") || /^[a-zA-Z]:[\\/]/.test(source.path); + if (!root.path || source.hadProtocol || sourceIsAbsolute) { + return { + path: source.path, + hadProtocol: source.hadProtocol || root.hadProtocol, + }; + } + return { + path: posixPath.join(root.path.replace(/\\/g, "/"), source.path), + hadProtocol: source.hadProtocol || root.hadProtocol, + }; +} + +export function normalizeSourcePath( + src: string, + mapPath: string, + extractedRoot: string, + sourceRoot: unknown +): string { + const source = sourceWithRoot(src, sourceRoot); + if (!source.path) return ""; + + if ( + !source.hadProtocol && + (source.path.startsWith("./") || source.path.startsWith("../")) + ) { + const mapRelDir = toPosix(relative(extractedRoot, dirname(mapPath))) || "."; + const relativeToExtracted = posixPath.normalize( + posixPath.join(mapRelDir, source.path) + ); + if (isSafeRelativePath(relativeToExtracted)) return relativeToExtracted; + return sanitizeEscapedSourcePath(relativeToExtracted, src); + } + + const normalized = normalizeForOutputPath(source.path); + if (isSafeRelativePath(normalized)) return normalized; + return sanitizeEscapedSourcePath(normalized, src); +} + +function restoredTargetPath( + restoredRoot: string, + sourcePath: string +): string | null { + const target = resolve(restoredRoot, ...sourcePath.split("/")); + const rel = relative(restoredRoot, target); + if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null; + return target; +} + +interface MapData { + sources?: unknown; + sourcesContent?: unknown; + sourceRoot?: unknown; +} + +export function restoreFromMap( + mapPath: string, + extractedRoot: string, + restoredRoot: string, + warnings: string[] +): number { + let raw: string; + try { + raw = readFileSync(mapPath, "utf8"); + } catch (e: any) { + warnings.push(`read ${mapPath}: ${e.message}`); + return 0; + } + let data: MapData; + try { + data = JSON.parse(raw); + } catch (e: any) { + warnings.push(`parse ${mapPath}: ${e.message}`); + return 0; + } + const sources = Array.isArray(data.sources) + ? (data.sources as unknown[]) + : null; + const contents = Array.isArray(data.sourcesContent) + ? (data.sourcesContent as unknown[]) + : null; + if (!sources || !contents) return 0; + if (sources.length !== contents.length) { + warnings.push(`${mapPath}: sources/sourcesContent length mismatch`); + } + const n = Math.min(sources.length, contents.length); + let written = 0; + for (let i = 0; i < n; i++) { + const src = sources[i]; + const content = contents[i]; + if (typeof src !== "string" || typeof content !== "string") continue; + if (shouldSkipRestoredSource(src)) continue; + const restoredPath = normalizeSourcePath( + src, + mapPath, + extractedRoot, + data.sourceRoot + ); + if (!restoredPath) continue; + const target = restoredTargetPath(restoredRoot, restoredPath); + if (!target) { + warnings.push(`${mapPath}: unsafe restored path for source ${src}`); + continue; + } + try { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + written += 1; + } catch (e: any) { + warnings.push(`write ${target}: ${e.message}`); + } + } + return written; +} + +function formatBatches(files: string[], json: boolean): Promise { + const maxArgLen = 50_000; + let batch: string[] = []; + let len = 0; + const flush = async () => { + if (batch.length === 0) return; + const args = ["-y", "prettier", "--write", ...batch]; + const code = json + ? await new Promise((res) => { + const p = spawn("npx", args, { stdio: ["ignore", "ignore", "pipe"] }); + let err = ""; + p.stderr?.on("data", (d) => (err += d.toString())); + p.on("close", (c) => { + if ((c ?? 1) !== 0) process.stderr.write(err); + res(c ?? 1); + }); + p.on("error", (e) => { + process.stderr.write(e.message); + res(1); + }); + }) + : await runForwarded("npx", args); + if (code !== 0) { + process.stderr.write( + `prettier batch exited with code ${code} (continuing)\n` + ); + } + batch = []; + len = 0; + }; + + return (async () => { + for (const f of files) { + batch.push(f); + len += f.length + 1; + if (len >= maxArgLen) await flush(); + } + await flush(); + })(); +} + +async function main(): Promise { + const started = Date.now(); + let opts: Options; + try { + opts = parseArgs(process.argv.slice(2)); + } catch (e: any) { + process.stderr.write(`Error: ${e.message}\n`); + process.exit(2); + } + + let resolved: ResolvedApp; + try { + if (opts.asar) { + const r = resolveByPath(opts.asar); + if (!r) throw new Error(`--asar path not found: ${opts.asar}`); + const appName = + opts.app && !isAbsolute(opts.app) + ? opts.app + : appNameFromPath(opts.asar); + resolved = { ...r, appName }; + } else { + resolved = resolveApp(opts.app); + } + } catch (e: any) { + fail(e.message, opts.json); + } + + const outputDir = resolve( + opts.output ?? + join( + downloadsDir(), + `${sanitizeAppName(resolved.appName)}-electron-extract` + ) + ); + try { + assertSafeOutputDir(outputDir, resolved.appName); + } catch (e: any) { + fail(e.message, opts.json); + } + + const extractedDir = join(outputDir, "extracted"); + const unpackedOut = join(outputDir, "extracted.unpacked"); + const restoredDir = join(outputDir, "restored"); + + if (opts.dryRun) { + const summary = { + status: "dry-run", + appName: resolved.appName, + asar: resolved.asarPath, + unpacked: resolved.unpackedDir, + output: outputDir, + extracted: extractedDir, + extractedUnpacked: opts.noUnpacked + ? null + : resolved.unpackedDir + ? unpackedOut + : null, + restored: opts.skipRestore ? null : restoredDir, + }; + if (opts.json) { + process.stdout.write(JSON.stringify(summary) + "\n"); + } else { + process.stdout.write( + `[dry-run] App: ${resolved.appName}\n` + + `[dry-run] Asar: ${resolved.asarPath}\n` + + `[dry-run] Unpacked: ${resolved.unpackedDir ?? "(none)"}\n` + + `[dry-run] Output: ${outputDir}\n` + + `[dry-run] extracted: ${extractedDir}\n` + + `[dry-run] unpacked: ${ + opts.noUnpacked + ? "(skipped)" + : resolved.unpackedDir + ? unpackedOut + : "(none)" + }\n` + + `[dry-run] restored: ${ + opts.skipRestore ? "(skipped)" : restoredDir + }\n` + ); + } + return; + } + + if (existsSync(outputDir) && dirIsNonEmpty(outputDir) && !opts.force) { + fail( + `output directory exists and is not empty: ${outputDir}\n Pass --force to allow writing into it, or choose another --output.`, + opts.json + ); + } + mkdirSync(outputDir, { recursive: true }); + + if (!opts.json) { + process.stdout.write(`App: ${resolved.appName}\n`); + process.stdout.write(`Asar: ${resolved.asarPath}\n`); + process.stdout.write(`Output: ${outputDir}\n`); + process.stdout.write(`Extracting asar...\n`); + } + + try { + await extractAsar(resolved.asarPath, extractedDir, opts.json); + } catch (e: any) { + fail(e.message, opts.json); + } + + const counts: Counts = { + extractedFiles: 0, + restoredFiles: 0, + formattedFiles: 0, + skippedNodeModules: 0, + unpackedFiles: 0, + }; + const warnings: string[] = []; + + let unpackedFinal: string | null = null; + if ( + !opts.noUnpacked && + resolved.unpackedDir && + existsAsDir(resolved.unpackedDir) + ) { + if (!opts.json) process.stdout.write(`Copying app.asar.unpacked...\n`); + try { + cpSync(resolved.unpackedDir, unpackedOut, { recursive: true }); + counts.unpackedFiles = countFiles(unpackedOut); + unpackedFinal = unpackedOut; + } catch (e: any) { + warnings.push(`copy unpacked: ${e.message}`); + } + } + + const walked = { jsFiles: [] as string[], cssFiles: [] as string[] }; + walk(extractedDir, walked, counts); + + const toFormat: string[] = []; + const restoredMapFiles = new Set(); + const skipFormatSet = new Set(); + + if (!opts.skipRestore) { + for (const js of walked.jsFiles) { + const mapPath = js + ".map"; + if (!existsAsFile(mapPath)) continue; + const w = restoreFromMap(mapPath, extractedDir, restoredDir, warnings); + if (w > 0) { + counts.restoredFiles += w; + restoredMapFiles.add(mapPath); + skipFormatSet.add(js); + const license = js + ".LICENSE.txt"; + if (existsAsFile(license)) skipFormatSet.add(license); + } + } + } + + if (!opts.skipFormat) { + for (const js of walked.jsFiles) + if (!skipFormatSet.has(js)) toFormat.push(js); + for (const css of walked.cssFiles) toFormat.push(css); + if (toFormat.length > 0) { + if (!opts.json) + process.stdout.write( + `Formatting ${toFormat.length} file(s) with prettier...\n` + ); + await formatBatches(toFormat, opts.json); + counts.formattedFiles = toFormat.length; + } + } + + const report: Report = { + appName: resolved.appName, + source: { + input: opts.app, + asar: resolved.asarPath, + platform: process.platform, + installRoot: resolved.installRoot, + }, + output: { + root: outputDir, + extracted: extractedDir, + unpacked: unpackedFinal, + restored: counts.restoredFiles > 0 ? restoredDir : null, + }, + counts, + warnings, + durationMs: Date.now() - started, + }; + + try { + writeFileSync( + join(outputDir, "extract-report.json"), + JSON.stringify(report, null, 2) + ); + } catch (e: any) { + warnings.push(`write extract-report.json: ${e.message}`); + } + + if (opts.json) { + process.stdout.write(JSON.stringify({ status: "ok", ...report }) + "\n"); + } else { + process.stdout.write( + `\nDone in ${(report.durationMs / 1000).toFixed(1)}s\n` + + ` Extracted: ${counts.extractedFiles} file(s) into ${extractedDir}\n` + + (counts.unpackedFiles > 0 + ? ` Unpacked copied: ${counts.unpackedFiles} file(s) into ${unpackedOut}\n` + : "") + + ` Restored: ${counts.restoredFiles} file(s)` + + (counts.restoredFiles > 0 ? ` into ${restoredDir}\n` : `\n`) + + ` Formatted: ${counts.formattedFiles} file(s)\n` + + ` node_modules dirs skipped: ${counts.skippedNodeModules}\n` + + (warnings.length > 0 + ? ` Warnings: ${warnings.length} (see extract-report.json)\n` + : "") + ); + } +} + +function isDirectRun(): boolean { + if (import.meta.main) return true; + const scriptPath = process.argv[1]; + return Boolean( + scriptPath && pathToFileURL(resolve(scriptPath)).href === import.meta.url + ); +} + +if (isDirectRun()) { + main().catch((e) => { + process.stderr.write(`Unexpected error: ${e?.stack ?? e}\n`); + process.exit(1); + }); +}