@intincrab/updiff 0.0.0-stage → 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ajay Chauhan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,357 @@
1
- # Temporary Holding Version
1
+ # updiff
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **See what a macOS app update actually changed: in the app, not in the changelog.**
4
+
5
+ ![macOS](https://img.shields.io/badge/platform-macOS-lightgrey)
6
+ ![Node 20+](https://img.shields.io/badge/node-%E2%89%A520-brightgreen)
7
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
8
+ [![CI](https://github.com/intincrab/updiff/actions/workflows/ci.yml/badge.svg)](https://github.com/intincrab/updiff/actions/workflows/ci.yml)
9
+
10
+ <p align="center">
11
+ <img src="docs/demo.svg" width="860" alt="A terminal runs updiff snapshot; the app Lumen then updates to 4.3.0, and updiff check reports 4 high findings (a new camera prompt, the camera entitlement, a background agent that starts at login, a login item) and 3 notable ones (an Electron upgrade, a new analytics package and a new domain).">
12
+ </p>
13
+
14
+ Release notes say "bug fixes and improvements". The update itself might ask for your
15
+ camera, register something that starts at login, ship a new helper that runs as root,
16
+ bundle a new analytics package, or start talking to a new service. updiff takes a
17
+ snapshot of each app and, after every update, shows you exactly what changed inside it.
18
+
19
+ It also covers what updates without asking: **browser and editor extensions**, and
20
+ **installer packages** you're about to run.
21
+
22
+ - **Read-only.** It never runs the app, an installer or its scripts, and never changes them.
23
+ - **Local.** Snapshots and reports stay in `~/.updiff`. Nothing is uploaded. Only `fetch`
24
+ downloads anything, and Gatekeeper may ask Apple whether an app is notarized.
25
+ - **Fast.** The first snapshot of 28 apps takes about 40 s on an Apple Silicon Mac (11 s
26
+ with `--no-gatekeeper`); later checks skip unchanged apps and take about 0.1 s.
27
+
28
+ ## What it looks like
29
+
30
+ A real update, Google Chrome 154 → 156, checked with `--deep`:
31
+
32
+ ```text
33
+ $ updiff check --deep
34
+ Google Chrome 154.0.8037.58 (8037.58) → 156.0.8078.80 (8078.80)
35
+ com.google.Chrome
36
+ 0 high · 2 notable · 6 info
37
+
38
+ NOTABLE
39
+ + New domain chromestorage.goog
40
+ chromestorage.goog
41
+ at Contents/Frameworks/Google Chrome Framework.framework/Versions/156.0.8078.80/Google Chrome Framework
42
+ + New domain perplexity.ai
43
+ frontend-cdn.perplexity.ai, suggest.perplexity.ai, www.perplexity.ai
44
+ at Contents/Frameworks/Google Chrome Framework.framework/Versions/156.0.8078.80/Google Chrome Framework
45
+
46
+ INFO
47
+ ~ Google Chrome Framework.framework 154.0.8037.58 → 156.0.8078.80
48
+ + New host under chromium.org
49
+ g-issues.chromium.org
50
+ + New host under google.com
51
+ groups.google.com
52
+ + New host under googleapis.com
53
+ federatedcompute-pa.googleapis.com
54
+ …
55
+
56
+ Report: ~/.updiff/reports/com.google.Chrome/…__154.0.8037.58__to__156.0.8078.80.md
57
+ ```
58
+
59
+ A quiet update looks like that. A worrying one looks like this. This is the "update" the
60
+ test suite builds, trimmed:
61
+
62
+ ```text
63
+ Fixture 1.0.0 (100) → 2.0.0 (200)
64
+ com.example.updiff-fixture
65
+ 6 high · 6 notable · 2 info
66
+
67
+ HIGH
68
+ - App dropped the hardened runtime
69
+ - Helper app Fixture Helper.app dropped the hardened runtime
70
+ + Can now ask for access to Camera
71
+ NSCameraUsageDescription: “Fixture can now see you.”
72
+ + App can now use the camera
73
+ com.apple.security.device.camera = true
74
+ + New background agent: com.example.updiff-fixture.agent.plist
75
+ label com.example.updiff-fixture.agent · runs Contents/MacOS/Fixture · starts at login · kept alive
76
+ + New login item: FixtureLauncher.app
77
+
78
+ NOTABLE
79
+ + New native Node module: fastnative.node
80
+ + Opens fixture-auth:// links
81
+ ~ Electron 33.2.0 → 35.1.0
82
+ + 1 new bundled npm package
83
+ ai-plugin@0.3.0
84
+ + New domain newvendor.io
85
+ native.newvendor.io, telemetry.newvendor.io
86
+ at …/fastnative.node, Contents/Resources/app.asar:dist/ai.js
87
+ …
88
+ ```
89
+
90
+ `+` added, `-` removed, `~` changed. In a terminal, high findings are red, notable
91
+ findings yellow and info cyan.
92
+
93
+ ## Install
94
+
95
+ With Homebrew:
96
+
97
+ ```bash
98
+ brew install intincrab/tap/updiff
99
+ ```
100
+
101
+ With npm, or without installing at all (macOS, Node.js 20 or newer). npm doesn't allow
102
+ the plain name `updiff` (it is too close to the existing `diff` package), so the package
103
+ lives under the `@intincrab` scope; the command is still `updiff`:
104
+
105
+ ```bash
106
+ npm install -g @intincrab/updiff
107
+ ```
108
+
109
+ ```bash
110
+ npx @intincrab/updiff scan Slack
111
+ ```
112
+
113
+ From source:
114
+
115
+ ```bash
116
+ git clone https://github.com/intincrab/updiff.git
117
+ cd updiff
118
+ npm install && npm run build && npm link
119
+ ```
120
+
121
+ ## Quick start
122
+
123
+ Take a baseline of every app, then check after updates:
124
+
125
+ ```bash
126
+ updiff snapshot # once, before the next round of updates
127
+ updiff check # after updating: shows what changed in each updated app
128
+ ```
129
+
130
+ Or let it check by itself:
131
+
132
+ ```bash
133
+ updiff agent install # runs `updiff check` whenever your app folders change
134
+ ```
135
+
136
+ The agent waits until an update has finished writing, then posts a notification titled
137
+ "Updiff: Google Chrome 156.0.8078.80" that says "0 high, 2 notable changes". The full
138
+ report is saved in `~/.updiff/reports/`. It also checks your browser and editor extensions.
139
+
140
+ No baseline yet? Compare an installed app with its previous release, downloaded from
141
+ Homebrew's history and checked against the checksum Homebrew recorded:
142
+
143
+ ```bash
144
+ updiff fetch Ghostty # Ghostty 1.3.0 → 1.3.1: 0 high · 0 notable · 2 info
145
+ ```
146
+
147
+ Before running an installer, see what it would do:
148
+
149
+ ```bash
150
+ updiff pkg ~/Downloads/Installer.pkg
151
+ ```
152
+
153
+ And see what your extensions can reach, or what changed since last time:
154
+
155
+ ```bash
156
+ updiff extensions
157
+ updiff extensions check
158
+ ```
159
+
160
+ ## Commands
161
+
162
+ | Command | What it does |
163
+ |---|---|
164
+ | `updiff scan <app>` | Summarise one app: signing, sandbox, entitlements, privacy prompts, nested code, background items, Electron and hostnames. Stores nothing. |
165
+ | `updiff snapshot [app…]` | Store a snapshot of each app, or of every app when none are named. Unchanged apps are skipped. |
166
+ | `updiff check` | Snapshot every app that changed since its last snapshot and print what's different. Reports are saved as Markdown. |
167
+ | `updiff diff <app>` | If the installed app changed since its newest snapshot, compare the two; otherwise compare its two newest snapshots. |
168
+ | `updiff diff <a> <b>` | Compare any two things: `.app` bundles, snapshot `.json` files or stored apps. Good for a downloaded beta against the installed version. |
169
+ | `updiff list [app]` | List stored apps, or one app's snapshots. |
170
+ | `updiff fetch <app> [version]` | Download an earlier release from the app's Homebrew cask history, verify it against the checksum Homebrew pinned, store it as a snapshot and compare it with the installed app. With no version, the release before the installed one. `--list` shows the history. |
171
+ | `updiff pkg <installer.pkg> [older.pkg]` | Show what an installer package would do before you run it: scripts with their text, installer JavaScript, apps, launch daemons and agents (including ones apps register from inside their bundle), privileged helpers, system extensions, everything written outside /Applications, signature and Gatekeeper's verdict. With two packages, what changed. Nothing is installed or run. |
172
+ | `updiff extensions [check]` | List browser and editor extensions with what they can reach. `check` reports what changed since the last check. |
173
+ | `updiff agent install \| uninstall \| status` | Manage the LaunchAgent that runs `check` after updates. |
174
+
175
+ `<app>` can be a path (`/Applications/Slack.app`), a name from your Applications folders
176
+ (`Slack`, `"Google Chrome"`) or a stored bundle id (`com.tinyspeck.slackmacgap`).
177
+
178
+ ## Options
179
+
180
+ | Option | Applies to | Effect |
181
+ |---|---|---|
182
+ | `--format text\|md\|json` | all | Output format. JSON is for scripts. |
183
+ | `--min-severity high\|notable\|info` | `diff`, `check` | Hide less important findings. |
184
+ | `--out <file>` | `scan`, `diff` | Write the result to a file. |
185
+ | `--deep` | scanning | Also search framework binaries for hostnames. Needed for apps like Chrome that keep their code in a framework. |
186
+ | `--full` | text output | Don't shorten long details. |
187
+ | `--force` | `snapshot` | Store a snapshot even if nothing changed. |
188
+ | `--apps-dir <dir>` | all | Where to look for apps. Repeatable; the default is `/Applications` and `~/Applications`. |
189
+ | `--home <dir>` | all | Data folder. Default `~/.updiff`, or `$UPDIFF_HOME`. |
190
+ | `--keep <n>` | `snapshot`, `check` | Snapshots kept per app (default 10). |
191
+ | `--notify` | `check` | Post a notification for each updated app. |
192
+ | `--extensions` | `check` | Also check browser and editor extensions (the agent does). |
193
+ | `--no-gatekeeper` | scanning | Skip the Gatekeeper/notarization check (faster). |
194
+ | `--cask <name>` | `fetch` | The Homebrew cask, when it isn't the app's name. |
195
+ | `--list` | `fetch` | List the releases in the cask's history. |
196
+ | `--settle <seconds>` | `check` | Wait until no app has changed for this long before scanning. |
197
+ | `--quiet`, `--no-color` | all | Print less; plain text. `NO_COLOR` is respected too. |
198
+ | `--interval <seconds>` | `agent install` | Fallback timer between checks (default 21600, i.e. 6 hours). |
199
+ | `--dry-run` | `agent install` | Print the LaunchAgent without installing it. |
200
+ | `--no-load` | `agent install`, `agent uninstall` | Write or remove the file but don't call `launchctl`. |
201
+
202
+ Exit codes: `0` success, `2` usage error (unknown app, bad option), `1` unexpected error.
203
+
204
+ ## What it checks
205
+
206
+ | | High | Notable | Info |
207
+ |---|---|---|---|
208
+ | **Signing** | different developer team; no longer developer-signed; hardened runtime removed; no longer notarized; Gatekeeper rejects it or can't verify its signature | signing certificate changed; distribution changed (Developer ID ↔ App Store) | hardened runtime added; notarization ticket stapled or unstapled |
209
+ | **Entitlements**, for every executable in the bundle | camera, microphone, location, contacts, calendars, photos, Apple Events, USB, Bluetooth, library validation off, debugging, Endpoint Security, network extensions, sandbox exceptions; sandbox removed | any other new entitlement; new values in app groups, keychain groups or associated domains | removals |
210
+ | **Privacy prompts** (`NS…UsageDescription`) | a new prompt | | reworded or removed prompts |
211
+ | **Background** | new LaunchAgent or LaunchDaemon, login item, privileged helper, system extension | changed background item | removals |
212
+ | **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed; feed set in the code changed | |
213
+ | **Network** | App Transport Security allows plain HTTP | new ATS exception domains | |
214
+ | **Code** | | new helper apps, XPC services, app extensions, plug-ins, executables, native Node modules, frameworks | framework versions; removals |
215
+ | **Electron** | | Electron version; new bundled npm packages | package version changes and removals |
216
+ | **Hostnames** | | new domains | new hosts under domains already present; domains no longer mentioned |
217
+ | **URL schemes** | | new `scheme://` handlers | removals |
218
+ | **Identity** | the two sides are different apps | Dock icon shown or hidden; moved to or from the Mac App Store | version, minimum macOS, size |
219
+ | **Browser extensions** | access to all websites; sensitive APIs (cookies, history, webRequest, debugger, nativeMessaging, proxy…); a different update server; a new extension with access to all websites | other new permissions; access to specific new sites; a new extension | low-reach permissions (storage, alarms…); optional permissions; removals; versions |
220
+ | **Editor extensions** (VS Code, Cursor, Windsurf, VSCodium) | starts installing other extensions | now starts with every window; new npm packages; new native code; new domains in the code; a new extension | removals; versions |
221
+
222
+ Gatekeeper's verdict comes from `spctl`, which may contact Apple for apps without a
223
+ stapled notarization ticket. Sparkle feeds are read from `Info.plist` and, for apps that
224
+ set them in code (Ghostty, ChatGPT), from URLs in the binary; `scan` also shows this Mac's
225
+ update settings for the app. Binaries that are FairPlay-encrypted (iPhone and iPad apps)
226
+ are flagged, because their strings can't be read. Mac App Store apps for macOS are not
227
+ encrypted, so their hostnames are read like any other app's.
228
+
229
+ Code inside a bundle is matched between versions by its path and, if that fails, by its
230
+ bundle id. So a renamed helper isn't reported as removed plus added, and Chrome's
231
+ `Versions/154…` → `Versions/156…` framework folders count as the same thing.
232
+
233
+ ## About hostnames
234
+
235
+ updiff finds hostnames in URLs (`https://`, `wss://`, …) and in quoted strings like
236
+ `"events.vendor.io"`. It looks in executables, native modules, Electron `app.asar`
237
+ archives and text resources, and reports them grouped by domain.
238
+
239
+ > **A hostname in the code is not proof that the app connects to it.** It shows what the
240
+ > code mentions: API endpoints, links, sometimes dead code. Treat a new domain as a lead
241
+ > and confirm it with a network monitor such as Little Snitch or LuLu.
242
+
243
+ To keep reports readable, it ignores:
244
+ - source maps, READMEs, licences and the `package.json` files inside `node_modules`
245
+ - XML namespaces and reserved names (`www.w3.org`, `example.com`, `localhost`, `*.test`)
246
+ - bare strings whose ending reads like a file type (`Foo.app`, `README.md`, `build.sh`)
247
+ - any file with more than 1,000 bare hostnames, which is a bundled list rather than code
248
+ (Discord's main bundle carries one with about 6,100 entries). These are named in a
249
+ warning.
250
+
251
+ Framework binaries are only searched with `--deep`. Electron apps keep their own code in
252
+ `app.asar`, which is always searched.
253
+
254
+ ## Where things are stored
255
+
256
+ ```text
257
+ ~/.updiff/
258
+ ├── snapshots/<bundle id>/<time>__<version>__<build>.json one JSON file per snapshot
259
+ ├── reports/<bundle id>/<time>__<from>__to__<to>.md one report per detected update
260
+ ├── extensions/<time>.json installed extensions (last 10)
261
+ ├── reports/extensions/<time>__<from>__to__<to>.md one report per extension change
262
+ └── agent.log output of background checks
263
+ ```
264
+
265
+ A snapshot records a fingerprint of the app's code signature seal
266
+ (`_CodeSignature/CodeResources`), so an unchanged app is recognised without a rescan.
267
+ Delete the folder at any time to start over.
268
+
269
+ ## Privacy and safety
270
+
271
+ - updiff reads files and runs Apple's own tools:
272
+ - `plutil` and `defaults` to read plists and an app's update settings
273
+ - `codesign`, `spctl` and `stapler` to read signatures, Gatekeeper's verdict and
274
+ notarization tickets
275
+ - `hdiutil` (read-only) and `pkgutil --expand-full` to open disk images and packages
276
+ - `osascript` for notifications, `launchctl` for the agent
277
+ - It never runs app code, installer scripts or installer JavaScript.
278
+ - Nothing it records leaves your Mac. `fetch` downloads from Homebrew, GitHub and the
279
+ app's vendor; `spctl` may ask Apple about notarization. Nothing else uses the network.
280
+ - The agent is a LaunchAgent in your user account (`dev.updiff.check`). It runs with your
281
+ permissions, at low priority. `updiff agent install --dry-run` shows exactly what it
282
+ installs; `updiff agent uninstall` removes it.
283
+
284
+ ## Limitations
285
+
286
+ - **Static only.** Plugins, models or config that an app downloads after launch aren't in
287
+ the bundle, so updiff can't see them.
288
+ - **Hostnames are pattern-matched.** Hosts assembled at runtime (`"api." + region + ".x.com"`)
289
+ are missed, and a mentioned host is not necessarily contacted.
290
+ - **Baselines.** `check` compares with the last snapshot. For an app you never snapshotted,
291
+ `updiff fetch` gets its previous release, if the app is in Homebrew.
292
+ - **Update feeds built at runtime** (AltTab assembles its URL from pieces) can't be found;
293
+ `scan` says when Sparkle is bundled but no feed was found.
294
+ - **Not covered yet:** Safari web extensions' permissions, managed browser policies, and
295
+ extensions for JetBrains IDEs.
296
+
297
+ ## The ledger
298
+
299
+ [`ledger/`](ledger) runs updiff every day on new releases of about 30 popular Mac apps
300
+ (Slack, Zoom, Cursor, VS Code, Postman, Chrome, Discord, …), found through their
301
+ Homebrew casks. It publishes what changed in each release as a static site with an Atom
302
+ feed. Reports are plain JSON in [`ledger/data`](ledger/data). See
303
+ [ledger/README.md](ledger/README.md) to run it or turn on the daily GitHub Action.
304
+
305
+ ## How it works
306
+
307
+ Every scan, a few steps:
308
+ 1. Read `Info.plist` with `plutil`.
309
+ 2. Walk the bundle without following symlinks, and skip framework versions that
310
+ `Versions/Current` doesn't point to.
311
+ 3. Classify nested code by type and location.
312
+ 4. Read each item's signature and entitlements with `codesign`.
313
+ 5. Read `app.asar` with a built-in parser, without extracting it.
314
+ 6. Search text and the arm64 slice of each binary for hostnames.
315
+
316
+ The diff compares two snapshots and assigns each change a severity. The full design and
317
+ the reasoning behind the rules are in [docs/DESIGN.md](docs/DESIGN.md).
318
+
319
+ ## Development
320
+
321
+ ```bash
322
+ npm install
323
+ npm test # unit + integration tests
324
+ npm run typecheck
325
+ npm run build # compiles to dist/
326
+ npm start -- scan Ghostty # run from source (Node 22.18+ runs the TypeScript directly)
327
+
328
+ # re-record the README demo: a real run against a generated demo app, rendered to docs/demo.svg
329
+ node scripts/demo/record.ts && node scripts/demo/render-svg.ts
330
+ ```
331
+
332
+ The integration tests build two versions of a small Electron-style app with clang,
333
+ `@electron/asar` and `codesign`. They plant permission, background, package and network
334
+ changes, then check that each one is reported at the right severity, both through the
335
+ library and through the commands. Without a C compiler, the assertions about hostnames
336
+ inside binaries are skipped.
337
+
338
+ CI runs all of this on a macOS runner for every push, then installs the packed tarball
339
+ and runs it on Node 20, 22 and 24.
340
+
341
+ ### Releasing
342
+
343
+ Bump the version in `package.json` and `src/version.ts` (a test checks they match),
344
+ commit, and push a matching tag:
345
+
346
+ ```bash
347
+ git tag v0.2.0 && git push origin v0.2.0
348
+ ```
349
+
350
+ The [release workflow](.github/workflows/release.yml) tests and packs updiff, creates a
351
+ GitHub Release with the tarball, and publishes it to npm with provenance. The
352
+ [Homebrew tap](https://github.com/intincrab/homebrew-tap) picks up the new release within
353
+ a few hours, or straight away if you run its workflow by hand.
354
+
355
+ ## License
356
+
357
+ [MIT](LICENSE)
package/dist/agent.js ADDED
@@ -0,0 +1,63 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { run } from "./exec.js";
6
+ import { toPlistXml } from "./plist.js";
7
+ export const AGENT_LABEL = 'dev.updiff.check';
8
+ export function launchAgentsDir() {
9
+ return process.env.UPDIFF_LAUNCH_AGENTS_DIR || join(homedir(), 'Library', 'LaunchAgents');
10
+ }
11
+ export function agentPlist(o) {
12
+ const args = [o.nodePath, o.cliPath, 'check', '--extensions', '--notify', '--quiet', '--settle', '90', '--home', o.home];
13
+ for (const d of o.appsDirs)
14
+ args.push('--apps-dir', d);
15
+ return toPlistXml({
16
+ Label: AGENT_LABEL,
17
+ ProgramArguments: args,
18
+ // Runs after anything changes in the app folders, and on a timer as a fallback.
19
+ WatchPaths: o.watchPaths,
20
+ StartInterval: o.intervalSeconds,
21
+ // An update writes many files; wait before running again.
22
+ ThrottleInterval: 120,
23
+ RunAtLoad: false,
24
+ ProcessType: 'Background',
25
+ LowPriorityIO: true,
26
+ Nice: 10,
27
+ StandardOutPath: join(o.home, 'agent.log'),
28
+ StandardErrorPath: join(o.home, 'agent.log'),
29
+ });
30
+ }
31
+ function domain() {
32
+ return `gui/${userInfo().uid}`;
33
+ }
34
+ export async function installAgent(o, load) {
35
+ const dir = launchAgentsDir();
36
+ await mkdir(dir, { recursive: true });
37
+ await mkdir(o.home, { recursive: true });
38
+ const path = join(dir, `${AGENT_LABEL}.plist`);
39
+ if (load && existsSync(path))
40
+ await run('/bin/launchctl', ['bootout', `${domain()}/${AGENT_LABEL}`]);
41
+ await writeFile(path, agentPlist(o));
42
+ if (!load)
43
+ return { path, loaded: false };
44
+ const res = await run('/bin/launchctl', ['bootstrap', domain(), path]);
45
+ return { path, loaded: res.code === 0, message: res.code === 0 ? undefined : res.stderr.trim() };
46
+ }
47
+ export async function uninstallAgent(unload) {
48
+ const path = join(launchAgentsDir(), `${AGENT_LABEL}.plist`);
49
+ const existed = existsSync(path);
50
+ if (unload)
51
+ await run('/bin/launchctl', ['bootout', `${domain()}/${AGENT_LABEL}`]);
52
+ await rm(path, { force: true });
53
+ return { path, existed };
54
+ }
55
+ export async function agentStatus() {
56
+ const path = join(launchAgentsDir(), `${AGENT_LABEL}.plist`);
57
+ const res = await run('/bin/launchctl', ['print', `${domain()}/${AGENT_LABEL}`]);
58
+ return { path, installed: existsSync(path), loaded: res.code === 0 };
59
+ }
60
+ export async function notify(title, message) {
61
+ const q = (s) => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
62
+ await run('/usr/bin/osascript', ['-e', `display notification ${q(message)} with title ${q(title)}`]);
63
+ }
package/dist/asar.js ADDED
@@ -0,0 +1,91 @@
1
+ import { closeSync, existsSync, openSync, readSync, readFileSync } from 'node:fs';
2
+ /**
3
+ * Reads Electron `.asar` archives without extracting them.
4
+ *
5
+ * Layout: an 8-byte pickle that holds the header size, then a pickle whose payload is
6
+ * a length-prefixed JSON header, then the file data. A file's `offset` counts from the
7
+ * start of the data. Files marked `unpacked` live in `<archive>.unpacked/`.
8
+ */
9
+ export class AsarArchive {
10
+ path;
11
+ header;
12
+ dataOffset;
13
+ fd;
14
+ constructor(path, fd, header, dataOffset) {
15
+ this.path = path;
16
+ this.fd = fd;
17
+ this.header = header;
18
+ this.dataOffset = dataOffset;
19
+ }
20
+ static open(path) {
21
+ const fd = openSync(path, 'r');
22
+ try {
23
+ const sizeBuf = Buffer.alloc(8);
24
+ if (readSync(fd, sizeBuf, 0, 8, 0) !== 8)
25
+ throw new Error('File is too small to be an asar archive');
26
+ const headerPickleSize = sizeBuf.readUInt32LE(4);
27
+ if (headerPickleSize < 8 || headerPickleSize > 512 * 1024 * 1024)
28
+ throw new Error('Invalid asar header size');
29
+ const headerBuf = Buffer.alloc(headerPickleSize);
30
+ if (readSync(fd, headerBuf, 0, headerPickleSize, 8) !== headerPickleSize)
31
+ throw new Error('Truncated asar header');
32
+ const jsonLength = headerBuf.readUInt32LE(4);
33
+ if (jsonLength > headerPickleSize - 8)
34
+ throw new Error('Invalid asar header length');
35
+ const header = JSON.parse(headerBuf.toString('utf8', 8, 8 + jsonLength));
36
+ if (!header.files)
37
+ throw new Error('asar header has no files');
38
+ return new AsarArchive(path, fd, header, 8 + headerPickleSize);
39
+ }
40
+ catch (err) {
41
+ closeSync(fd);
42
+ throw err;
43
+ }
44
+ }
45
+ *files() {
46
+ const stack = [['', this.header]];
47
+ while (stack.length) {
48
+ const [prefix, node] = stack.pop();
49
+ for (const [name, child] of Object.entries(node.files ?? {})) {
50
+ const path = prefix ? `${prefix}/${name}` : name;
51
+ if (child.files)
52
+ stack.push([path, child]);
53
+ else if (child.link === undefined) {
54
+ yield {
55
+ path,
56
+ size: child.size ?? 0,
57
+ offset: Number(child.offset ?? 0),
58
+ unpacked: child.unpacked === true,
59
+ executable: child.executable === true,
60
+ };
61
+ }
62
+ }
63
+ }
64
+ }
65
+ /** Reads a file's bytes, or the first `limit` bytes. */
66
+ read(file, limit = Infinity) {
67
+ const length = Math.min(file.size, limit);
68
+ if (file.unpacked) {
69
+ const unpackedPath = `${this.path}.unpacked/${file.path}`;
70
+ if (!existsSync(unpackedPath))
71
+ return Buffer.alloc(0);
72
+ const buf = readFileSync(unpackedPath);
73
+ return length < buf.length ? buf.subarray(0, length) : buf;
74
+ }
75
+ const buf = Buffer.alloc(length);
76
+ let read = 0;
77
+ while (read < length) {
78
+ const n = readSync(this.fd, buf, read, length - read, this.dataOffset + file.offset + read);
79
+ if (n === 0)
80
+ break;
81
+ read += n;
82
+ }
83
+ return read < length ? buf.subarray(0, read) : buf;
84
+ }
85
+ close() {
86
+ if (this.fd >= 0) {
87
+ closeSync(this.fd);
88
+ this.fd = -1;
89
+ }
90
+ }
91
+ }