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 <path> explicitly.

Safety: refuses to write to /, the user home, or the current working
directory, and refuses non-empty existing output dirs without --force.
This commit is contained in:
Jim Liu 宝玉
2026-05-24 17:00:31 -05:00
parent 460bd087c6
commit 3cc1225b18
6 changed files with 1266 additions and 0 deletions
+140
View File
@@ -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 <app>", "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 <app>", "look inside <app>", "how is <app> built"
- "get the source code of Codex / Cursor / Discord / Slack / VS Code / Notion / Obsidian / ChatGPT desktop"
- "提取 Electron 应用", "看 <app> 的源码", "反编译 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 "<app>" [--output <dir>] [--asar <path>] [--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/<AppName>-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/<hash>.<ext>` 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>` | | App name or absolute path. Required unless `--asar` is given. | — |
| `--output` | `-o` | Output directory | `~/Downloads/<AppName>-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/<AppName>-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 <asar>.unpacked/ if present
│ └── ... # native modules (.node), large assets
└── restored/ # only present if at least one .js.map was usable
└── <original/source/tree> # 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.
@@ -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 });
}
});
@@ -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 <app> [options]
Extract resources & JavaScript from an installed Electron app.
Arguments:
<app> 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/<App>-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 <app> 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<number> {
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<void> {
mkdirSync(dest, { recursive: true });
const args = ["-y", "@electron/asar", "extract", asarPath, dest];
const code = json
? await new Promise<number>((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<void> {
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<number>((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<void> {
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<string>();
const skipFormatSet = new Set<string>();
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);
});
}