@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/dist/plist.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { run } from "./exec.js";
|
|
2
|
+
const TOKEN_RE = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<(\/?)([A-Za-z][\w.-]*)[^>]*?(\/?)>|([^<]+)/g;
|
|
3
|
+
function tokenize(xml) {
|
|
4
|
+
const tokens = [];
|
|
5
|
+
for (const m of xml.matchAll(TOKEN_RE)) {
|
|
6
|
+
if (m[2]) {
|
|
7
|
+
const kind = m[1] ? 'close' : m[3] ? 'empty' : 'open';
|
|
8
|
+
tokens.push({ kind, name: m[2], text: '' });
|
|
9
|
+
}
|
|
10
|
+
else if (m[4] !== undefined) {
|
|
11
|
+
tokens.push({ kind: 'text', name: '', text: m[4] });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return tokens;
|
|
15
|
+
}
|
|
16
|
+
export function decodeEntities(text) {
|
|
17
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|lt|gt|amp|quot|apos);/g, (_, e) => {
|
|
18
|
+
switch (e) {
|
|
19
|
+
case 'lt':
|
|
20
|
+
return '<';
|
|
21
|
+
case 'gt':
|
|
22
|
+
return '>';
|
|
23
|
+
case 'amp':
|
|
24
|
+
return '&';
|
|
25
|
+
case 'quot':
|
|
26
|
+
return '"';
|
|
27
|
+
case 'apos':
|
|
28
|
+
return "'";
|
|
29
|
+
default:
|
|
30
|
+
return String.fromCodePoint(e.startsWith('#x') ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
class Parser {
|
|
35
|
+
i = 0;
|
|
36
|
+
tokens;
|
|
37
|
+
constructor(tokens) {
|
|
38
|
+
this.tokens = tokens;
|
|
39
|
+
}
|
|
40
|
+
next() {
|
|
41
|
+
// Whitespace between elements carries no meaning in a plist.
|
|
42
|
+
while (this.i < this.tokens.length) {
|
|
43
|
+
const t = this.tokens[this.i++];
|
|
44
|
+
if (t.kind === 'text' && t.text.trim() === '')
|
|
45
|
+
continue;
|
|
46
|
+
return t;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
readText(name) {
|
|
51
|
+
let text = '';
|
|
52
|
+
while (this.i < this.tokens.length) {
|
|
53
|
+
const t = this.tokens[this.i++];
|
|
54
|
+
if (t.kind === 'text')
|
|
55
|
+
text += t.text;
|
|
56
|
+
else if (t.kind === 'close' && t.name === name)
|
|
57
|
+
return decodeEntities(text);
|
|
58
|
+
else
|
|
59
|
+
throw new Error(`Unexpected <${t.name}> inside <${name}>`);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`Unterminated <${name}>`);
|
|
62
|
+
}
|
|
63
|
+
parseDocument() {
|
|
64
|
+
const first = this.next();
|
|
65
|
+
if (!first)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (first.kind === 'open' && first.name === 'plist') {
|
|
68
|
+
const inner = this.next();
|
|
69
|
+
if (!inner || (inner.kind === 'close' && inner.name === 'plist'))
|
|
70
|
+
return undefined;
|
|
71
|
+
return this.parseValue(inner);
|
|
72
|
+
}
|
|
73
|
+
if (first.kind === 'empty' && first.name === 'plist')
|
|
74
|
+
return undefined;
|
|
75
|
+
return this.parseValue(first);
|
|
76
|
+
}
|
|
77
|
+
parseValue(t) {
|
|
78
|
+
if (t.kind === 'empty') {
|
|
79
|
+
switch (t.name) {
|
|
80
|
+
case 'true':
|
|
81
|
+
return true;
|
|
82
|
+
case 'false':
|
|
83
|
+
return false;
|
|
84
|
+
case 'dict':
|
|
85
|
+
return {};
|
|
86
|
+
case 'array':
|
|
87
|
+
return [];
|
|
88
|
+
case 'string':
|
|
89
|
+
case 'data':
|
|
90
|
+
case 'date':
|
|
91
|
+
return '';
|
|
92
|
+
default:
|
|
93
|
+
throw new Error(`Unsupported empty element <${t.name}/>`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (t.kind !== 'open')
|
|
97
|
+
throw new Error(`Expected a value, got ${t.kind} ${t.name || t.text.trim()}`);
|
|
98
|
+
switch (t.name) {
|
|
99
|
+
case 'string':
|
|
100
|
+
case 'date':
|
|
101
|
+
return this.readText(t.name);
|
|
102
|
+
case 'data':
|
|
103
|
+
return `data:${this.readText('data').replace(/\s+/g, '')}`;
|
|
104
|
+
case 'integer':
|
|
105
|
+
case 'real':
|
|
106
|
+
return Number(this.readText(t.name).trim());
|
|
107
|
+
case 'dict': {
|
|
108
|
+
const out = {};
|
|
109
|
+
for (;;) {
|
|
110
|
+
const k = this.next();
|
|
111
|
+
if (!k)
|
|
112
|
+
throw new Error('Unterminated <dict>');
|
|
113
|
+
if (k.kind === 'close' && k.name === 'dict')
|
|
114
|
+
return out;
|
|
115
|
+
let key = '';
|
|
116
|
+
if (k.kind === 'open' && k.name === 'key')
|
|
117
|
+
key = this.readText('key');
|
|
118
|
+
else if (!(k.kind === 'empty' && k.name === 'key'))
|
|
119
|
+
throw new Error(`Expected <key>, got <${k.name}>`);
|
|
120
|
+
const v = this.next();
|
|
121
|
+
if (!v)
|
|
122
|
+
throw new Error(`Missing value for key ${key}`);
|
|
123
|
+
out[key] = this.parseValue(v);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
case 'array': {
|
|
127
|
+
const out = [];
|
|
128
|
+
for (;;) {
|
|
129
|
+
const v = this.next();
|
|
130
|
+
if (!v)
|
|
131
|
+
throw new Error('Unterminated <array>');
|
|
132
|
+
if (v.kind === 'close' && v.name === 'array')
|
|
133
|
+
return out;
|
|
134
|
+
out.push(this.parseValue(v));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
default:
|
|
138
|
+
throw new Error(`Unsupported element <${t.name}>`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Parses an XML property list. Returns undefined for an empty document. */
|
|
143
|
+
export function parsePlistXml(xml) {
|
|
144
|
+
return new Parser(tokenize(xml)).parseDocument();
|
|
145
|
+
}
|
|
146
|
+
/** Reads a plist in any format (XML, binary or JSON) through `plutil`. */
|
|
147
|
+
export async function readPlistFile(path) {
|
|
148
|
+
const res = await run('/usr/bin/plutil', ['-convert', 'xml1', '-o', '-', path]);
|
|
149
|
+
if (res.code !== 0)
|
|
150
|
+
return undefined;
|
|
151
|
+
const value = parsePlistXml(res.stdout);
|
|
152
|
+
return isDict(value) ? value : undefined;
|
|
153
|
+
}
|
|
154
|
+
export function isDict(v) {
|
|
155
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
156
|
+
}
|
|
157
|
+
export function str(v) {
|
|
158
|
+
return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : undefined;
|
|
159
|
+
}
|
|
160
|
+
function escapeXml(s) {
|
|
161
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
162
|
+
}
|
|
163
|
+
function serialize(v, indent) {
|
|
164
|
+
if (typeof v === 'string')
|
|
165
|
+
return `${indent}<string>${escapeXml(v)}</string>`;
|
|
166
|
+
if (typeof v === 'boolean')
|
|
167
|
+
return `${indent}<${v}/>`;
|
|
168
|
+
if (typeof v === 'number')
|
|
169
|
+
return Number.isInteger(v) ? `${indent}<integer>${v}</integer>` : `${indent}<real>${v}</real>`;
|
|
170
|
+
if (Array.isArray(v)) {
|
|
171
|
+
if (v.length === 0)
|
|
172
|
+
return `${indent}<array/>`;
|
|
173
|
+
return [`${indent}<array>`, ...v.map((x) => serialize(x, indent + '\t')), `${indent}</array>`].join('\n');
|
|
174
|
+
}
|
|
175
|
+
const keys = Object.keys(v);
|
|
176
|
+
if (keys.length === 0)
|
|
177
|
+
return `${indent}<dict/>`;
|
|
178
|
+
const lines = [`${indent}<dict>`];
|
|
179
|
+
for (const k of keys) {
|
|
180
|
+
lines.push(`${indent}\t<key>${escapeXml(k)}</key>`);
|
|
181
|
+
lines.push(serialize(v[k], indent + '\t'));
|
|
182
|
+
}
|
|
183
|
+
lines.push(`${indent}</dict>`);
|
|
184
|
+
return lines.join('\n');
|
|
185
|
+
}
|
|
186
|
+
/** Serialises a value as an XML property list. */
|
|
187
|
+
export function toPlistXml(v) {
|
|
188
|
+
return [
|
|
189
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
190
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
191
|
+
'<plist version="1.0">',
|
|
192
|
+
serialize(v, ''),
|
|
193
|
+
'</plist>',
|
|
194
|
+
'',
|
|
195
|
+
].join('\n');
|
|
196
|
+
}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { registrableDomain } from "./hosts.js";
|
|
2
|
+
import { formatBytes } from "./util.js";
|
|
3
|
+
export const HOSTNAME_CAVEAT = 'A hostname found in the code is not proof that the app connects to it. It shows what the code mentions: endpoints, links and sometimes dead code.';
|
|
4
|
+
const SEVERITY_LABEL = { high: 'High', notable: 'Notable', info: 'Info' };
|
|
5
|
+
const CHANGE_MARK = { added: '+', removed: '-', changed: '~' };
|
|
6
|
+
const plain = { bold: (s) => s, dim: (s) => s, sev: (_, s) => s };
|
|
7
|
+
const ansi = {
|
|
8
|
+
bold: (s) => `\x1b[1m${s}\x1b[22m`,
|
|
9
|
+
dim: (s) => `\x1b[2m${s}\x1b[22m`,
|
|
10
|
+
sev: (sev, s) => `\x1b[${sev === 'high' ? 31 : sev === 'notable' ? 33 : 36}m${s}\x1b[39m`,
|
|
11
|
+
};
|
|
12
|
+
function clip(s, max) {
|
|
13
|
+
return s.length > max ? `${s.slice(0, max - 1)}… (${s.length - max + 1} more characters; use --format md or json for all)` : s;
|
|
14
|
+
}
|
|
15
|
+
export function renderText(report, options = {}) {
|
|
16
|
+
const p = options.color ? ansi : plain;
|
|
17
|
+
const max = options.full ? Infinity : 400;
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push(p.bold(`${report.app.name} ${report.from.version} (${report.from.build}) → ${report.to.version} (${report.to.build})`));
|
|
20
|
+
lines.push(p.dim(`${report.app.bundleId}`));
|
|
21
|
+
lines.push(`${p.sev('high', `${report.counts.high} high`)} · ${p.sev('notable', `${report.counts.notable} notable`)} · ${p.sev('info', `${report.counts.info} info`)}`);
|
|
22
|
+
if (report.identical) {
|
|
23
|
+
lines.push('', 'No changes: both sides have the same sealed contents.');
|
|
24
|
+
return lines.join('\n') + '\n';
|
|
25
|
+
}
|
|
26
|
+
for (const sev of ['high', 'notable', 'info']) {
|
|
27
|
+
const group = report.findings.filter((f) => f.severity === sev);
|
|
28
|
+
if (!group.length)
|
|
29
|
+
continue;
|
|
30
|
+
lines.push('', p.sev(sev, p.bold(SEVERITY_LABEL[sev].toUpperCase())));
|
|
31
|
+
for (const f of group) {
|
|
32
|
+
lines.push(` ${p.sev(sev, CHANGE_MARK[f.change])} ${f.title}`);
|
|
33
|
+
if (f.detail)
|
|
34
|
+
lines.push(` ${clip(f.detail, max)}`);
|
|
35
|
+
if (f.where?.length)
|
|
36
|
+
lines.push(p.dim(` at ${f.where.join(', ')}`));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (report.findings.some((f) => f.category === 'host'))
|
|
40
|
+
lines.push('', p.dim(HOSTNAME_CAVEAT));
|
|
41
|
+
return lines.join('\n') + '\n';
|
|
42
|
+
}
|
|
43
|
+
function mdEscape(s) {
|
|
44
|
+
return s.replace(/([\\`*_[\]<>|])/g, '\\$1');
|
|
45
|
+
}
|
|
46
|
+
export function renderMarkdown(report) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
lines.push(`# ${mdEscape(report.app.name)}: ${report.from.version} → ${report.to.version}`, '');
|
|
49
|
+
lines.push(`\`${report.app.bundleId}\` · build ${report.from.build} → ${report.to.build}`, '');
|
|
50
|
+
lines.push(`| | From | To |`, `|---|---|---|`);
|
|
51
|
+
lines.push(`| Snapshot | ${report.from.takenAt} | ${report.to.takenAt} |`);
|
|
52
|
+
lines.push(`| Path | \`${report.from.path}\` | \`${report.to.path}\` |`, '');
|
|
53
|
+
lines.push(`**${report.counts.high} high · ${report.counts.notable} notable · ${report.counts.info} info**`, '');
|
|
54
|
+
if (report.identical) {
|
|
55
|
+
lines.push('No changes: both sides have the same sealed contents.', '');
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
for (const sev of ['high', 'notable', 'info']) {
|
|
59
|
+
const group = report.findings.filter((f) => f.severity === sev);
|
|
60
|
+
if (!group.length)
|
|
61
|
+
continue;
|
|
62
|
+
lines.push(`## ${SEVERITY_LABEL[sev]}`, '');
|
|
63
|
+
for (const f of group) {
|
|
64
|
+
lines.push(`- ${CHANGE_MARK[f.change] === '+' ? 'Added' : CHANGE_MARK[f.change] === '-' ? 'Removed' : 'Changed'}: **${mdEscape(f.title)}**`);
|
|
65
|
+
if (f.detail)
|
|
66
|
+
lines.push(` ${mdEscape(f.detail)}`);
|
|
67
|
+
if (f.where?.length)
|
|
68
|
+
lines.push(` <sub>${f.where.map((w) => `\`${w}\``).join(', ')}</sub>`);
|
|
69
|
+
}
|
|
70
|
+
lines.push('');
|
|
71
|
+
}
|
|
72
|
+
if (report.findings.some((f) => f.category === 'host'))
|
|
73
|
+
lines.push(`> ${HOSTNAME_CAVEAT}`, '');
|
|
74
|
+
lines.push('<sub>Generated by updiff. Static inspection only: the app was not run.</sub>', '');
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
export function renderJson(value) {
|
|
78
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
79
|
+
}
|
|
80
|
+
const KIND_PLURAL = {
|
|
81
|
+
main: 'main executable',
|
|
82
|
+
helperApp: 'helper apps',
|
|
83
|
+
xpcService: 'XPC services',
|
|
84
|
+
appExtension: 'app extensions',
|
|
85
|
+
loginItem: 'login items',
|
|
86
|
+
privilegedHelper: 'privileged helpers',
|
|
87
|
+
systemExtension: 'system extensions',
|
|
88
|
+
framework: 'frameworks',
|
|
89
|
+
plugin: 'plug-in bundles',
|
|
90
|
+
executable: 'other executables',
|
|
91
|
+
nativeModule: 'native Node modules',
|
|
92
|
+
};
|
|
93
|
+
export function renderScan(s, options = {}) {
|
|
94
|
+
const p = options.color ? ansi : plain;
|
|
95
|
+
const main = s.code.find((c) => c.kind === 'main');
|
|
96
|
+
const lines = [];
|
|
97
|
+
lines.push(p.bold(`${s.app.name} ${s.app.version} (${s.app.build})`), p.dim(`${s.app.bundleId} · ${s.app.path}`), '');
|
|
98
|
+
const sig = s.signing;
|
|
99
|
+
lines.push(`Signing ${sig.status}${sig.teamId ? `, team ${sig.teamId}` : ''}${sig.authorities[0] ? ` (${sig.authorities[0]})` : ''}${sig.hardenedRuntime ? ', hardened runtime' : ', no hardened runtime'}`);
|
|
100
|
+
const ents = Object.keys(main?.entitlements ?? {});
|
|
101
|
+
const sandboxed = main?.entitlements?.['com.apple.security.app-sandbox'] === true;
|
|
102
|
+
lines.push(`Sandbox ${sandboxed ? 'yes' : 'no'}`);
|
|
103
|
+
lines.push(`Entitlements ${ents.length ? ents.join(', ') : 'none'}`);
|
|
104
|
+
const privacy = Object.keys(s.info.privacyUsage);
|
|
105
|
+
lines.push(`Privacy asks ${privacy.length ? privacy.map((k) => k.replace(/^NS/, '').replace(/UsageDescription$/, '')).join(', ') : 'none'}`);
|
|
106
|
+
lines.push(`URL schemes ${s.info.urlSchemes.length ? s.info.urlSchemes.join(', ') : 'none'}`);
|
|
107
|
+
if (s.info.sparkleFeedUrl)
|
|
108
|
+
lines.push(`Update feed ${s.info.sparkleFeedUrl}`);
|
|
109
|
+
const counts = new Map();
|
|
110
|
+
for (const c of s.code)
|
|
111
|
+
if (c.kind !== 'main')
|
|
112
|
+
counts.set(c.kind, (counts.get(c.kind) ?? 0) + 1);
|
|
113
|
+
lines.push(`Nested code ${counts.size ? [...counts].map(([k, n]) => `${n} ${KIND_PLURAL[k]}`).join(', ') : 'none'}`);
|
|
114
|
+
lines.push(`Background ${s.backgroundItems.length ? s.backgroundItems.map((b) => `${b.label ?? b.file}${b.program ? ` → ${b.program}` : ''}`).join(', ') : 'none'}`);
|
|
115
|
+
if (s.electron) {
|
|
116
|
+
lines.push(`Electron ${s.electron.electronVersion ?? 'version unknown'}${s.electron.appSource ? `, code in ${s.electron.appSource}` : ''}, ${Object.keys(s.electron.packages).length} bundled npm packages`);
|
|
117
|
+
}
|
|
118
|
+
const domains = new Set(Object.keys(s.hosts).map(registrableDomain));
|
|
119
|
+
lines.push(`Hostnames ${Object.keys(s.hosts).length} hosts across ${domains.size} domains`);
|
|
120
|
+
lines.push(p.dim(`Scanned ${s.stats.filesScanned} files (${formatBytes(s.stats.bytesScanned)}) of ${s.app.fileCount} (${formatBytes(s.app.sizeBytes)}) in ${(s.stats.scanMs / 1000).toFixed(1)} s${s.stats.deep ? ', deep' : ''}`));
|
|
121
|
+
for (const w of s.stats.warnings)
|
|
122
|
+
lines.push(p.sev('notable', `warning: ${w}`));
|
|
123
|
+
return lines.join('\n') + '\n';
|
|
124
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
function safe(s) {
|
|
5
|
+
return s.replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 80) || '_';
|
|
6
|
+
}
|
|
7
|
+
export function defaultHome() {
|
|
8
|
+
return process.env.UPDIFF_HOME || join(homedir(), '.updiff');
|
|
9
|
+
}
|
|
10
|
+
export class Store {
|
|
11
|
+
home;
|
|
12
|
+
constructor(home = defaultHome()) {
|
|
13
|
+
this.home = home;
|
|
14
|
+
}
|
|
15
|
+
get snapshotsDir() {
|
|
16
|
+
return join(this.home, 'snapshots');
|
|
17
|
+
}
|
|
18
|
+
get reportsDir() {
|
|
19
|
+
return join(this.home, 'reports');
|
|
20
|
+
}
|
|
21
|
+
async save(s) {
|
|
22
|
+
const dir = join(this.snapshotsDir, safe(s.app.bundleId));
|
|
23
|
+
await mkdir(dir, { recursive: true });
|
|
24
|
+
const file = join(dir, `${s.takenAt.replace(/[:.]/g, '-')}__${safe(s.app.version)}__${safe(s.app.build)}.json`);
|
|
25
|
+
await writeFile(file, JSON.stringify(s));
|
|
26
|
+
return file;
|
|
27
|
+
}
|
|
28
|
+
async load(file) {
|
|
29
|
+
const s = JSON.parse(await readFile(file, 'utf8'));
|
|
30
|
+
if (!s.app?.bundleId || !s.fingerprint)
|
|
31
|
+
throw new Error(`${file} is not an updiff snapshot`);
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
async bundleIds() {
|
|
35
|
+
try {
|
|
36
|
+
const dirents = await readdir(this.snapshotsDir, { withFileTypes: true });
|
|
37
|
+
return dirents.filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Snapshots of one app, oldest first. */
|
|
44
|
+
async list(bundleId) {
|
|
45
|
+
const dir = join(this.snapshotsDir, safe(bundleId));
|
|
46
|
+
let names;
|
|
47
|
+
try {
|
|
48
|
+
names = (await readdir(dir)).filter((n) => n.endsWith('.json')).sort();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
return names.map((n) => {
|
|
54
|
+
const [stamp = '', version = '', build = ''] = n.slice(0, -5).split('__');
|
|
55
|
+
return { file: join(dir, n), bundleId, takenAt: stamp, version, build };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
/** The newest `count` snapshots of one app, newest first. */
|
|
59
|
+
async latest(bundleId, count = 1) {
|
|
60
|
+
const list = await this.list(bundleId);
|
|
61
|
+
const picked = list.slice(-count).reverse();
|
|
62
|
+
return Promise.all(picked.map((p) => this.load(p.file)));
|
|
63
|
+
}
|
|
64
|
+
/** Finds a stored app by bundle id or by name, ignoring case. */
|
|
65
|
+
async resolveBundleId(query) {
|
|
66
|
+
const ids = await this.bundleIds();
|
|
67
|
+
const q = query.toLowerCase();
|
|
68
|
+
const exact = ids.find((id) => id.toLowerCase() === q || id.toLowerCase() === safe(query).toLowerCase());
|
|
69
|
+
if (exact)
|
|
70
|
+
return exact;
|
|
71
|
+
for (const id of ids) {
|
|
72
|
+
const [latest] = await this.latest(id, 1);
|
|
73
|
+
if (latest && latest.app.name.toLowerCase() === q.replace(/\.app$/, ''))
|
|
74
|
+
return id;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
async prune(bundleId, keep) {
|
|
79
|
+
const list = await this.list(bundleId);
|
|
80
|
+
const drop = list.slice(0, Math.max(0, list.length - keep));
|
|
81
|
+
await Promise.all(drop.map((d) => rm(d.file, { force: true })));
|
|
82
|
+
return drop.length;
|
|
83
|
+
}
|
|
84
|
+
async saveReport(bundleId, fromVersion, toVersion, ext, content) {
|
|
85
|
+
const dir = join(this.reportsDir, safe(bundleId));
|
|
86
|
+
await mkdir(dir, { recursive: true });
|
|
87
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
88
|
+
const file = join(dir, `${stamp}__${safe(fromVersion)}__to__${safe(toVersion)}.${ext}`);
|
|
89
|
+
await writeFile(file, content);
|
|
90
|
+
return file;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const SNAPSHOT_SCHEMA = 1;
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Runs `fn` over `items` with at most `limit` calls in flight, keeping order. */
|
|
2
|
+
export async function mapLimit(items, limit, fn) {
|
|
3
|
+
const out = new Array(items.length);
|
|
4
|
+
let next = 0;
|
|
5
|
+
const worker = async () => {
|
|
6
|
+
while (next < items.length) {
|
|
7
|
+
const i = next++;
|
|
8
|
+
out[i] = await fn(items[i], i);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
export function sortedUnique(values) {
|
|
15
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
16
|
+
}
|
|
17
|
+
export function formatBytes(n) {
|
|
18
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
19
|
+
let v = Math.abs(n);
|
|
20
|
+
let u = 0;
|
|
21
|
+
while (v >= 1024 && u < units.length - 1) {
|
|
22
|
+
v /= 1024;
|
|
23
|
+
u++;
|
|
24
|
+
}
|
|
25
|
+
return `${n < 0 ? '-' : ''}${u === 0 ? v : v.toFixed(1)} ${units[u]}`;
|
|
26
|
+
}
|
|
27
|
+
/** Stable JSON for comparing plist values. */
|
|
28
|
+
export function stableJson(v) {
|
|
29
|
+
if (Array.isArray(v))
|
|
30
|
+
return `[${v.map(stableJson).join(',')}]`;
|
|
31
|
+
if (v && typeof v === 'object') {
|
|
32
|
+
const o = v;
|
|
33
|
+
return `{${Object.keys(o)
|
|
34
|
+
.sort()
|
|
35
|
+
.map((k) => `${JSON.stringify(k)}:${stableJson(o[k])}`)
|
|
36
|
+
.join(',')}}`;
|
|
37
|
+
}
|
|
38
|
+
return JSON.stringify(v);
|
|
39
|
+
}
|
package/dist/version.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intincrab/updiff",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "See what a macOS app update actually changed: entitlements, privacy prompts, background items, helpers, bundled packages and hostnames.",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/intincrab/updiff.git"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/intincrab/updiff#readme",
|
|
10
|
+
"bugs": {
|
|
11
|
+
"url": "https://github.com/intincrab/updiff/issues"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"updiff": "dist/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"os": [
|
|
23
|
+
"darwin"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
31
|
+
"test": "node --test --test-concurrency=1 test/*.test.ts",
|
|
32
|
+
"start": "node src/cli.ts",
|
|
33
|
+
"ledger": "node ledger/src/cli.ts",
|
|
34
|
+
"prepublishOnly": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@electron/asar": "^3.4.1",
|
|
39
|
+
"@types/node": "^24.19.2",
|
|
40
|
+
"typescript": "^5.9.3"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"macos",
|
|
44
|
+
"security",
|
|
45
|
+
"privacy",
|
|
46
|
+
"entitlements",
|
|
47
|
+
"codesign",
|
|
48
|
+
"electron",
|
|
49
|
+
"diff",
|
|
50
|
+
"app-updates",
|
|
51
|
+
"cli"
|
|
52
|
+
],
|
|
53
|
+
"author": "Ajay Chauhan",
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
}
|
|
57
|
+
}
|