Repository: ChiChou/frida-ipa-dump
Branch: main
Commit: 71fbd4d72868
Files: 21
Total size: 41.0 KB
Directory structure:
gitextract_1gu8ebsy/
├── .github/
│ ├── FUNDING.yml
│ ├── ISSUE_TEMPLATE/
│ │ └── bug_report.md
│ └── workflows/
│ └── npm-publish.yml
├── .gitignore
├── .npmignore
├── LICENSE
├── README.md
├── README.zh-CN.md
├── agent/
│ ├── .gitignore
│ ├── .npmignore
│ ├── package.json
│ ├── src/
│ │ ├── app.ts
│ │ ├── shared.ts
│ │ └── springboard.ts
│ └── tsconfig.json
├── bin/
│ └── bagbak.ts
├── index.ts
├── lib/
│ └── utils.ts
├── package.json
├── tsconfig.json
└── tsdown.config.ts
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: ChiChou
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
Before you submit the issue, please check the FAQ section in Wiki: https://github.com/ChiChou/bagbak/wiki#faq
Then delete this section.
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. Ubuntu]
- nodejs: [e.g. v18.16.0]
- frida on device version
- iOS and jailbreak version
- The app you are trying to work on [e.g. com.example.app, better with AppStore link]
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/workflows/npm-publish.yml
================================================
name: Node.js Package
on:
push:
tags:
- v*
jobs:
publish-npm:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
registry-url: 'https://registry.npmjs.org'
- name: Ensure npm version for Trusted Publishing
run: npm install -g npm@latest
- name: Install agent dependencies
run: npm ci
working-directory: agent
- name: Build agent
run: npm run build
working-directory: agent
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Pack tarball
run: npm pack
- name: Publish to npm
run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: dummy
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
files: '*.tgz'
================================================
FILE: .gitignore
================================================
dist/
.vscode/
.ccls-cache/
dump/
output/
*.app/
*.ipa
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
================================================
FILE: .npmignore
================================================
!agent/dist/springboard.js
!agent/dist/app.js
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2019 CodeColorist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# bagbak
[](https://www.npmjs.com/package/bagbak)
[](https://www.npmjs.com/package/bagbak)
[](https://github.com/chichou/bagbak/issues)
[](https://github.com/sponsors/chichou)
[](LICENSE)
Yet another frida based App decryptor. Requires jailbroken iOS device and [frida.re](https://www.frida.re/)
Tested on iOS 15 (Dopamine) and iOS 16 (palera1n).
*The name of this project doesn't have any meaning. I was just listening to that song while typing.*
## Prerequisites
**Note:** bagbak@5 requires frida@17. If your frida-server is v16, use `npm install -g bagbak@4` instead.
### On device
* [frida.re](https://www.frida.re/docs/ios/)
### On desktop
- [node.js](https://nodejs.org/).
- `npm install -g bagbak`
## Usage
bagbak [bundle id or name]
```
Options:
-l, --list list apps
-j, --json output as json (only works with --list)
-U, --usb connect to USB device (default)
-R, --remote connect to remote frida-server
-D, --device <uuid> connect to device with the given ID
-H, --host <host> connect to remote frida-server on HOST
-d, --debug enable debug output
-o, --output <output> ipa filename or directory to dump to
--remove-keys <keys> Info.plist keys to remove (comma-separated)
-h, --help display help for command
```
Dump modes (second argument):
* `all` (default) — full IPA with all binaries decrypted
* `main` (alias: `app`) — decrypt main app binary only
* `extensions` (aliases: `ext`, `exts`) — decrypt extension binaries only
* `binaries` (aliases: `bin`, `executables`) — decrypt all binaries, output as zip
Environments variables:
* `DEBUG=1` enable debug output for troubleshooting
Example:
* `bagbak -l` to list all apps
* `bagbak com.google.chrome.ios` to dump app to `com.google.chrome.ios-[version].ipa`
* `bagbak com.google.chrome.ios main` to dump only the main binary
* `bagbak --remove-keys UISupportedDevices com.google.chrome.ios` to remove device restrictions from Info.plist
================================================
FILE: README.zh-CN.md
================================================
# bagbak
[](https://www.npmjs.com/package/bagbak)
[](https://www.npmjs.com/package/bagbak)
[](https://github.com/chichou/bagbak/issues)
[](https://github.com/sponsors/chichou)
[](LICENSE)
又一个基于 Frida 的 App 解密工具。需要越狱的 iOS 设备和 [frida.re](https://www.frida.re/)
已在 iOS 15 (Dopamine) 和 iOS 16 (palera1n) 上测试通过。
*这个项目的名字没有任何含义,我写代码的时候正在听那首歌。*
## 环境要求
**注意:** bagbak@5 需要 frida@17。如果你的 frida-server 是 v16,请使用 `npm install -g bagbak@4`。
### 设备端
* [frida.re](https://www.frida.re/docs/ios/)
### 电脑端
- [node.js](https://nodejs.org/)
- `npm install -g bagbak`
## 使用方法
bagbak [bundle id 或应用名称]
```
Options:
-l, --list 列出所有应用
-j, --json 以 JSON 格式输出 (仅配合 --list 使用)
-U, --usb 连接 USB 设备 (默认)
-R, --remote 连接远程 frida-server
-D, --device <uuid> 连接到指定 ID 的设备
-H, --host <host> 连接到指定主机的远程 frida-server
-d, --debug 启用调试输出
-o, --output <output> ipa 文件名或输出目录
--remove-keys <keys> 需要移除的 Info.plist 键 (逗号分隔)
-h, --help 显示帮助信息
```
解密模式 (第二个参数):
* `all` (默认) — 完整 IPA,解密所有二进制文件
* `main` (别名: `app`) — 仅解密主应用二进制文件
* `extensions` (别名: `ext`, `exts`) — 仅解密扩展二进制文件
* `binaries` (别名: `bin`, `executables`) — 解密所有二进制文件,输出为 zip
环境变量:
* `DEBUG=1` 启用调试输出
示例:
* `bagbak -l` 列出所有应用
* `bagbak com.google.chrome.ios` 解密应用并输出到 `com.google.chrome.ios-[version].ipa`
* `bagbak com.google.chrome.ios main` 仅解密主应用二进制文件
* `bagbak --remove-keys UISupportedDevices com.google.chrome.ios` 移除 Info.plist 中的设备限制
<p align="center">想看更多中文技术分享?欢迎关注我的公众号</p>
<p align="center"><image src="images/weixin.jpg" width="240" /></p>
================================================
FILE: agent/.gitignore
================================================
/node_modules/
/dist/springboard.js
/dist/app.js
================================================
FILE: agent/.npmignore
================================================
!dist/springboard.js
!dist/app.js
================================================
FILE: agent/package.json
================================================
{
"name": "bagbak-agent",
"version": "1.0.0",
"description": "Frida agent written in TypeScript",
"private": true,
"main": "src/index.ts",
"type": "module",
"scripts": {
"prepare": "npm run build",
"build": "frida-compile src/springboard.ts -o dist/springboard.js -c && frida-compile src/app.ts -o dist/app.js -c",
"watch": "frida-compile src/springboard.ts -o dist/springboard.js -w & frida-compile src/app.ts -o dist/app.js -w"
},
"devDependencies": {
"@types/frida-gum": "^19.0.0",
"@types/node": "^18.14.0",
"frida-compile": "^19.0.5"
},
"dependencies": {
"frida-objc-bridge": "^8.0.5",
"frida-remote-stream": "^6.0.3"
}
}
================================================
FILE: agent/src/app.ts
================================================
import ObjC from "frida-objc-bridge";
import type { MachOTasks } from "./shared.js";
import { getApi } from "./shared.js";
const HIGH_WATER_MARK = 1024 * 1024;
const O_RDWR = 0x0002;
const EXTENSION_SYMBOLS = [
"libxpc.dylib`xpc_main",
"Foundation`NSExtensionMain",
"ExtensionFoundation`EXExtensionMain",
];
const APP_SYMBOLS = ["UIKitCore`UIApplicationMain", "AppKit`NSApplicationMain"];
function replaceWithRunLoop(symbols: string[]) {
const CFRunLoopRun =
Process.getModuleByName("CoreFoundation").getExportByName("CFRunLoopRun");
for (const symbol of symbols) {
const [dylib, func] = symbol.split("`");
try {
const p = Process.getModuleByName(dylib).getExportByName(func);
console.log("replace function", symbol, p);
Interceptor.replace(p, CFRunLoopRun);
} catch (e: unknown) {
console.log("skip", symbol, (e as Error).message);
}
}
ObjC.schedule(ObjC.mainQueue, () => {
console.log("mainQueue scheduled");
});
}
rpc.exports = {
hookAppMain() {
replaceWithRunLoop(APP_SYMBOLS);
},
hookExtensionMain() {
replaceWithRunLoop(EXTENSION_SYMBOLS);
},
dump(
remoteRoot: string,
tempRoot: string,
binaries: MachOTasks,
isExtension: boolean = false,
) {
const { open, close, pwrite, exit } = getApi();
for (const [relative, info] of Object.entries(binaries)) {
console.log("decrypt", relative);
const { offset, size } = info.encrypt;
const absoluteOriginal = remoteRoot + "/" + relative;
const absoluteTemp = tempRoot + "/" + relative;
const mod = Module.load(absoluteOriginal);
const fatOffset = Process.findRangeByAddress(mod.base)!.file!.offset;
console.log("module =>", mod.name, mod.base, mod.size);
console.log("encrypted =>", offset, size, "fatOffset =>", fatOffset);
const fd = open(Memory.allocUtf8String(absoluteTemp), O_RDWR) as number;
if (fd < 0) {
console.error("Failed to open", absoluteTemp);
continue;
}
let p = mod.base.add(offset);
let fileOffset = offset + fatOffset;
let remaining = size;
while (remaining > 0) {
const chunk = Math.min(HIGH_WATER_MARK, remaining);
pwrite(fd, p, chunk, fileOffset);
p = p.add(chunk);
fileOffset += chunk;
remaining -= chunk;
}
// patch LC_ENCRYPTION_INFO_64: zero out cryptoff, cryptsize, cryptid
const zeros = Memory.alloc(12);
pwrite(fd, zeros, 12, info.offset + 8 + fatOffset);
close(fd);
send({ event: "patch", name: relative });
recv("ack", () => {}).wait();
}
if (isExtension) {
ObjC.schedule(ObjC.mainQueue, () => {
exit(0);
});
}
return true;
},
};
================================================
FILE: agent/src/shared.ts
================================================
import ObjC from "frida-objc-bridge";
export interface EncryptInfo {
offset: number;
size: number;
id: number;
}
export interface MachOInfo {
type: number;
encrypt: EncryptInfo;
offset: number; // offset of LC_ENCRYPTION_INFO_64 command
}
export interface ExtensionInfo {
id: string;
path: string;
exec: string;
abs: string;
}
export type MachOTasks = Record<string, MachOInfo>;
type NativeAPI = {
open: NativeFunction<number, [NativePointerValue, number]>;
close: NativeFunction<number, [number]>;
read: NativeFunction<number, [number, NativePointerValue, number]>;
pwrite: NativeFunction<number, [number, NativePointerValue, number, number]>;
exit: NativeFunction<void, [number]>;
};
let api: NativeAPI | null = null;
export function nsError<T>(fn: (pError: NativePointer) => T): T {
const pError = Memory.alloc(Process.pointerSize);
pError.writePointer(NULL);
const result = fn(pError);
const err = pError.readPointer();
if (!err.isNull()) throw new Error(new ObjC.Object(err).toString());
return result;
}
export function getApi(): NativeAPI {
if (api) return api;
api = {
open: new NativeFunction(Module.getGlobalExportByName("open"), "int", [
"pointer",
"int",
]),
close: new NativeFunction(Module.getGlobalExportByName("close"), "int", [
"int",
]),
read: new NativeFunction(Module.getGlobalExportByName("read"), "long", [
"int",
"pointer",
"long",
]),
pwrite: new NativeFunction(Module.getGlobalExportByName("pwrite"), "long", [
"int",
"pointer",
"long",
"long",
]),
exit: new NativeFunction(Module.getGlobalExportByName("exit"), "void", [
"int",
]),
};
return api;
}
================================================
FILE: agent/src/springboard.ts
================================================
import ObjC from "frida-objc-bridge";
import Controller, { type Packet } from "frida-remote-stream";
import type { ExtensionInfo, MachOInfo, MachOTasks } from "./shared.js";
import { getApi, nsError } from "./shared.js";
const MH_MAGIC_64 = 0xfeedfacf;
const LC_ENCRYPTION_INFO_64 = 0x2c;
const HEADER_SIZE_64 = 32;
const O_RDONLY = 0;
const STREAM_CHUNK = 2 * 1024 * 1024;
const EXCLUDE_DIRS = new Set(["SC_Info", "_CodeSignature"]);
const EXCLUDE_FILES = new Set([
"iTunesMetadata.plist",
"embedded.mobileprovision",
]);
function fileMgr() {
return ObjC.classes.NSFileManager.defaultManager();
}
function parseMachO(path: string): MachOInfo | null {
const { open, close, read } = getApi();
const fd = open(Memory.allocUtf8String(path), O_RDONLY) as number;
if (fd < 0) return null;
try {
const hdr = Memory.alloc(HEADER_SIZE_64);
if ((read(fd, hdr, HEADER_SIZE_64) as number) < HEADER_SIZE_64) return null;
if (hdr.readU32() !== MH_MAGIC_64) return null;
const type = hdr.add(12).readU32();
const ncmds = hdr.add(16).readU32();
const sizeOfCmds = hdr.add(20).readU32();
const cmds = Memory.alloc(sizeOfCmds);
if ((read(fd, cmds, sizeOfCmds) as number) < sizeOfCmds) return null;
const result: MachOInfo = {
type,
encrypt: { offset: 0, size: 0, id: 0 },
offset: 0,
};
for (let off = 0, i = 0; i < ncmds && off + 8 <= sizeOfCmds; i++) {
const cmd = cmds.add(off).readU32();
const cmdsize = cmds.add(off + 4).readU32();
if (cmd === LC_ENCRYPTION_INFO_64) {
result.encrypt = {
offset: cmds.add(off + 8).readU32(),
size: cmds.add(off + 12).readU32(),
id: cmds.add(off + 16).readU32(),
};
result.offset = off + HEADER_SIZE_64;
}
off += cmdsize;
}
return result;
} finally {
close(fd);
}
}
function scanDir(root: string, dir: string, tasks: MachOTasks): void {
const items = fileMgr().contentsOfDirectoryAtPath_error_(dir, NULL);
if (!items) return;
for (let i = 0; i < items.count(); i++) {
const name: string = items.objectAtIndex_(i).toString();
const full = dir + "/" + name;
const pIsDir = Memory.alloc(Process.pointerSize);
pIsDir.writeU8(0);
if (!fileMgr().fileExistsAtPath_isDirectory_(full, pIsDir)) continue;
if (pIsDir.readU8()) {
if (!EXCLUDE_DIRS.has(name)) scanDir(root, full, tasks);
} else {
const info = parseMachO(full);
if (info && info.encrypt.id !== 0) {
tasks[full.substring(root.length + 1)] = info;
}
}
}
}
function removeExcludedDirs(dir: string): void {
const items = fileMgr().contentsOfDirectoryAtPath_error_(dir, NULL);
if (!items) return;
for (let i = 0; i < items.count(); i++) {
const name: string = items.objectAtIndex_(i).toString();
const full = dir + "/" + name;
const pIsDir = Memory.alloc(Process.pointerSize);
pIsDir.writeU8(0);
if (!fileMgr().fileExistsAtPath_isDirectory_(full, pIsDir)) continue;
if (!pIsDir.readU8()) continue;
if (EXCLUDE_DIRS.has(name)) {
fileMgr().removeItemAtPath_error_(full, NULL);
} else {
removeExcludedDirs(full);
}
}
}
function zipDirectory(sourceDir: string, destPath: string): string {
const coordinator =
ObjC.classes.NSFileCoordinator.alloc().initWithFilePresenter_(null);
const folderURL = ObjC.classes.NSURL.fileURLWithPath_(sourceDir);
const block = new ObjC.Block({
retType: "void",
argTypes: ["object"],
implementation(newURL: ObjC.Object) {
nsError((e) =>
fileMgr().copyItemAtPath_toPath_error_(
newURL.path().toString(),
destPath,
e,
),
);
},
});
const NSFileCoordinatorReadingForUploading = 1 << 3;
nsError((e) =>
coordinator.coordinateReadingItemAtURL_options_error_byAccessor_(
folderURL,
NSFileCoordinatorReadingForUploading,
e,
block,
),
);
return destPath;
}
rpc.exports = {
prepare(bundlePath: string, bundleId: string, removeKeys: string[] = []) {
const tmp = Process.getTmpDir().replace(/\/$/, "");
const bundleName = bundlePath.split("/").pop()!;
const base = tmp + "/.bagbak-" + bundleName;
const payloadDir = base + "/Payload";
const root = payloadDir + "/" + bundleName;
fileMgr().removeItemAtPath_error_(base, NULL);
nsError((e) =>
fileMgr().createDirectoryAtPath_withIntermediateDirectories_attributes_error_(
payloadDir,
true,
NULL,
e,
),
);
nsError((e) => fileMgr().copyItemAtPath_toPath_error_(bundlePath, root, e));
removeExcludedDirs(root);
for (const f of EXCLUDE_FILES) {
fileMgr().removeItemAtPath_error_(root + "/" + f, NULL);
}
const infoPlist = root + "/Info.plist";
const infoPlistURL = ObjC.classes.NSURL.fileURLWithPath_(infoPlist);
const dict =
ObjC.classes.NSMutableDictionary.alloc().initWithContentsOfURL_(
infoPlistURL,
);
if (dict && removeKeys.length > 0) {
for (const key of removeKeys) {
dict.removeObjectForKey_(key);
}
dict.writeToURL_atomically_(infoPlistURL, true);
}
const tasks: MachOTasks = {};
scanDir(root, root, tasks);
ObjC.classes.NSBundle.bundleWithPath_(
"/System/Library/Frameworks/CoreServices.framework/",
).load();
const app =
ObjC.classes.LSApplicationProxy.applicationProxyForIdentifier_(bundleId);
if (!app) throw new Error(`app ${bundleId} not found`);
const extensions: ExtensionInfo[] = [];
const plugins = app.plugInKitPlugins();
for (let i = 0; i < plugins.count(); i++) {
const plugin = plugins.objectAtIndex_(i);
const plist = plugin.infoPlist();
const exec: string = plist.objectForKey_("CFBundleExecutable").toString();
const path: string = plist.objectForKey_("Path").toString();
extensions.push({
id: plugin.bundleIdentifier().toString(),
path,
exec,
abs: path + "/" + exec,
});
}
const mainBinary: string = app.bundleExecutable().toString();
return { base, root, tasks, extensions, mainBinary };
},
zip(base: string) {
return zipDirectory(base + "/Payload", base + "/app.ipa");
},
zipFiles(root: string, files: string[]) {
const base = root.replace(/\/Payload\/[^/]+$/, "");
const staging = base + "/binaries";
const zipDest = base + "/binaries.zip";
fileMgr().removeItemAtPath_error_(staging, NULL);
fileMgr().removeItemAtPath_error_(zipDest, NULL);
for (const relative of files) {
const src = root + "/" + relative;
const dst = staging + "/" + relative;
const dstDir = dst.substring(0, dst.lastIndexOf("/"));
nsError((e) =>
fileMgr().createDirectoryAtPath_withIntermediateDirectories_attributes_error_(
dstDir,
true,
NULL,
e,
),
);
nsError((e) => fileMgr().copyItemAtPath_toPath_error_(src, dst, e));
}
return zipDirectory(staging, zipDest);
},
stream(filePath: string) {
const { open, close, read } = getApi();
const attrs = fileMgr().attributesOfItemAtPath_error_(filePath, NULL);
const fileSize: number = attrs
.objectForKey_("NSFileSize")
.unsignedLongLongValue();
console.log("stream:", filePath, "size:", fileSize);
const fd = open(Memory.allocUtf8String(filePath), O_RDONLY) as number;
if (fd < 0) throw new Error("Failed to open " + filePath);
const controller = new Controller();
// agent → host: send stanza requests
controller.events.on("send", (packet: Packet) => {
const buf = packet.data as Buffer | null;
send(
packet.stanza,
buf
? (buf.buffer.slice(
buf.byteOffset,
buf.byteOffset + buf.byteLength,
) as ArrayBuffer)
: null,
);
});
// host → agent: receive stanza responses
function listen() {
recv("stream", (message: any, data: ArrayBuffer | null) => {
controller.receive({ stanza: message, data: data as any });
listen();
});
}
listen();
const sink = controller.open("ipa", { size: fileSize });
return new Promise<boolean>((resolve, reject) => {
sink.on("error", reject);
sink.on("finish", () => {
close(fd);
resolve(true);
});
const buf = Memory.alloc(STREAM_CHUNK);
let remaining = fileSize;
function writeNext() {
while (remaining > 0) {
const n = Math.min(STREAM_CHUNK, remaining);
const bytesRead = read(fd, buf, n) as number;
if (bytesRead <= 0) {
sink.end();
return;
}
const chunk = Buffer.from(buf.readByteArray(bytesRead)!);
remaining -= bytesRead;
if (remaining <= 0) {
sink.end(chunk);
return;
}
if (!sink.write(chunk)) {
sink.once("drain", writeNext);
return;
}
}
sink.end();
}
writeNext();
});
},
cleanup(base: string) {
fileMgr().removeItemAtPath_error_(base, NULL);
},
};
================================================
FILE: agent/tsconfig.json
================================================
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "Node16",
"strict": true,
"noEmit": true,
},
"include": ["src/**/*.ts"],
}
================================================
FILE: bin/bagbak.ts
================================================
#!/usr/bin/env node
import chalk from "chalk";
import { Command } from "commander";
import {
DeviceManager,
getDevice,
getRemoteDevice,
getUsbDevice,
Scope,
} from "frida";
import type { Device } from "frida";
import { BagBak, type DumpMode } from "../index.ts";
import { enableDebug, version } from "../lib/utils.ts";
const VALID_MODES = ["all", "main", "extensions", "binaries"] as const;
const MODE_ALIASES: Record<string, DumpMode> = {
app: "main",
ext: "extensions",
exts: "extensions",
executables: "binaries",
bin: "binaries",
};
interface Options {
device?: string;
usb?: boolean;
remote?: boolean;
host?: string;
debug?: boolean;
list?: boolean;
json?: boolean;
output?: string;
removeKeys?: string;
}
function getDeviceFromOptions(opts: Options): Promise<Device> {
let count = 0;
if (opts.device) count++;
if (opts.usb) count++;
if (opts.remote) count++;
if (opts.host) count++;
if (count === 0 || opts.usb) return getUsbDevice();
if (count > 1)
throw new Error(
"Only one of --device, --usb, --remote, --host can be specified",
);
if (opts.device) return getDevice(opts.device);
if (opts.remote) return getRemoteDevice();
if (opts.host) {
const manager = new DeviceManager();
return manager.addRemoteDevice(opts.host);
}
return getUsbDevice();
}
async function main() {
const program = new Command();
program
.name("bagbak")
.option("-l, --list", "list apps")
.option("-j, --json", "output as json (only works with --list)")
.option("-U, --usb", "connect to USB device (default)")
.option("-R, --remote", "connect to remote frida-server")
.option("-D, --device <uuid>", "connect to device with the given ID")
.option("-H, --host <host>", "connect to remote frida-server on HOST")
.option("-d, --debug", "enable debug output")
.option("-o, --output <output>", "ipa filename or directory")
.option("--remove-keys <keys>", "Info.plist keys to remove (comma-separated)")
.argument("[target]", "bundle id or name")
.argument("[mode]", "dump mode: all, main (app), extensions (ext, exts), binaries (bin, executables)", "all")
.version(version());
program.parse(process.argv);
const opts = program.opts<Options>();
if (opts.debug) enableDebug(true);
const device = await getDeviceFromOptions(opts);
const info = await device.querySystemParameters();
if (
info.access !== "full" ||
info.os.id !== "ios" ||
info.platform !== "darwin" ||
info.arch !== "arm64"
) {
console.error("This tool requires a jailbroken 64bit iOS device");
process.exit(1);
}
if (opts.list) {
const apps = await device.enumerateApplications({ scope: Scope.Metadata });
if (opts.json) {
console.log(JSON.stringify(apps, null, 2));
return;
}
const verWidth = Math.max(
...apps.map((app) => (app.parameters?.version as string)?.length || 0),
);
const idWidth = Math.max(...apps.map((app) => app.identifier.length));
console.log(
chalk.gray("Version".padStart(verWidth)),
chalk.gray("Identifier".padEnd(idWidth)),
chalk.gray("Name"),
);
console.log(chalk.gray("\u2500".repeat(10 + verWidth + idWidth)));
for (const app of apps) {
console.log(
chalk.yellowBright(
((app.parameters?.version as string) || "").padStart(verWidth),
),
chalk.greenBright(app.identifier.padEnd(idWidth)),
app.name,
);
}
return;
}
if (program.args.length >= 1) {
const target = program.args[0];
const rawMode = program.args[1] || "all";
const mode = (MODE_ALIASES[rawMode] || rawMode) as DumpMode;
if (!VALID_MODES.includes(mode)) {
console.error(
chalk.red(`Invalid mode "${rawMode}". Must be one of: ${VALID_MODES.join(", ")} (aliases: ${Object.keys(MODE_ALIASES).join(", ")})`),
);
process.exit(1);
}
const apps = await device.enumerateApplications({ scope: Scope.Metadata });
const app = apps.find(
(app) => app.name === target || app.identifier === target,
);
if (!app) throw new Error(`Unable to find app ${target}`);
const job = new BagBak(device, app);
job
.on("status", (msg: string) => {
console.log(chalk.greenBright("[info]"), msg);
})
.on("patch", (name: string) => {
console.log(chalk.redBright("[decrypt]"), name);
})
.on("streaming", (totalSize: number) => {
console.log(
chalk.greenBright("[info]"),
`Streaming ${(totalSize / 1024 / 1024).toFixed(1)} MB from device...`,
);
})
.on("progress", (transferred: number, totalSize: number) => {
const percent = Math.min(transferred / totalSize, 1);
const barWidth = 30;
const filled = Math.round(barWidth * percent);
const bar =
chalk.greenBright("\u2588".repeat(filled)) +
chalk.gray("\u2591".repeat(barWidth - filled));
const mb = (transferred / 1024 / 1024).toFixed(1);
const totalMb = (totalSize / 1024 / 1024).toFixed(1);
const pct = (percent * 100).toFixed(0).padStart(3);
process.stdout.write(
`\r ${bar} ${pct}% ${mb}/${totalMb} MB`,
);
if (transferred >= totalSize) {
process.stdout.write("\n");
}
});
const removeKeys = opts.removeKeys
? opts.removeKeys.split(",").map((k) => k.trim())
: [];
const saved = await job.pack(opts.output, mode, removeKeys);
console.log(`Saved to ${chalk.yellow(saved)}`);
return;
}
program.help();
}
main();
================================================
FILE: index.ts
================================================
import { EventEmitter } from "events";
import { createWriteStream, type PathLike } from "fs";
import { resolve } from "path";
import { Transform } from "stream";
import { pipeline } from "stream/promises";
import chalk from "chalk";
import type { Application, Device, Script } from "frida";
import Controller from "frida-remote-stream";
import { debug, directoryExists, readFromPackage, sleep } from "./lib/utils.ts";
const MH_EXECUTE = 0x2;
const MAX_DECRYPT_RETRIES = 3;
export type DumpMode = "all" | "main" | "extensions" | "binaries";
interface Extension {
id: string;
path: string;
abs: string;
}
interface BinaryInfo {
type: number;
[key: string]: unknown;
}
interface PrepareResult {
base: string;
root: string;
tasks: Record<string, BinaryInfo>;
extensions: Extension[];
mainBinary: string;
}
export class BagBak extends EventEmitter {
#device: Device;
#app: Application;
constructor(device: Device, app: Application) {
super();
this.#app = app;
this.#device = device;
}
get bundle() {
return this.#app.identifier;
}
get remote() {
return this.#app.parameters.path as string;
}
async #attach() {
const session = await this.#device.attach("SpringBoard");
const code = await readFromPackage("agent", "dist", "springboard.js");
const script = await session.createScript(code.toString());
script.logHandler = (level, text) =>
console.log("[springboard]", level, text);
await script.load();
return { session, script };
}
async #decrypt(
pid: number,
remoteRoot: string,
root: string,
binaries: Record<string, BinaryInfo>,
isExtension: boolean,
) {
const session = await this.#device.attach(pid);
const code = await readFromPackage("agent", "dist", "app.js");
const script = await session.createScript(code.toString());
script.logHandler = (level, text) => debug("[app]", level, text);
script.message.connect((message) => {
if (message.type === "send" && message.payload?.event === "patch") {
this.emit("patch", message.payload.name);
script.post({ type: "ack" });
}
});
await script.load();
if (isExtension) {
await script.exports.hookExtensionMain();
} else {
await script.exports.hookAppMain();
}
await this.#device.resume(pid);
const result = await script.exports.dump(
remoteRoot,
root,
binaries,
isExtension,
);
debug("dump result =>", result);
await script.unload();
await session.detach();
}
async #decryptWithRetry(
label: string,
spawnTarget: string | string[],
remoteRoot: string,
root: string,
binaries: Record<string, BinaryInfo>,
isExtension: boolean,
): Promise<void> {
let lastError: unknown;
for (let attempt = 1; attempt <= MAX_DECRYPT_RETRIES; attempt++) {
const pid = await this.#device.spawn(spawnTarget, {
env: { DISABLE_TWEAKS: "1" },
});
debug("spawned", label, "pid =>", pid);
try {
await this.#decrypt(pid, remoteRoot, root, binaries, isExtension);
return;
} catch (e) {
lastError = e;
if (attempt < MAX_DECRYPT_RETRIES) {
this.emit(
"status",
`Retry ${attempt}/${MAX_DECRYPT_RETRIES} for ${label}...`,
);
await sleep(1000);
}
} finally {
await this.#device.kill(pid).catch(() => {});
}
}
throw lastError;
}
async #pull(coordScript: Script, zipPath: string, destPath: string) {
const controller = new Controller();
const done = new Promise<void>((resolve, reject) => {
controller.events.on("stream", (source: any) => {
const totalSize: number = source.details.size;
this.emit("streaming", totalSize);
let transferred = 0;
const progress = new Transform({
transform(chunk, _encoding, callback) {
transferred += chunk.length;
this.push(chunk);
callback();
},
});
const interval = setInterval(() => {
this.emit("progress", transferred, totalSize);
}, 200);
pipeline(source, progress, createWriteStream(destPath)).then(
() => {
clearInterval(interval);
this.emit("progress", totalSize, totalSize);
resolve();
},
(err) => {
clearInterval(interval);
reject(err);
},
);
});
});
controller.events.on("send", (packet: any) => {
coordScript.post({ type: "stream", ...packet.stanza }, packet.data);
});
const handler = (message: any, data: any) => {
if (
message.type === "send" &&
typeof message.payload?.name === "string"
) {
controller.receive({ stanza: message.payload, data });
}
};
coordScript.message.connect(handler);
try {
await coordScript.exports.stream(zipPath);
await done;
} finally {
coordScript.message.disconnect(handler);
}
}
async pack(suggested?: PathLike, mode: DumpMode = "all", removeKeys: string[] = []): Promise<string> {
const { session: coordSession, script: coordScript } = await this.#attach();
try {
this.emit("status", "Preparing app bundle...");
const { base, root, tasks, extensions, mainBinary } =
(await coordScript.exports.prepare(
this.remote,
this.bundle,
removeKeys,
)) as PrepareResult;
const taskCount = Object.keys(tasks).length;
debug("root", root);
debug("tasks", taskCount, "encrypted binaries");
debug("extensions", extensions.length);
if (taskCount === 0) {
this.emit("status", "No encrypted binaries found");
}
const groupByExtensions = new Map<string, Record<string, BinaryInfo>>(
extensions.map((ext) => [ext.id, {}]),
);
const binariesForMain: Record<string, BinaryInfo> = {};
for (const [relative, info] of Object.entries(tasks)) {
const absolute = this.remote + "/" + relative;
const ext = extensions.find((ext) => absolute.startsWith(ext.path));
if (ext) {
debug("scope for", chalk.green(relative), "is", chalk.gray(ext.id));
groupByExtensions.get(ext.id)![relative] = info;
} else if (
info.type === MH_EXECUTE &&
absolute !== this.remote + "/" + mainBinary
) {
console.error(chalk.red("Executable"), chalk.yellowBright(relative));
console.error(
chalk.red(
"is not within any extension. Likely requires higher MinimumOSVersion.",
),
);
console.error(chalk.red("This binary will be left encrypted."));
} else {
debug("scope for", relative, "is", chalk.green("main app"));
binariesForMain[relative] = info;
}
}
const decryptMain = mode !== "extensions";
const decryptExtensions = mode !== "main";
if (decryptMain && Object.keys(binariesForMain).length) {
this.emit("status", "Decrypting main app...");
await this.#decryptWithRetry(
"main app",
this.bundle,
this.remote,
root,
binariesForMain,
false,
);
}
if (decryptExtensions) {
for (const [extId, binaries] of groupByExtensions.entries()) {
if (Object.keys(binaries).length === 0) continue;
const ext = extensions.find((e) => e.id === extId)!;
this.emit("status", `Decrypting extension ${extId}...`);
await this.#decryptWithRetry(
extId,
[ext.abs],
this.remote,
root,
binaries,
true,
);
}
}
const ver = (this.#app.parameters.version as string) || "Unknown";
let remotePath: string;
let defaultFilename: string;
let ext: string;
if (mode === "all") {
this.emit("status", "Packaging IPA...");
remotePath = (await coordScript.exports.zip(base)) as string;
ext = ".ipa";
defaultFilename = `${this.bundle}-${ver}.ipa`;
} else {
const files: string[] = [];
if (mode === "main" || mode === "binaries") {
files.push(...Object.keys(binariesForMain));
}
if (mode === "extensions" || mode === "binaries") {
for (const [extId, binaries] of groupByExtensions.entries()) {
files.push(...Object.keys(binaries));
}
}
const plists = new Set<string>();
for (const f of files) {
const dir = f.lastIndexOf("/");
plists.add(dir === -1 ? "Info.plist" : f.substring(0, dir) + "/Info.plist");
}
files.push(...plists);
this.emit("status", `Compressing ${files.length} files...`);
remotePath = (await coordScript.exports.zipFiles(
root,
files,
)) as string;
ext = ".zip";
defaultFilename = `${this.bundle}-${ver}-${mode}.zip`;
}
debug("remote path:", remotePath);
const suggestedStr = suggested?.toString();
const dest = suggestedStr
? (await directoryExists(suggestedStr))
? suggestedStr + "/" + defaultFilename
: suggestedStr
: defaultFilename;
if (ext && !dest.endsWith(ext))
throw new Error(`Invalid filename ${dest}, expected ${ext} extension`);
this.emit("status", "Downloading...");
await this.#pull(coordScript, remotePath, resolve(process.cwd(), dest));
await coordScript.exports.cleanup(base);
return dest;
} finally {
await coordScript.unload().catch(() => {});
await coordSession.detach().catch(() => {});
}
}
}
================================================
FILE: lib/utils.ts
================================================
import { readFile, stat } from "fs/promises";
import { type PathLike } from "fs";
import { dirname, join } from "path";
import { fileURLToPath } from "url";
import pkg from "../package.json" with { type: "json" };
export const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
export const directoryExists = (path: PathLike): Promise<boolean> =>
stat(path)
.then((info) => info.isDirectory())
.catch(() => false);
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = join(
__dirname,
process.env.TSDOWN_BUILD ? join("..", "..") : "..",
);
export function readFromPackage(...components: string[]): Promise<string> {
return readFile(join(packageRoot, ...components), "utf8");
}
export function version(): string {
return pkg.version;
}
let __debug = "DEBUG" in process.env;
export function debug(...args: unknown[]) {
if (__debug) console.log(...args);
}
export function enableDebug(value?: boolean): boolean {
if (value !== undefined) __debug = value;
return __debug;
}
================================================
FILE: package.json
================================================
{
"name": "bagbak",
"version": "5.1.2",
"description": "Dump iOS app from a jailbroken device, based on frida.re",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"scripts": {
"build": "tsdown",
"prepublishOnly": "npm run build",
"test": "echo 'Not supported'"
},
"author": "CodeColorist",
"license": "MIT",
"bin": {
"bagbak": "dist/bin/bagbak.mjs"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"files": [
"/dist",
"/agent/dist/app.js",
"/agent/dist/springboard.js"
],
"engines": {
"node": ">=18.0.0"
},
"type": "module",
"devDependencies": {
"@types/frida-gum": "^19.0.2",
"@types/node": "^25.5.0",
"tsdown": "^0.21.4",
"typescript": "^5.9.3"
},
"dependencies": {
"chalk": "^5.6.2",
"commander": "^14.0.3",
"frida": "^17.8.2",
"frida-remote-stream": "^6.0.3"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ChiChou/bagbak.git"
},
"keywords": [
"ios",
"reverse-engineering",
"frida",
"jailbreak"
],
"bugs": {
"url": "https://github.com/ChiChou/bagbak/issues"
},
"homepage": "https://github.com/ChiChou/bagbak#readme"
}
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"module": "nodenext",
"noEmit": true,
"rewriteRelativeImportExtensions": true,
},
}
================================================
FILE: tsdown.config.ts
================================================
import { defineConfig } from "tsdown";
export default defineConfig({
entry: ["index.ts", "bin/bagbak.ts"],
format: "esm",
dts: true,
outDir: "dist",
unbundle: true,
define: {
"process.env.TSDOWN_BUILD": "'1'",
},
});
gitextract_1gu8ebsy/ ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── workflows/ │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── README.zh-CN.md ├── agent/ │ ├── .gitignore │ ├── .npmignore │ ├── package.json │ ├── src/ │ │ ├── app.ts │ │ ├── shared.ts │ │ └── springboard.ts │ └── tsconfig.json ├── bin/ │ └── bagbak.ts ├── index.ts ├── lib/ │ └── utils.ts ├── package.json ├── tsconfig.json └── tsdown.config.ts
SYMBOL INDEX (56 symbols across 6 files)
FILE: agent/src/app.ts
constant HIGH_WATER_MARK (line 5) | const HIGH_WATER_MARK = 1024 * 1024;
constant O_RDWR (line 6) | const O_RDWR = 0x0002;
constant EXTENSION_SYMBOLS (line 8) | const EXTENSION_SYMBOLS = [
constant APP_SYMBOLS (line 14) | const APP_SYMBOLS = ["UIKitCore`UIApplicationMain", "AppKit`NSApplicatio...
function replaceWithRunLoop (line 16) | function replaceWithRunLoop(symbols: string[]) {
method hookAppMain (line 37) | hookAppMain() {
method hookExtensionMain (line 40) | hookExtensionMain() {
method dump (line 44) | dump(
FILE: agent/src/shared.ts
type EncryptInfo (line 3) | interface EncryptInfo {
type MachOInfo (line 9) | interface MachOInfo {
type ExtensionInfo (line 15) | interface ExtensionInfo {
type MachOTasks (line 22) | type MachOTasks = Record<string, MachOInfo>;
type NativeAPI (line 24) | type NativeAPI = {
function nsError (line 34) | function nsError<T>(fn: (pError: NativePointer) => T): T {
function getApi (line 43) | function getApi(): NativeAPI {
FILE: agent/src/springboard.ts
constant MH_MAGIC_64 (line 6) | const MH_MAGIC_64 = 0xfeedfacf;
constant LC_ENCRYPTION_INFO_64 (line 7) | const LC_ENCRYPTION_INFO_64 = 0x2c;
constant HEADER_SIZE_64 (line 8) | const HEADER_SIZE_64 = 32;
constant O_RDONLY (line 9) | const O_RDONLY = 0;
constant STREAM_CHUNK (line 10) | const STREAM_CHUNK = 2 * 1024 * 1024;
constant EXCLUDE_DIRS (line 12) | const EXCLUDE_DIRS = new Set(["SC_Info", "_CodeSignature"]);
constant EXCLUDE_FILES (line 13) | const EXCLUDE_FILES = new Set([
function fileMgr (line 18) | function fileMgr() {
function parseMachO (line 22) | function parseMachO(path: string): MachOInfo | null {
function scanDir (line 65) | function scanDir(root: string, dir: string, tasks: MachOTasks): void {
function removeExcludedDirs (line 88) | function removeExcludedDirs(dir: string): void {
function zipDirectory (line 109) | function zipDirectory(sourceDir: string, destPath: string): string {
method prepare (line 143) | prepare(bundlePath: string, bundleId: string, removeKeys: string[] = []) {
method zip (line 213) | zip(base: string) {
method zipFiles (line 217) | zipFiles(root: string, files: string[]) {
method stream (line 245) | stream(filePath: string) {
method cleanup (line 323) | cleanup(base: string) {
FILE: bin/bagbak.ts
constant VALID_MODES (line 18) | const VALID_MODES = ["all", "main", "extensions", "binaries"] as const;
constant MODE_ALIASES (line 20) | const MODE_ALIASES: Record<string, DumpMode> = {
type Options (line 28) | interface Options {
function getDeviceFromOptions (line 40) | function getDeviceFromOptions(opts: Options): Promise<Device> {
function main (line 64) | async function main() {
FILE: index.ts
constant MH_EXECUTE (line 13) | const MH_EXECUTE = 0x2;
constant MAX_DECRYPT_RETRIES (line 14) | const MAX_DECRYPT_RETRIES = 3;
type DumpMode (line 16) | type DumpMode = "all" | "main" | "extensions" | "binaries";
type Extension (line 18) | interface Extension {
type BinaryInfo (line 24) | interface BinaryInfo {
type PrepareResult (line 29) | interface PrepareResult {
class BagBak (line 37) | class BagBak extends EventEmitter {
method constructor (line 41) | constructor(device: Device, app: Application) {
method bundle (line 47) | get bundle() {
method remote (line 51) | get remote() {
method #attach (line 55) | async #attach() {
method #decrypt (line 65) | async #decrypt(
method #decryptWithRetry (line 105) | async #decryptWithRetry(
method #pull (line 138) | async #pull(coordScript: Script, zipPath: string, destPath: string) {
method pack (line 195) | async pack(suggested?: PathLike, mode: DumpMode = "all", removeKeys: s...
FILE: lib/utils.ts
function readFromPackage (line 22) | function readFromPackage(...components: string[]): Promise<string> {
function version (line 26) | function version(): string {
function debug (line 32) | function debug(...args: unknown[]) {
function enableDebug (line 36) | function enableDebug(value?: boolean): boolean {
Condensed preview — 21 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (46K chars).
[
{
"path": ".github/FUNDING.yml",
"chars": 63,
"preview": "# These are supported funding model platforms\n\ngithub: ChiChou\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 849,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n\n---\n\nBefore you submi"
},
{
"path": ".github/workflows/npm-publish.yml",
"chars": 1108,
"preview": "name: Node.js Package\n\non:\n push:\n tags:\n - v*\n\njobs:\n publish-npm:\n runs-on: ubuntu-latest\n permissions"
},
{
"path": ".gitignore",
"chars": 1673,
"preview": "dist/\n.vscode/\n.ccls-cache/\ndump/\noutput/\n\n*.app/\n*.ipa\n\n.DS_Store\n\n# Logs\nlogs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyar"
},
{
"path": ".npmignore",
"chars": 46,
"preview": "!agent/dist/springboard.js\n!agent/dist/app.js\n"
},
{
"path": "LICENSE",
"chars": 1069,
"preview": "MIT License\n\nCopyright (c) 2019 CodeColorist\n\nPermission is hereby granted, free of charge, to any person obtaining a co"
},
{
"path": "README.md",
"chars": 2334,
"preview": "# bagbak\n\n[](https://www.npmjs.com/package/bagbak)\n[](https://www.npmjs.com/package/bagbak)\n[. The extraction includes 21 files (41.0 KB), approximately 11.6k tokens, and a symbol index with 56 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.