@intincrab/updiff 0.0.0-stage → 0.1.1
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 +21 -0
- package/README.md +310 -2
- package/dist/agent.js +63 -0
- package/dist/asar.js +91 -0
- package/dist/bundle.js +628 -0
- package/dist/cli.js +8 -0
- package/dist/codesign.js +62 -0
- package/dist/commands.js +510 -0
- package/dist/diff.js +525 -0
- package/dist/exec.js +19 -0
- package/dist/hosts.js +120 -0
- package/dist/plist.js +196 -0
- package/dist/report.js +124 -0
- package/dist/store.js +92 -0
- package/dist/types.js +1 -0
- package/dist/util.js +39 -0
- package/dist/version.js +2 -0
- package/package.json +55 -4
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,311 @@
|
|
|
1
|
-
#
|
|
1
|
+
# updiff
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**See what a macOS app update actually changed: in the app, not in the changelog.**
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
[](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
|
+
- **Read-only.** It never runs the app, never changes it and never touches the network.
|
|
20
|
+
- **Local.** Snapshots and reports stay in `~/.updiff`. Nothing is uploaded.
|
|
21
|
+
- **Fast.** The first snapshot of 28 apps took 11 s on an Apple Silicon Mac; later checks
|
|
22
|
+
skip unchanged apps and take about 0.1 s.
|
|
23
|
+
|
|
24
|
+
## What it looks like
|
|
25
|
+
|
|
26
|
+
A real update, Google Chrome 154 → 156, checked with `--deep`:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
$ updiff check --deep
|
|
30
|
+
Google Chrome 154.0.8037.58 (8037.58) → 156.0.8078.80 (8078.80)
|
|
31
|
+
com.google.Chrome
|
|
32
|
+
0 high · 2 notable · 6 info
|
|
33
|
+
|
|
34
|
+
NOTABLE
|
|
35
|
+
+ New domain chromestorage.goog
|
|
36
|
+
chromestorage.goog
|
|
37
|
+
at Contents/Frameworks/Google Chrome Framework.framework/Versions/156.0.8078.80/Google Chrome Framework
|
|
38
|
+
+ New domain perplexity.ai
|
|
39
|
+
frontend-cdn.perplexity.ai, suggest.perplexity.ai, www.perplexity.ai
|
|
40
|
+
at Contents/Frameworks/Google Chrome Framework.framework/Versions/156.0.8078.80/Google Chrome Framework
|
|
41
|
+
|
|
42
|
+
INFO
|
|
43
|
+
~ Google Chrome Framework.framework 154.0.8037.58 → 156.0.8078.80
|
|
44
|
+
+ New host under chromium.org
|
|
45
|
+
g-issues.chromium.org
|
|
46
|
+
+ New host under google.com
|
|
47
|
+
groups.google.com
|
|
48
|
+
+ New host under googleapis.com
|
|
49
|
+
federatedcompute-pa.googleapis.com
|
|
50
|
+
…
|
|
51
|
+
|
|
52
|
+
Report: ~/.updiff/reports/com.google.Chrome/…__154.0.8037.58__to__156.0.8078.80.md
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
A quiet update looks like that. A worrying one looks like this. This is the "update" the
|
|
56
|
+
test suite builds, trimmed:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
Fixture 1.0.0 (100) → 2.0.0 (200)
|
|
60
|
+
com.example.updiff-fixture
|
|
61
|
+
6 high · 6 notable · 2 info
|
|
62
|
+
|
|
63
|
+
HIGH
|
|
64
|
+
- App dropped the hardened runtime
|
|
65
|
+
- Helper app Fixture Helper.app dropped the hardened runtime
|
|
66
|
+
+ Can now ask for access to Camera
|
|
67
|
+
NSCameraUsageDescription: “Fixture can now see you.”
|
|
68
|
+
+ App can now use the camera
|
|
69
|
+
com.apple.security.device.camera = true
|
|
70
|
+
+ New background agent: com.example.updiff-fixture.agent.plist
|
|
71
|
+
label com.example.updiff-fixture.agent · runs Contents/MacOS/Fixture · starts at login · kept alive
|
|
72
|
+
+ New login item: FixtureLauncher.app
|
|
73
|
+
|
|
74
|
+
NOTABLE
|
|
75
|
+
+ New native Node module: fastnative.node
|
|
76
|
+
+ Opens fixture-auth:// links
|
|
77
|
+
~ Electron 33.2.0 → 35.1.0
|
|
78
|
+
+ 1 new bundled npm package
|
|
79
|
+
ai-plugin@0.3.0
|
|
80
|
+
+ New domain newvendor.io
|
|
81
|
+
native.newvendor.io, telemetry.newvendor.io
|
|
82
|
+
at …/fastnative.node, Contents/Resources/app.asar:dist/ai.js
|
|
83
|
+
…
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`+` added, `-` removed, `~` changed. In a terminal, high findings are red, notable
|
|
87
|
+
findings yellow and info cyan.
|
|
88
|
+
|
|
89
|
+
## Install
|
|
90
|
+
|
|
91
|
+
With Homebrew:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
brew install intincrab/tap/updiff
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
With npm, or without installing at all (macOS, Node.js 20 or newer). npm doesn't allow
|
|
98
|
+
the plain name `updiff` (it is too close to the existing `diff` package), so the package
|
|
99
|
+
lives under the `@intincrab` scope; the command is still `updiff`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npm install -g @intincrab/updiff
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
npx @intincrab/updiff scan Slack
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
From source:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
git clone https://github.com/intincrab/updiff.git
|
|
113
|
+
cd updiff
|
|
114
|
+
npm install && npm run build && npm link
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Quick start
|
|
118
|
+
|
|
119
|
+
Take a baseline of every app, then check after updates:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
updiff snapshot # once, before the next round of updates
|
|
123
|
+
updiff check # after updating: shows what changed in each updated app
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Or let it check by itself:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
updiff agent install # runs `updiff check` whenever your app folders change
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The agent waits until an update has finished writing, then posts a notification titled
|
|
133
|
+
"Updiff: Google Chrome 156.0.8078.80" that says "0 high, 2 notable changes". The full
|
|
134
|
+
report is saved in `~/.updiff/reports/`.
|
|
135
|
+
|
|
136
|
+
## Commands
|
|
137
|
+
|
|
138
|
+
| Command | What it does |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `updiff scan <app>` | Summarise one app: signing, sandbox, entitlements, privacy prompts, nested code, background items, Electron and hostnames. Stores nothing. |
|
|
141
|
+
| `updiff snapshot [app…]` | Store a snapshot of each app, or of every app when none are named. Unchanged apps are skipped. |
|
|
142
|
+
| `updiff check` | Snapshot every app that changed since its last snapshot and print what's different. Reports are saved as Markdown. |
|
|
143
|
+
| `updiff diff <app>` | If the installed app changed since its newest snapshot, compare the two; otherwise compare its two newest snapshots. |
|
|
144
|
+
| `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. |
|
|
145
|
+
| `updiff list [app]` | List stored apps, or one app's snapshots. |
|
|
146
|
+
| `updiff agent install \| uninstall \| status` | Manage the LaunchAgent that runs `check` after updates. |
|
|
147
|
+
|
|
148
|
+
`<app>` can be a path (`/Applications/Slack.app`), a name from your Applications folders
|
|
149
|
+
(`Slack`, `"Google Chrome"`) or a stored bundle id (`com.tinyspeck.slackmacgap`).
|
|
150
|
+
|
|
151
|
+
## Options
|
|
152
|
+
|
|
153
|
+
| Option | Applies to | Effect |
|
|
154
|
+
|---|---|---|
|
|
155
|
+
| `--format text\|md\|json` | all | Output format. JSON is for scripts. |
|
|
156
|
+
| `--min-severity high\|notable\|info` | `diff`, `check` | Hide less important findings. |
|
|
157
|
+
| `--out <file>` | `scan`, `diff` | Write the result to a file. |
|
|
158
|
+
| `--deep` | scanning | Also search framework binaries for hostnames. Needed for apps like Chrome that keep their code in a framework. |
|
|
159
|
+
| `--full` | text output | Don't shorten long details. |
|
|
160
|
+
| `--force` | `snapshot` | Store a snapshot even if nothing changed. |
|
|
161
|
+
| `--apps-dir <dir>` | all | Where to look for apps. Repeatable; the default is `/Applications` and `~/Applications`. |
|
|
162
|
+
| `--home <dir>` | all | Data folder. Default `~/.updiff`, or `$UPDIFF_HOME`. |
|
|
163
|
+
| `--keep <n>` | `snapshot`, `check` | Snapshots kept per app (default 10). |
|
|
164
|
+
| `--notify` | `check` | Post a notification for each updated app. |
|
|
165
|
+
| `--settle <seconds>` | `check` | Wait until no app has changed for this long before scanning. |
|
|
166
|
+
| `--quiet`, `--no-color` | all | Print less; plain text. `NO_COLOR` is respected too. |
|
|
167
|
+
| `--interval <seconds>` | `agent install` | Fallback timer between checks (default 21600, i.e. 6 hours). |
|
|
168
|
+
| `--dry-run` | `agent install` | Print the LaunchAgent without installing it. |
|
|
169
|
+
| `--no-load` | `agent install`, `agent uninstall` | Write or remove the file but don't call `launchctl`. |
|
|
170
|
+
|
|
171
|
+
Exit codes: `0` success, `2` usage error (unknown app, bad option), `1` unexpected error.
|
|
172
|
+
|
|
173
|
+
## What it checks
|
|
174
|
+
|
|
175
|
+
| | High | Notable | Info |
|
|
176
|
+
|---|---|---|---|
|
|
177
|
+
| **Signing** | different developer team; no longer developer-signed; hardened runtime removed | signing certificate changed | hardened runtime added |
|
|
178
|
+
| **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 |
|
|
179
|
+
| **Privacy prompts** (`NS…UsageDescription`) | a new prompt | | reworded or removed prompts |
|
|
180
|
+
| **Background** | new LaunchAgent or LaunchDaemon, login item, privileged helper, system extension | changed background item | removals |
|
|
181
|
+
| **Updater** (Sparkle) | update feed moved; update signing key changed | feed added or removed | |
|
|
182
|
+
| **Network** | App Transport Security allows plain HTTP | new ATS exception domains | |
|
|
183
|
+
| **Code** | | new helper apps, XPC services, app extensions, plug-ins, executables, native Node modules, frameworks | framework versions; removals |
|
|
184
|
+
| **Electron** | | Electron version; new bundled npm packages | package version changes and removals |
|
|
185
|
+
| **Hostnames** | | new domains | new hosts under domains already present; domains no longer mentioned |
|
|
186
|
+
| **URL schemes** | | new `scheme://` handlers | removals |
|
|
187
|
+
| **Identity** | the two sides are different apps | Dock icon shown or hidden | version, minimum macOS, size |
|
|
188
|
+
|
|
189
|
+
Code inside a bundle is matched between versions by its path and, if that fails, by its
|
|
190
|
+
bundle id. So a renamed helper isn't reported as removed plus added, and Chrome's
|
|
191
|
+
`Versions/154…` → `Versions/156…` framework folders count as the same thing.
|
|
192
|
+
|
|
193
|
+
## About hostnames
|
|
194
|
+
|
|
195
|
+
updiff finds hostnames in URLs (`https://`, `wss://`, …) and in quoted strings like
|
|
196
|
+
`"events.vendor.io"`. It looks in executables, native modules, Electron `app.asar`
|
|
197
|
+
archives and text resources, and reports them grouped by domain.
|
|
198
|
+
|
|
199
|
+
> **A hostname in the code is not proof that the app connects to it.** It shows what the
|
|
200
|
+
> code mentions: API endpoints, links, sometimes dead code. Treat a new domain as a lead
|
|
201
|
+
> and confirm it with a network monitor such as Little Snitch or LuLu.
|
|
202
|
+
|
|
203
|
+
To keep reports readable, it ignores:
|
|
204
|
+
- source maps, READMEs, licences and the `package.json` files inside `node_modules`
|
|
205
|
+
- XML namespaces and reserved names (`www.w3.org`, `example.com`, `localhost`, `*.test`)
|
|
206
|
+
- bare strings whose ending reads like a file type (`Foo.app`, `README.md`, `build.sh`)
|
|
207
|
+
- any file with more than 1,000 bare hostnames, which is a bundled list rather than code
|
|
208
|
+
(Discord's main bundle carries one with about 6,100 entries). These are named in a
|
|
209
|
+
warning.
|
|
210
|
+
|
|
211
|
+
Framework binaries are only searched with `--deep`. Electron apps keep their own code in
|
|
212
|
+
`app.asar`, which is always searched.
|
|
213
|
+
|
|
214
|
+
## Where things are stored
|
|
215
|
+
|
|
216
|
+
```text
|
|
217
|
+
~/.updiff/
|
|
218
|
+
├── snapshots/<bundle id>/<time>__<version>__<build>.json one JSON file per snapshot
|
|
219
|
+
├── reports/<bundle id>/<time>__<from>__to__<to>.md one report per detected update
|
|
220
|
+
└── agent.log output of background checks
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
A snapshot records a fingerprint of the app's code signature seal
|
|
224
|
+
(`_CodeSignature/CodeResources`), so an unchanged app is recognised without a rescan.
|
|
225
|
+
Delete the folder at any time to start over.
|
|
226
|
+
|
|
227
|
+
## Privacy and safety
|
|
228
|
+
|
|
229
|
+
- updiff reads files and runs Apple's own tools:
|
|
230
|
+
- `plutil` to read plists
|
|
231
|
+
- `codesign` to read signatures and entitlements
|
|
232
|
+
- `osascript` for notifications
|
|
233
|
+
- `launchctl` for the agent
|
|
234
|
+
- It never runs app code.
|
|
235
|
+
- It never makes network requests, and nothing it records leaves your Mac.
|
|
236
|
+
- The agent is a LaunchAgent in your user account (`dev.updiff.check`). It runs with your
|
|
237
|
+
permissions, at low priority. `updiff agent install --dry-run` shows exactly what it
|
|
238
|
+
installs; `updiff agent uninstall` removes it.
|
|
239
|
+
|
|
240
|
+
## Limitations
|
|
241
|
+
|
|
242
|
+
- **Static only.** Plugins, models or config that an app downloads after launch aren't in
|
|
243
|
+
the bundle, so updiff can't see them.
|
|
244
|
+
- **Hostnames are pattern-matched.** Hosts assembled at runtime (`"api." + region + ".x.com"`)
|
|
245
|
+
are missed, and a mentioned host is not necessarily contacted.
|
|
246
|
+
- **The first update needs a baseline.** Run `updiff snapshot` before updating. You can
|
|
247
|
+
also diff a downloaded copy of the old version against the installed one.
|
|
248
|
+
- **Not covered yet:** Mac App Store receipts, Safari web extension manifests, managed
|
|
249
|
+
browser policies and installer `.pkg` scripts.
|
|
250
|
+
|
|
251
|
+
## The ledger
|
|
252
|
+
|
|
253
|
+
[`ledger/`](ledger) runs updiff every day on new releases of about 30 popular Mac apps
|
|
254
|
+
(Slack, Zoom, Cursor, VS Code, Postman, Chrome, Discord, …), found through their
|
|
255
|
+
Homebrew casks. It publishes what changed in each release as a static site with an Atom
|
|
256
|
+
feed. Reports are plain JSON in [`ledger/data`](ledger/data). See
|
|
257
|
+
[ledger/README.md](ledger/README.md) to run it or turn on the daily GitHub Action.
|
|
258
|
+
|
|
259
|
+
## How it works
|
|
260
|
+
|
|
261
|
+
Every scan, a few steps:
|
|
262
|
+
1. Read `Info.plist` with `plutil`.
|
|
263
|
+
2. Walk the bundle without following symlinks, and skip framework versions that
|
|
264
|
+
`Versions/Current` doesn't point to.
|
|
265
|
+
3. Classify nested code by type and location.
|
|
266
|
+
4. Read each item's signature and entitlements with `codesign`.
|
|
267
|
+
5. Read `app.asar` with a built-in parser, without extracting it.
|
|
268
|
+
6. Search text and the arm64 slice of each binary for hostnames.
|
|
269
|
+
|
|
270
|
+
The diff compares two snapshots and assigns each change a severity. The full design and
|
|
271
|
+
the reasoning behind the rules are in [docs/DESIGN.md](docs/DESIGN.md).
|
|
272
|
+
|
|
273
|
+
## Development
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
npm install
|
|
277
|
+
npm test # unit + integration tests
|
|
278
|
+
npm run typecheck
|
|
279
|
+
npm run build # compiles to dist/
|
|
280
|
+
npm start -- scan Ghostty # run from source (Node 22.18+ runs the TypeScript directly)
|
|
281
|
+
|
|
282
|
+
# re-record the README demo: a real run against a generated demo app, rendered to docs/demo.svg
|
|
283
|
+
node scripts/demo/record.ts && node scripts/demo/render-svg.ts
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The integration tests build two versions of a small Electron-style app with clang,
|
|
287
|
+
`@electron/asar` and `codesign`. They plant permission, background, package and network
|
|
288
|
+
changes, then check that each one is reported at the right severity, both through the
|
|
289
|
+
library and through the commands. Without a C compiler, the assertions about hostnames
|
|
290
|
+
inside binaries are skipped.
|
|
291
|
+
|
|
292
|
+
CI runs all of this on a macOS runner for every push, then installs the packed tarball
|
|
293
|
+
and runs it on Node 20, 22 and 24.
|
|
294
|
+
|
|
295
|
+
### Releasing
|
|
296
|
+
|
|
297
|
+
Bump the version in `package.json` and `src/version.ts` (a test checks they match),
|
|
298
|
+
commit, and push a matching tag:
|
|
299
|
+
|
|
300
|
+
```bash
|
|
301
|
+
git tag v0.2.0 && git push origin v0.2.0
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The [release workflow](.github/workflows/release.yml) tests and packs updiff, creates a
|
|
305
|
+
GitHub Release with the tarball, and publishes it to npm with provenance. The
|
|
306
|
+
[Homebrew tap](https://github.com/intincrab/homebrew-tap) picks up the new release within
|
|
307
|
+
a few hours, or straight away if you run its workflow by hand.
|
|
308
|
+
|
|
309
|
+
## License
|
|
310
|
+
|
|
311
|
+
[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', '--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
|
+
}
|