@orbital_mcp/patchrelay 0.1.0-alpha04 → 0.1.0-alpha05
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/.patchrelay-tools/build.gradle +22 -0
- package/.patchrelay-tools/settings.gradle +1 -0
- package/.patchrelay-tools/src/main/groovy/dev/patchrelay/gradle/FunctionsPlugin.groovy +328 -0
- package/{gradle-plugin → .patchrelay-tools}/src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy +226 -220
- package/.patchrelay-tools/src/main/kotlin/dev/patchrelay/compiler/PatchRelayCompilerRegistrar.kt +70 -0
- package/.patchrelay-tools/src/main/resources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
- package/.patchrelay-tools/src/main/resources/patchrelay/ComposeScreens.kt +139 -0
- package/README.md +249 -174
- package/package.json +2 -2
- package/src/main.mjs +511 -279
- package/src/project.mjs +196 -174
- package/gradle-plugin/build.gradle +0 -15
- package/gradle-plugin/settings.gradle +0 -1
package/src/project.mjs
CHANGED
|
@@ -1,174 +1,196 @@
|
|
|
1
|
-
import { createHash, createPrivateKey, createPublicKey, constants, sign, verify } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, realpathSync, lstatSync, mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
-
import { resolve, relative, dirname, isAbsolute, basename, join } from 'node:path';
|
|
4
|
-
import { spawnSync } from 'node:child_process';
|
|
5
|
-
|
|
6
|
-
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
7
|
-
export const readJson = file => JSON.parse(readFileSync(file, 'utf8'));
|
|
8
|
-
export function baselineFiles(folder) {
|
|
9
|
-
if (!existsSync(folder)) return [];
|
|
10
|
-
return readdirSync(folder, { withFileTypes: true }).flatMap(entry => {
|
|
11
|
-
const path = inside(folder, entry.name);
|
|
12
|
-
if (entry.isSymbolicLink()) return [];
|
|
13
|
-
if (entry.isDirectory()) return baselineFiles(path);
|
|
14
|
-
return entry.isFile() && entry.name === 'baseline.json' ? [path] : [];
|
|
15
|
-
}).sort();
|
|
16
|
-
}
|
|
17
|
-
export const routeId = value => sha256(JSON.stringify([value.appId, value.baseVersion, value.hostSha256]));
|
|
18
|
-
// Match Python's existing sorted, ASCII-escaped JSON contract, including Unicode paths.
|
|
19
|
-
export const hostHash = files => sha256(JSON.stringify(Object.fromEntries(Object.entries(files)
|
|
20
|
-
.sort(([a], [b]) => Buffer.compare(Buffer.from(a), Buffer.from(b)))))
|
|
21
|
-
.replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`));
|
|
22
|
-
|
|
23
|
-
export function inside(root, path) {
|
|
24
|
-
const full = resolve(root, path);
|
|
25
|
-
const child = relative(resolve(root), full);
|
|
26
|
-
if (child === '..' || child.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(child)) {
|
|
27
|
-
throw new Error('Project path escapes the project directory.');
|
|
28
|
-
}
|
|
29
|
-
if (existsSync(full) && realpathSync(full) !== full) {
|
|
30
|
-
const actual = relative(realpathSync(root), realpathSync(full));
|
|
31
|
-
if (actual === '..' || actual.startsWith('..\\') || actual.startsWith('../') || isAbsolute(actual)) throw new Error('Project symlink escapes the project directory.');
|
|
32
|
-
}
|
|
33
|
-
return full;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function fingerprint(root, paths, extensions) {
|
|
37
|
-
const result = {};
|
|
38
|
-
function visit(file) {
|
|
39
|
-
inside(root, relative(root, file));
|
|
40
|
-
const info = lstatSync(file);
|
|
41
|
-
if (info.isSymbolicLink()) throw new Error('Declare real source paths, not symbolic links.');
|
|
42
|
-
if (info.isDirectory()) {
|
|
43
|
-
for (const name of readdirSync(file).sort()) visit(join(file, name));
|
|
44
|
-
} else if (info.isFile()) {
|
|
45
|
-
let data = readFileSync(file);
|
|
46
|
-
if (extensions.some(suffix => basename(file).endsWith(suffix))) data = Buffer.from(data.toString('utf8').replaceAll('\r\n', '\n'));
|
|
47
|
-
result[relative(root, file).replaceAll('\\', '/')] = sha256(data);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
for (const path of paths) visit(inside(root, path));
|
|
51
|
-
return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function checkNative(before, after) {
|
|
55
|
-
const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(name => before[name] !== after[name]);
|
|
56
|
-
if (changed.length) throw new Error(`Native inputs changed; build a new base: ${changed.slice(0, 10).join(', ')}`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const payload = Buffer.from(
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
return
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function
|
|
132
|
-
if (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1
|
+
import { createHash, createPrivateKey, createPublicKey, constants, sign, verify } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, lstatSync, mkdirSync, openSync, closeSync, unlinkSync, writeFileSync, renameSync } from 'node:fs';
|
|
3
|
+
import { resolve, relative, dirname, isAbsolute, basename, join } from 'node:path';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
7
|
+
export const readJson = file => JSON.parse(readFileSync(file, 'utf8'));
|
|
8
|
+
export function baselineFiles(folder) {
|
|
9
|
+
if (!existsSync(folder)) return [];
|
|
10
|
+
return readdirSync(folder, { withFileTypes: true }).flatMap(entry => {
|
|
11
|
+
const path = inside(folder, entry.name);
|
|
12
|
+
if (entry.isSymbolicLink()) return [];
|
|
13
|
+
if (entry.isDirectory()) return baselineFiles(path);
|
|
14
|
+
return entry.isFile() && entry.name === 'baseline.json' ? [path] : [];
|
|
15
|
+
}).sort();
|
|
16
|
+
}
|
|
17
|
+
export const routeId = value => sha256(JSON.stringify([value.appId, value.baseVersion, value.hostSha256]));
|
|
18
|
+
// Match Python's existing sorted, ASCII-escaped JSON contract, including Unicode paths.
|
|
19
|
+
export const hostHash = files => sha256(JSON.stringify(Object.fromEntries(Object.entries(files)
|
|
20
|
+
.sort(([a], [b]) => Buffer.compare(Buffer.from(a), Buffer.from(b)))))
|
|
21
|
+
.replace(/[\u007f-\uffff]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`));
|
|
22
|
+
|
|
23
|
+
export function inside(root, path) {
|
|
24
|
+
const full = resolve(root, path);
|
|
25
|
+
const child = relative(resolve(root), full);
|
|
26
|
+
if (child === '..' || child.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(child)) {
|
|
27
|
+
throw new Error('Project path escapes the project directory.');
|
|
28
|
+
}
|
|
29
|
+
if (existsSync(full) && realpathSync(full) !== full) {
|
|
30
|
+
const actual = relative(realpathSync(root), realpathSync(full));
|
|
31
|
+
if (actual === '..' || actual.startsWith('..\\') || actual.startsWith('../') || isAbsolute(actual)) throw new Error('Project symlink escapes the project directory.');
|
|
32
|
+
}
|
|
33
|
+
return full;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function fingerprint(root, paths, extensions) {
|
|
37
|
+
const result = {};
|
|
38
|
+
function visit(file) {
|
|
39
|
+
inside(root, relative(root, file));
|
|
40
|
+
const info = lstatSync(file);
|
|
41
|
+
if (info.isSymbolicLink()) throw new Error('Declare real source paths, not symbolic links.');
|
|
42
|
+
if (info.isDirectory()) {
|
|
43
|
+
for (const name of readdirSync(file).sort()) visit(join(file, name));
|
|
44
|
+
} else if (info.isFile()) {
|
|
45
|
+
let data = readFileSync(file);
|
|
46
|
+
if (extensions.some(suffix => basename(file).endsWith(suffix))) data = Buffer.from(data.toString('utf8').replaceAll('\r\n', '\n'));
|
|
47
|
+
result[relative(root, file).replaceAll('\\', '/')] = sha256(data);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
for (const path of paths) visit(inside(root, path));
|
|
51
|
+
return Object.fromEntries(Object.entries(result).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function checkNative(before, after) {
|
|
55
|
+
const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])].filter(name => before[name] !== after[name]);
|
|
56
|
+
if (changed.length) throw new Error(`Native inputs changed; build a new base: ${changed.slice(0, 10).join(', ')}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function compareFiles(before, after) {
|
|
60
|
+
const report = { added: [], modified: [], removed: [] };
|
|
61
|
+
for (const path of [...new Set([...Object.keys(before), ...Object.keys(after)])].sort()) {
|
|
62
|
+
if (!Object.hasOwn(before, path)) report.added.push(path);
|
|
63
|
+
else if (!Object.hasOwn(after, path)) report.removed.push(path);
|
|
64
|
+
else if (before[path] !== after[path]) report.modified.push(path);
|
|
65
|
+
}
|
|
66
|
+
return report;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function emptyOutput(path) {
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
mkdirSync(path); // Exclusive: never overwrite an existing release directory.
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function command(root, executable, args, capture = false) {
|
|
75
|
+
const result = spawnSync(executable, args, { cwd: root, stdio: capture ? 'pipe' : 'inherit', encoding: 'utf8', shell: false });
|
|
76
|
+
if (result.error) throw new Error(`Cannot run ${executable}: ${result.error.message}`);
|
|
77
|
+
if (result.status !== 0) throw new Error(`${executable} failed (${result.status}).${capture ? ` ${result.stderr?.trim() || ''}` : ''}`);
|
|
78
|
+
return result.stdout?.trim();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function jdkTool(name) {
|
|
82
|
+
const binary = process.platform === 'win32' ? `${name}.exe` : name;
|
|
83
|
+
return process.env.JAVA_HOME ? join(process.env.JAVA_HOME, 'bin', binary) : binary;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function gradle(root, tasks) {
|
|
87
|
+
command(root, jdkTool('java'), ['-classpath', inside(root, 'gradle/wrapper/gradle-wrapper.jar'),
|
|
88
|
+
'org.gradle.wrapper.GradleWrapperMain', ...tasks, '--console=plain', '--max-workers=2']);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadBaseline(file) {
|
|
92
|
+
const baseline = readJson(file);
|
|
93
|
+
if (baseline.format !== 3 || !baseline.nativeFiles || !baseline.screenFiles || !Array.isArray(baseline.screenIds) ||
|
|
94
|
+
!baseline.screenIds.length || !/^[a-f0-9]{64}$/.test(baseline.hostSha256) ||
|
|
95
|
+
hostHash(baseline.nativeFiles) !== baseline.hostSha256) throw new Error('Invalid baseline compatibility record.');
|
|
96
|
+
if (baseline.sourcesSha256) {
|
|
97
|
+
const archive = join(dirname(file), 'baseline-sources.zip');
|
|
98
|
+
if (!existsSync(archive) || sha256(readFileSync(archive)) !== baseline.sourcesSha256) throw new Error('Baseline source archive is missing or altered.');
|
|
99
|
+
}
|
|
100
|
+
return baseline;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function validateUrl(value) {
|
|
104
|
+
let url;
|
|
105
|
+
try { url = new URL(value); } catch { throw new Error('Use an absolute HTTPS artifact URL.'); }
|
|
106
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.hash) throw new Error('Use HTTPS without URL credentials or a fragment.');
|
|
107
|
+
return url.href;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function signedManifest(baseline, version, artifactUrl, bytes, keyBytes, publicKeyBytes) {
|
|
111
|
+
if (!Number.isSafeInteger(version) || version <= 0) throw new Error('Patch version must be a positive safe integer.');
|
|
112
|
+
if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new Error('Patch must be between 1 byte and 2 MiB.');
|
|
113
|
+
new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
114
|
+
return signPayload({ schema: 3, screenApi: baseline.screenApi, runtime: 'kotlin-screen-js',
|
|
115
|
+
hostSha256: baseline.hostSha256, appId: baseline.appId, baseVersion: baseline.baseVersion, version,
|
|
116
|
+
screenIds: baseline.screenIds, artifactUrl: validateUrl(artifactUrl), sha256: sha256(bytes) }, keyBytes, publicKeyBytes);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function signPayload(value, keyBytes, publicKeyBytes) {
|
|
120
|
+
const key = createPrivateKey({ key: Buffer.from(keyBytes.toString().trim(), 'base64'), format: 'der', type: 'pkcs8' });
|
|
121
|
+
if (key.asymmetricKeyType !== 'rsa' || key.asymmetricKeyDetails.modulusLength < 3072) throw new Error('Use a PatchRelay RSA signing key of at least 3072 bits.');
|
|
122
|
+
if (publicKeyBytes && !createPublicKey(key).export({ format: 'der', type: 'spki' }).equals(Buffer.from(publicKeyBytes.toString().trim(), 'base64'))) {
|
|
123
|
+
throw new Error('Signing key does not match the configured app public key.');
|
|
124
|
+
}
|
|
125
|
+
const payload = Buffer.from(JSON.stringify(value));
|
|
126
|
+
const envelope = { payload: payload.toString('base64'), signature: sign('sha256', payload, { key, padding: constants.RSA_PKCS1_PADDING }).toString('base64') };
|
|
127
|
+
if (Buffer.byteLength(JSON.stringify(envelope)) > 64 * 1024) throw new Error('Signed metadata exceeds the SDK 64 KiB limit.');
|
|
128
|
+
return envelope;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function verifiedEnvelope(bytes, publicKeyBytes) {
|
|
132
|
+
if (bytes.length > 64 * 1024) throw new Error('Signed metadata exceeds 64 KiB.');
|
|
133
|
+
const envelope = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
134
|
+
if (typeof envelope.payload !== 'string' || typeof envelope.signature !== 'string') throw new Error('Invalid signed envelope.');
|
|
135
|
+
const payload = Buffer.from(envelope.payload, 'base64');
|
|
136
|
+
const key = createPublicKey({ key: Buffer.from(publicKeyBytes.toString().trim(), 'base64'), format: 'der', type: 'spki' });
|
|
137
|
+
if (!verify('sha256', payload, key, Buffer.from(envelope.signature, 'base64'))) throw new Error('Metadata signature is invalid.');
|
|
138
|
+
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(payload));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function updateCatalog(previous, manifest, manifestUrl, channel) {
|
|
142
|
+
if (!/^[a-z][a-z0-9_-]{0,31}$/.test(channel)) throw new Error('Use a lowercase channel name, e.g. production or beta.');
|
|
143
|
+
if (manifest.schema !== 3 || manifest.screenApi !== 2 || manifest.runtime !== 'kotlin-screen-js' || typeof manifest.appId !== 'string' ||
|
|
144
|
+
!manifest.appId || typeof manifest.baseVersion !== 'string' || !manifest.baseVersion || manifest.baseVersion.length > 128 ||
|
|
145
|
+
!/^[a-f0-9]{64}$/.test(manifest.hostSha256) || !Number.isSafeInteger(manifest.version) || manifest.version <= 0) throw new Error('Invalid patch route.');
|
|
146
|
+
const catalog = previous || { schema: 1, kind: 'patchrelay-catalog', appId: manifest.appId, channel, entries: [] };
|
|
147
|
+
if (catalog.schema !== 1 || catalog.kind !== 'patchrelay-catalog' || catalog.appId !== manifest.appId || catalog.channel !== channel ||
|
|
148
|
+
!Array.isArray(catalog.entries) || catalog.entries.length > 64) throw new Error('Existing catalog belongs to a different app/channel or is invalid.');
|
|
149
|
+
const seen = new Set();
|
|
150
|
+
for (const entry of catalog.entries) {
|
|
151
|
+
const route = JSON.stringify([entry.baseVersion, entry.hostSha256]);
|
|
152
|
+
if (typeof entry.baseVersion !== 'string' || !entry.baseVersion || entry.baseVersion.length > 128 || !/^[a-f0-9]{64}$/.test(entry.hostSha256) ||
|
|
153
|
+
!Number.isSafeInteger(entry.version) || entry.version <= 0 || seen.has(route)) throw new Error('Invalid or duplicate catalog route.');
|
|
154
|
+
validateUrl(entry.manifestUrl);
|
|
155
|
+
seen.add(route);
|
|
156
|
+
}
|
|
157
|
+
const old = catalog.entries.find(entry => entry.baseVersion === manifest.baseVersion && entry.hostSha256 === manifest.hostSha256);
|
|
158
|
+
if (old && manifest.version <= old.version) throw new Error('Catalog patch versions must increase for each exact base.');
|
|
159
|
+
const entries = catalog.entries.filter(entry => entry !== old);
|
|
160
|
+
entries.push({ baseVersion: manifest.baseVersion, hostSha256: manifest.hostSha256, version: manifest.version, manifestUrl: validateUrl(manifestUrl) });
|
|
161
|
+
if (entries.length > 64) throw new Error('Catalog has more than 64 base builds.');
|
|
162
|
+
return { schema: 1, kind: 'patchrelay-catalog', appId: manifest.appId, channel, entries };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function reserveVersion(root, baseline, version, writePatch) {
|
|
166
|
+
const folder = inside(root, '.patchrelay/versions');
|
|
167
|
+
mkdirSync(folder, { recursive: true });
|
|
168
|
+
const file = join(folder, `${routeId(baseline)}.json`);
|
|
169
|
+
let lock;
|
|
170
|
+
try { lock = openSync(file + '.lock', 'wx'); }
|
|
171
|
+
catch { throw new Error('Version record is locked. Another patch build may be running; inspect stale locks before removing them.'); }
|
|
172
|
+
try {
|
|
173
|
+
if (existsSync(file)) {
|
|
174
|
+
const record = readJson(file);
|
|
175
|
+
if (record.format !== 1 || !Number.isSafeInteger(record.highest) || record.highest <= 0) throw new Error('Invalid local patch version record. Restore its backup.');
|
|
176
|
+
if (version <= record.highest) throw new Error(`Patch version must exceed ${record.highest} for this exact base.`);
|
|
177
|
+
}
|
|
178
|
+
const temporary = file + '.pending';
|
|
179
|
+
// Reserve before emitting files: a failed write consumes a version rather than reusing a signature identity.
|
|
180
|
+
writeFileSync(temporary, JSON.stringify({ format: 1, highest: version }) + '\n', { flag: 'wx', flush: true });
|
|
181
|
+
renameSync(temporary, file);
|
|
182
|
+
writePatch();
|
|
183
|
+
} finally { closeSync(lock); unlinkSync(file + '.lock'); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// The native APK already contains these exact hooks. New/removed IDs need a new APK.
|
|
187
|
+
export function checkFunctions(baseline, current) {
|
|
188
|
+
const scalar = '(?:Int|Double|Boolean|String)\\??';
|
|
189
|
+
const signature = new RegExp(`^(?:suspend )?\\((?:${scalar}(?:,${scalar}){0,7})?\\)->(?:${scalar}|Unit)$`);
|
|
190
|
+
const valid = value => [1, 2].includes(value?.format) && Array.isArray(value.functions) && (value.format === 2 || value.functions.length > 0) && value.functions.length <= 64 &&
|
|
191
|
+
value.functions.every(f => /^[a-z][a-z0-9_.-]{0,95}$/.test(f.id) && typeof f.signature === 'string' && (value.format === 1 ? f.signature === '(Int,Int)->Int' : signature.test(f.signature))) &&
|
|
192
|
+
new Set(value.functions.map(f => f.id)).size === value.functions.length;
|
|
193
|
+
if (!valid(baseline) || !valid(current)) throw new Error('Missing or invalid generated function contract. Build a new APK baseline with dev.patchrelay.functions.');
|
|
194
|
+
const canonical = value => JSON.stringify(value.functions.map(f => [f.id, f.signature]).sort((a,b) => a[0].localeCompare(b[0])));
|
|
195
|
+
if (baseline.format !== current.format || canonical(baseline) !== canonical(current)) throw new Error('Incompatible function IDs/signatures: added, removed or changed hooks require a new APK baseline.');
|
|
196
|
+
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
plugins { id 'groovy'; id 'java-gradle-plugin' }
|
|
2
|
-
repositories { google(); mavenCentral() }
|
|
3
|
-
dependencies {
|
|
4
|
-
implementation localGroovy()
|
|
5
|
-
compileOnly 'com.android.tools.build:gradle-api:8.6.0'
|
|
6
|
-
}
|
|
7
|
-
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
|
|
8
|
-
gradlePlugin {
|
|
9
|
-
plugins {
|
|
10
|
-
baseline {
|
|
11
|
-
id = 'dev.patchrelay.baseline'
|
|
12
|
-
implementationClass = 'dev.patchrelay.gradle.PatchRelayPlugin'
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
rootProject.name = 'patchrelay-gradle-plugin'
|