rea-agents 0.5.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -16
- package/dist/application/Doctor.js +4 -4
- package/dist/application/EnhancedTools.js +44 -36
- package/dist/application/EvidenceBundleFiles.js +4 -3
- package/dist/application/MacHopper.js +175 -0
- package/dist/application/Setup.js +83 -54
- package/dist/application/Upgrade.js +106 -0
- package/dist/cli.js +37 -3
- package/dist/contracts/toolContracts.js +3 -3
- package/dist/contracts/toolOutputSchemas.js +48 -10
- package/dist/domain/errors.js +26 -0
- package/dist/domain/hopperValues.js +2 -0
- package/dist/domain/runtimeVersion.js +8 -0
- package/dist/native/NativeMacOSProvider.js +2 -0
- package/install.sh +79 -164
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -11,14 +11,14 @@
|
|
|
11
11
|
[](https://www.npmjs.com/package/rea-agents)
|
|
12
12
|
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
|
13
13
|
[](#68-tools-for-investigation)
|
|
14
|
-
[](https://nodejs.org/)
|
|
15
15
|
[](LICENSE)
|
|
16
16
|
|
|
17
17
|
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [68 tools](#68-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
|
|
18
18
|
|
|
19
19
|
<br />
|
|
20
20
|
|
|
21
|
-
<code>
|
|
21
|
+
<code>npm install --global rea-agents && rea setup</code>
|
|
22
22
|
|
|
23
23
|
</div>
|
|
24
24
|
|
|
@@ -81,15 +81,24 @@ REA shows how it reached its conclusions. It does not claim to recover original
|
|
|
81
81
|
|
|
82
82
|
## Quick start
|
|
83
83
|
|
|
84
|
-
###
|
|
84
|
+
### Install the CLI — recommended
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
|
|
87
|
+
npm install --global rea-agents
|
|
88
|
+
rea setup
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
+
Installing the CLI does not update Homebrew, Node.js, npm, Hopper, or coding-agent configuration. `rea setup` detects what is already present, prints every proposed change, and asks before applying it.
|
|
92
|
+
|
|
93
|
+
REA detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, and Devin. Registrations are additive, backup-first, and read back after writing. You can safely rerun setup.
|
|
94
|
+
|
|
95
|
+
An optional curl wrapper installs the same CLI package and starts setup only when a terminal is available:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
curl -fsSL https://raw.githubusercontent.com/morluto/rea/main/install.sh | bash
|
|
99
|
+
```
|
|
91
100
|
|
|
92
|
-
|
|
101
|
+
Pass installer options after `bash -s --`, for example `--dry-run`, `--no-setup`, or `--version 1.0.0`. The curl wrapper never installs prerequisites or configures integrations itself. See [Installation and setup](docs/installation.md) for its exact mutation boundary.
|
|
93
102
|
|
|
94
103
|
### With a coding agent — recommended
|
|
95
104
|
|
|
@@ -99,36 +108,48 @@ npx skills add morluto/rea
|
|
|
99
108
|
|
|
100
109
|
Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset.
|
|
101
110
|
|
|
102
|
-
|
|
111
|
+
Review the setup plan, approve it if appropriate, complete Hopper's one-time activation when prompted, then describe the app or feature you want to understand.
|
|
103
112
|
|
|
104
113
|
### From Terminal — no installation
|
|
105
114
|
|
|
106
115
|
```bash
|
|
107
|
-
npx -y rea-agents setup
|
|
116
|
+
npx -y rea-agents setup
|
|
108
117
|
npx -y rea-agents doctor
|
|
109
118
|
npx -y rea-agents analyze /Applications/Notes.app
|
|
110
119
|
```
|
|
111
120
|
|
|
112
|
-
|
|
121
|
+
Review the setup plan before confirming it. Restart a configured coding agent so it loads REA.
|
|
113
122
|
|
|
114
123
|
### From Terminal — install the `rea` command
|
|
115
124
|
|
|
116
125
|
```bash
|
|
117
126
|
npm install --global rea-agents
|
|
118
|
-
rea setup
|
|
127
|
+
rea setup
|
|
119
128
|
rea doctor
|
|
120
129
|
rea analyze /Applications/Notes.app
|
|
121
130
|
```
|
|
122
131
|
|
|
132
|
+
Update that global installation in place:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
rea upgrade
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
REA checks npm for the latest release and verifies that the running package is
|
|
139
|
+
the global installation it will replace. Source, local, and `npx` copies report
|
|
140
|
+
the manual `npm install --global rea-agents@latest` command instead of updating
|
|
141
|
+
an unrelated global package.
|
|
142
|
+
|
|
123
143
|
Choose either the no-install commands or the global installation. You do not need both.
|
|
124
144
|
|
|
125
145
|
### Requirements
|
|
126
146
|
|
|
127
147
|
- macOS 12 or newer
|
|
128
148
|
- Ubuntu 24.04+, Fedora 41+, or 64-bit Arch Linux
|
|
129
|
-
- Node.js 24.
|
|
149
|
+
- Node.js 22.19+ or 24.11+ (including newer releases)
|
|
150
|
+
- npm; REA does not require or install a particular npm version
|
|
130
151
|
|
|
131
|
-
|
|
152
|
+
Deep binary analysis currently uses [Hopper](https://www.hopperapp.com/), a separate desktop application with its own license. Setup reuses an existing installation. If Hopper is missing, interactive setup proposes the official package and includes it in the confirmation plan. Unattended installation requires `rea setup --yes --install-hopper`.
|
|
132
153
|
|
|
133
154
|
If something is not working, run:
|
|
134
155
|
|
|
@@ -140,7 +161,9 @@ npx -y rea-agents doctor
|
|
|
140
161
|
|
|
141
162
|
### Linux installation and troubleshooting
|
|
142
163
|
|
|
143
|
-
On
|
|
164
|
+
On macOS, approved setup downloads Hopper's official DMG, verifies it, and installs the app into `~/Applications` without Homebrew or administrator privileges. It opens Hopper once for activation; no manual drag-and-drop is required.
|
|
165
|
+
|
|
166
|
+
On Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux, approved setup downloads the matching official Hopper package, restricts downloads to Hopper's public origin, verifies the published size and checksum, and invokes `apt-get`, `dnf`, or `pacman`. When REA is not already running as root, `pkexec` presents the system authorization prompt. REA never invokes `sudo`.
|
|
144
167
|
|
|
145
168
|
The normal Linux launcher is `/opt/hopper/bin/Hopper`. If Hopper was installed elsewhere:
|
|
146
169
|
|
|
@@ -155,7 +178,7 @@ If doctor reports a missing analysis engine even though the file exists, inspect
|
|
|
155
178
|
ldd /opt/hopper/bin/Hopper | grep 'not found'
|
|
156
179
|
```
|
|
157
180
|
|
|
158
|
-
Install the missing distribution packages and rerun `rea setup
|
|
181
|
+
Install the missing distribution packages and rerun `rea setup`. Hopper is a desktop application: real analysis requires an active `DISPLAY` or `WAYLAND_DISPLAY`, plus one-time license activation. The curl installer places the `rea` command in `~/.local/bin` on Linux; add that directory to future shell `PATH` values if it is not already present.
|
|
159
182
|
|
|
160
183
|
REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
|
|
161
184
|
|
|
@@ -166,7 +189,7 @@ rea uninstall
|
|
|
166
189
|
rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
|
|
167
190
|
```
|
|
168
191
|
|
|
169
|
-
Uninstall preserves Hopper,
|
|
192
|
+
Uninstall preserves Hopper, Node.js, evidence, captures, external evidence roots, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
|
|
170
193
|
|
|
171
194
|
### CLI or coding agent?
|
|
172
195
|
|
|
@@ -275,7 +298,7 @@ REA is growing into a toolkit for understanding software across static artifacts
|
|
|
275
298
|
7. **More targets and platforms** — Windows-native providers and ConPTY verification, Linux parity, websites and APIs, mobile artifacts, firmware, document formats, and other software-defined systems.
|
|
276
299
|
8. **Differential reconstruction** — compare artifacts, functions, bundles, protocols, UIs, and process captures; track residual unknowns; verify a reconstruction against observed behavior.
|
|
277
300
|
|
|
278
|
-
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
|
|
301
|
+
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow. Once REA has multiple optional toolchains, setup can become capability-selective; the consent rules for that future work are recorded in the [installation roadmap](docs/roadmap.md).
|
|
279
302
|
|
|
280
303
|
See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
|
|
281
304
|
|
|
@@ -343,6 +366,7 @@ Or install the `rea` command globally:
|
|
|
343
366
|
```bash
|
|
344
367
|
npm install --global rea-agents
|
|
345
368
|
rea --help
|
|
369
|
+
rea upgrade
|
|
346
370
|
rea mcp
|
|
347
371
|
```
|
|
348
372
|
|
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { execFile } from "node:child_process";
|
|
7
7
|
import { promisify } from "node:util";
|
|
8
8
|
import { parseBinaryTarget } from "../domain/binaryTarget.js";
|
|
9
|
+
import { supportsNodeVersion } from "../domain/runtimeVersion.js";
|
|
9
10
|
import { probeHomebrew } from "./homebrew.js";
|
|
10
11
|
import { linuxHopperLauncherPath, linuxSharedLibrariesAvailable, readLinuxDistribution, } from "./LinuxHopper.js";
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -18,9 +19,8 @@ const SYSTEM_LINUX_HOPPER = "/opt/hopper/bin/Hopper";
|
|
|
18
19
|
*/
|
|
19
20
|
export const runDoctor = async (target, host = systemDoctorHost()) => {
|
|
20
21
|
const checks = [];
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
remediation: "Install Node.js 24.18 or newer.",
|
|
22
|
+
checks.push(check("node", supportsNodeVersion(host.nodeVersion), host.nodeVersion, {
|
|
23
|
+
remediation: "Install Node.js 22.19+ or 24.11+.",
|
|
24
24
|
classification: "missing_dependency",
|
|
25
25
|
}));
|
|
26
26
|
const macosVersion = host.platform === "darwin" ? await host.macosVersion() : undefined;
|
|
@@ -50,7 +50,7 @@ export const runDoctor = async (target, host = systemDoctorHost()) => {
|
|
|
50
50
|
break;
|
|
51
51
|
}
|
|
52
52
|
checks.push(check("hopper", hopperPath !== undefined, hopperPath, {
|
|
53
|
-
remediation: "Run rea setup
|
|
53
|
+
remediation: "Run rea setup to install Hopper, or set HOPPER_LAUNCHER_PATH.",
|
|
54
54
|
classification: "missing_analysis_engine",
|
|
55
55
|
}));
|
|
56
56
|
if (target !== undefined)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
|
2
|
-
import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, } from "../domain/errors.js";
|
|
3
|
-
import { parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
|
|
2
|
+
import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, projectAnalysisError, } from "../domain/errors.js";
|
|
3
|
+
import { addressDistance, parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
|
|
4
4
|
import { err, ok } from "../domain/result.js";
|
|
5
5
|
import { categorizeSwiftTypes, discoverObjcClasses, discoverObjcProtocols, discoverSwiftClasses, } from "../domain/symbolAnalysis.js";
|
|
6
6
|
/**
|
|
@@ -89,20 +89,29 @@ export class EnhancedTools {
|
|
|
89
89
|
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
|
|
90
90
|
}
|
|
91
91
|
async #batchDecompile(addresses, signal) {
|
|
92
|
-
|
|
93
|
-
return ok({ error: "No addresses provided" });
|
|
94
|
-
const entries = await Promise.all(addresses.map(async (address) => {
|
|
92
|
+
const items = await Promise.all(addresses.map(async (address) => {
|
|
95
93
|
const result = await this.#call("procedure_pseudo_code", { procedure: address }, signal);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
if (!result.ok)
|
|
95
|
+
return {
|
|
96
|
+
address,
|
|
97
|
+
status: "error",
|
|
98
|
+
error: projectAnalysisError(result.error),
|
|
99
|
+
};
|
|
100
|
+
if (typeof result.value !== "string" || result.value.length === 0)
|
|
101
|
+
return {
|
|
102
|
+
address,
|
|
103
|
+
status: "error",
|
|
104
|
+
error: projectAnalysisError(new AnalysisOutputError("procedure_pseudo_code", "provider returned empty pseudocode")),
|
|
105
|
+
};
|
|
106
|
+
return { address, status: "ok", pseudocode: result.value };
|
|
104
107
|
}));
|
|
105
|
-
|
|
108
|
+
const succeeded = items.filter(({ status }) => status === "ok").length;
|
|
109
|
+
return ok({
|
|
110
|
+
items,
|
|
111
|
+
total: items.length,
|
|
112
|
+
succeeded,
|
|
113
|
+
failed: items.length - succeeded,
|
|
114
|
+
});
|
|
106
115
|
}
|
|
107
116
|
async #callGraph(input, signal) {
|
|
108
117
|
const relation = input.direction === "forward" ? "callees" : "callers";
|
|
@@ -124,7 +133,8 @@ export class EnhancedTools {
|
|
|
124
133
|
if (!result.ok) {
|
|
125
134
|
graph[level].push({
|
|
126
135
|
address: current.address,
|
|
127
|
-
|
|
136
|
+
status: "error",
|
|
137
|
+
error: projectAnalysisError(result.error),
|
|
128
138
|
});
|
|
129
139
|
continue;
|
|
130
140
|
}
|
|
@@ -132,12 +142,14 @@ export class EnhancedTools {
|
|
|
132
142
|
if (!related.ok) {
|
|
133
143
|
graph[level].push({
|
|
134
144
|
address: current.address,
|
|
135
|
-
|
|
145
|
+
status: "error",
|
|
146
|
+
error: projectAnalysisError(related.error),
|
|
136
147
|
});
|
|
137
148
|
continue;
|
|
138
149
|
}
|
|
139
150
|
graph[level].push({
|
|
140
151
|
address: current.address,
|
|
152
|
+
status: "ok",
|
|
141
153
|
calls: [...related.value],
|
|
142
154
|
});
|
|
143
155
|
if (current.depth + 1 < input.depth) {
|
|
@@ -158,16 +170,24 @@ export class EnhancedTools {
|
|
|
158
170
|
: procedures;
|
|
159
171
|
}
|
|
160
172
|
async #findXrefs(name, signal) {
|
|
161
|
-
const
|
|
162
|
-
if (!
|
|
163
|
-
return
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
166
|
-
return ok({
|
|
167
|
-
const xrefs = await this.#call("xrefs", { address }, signal);
|
|
173
|
+
const names = await this.#allAddressed("list_names", signal);
|
|
174
|
+
if (!names.ok)
|
|
175
|
+
return names;
|
|
176
|
+
const resolved = names.value.find((entry) => entry.name === name);
|
|
177
|
+
if (resolved === undefined)
|
|
178
|
+
return ok({ status: "unresolved", name, reason: "name_not_found" });
|
|
179
|
+
const xrefs = await this.#call("xrefs", { address: resolved.address }, signal);
|
|
168
180
|
if (!xrefs.ok)
|
|
169
181
|
return xrefs;
|
|
170
|
-
|
|
182
|
+
if (!Array.isArray(xrefs.value) ||
|
|
183
|
+
xrefs.value.some((xref) => typeof xref !== "string"))
|
|
184
|
+
return err(new AnalysisOutputError("xrefs", "provider returned an invalid address list"));
|
|
185
|
+
return ok({
|
|
186
|
+
status: "resolved",
|
|
187
|
+
name,
|
|
188
|
+
address: resolved.address,
|
|
189
|
+
xrefs: xrefs.value,
|
|
190
|
+
});
|
|
171
191
|
}
|
|
172
192
|
async #binaryOverview(input, signal) {
|
|
173
193
|
const [segmentsResult, documentsResult, procedures, stringsResult] = await Promise.all([
|
|
@@ -346,15 +366,3 @@ export class EnhancedTools {
|
|
|
346
366
|
const invalidInput = (name, cause) => Promise.resolve(err(new AnalysisInputError(name, {
|
|
347
367
|
cause,
|
|
348
368
|
})));
|
|
349
|
-
const resolveAddress = (value) => {
|
|
350
|
-
if (typeof value === "string" && value.length > 0)
|
|
351
|
-
return value;
|
|
352
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
353
|
-
return undefined;
|
|
354
|
-
}
|
|
355
|
-
const candidate = value.address ?? value.name;
|
|
356
|
-
return typeof candidate === "string" && candidate.length > 0
|
|
357
|
-
? candidate
|
|
358
|
-
: undefined;
|
|
359
|
-
};
|
|
360
|
-
const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
|
|
@@ -60,8 +60,9 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
|
|
|
60
60
|
if (bytes > policy.maxBytes)
|
|
61
61
|
return err(new EvidenceFileError("write", "too-large"));
|
|
62
62
|
try {
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
const requestedPath = resolve(path);
|
|
64
|
+
const canonicalParent = await realpath(dirname(requestedPath));
|
|
65
|
+
const destination = resolve(canonicalParent, basename(requestedPath));
|
|
65
66
|
if (!(await isApproved(destination, policy.roots)))
|
|
66
67
|
return err(new EvidenceFileError("write", "outside-root"));
|
|
67
68
|
const existing = await lstat(destination).catch((cause) => {
|
|
@@ -80,7 +81,7 @@ export const writeEvidenceBundle = async (bundle, path, overwrite, policy) => {
|
|
|
80
81
|
mode: 0o600,
|
|
81
82
|
fsync: true,
|
|
82
83
|
});
|
|
83
|
-
return ok({ path:
|
|
84
|
+
return ok({ path: requestedPath, bytes });
|
|
84
85
|
}
|
|
85
86
|
catch (cause) {
|
|
86
87
|
return err(new EvidenceFileError("write", "io", { cause }));
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { access, mkdir, mkdtemp, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
const RELEASES_URL = "https://www.hopperapp.com/include/files-api.php?request=releases&public=true";
|
|
10
|
+
const DOWNLOAD_PREFIX = "https://www.hopperapp.com:443/downloader/public/";
|
|
11
|
+
const MAX_PACKAGE_BYTES = 100_000_000;
|
|
12
|
+
const BUNDLE_IDENTIFIER = "com.cryptic-apps.hopper-web-4";
|
|
13
|
+
/** Download, verify, and install Hopper into the current user's Applications directory. */
|
|
14
|
+
export const installMacHopper = async (host = systemMacHopperInstallHost()) => {
|
|
15
|
+
let temporary;
|
|
16
|
+
let mounted = false;
|
|
17
|
+
try {
|
|
18
|
+
const metadata = await host.download(RELEASES_URL);
|
|
19
|
+
if (!metadata.ok)
|
|
20
|
+
return { status: "failed", reason: "release_metadata" };
|
|
21
|
+
const parsed = releasesSchema.safeParse(parseJson(metadata.bytes));
|
|
22
|
+
if (!parsed.success)
|
|
23
|
+
return { status: "failed", reason: "release_metadata" };
|
|
24
|
+
const release = parsed.data["OS X"];
|
|
25
|
+
if (!release.filename.startsWith(DOWNLOAD_PREFIX))
|
|
26
|
+
return { status: "failed", reason: "release_metadata" };
|
|
27
|
+
const archive = await host.download(release.filename);
|
|
28
|
+
if (!archive.ok)
|
|
29
|
+
return { status: "failed", reason: "download" };
|
|
30
|
+
if (!integrityMatches(archive.bytes, release))
|
|
31
|
+
return { status: "failed", reason: "integrity" };
|
|
32
|
+
const destination = host.destination();
|
|
33
|
+
if (await host.destinationExists(destination))
|
|
34
|
+
return { status: "failed", reason: "destination_exists" };
|
|
35
|
+
temporary = await host.createTemporaryDirectory();
|
|
36
|
+
const packagePath = join(temporary, "hopper.dmg");
|
|
37
|
+
const mountPath = join(temporary, "mounted");
|
|
38
|
+
await host.createMountDirectory(mountPath);
|
|
39
|
+
await host.writePackage(packagePath, archive.bytes);
|
|
40
|
+
if (!(await host.mount(packagePath, mountPath)))
|
|
41
|
+
return { status: "failed", reason: "mount" };
|
|
42
|
+
mounted = true;
|
|
43
|
+
const source = await host.appBundle(mountPath);
|
|
44
|
+
if (source === undefined || !(await host.validBundle(source)))
|
|
45
|
+
return { status: "failed", reason: "bundle" };
|
|
46
|
+
const stage = `${destination}.rea-stage`;
|
|
47
|
+
if (!(await host.installBundle(source, destination, stage)))
|
|
48
|
+
return { status: "failed", reason: "copy" };
|
|
49
|
+
const launcherPath = join(destination, "Contents/MacOS/hopper");
|
|
50
|
+
if (!(await host.launcherReady(launcherPath)))
|
|
51
|
+
return { status: "failed", reason: "launcher_missing" };
|
|
52
|
+
await host.openApplication(destination);
|
|
53
|
+
return { status: "installed", launcherPath };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { status: "failed", reason: "download" };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
if (temporary !== undefined) {
|
|
60
|
+
const mountPath = join(temporary, "mounted");
|
|
61
|
+
if (mounted)
|
|
62
|
+
await host.unmount(mountPath);
|
|
63
|
+
await host.cleanup(temporary);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const systemMacHopperInstallHost = () => ({
|
|
68
|
+
async download(url) {
|
|
69
|
+
const response = await fetch(url, {
|
|
70
|
+
headers: { accept: "application/json", "user-agent": "rea-installer" },
|
|
71
|
+
signal: AbortSignal.timeout(120_000),
|
|
72
|
+
});
|
|
73
|
+
const length = Number(response.headers.get("content-length") ?? "0");
|
|
74
|
+
if (length > MAX_PACKAGE_BYTES)
|
|
75
|
+
return { ok: false, bytes: new Uint8Array() };
|
|
76
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
77
|
+
return { ok: response.ok && bytes.byteLength <= MAX_PACKAGE_BYTES, bytes };
|
|
78
|
+
},
|
|
79
|
+
createTemporaryDirectory: () => mkdtemp(join(tmpdir(), "rea-hopper-mac-")),
|
|
80
|
+
createMountDirectory: (path) => mkdir(path),
|
|
81
|
+
writePackage: (path, bytes) => writeFile(path, bytes, { mode: 0o600 }),
|
|
82
|
+
async mount(packagePath, mountPath) {
|
|
83
|
+
return commandSucceeds("hdiutil", [
|
|
84
|
+
"attach",
|
|
85
|
+
"-readonly",
|
|
86
|
+
"-nobrowse",
|
|
87
|
+
"-mountpoint",
|
|
88
|
+
mountPath,
|
|
89
|
+
packagePath,
|
|
90
|
+
]);
|
|
91
|
+
},
|
|
92
|
+
async unmount(mountPath) {
|
|
93
|
+
await commandSucceeds("hdiutil", ["detach", mountPath, "-force"]);
|
|
94
|
+
},
|
|
95
|
+
async appBundle(mountPath) {
|
|
96
|
+
const entries = await readdir(mountPath, { withFileTypes: true });
|
|
97
|
+
const app = entries.find((entry) => entry.isDirectory() && /^Hopper.*\.app$/iu.test(entry.name));
|
|
98
|
+
return app === undefined ? undefined : join(mountPath, app.name);
|
|
99
|
+
},
|
|
100
|
+
async validBundle(appPath) {
|
|
101
|
+
if (!(await commandSucceeds("codesign", [
|
|
102
|
+
"--verify",
|
|
103
|
+
"--deep",
|
|
104
|
+
"--strict",
|
|
105
|
+
appPath,
|
|
106
|
+
])))
|
|
107
|
+
return false;
|
|
108
|
+
try {
|
|
109
|
+
const identifier = (await execFileAsync("/usr/libexec/PlistBuddy", [
|
|
110
|
+
"-c",
|
|
111
|
+
"Print :CFBundleIdentifier",
|
|
112
|
+
join(appPath, "Contents/Info.plist"),
|
|
113
|
+
])).stdout.trim();
|
|
114
|
+
return identifier === BUNDLE_IDENTIFIER;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
async destinationExists(path) {
|
|
121
|
+
try {
|
|
122
|
+
await access(path);
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
async installBundle(source, destination, stage) {
|
|
130
|
+
try {
|
|
131
|
+
await mkdir(join(homedir(), "Applications"), { recursive: true });
|
|
132
|
+
await rm(stage, { recursive: true, force: true });
|
|
133
|
+
await execFileAsync("ditto", [source, stage]);
|
|
134
|
+
await rename(stage, destination);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
await rm(stage, { recursive: true, force: true });
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
async launcherReady(path) {
|
|
143
|
+
try {
|
|
144
|
+
await access(path);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
async openApplication(path) {
|
|
152
|
+
await execFileAsync("open", [path]);
|
|
153
|
+
},
|
|
154
|
+
cleanup: (path) => rm(path, { recursive: true, force: true }),
|
|
155
|
+
destination: () => join(homedir(), "Applications/Hopper Disassembler.app"),
|
|
156
|
+
});
|
|
157
|
+
const parseJson = (bytes) => JSON.parse(new TextDecoder().decode(bytes));
|
|
158
|
+
const integrityMatches = (bytes, release) => bytes.byteLength === Number(release.file_length) &&
|
|
159
|
+
bytes.byteLength <= MAX_PACKAGE_BYTES &&
|
|
160
|
+
createHash("sha1").update(bytes).digest("hex") === release.file_hash;
|
|
161
|
+
const commandSucceeds = async (command, args) => {
|
|
162
|
+
try {
|
|
163
|
+
await execFileAsync(command, args);
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
const releaseSchema = z.object({
|
|
171
|
+
filename: z.string().url(),
|
|
172
|
+
file_length: z.string().regex(/^\d+$/u),
|
|
173
|
+
file_hash: z.string().regex(/^[a-f0-9]{40}$/u),
|
|
174
|
+
});
|
|
175
|
+
const releasesSchema = z.object({ "OS X": releaseSchema });
|