@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
|
@@ -1,220 +1,226 @@
|
|
|
1
|
-
package dev.patchrelay.gradle
|
|
2
|
-
|
|
3
|
-
import com.android.build.api.artifact.SingleArtifact
|
|
4
|
-
import com.android.build.api.variant.BuildConfigField
|
|
5
|
-
import groovy.json.JsonOutput
|
|
6
|
-
import groovy.json.JsonSlurper
|
|
7
|
-
import org.gradle.api.*
|
|
8
|
-
import org.gradle.api.file.DirectoryProperty
|
|
9
|
-
import org.gradle.api.tasks.*
|
|
10
|
-
import java.nio.file.Files
|
|
11
|
-
import java.nio.file.StandardCopyOption
|
|
12
|
-
import java.security.MessageDigest
|
|
13
|
-
import java.util.zip.ZipEntry
|
|
14
|
-
import java.util.zip.ZipFile
|
|
15
|
-
import java.util.zip.ZipOutputStream
|
|
16
|
-
|
|
17
|
-
class Contract {
|
|
18
|
-
static String hash(byte[] bytes) { MessageDigest.getInstance('SHA-256').digest(bytes).encodeHex().toString() }
|
|
19
|
-
static String fileHash(File file) {
|
|
20
|
-
def digest = MessageDigest.getInstance('SHA-256')
|
|
21
|
-
file.withInputStream { input ->
|
|
22
|
-
byte[] buffer = new byte[8192]
|
|
23
|
-
int length
|
|
24
|
-
while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length)
|
|
25
|
-
}
|
|
26
|
-
digest.digest().encodeHex().toString()
|
|
27
|
-
}
|
|
28
|
-
static Map read(File file) { new JsonSlurper().parse(file) as Map }
|
|
29
|
-
static File inside(File root, String path) {
|
|
30
|
-
File file = new File(root, path).canonicalFile
|
|
31
|
-
if (!file.toPath().startsWith(root.canonicalFile.toPath())) throw new GradleException('PatchRelay input escapes project directory')
|
|
32
|
-
file
|
|
33
|
-
}
|
|
34
|
-
static Map fingerprint(File root, List paths, List extensions) {
|
|
35
|
-
def files = new TreeMap<String,String>()
|
|
36
|
-
paths.each { path ->
|
|
37
|
-
File input = inside(root, path.toString())
|
|
38
|
-
if (!input.exists()) throw new GradleException("Missing PatchRelay input: $path")
|
|
39
|
-
def visit = { File file ->
|
|
40
|
-
inside(root, root.toPath().relativize(file.toPath()).toString())
|
|
41
|
-
if (Files.isSymbolicLink(file.toPath())) throw new GradleException('PatchRelay source links are unsupported')
|
|
42
|
-
String digest = (extensions.any { file.name.endsWith(it.toString()) }
|
|
43
|
-
? hash(file.getText('UTF-8').replace('\r\n','\n').getBytes('UTF-8')) : fileHash(file))
|
|
44
|
-
files[root.toPath().relativize(file.toPath()).toString().replace('\\','/')] = digest
|
|
45
|
-
}
|
|
46
|
-
if (input.isFile()) visit(input)
|
|
47
|
-
else input.eachFileRecurse { File file -> if (file.isFile()) visit(file) }
|
|
48
|
-
}
|
|
49
|
-
files
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
abstract class PrepareBaseline extends DefaultTask {
|
|
54
|
-
@OutputDirectory abstract DirectoryProperty getOutputDirectory()
|
|
55
|
-
@Internal Map options
|
|
56
|
-
@Internal def namespaceValue
|
|
57
|
-
@Internal def applicationId
|
|
58
|
-
|
|
59
|
-
@TaskAction void prepare() {
|
|
60
|
-
File root = project.rootDir
|
|
61
|
-
String namespace = namespaceValue.get()
|
|
62
|
-
String appId = applicationId.get()
|
|
63
|
-
Map runtime = Contract.read(Contract.inside(root, options.runtimeFile))
|
|
64
|
-
if (runtime.format != 1 || runtime.screenApi != 2 || runtime.appId != appId) throw new GradleException('PatchRelay runtime does not match this application variant')
|
|
65
|
-
File program = Contract.inside(root, options.programFile)
|
|
66
|
-
byte[] bytes = program.bytes
|
|
67
|
-
if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new GradleException('PatchRelay program must be 1 byte–2 MiB')
|
|
68
|
-
java.nio.charset.StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes))
|
|
69
|
-
File asset = Contract.inside(root, options.baselineAsset)
|
|
70
|
-
asset.parentFile.mkdirs()
|
|
71
|
-
if (!asset.isFile() || !Arrays.equals(asset.bytes, bytes)) Files.copy(program.toPath(), asset.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
|
72
|
-
Map nativeFiles = Contract.fingerprint(root, runtime.nativeInputs, runtime.textExtensions)
|
|
73
|
-
Map screenFiles = Contract.fingerprint(root, options.screenInputs, runtime.textExtensions)
|
|
74
|
-
String host = Contract.hash(JsonOutput.toJson(nativeFiles).getBytes('UTF-8'))
|
|
75
|
-
File output = outputDirectory.get().asFile
|
|
76
|
-
output.mkdirs()
|
|
77
|
-
File source = new File(output, namespace.replace('.','/') + '/patchrelay/PatchRelayBuild.java')
|
|
78
|
-
source.parentFile.mkdirs()
|
|
79
|
-
|
|
80
|
-
new
|
|
81
|
-
new File(output, '
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@Internal
|
|
92
|
-
@Internal def
|
|
93
|
-
@Internal def
|
|
94
|
-
@Internal
|
|
95
|
-
@Internal String
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
pointer.
|
|
100
|
-
pointer.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
File
|
|
109
|
-
|
|
110
|
-
Map
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
File
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
Contract.fileHash(new File(destination,
|
|
137
|
-
Contract.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
destination.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
File
|
|
149
|
-
new
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
baseline.
|
|
159
|
-
baseline
|
|
160
|
-
baseline
|
|
161
|
-
baseline.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
project.
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
variant.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
1
|
+
package dev.patchrelay.gradle
|
|
2
|
+
|
|
3
|
+
import com.android.build.api.artifact.SingleArtifact
|
|
4
|
+
import com.android.build.api.variant.BuildConfigField
|
|
5
|
+
import groovy.json.JsonOutput
|
|
6
|
+
import groovy.json.JsonSlurper
|
|
7
|
+
import org.gradle.api.*
|
|
8
|
+
import org.gradle.api.file.DirectoryProperty
|
|
9
|
+
import org.gradle.api.tasks.*
|
|
10
|
+
import java.nio.file.Files
|
|
11
|
+
import java.nio.file.StandardCopyOption
|
|
12
|
+
import java.security.MessageDigest
|
|
13
|
+
import java.util.zip.ZipEntry
|
|
14
|
+
import java.util.zip.ZipFile
|
|
15
|
+
import java.util.zip.ZipOutputStream
|
|
16
|
+
|
|
17
|
+
class Contract {
|
|
18
|
+
static String hash(byte[] bytes) { MessageDigest.getInstance('SHA-256').digest(bytes).encodeHex().toString() }
|
|
19
|
+
static String fileHash(File file) {
|
|
20
|
+
def digest = MessageDigest.getInstance('SHA-256')
|
|
21
|
+
file.withInputStream { input ->
|
|
22
|
+
byte[] buffer = new byte[8192]
|
|
23
|
+
int length
|
|
24
|
+
while ((length = input.read(buffer)) != -1) digest.update(buffer, 0, length)
|
|
25
|
+
}
|
|
26
|
+
digest.digest().encodeHex().toString()
|
|
27
|
+
}
|
|
28
|
+
static Map read(File file) { new JsonSlurper().parse(file) as Map }
|
|
29
|
+
static File inside(File root, String path) {
|
|
30
|
+
File file = new File(root, path).canonicalFile
|
|
31
|
+
if (!file.toPath().startsWith(root.canonicalFile.toPath())) throw new GradleException('PatchRelay input escapes project directory')
|
|
32
|
+
file
|
|
33
|
+
}
|
|
34
|
+
static Map fingerprint(File root, List paths, List extensions) {
|
|
35
|
+
def files = new TreeMap<String,String>()
|
|
36
|
+
paths.each { path ->
|
|
37
|
+
File input = inside(root, path.toString())
|
|
38
|
+
if (!input.exists()) throw new GradleException("Missing PatchRelay input: $path")
|
|
39
|
+
def visit = { File file ->
|
|
40
|
+
inside(root, root.toPath().relativize(file.toPath()).toString())
|
|
41
|
+
if (Files.isSymbolicLink(file.toPath())) throw new GradleException('PatchRelay source links are unsupported')
|
|
42
|
+
String digest = (extensions.any { file.name.endsWith(it.toString()) }
|
|
43
|
+
? hash(file.getText('UTF-8').replace('\r\n','\n').getBytes('UTF-8')) : fileHash(file))
|
|
44
|
+
files[root.toPath().relativize(file.toPath()).toString().replace('\\','/')] = digest
|
|
45
|
+
}
|
|
46
|
+
if (input.isFile()) visit(input)
|
|
47
|
+
else input.eachFileRecurse { File file -> if (file.isFile()) visit(file) }
|
|
48
|
+
}
|
|
49
|
+
files
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
abstract class PrepareBaseline extends DefaultTask {
|
|
54
|
+
@OutputDirectory abstract DirectoryProperty getOutputDirectory()
|
|
55
|
+
@Internal Map options
|
|
56
|
+
@Internal def namespaceValue
|
|
57
|
+
@Internal def applicationId
|
|
58
|
+
|
|
59
|
+
@TaskAction void prepare() {
|
|
60
|
+
File root = project.rootDir
|
|
61
|
+
String namespace = namespaceValue.get()
|
|
62
|
+
String appId = applicationId.get()
|
|
63
|
+
Map runtime = Contract.read(Contract.inside(root, options.runtimeFile))
|
|
64
|
+
if (runtime.format != 1 || runtime.screenApi != 2 || runtime.appId != appId) throw new GradleException('PatchRelay runtime does not match this application variant')
|
|
65
|
+
File program = Contract.inside(root, options.programFile)
|
|
66
|
+
byte[] bytes = program.bytes
|
|
67
|
+
if (!bytes.length || bytes.length > 2 * 1024 * 1024) throw new GradleException('PatchRelay program must be 1 byte–2 MiB')
|
|
68
|
+
java.nio.charset.StandardCharsets.UTF_8.newDecoder().decode(java.nio.ByteBuffer.wrap(bytes))
|
|
69
|
+
File asset = Contract.inside(root, options.baselineAsset)
|
|
70
|
+
asset.parentFile.mkdirs()
|
|
71
|
+
if (!asset.isFile() || !Arrays.equals(asset.bytes, bytes)) Files.copy(program.toPath(), asset.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
|
72
|
+
Map nativeFiles = Contract.fingerprint(root, runtime.nativeInputs, runtime.textExtensions)
|
|
73
|
+
Map screenFiles = Contract.fingerprint(root, options.screenInputs, runtime.textExtensions)
|
|
74
|
+
String host = Contract.hash(JsonOutput.toJson(nativeFiles).getBytes('UTF-8'))
|
|
75
|
+
File output = outputDirectory.get().asFile
|
|
76
|
+
output.mkdirs()
|
|
77
|
+
File source = new File(output, namespace.replace('.','/') + '/patchrelay/PatchRelayBuild.java')
|
|
78
|
+
source.parentFile.mkdirs()
|
|
79
|
+
// A Java constant can be inlined into cached Kotlin consumers from an older base.
|
|
80
|
+
source.setText("package ${namespace}.patchrelay;\npublic final class PatchRelayBuild { public static final String HOST_SHA256 = new String(\"$host\"); }\n", 'UTF-8')
|
|
81
|
+
new File(output, 'baseline-screen.js').bytes = bytes
|
|
82
|
+
new File(output, 'contract.json').setText(JsonOutput.toJson([
|
|
83
|
+
format:3, appId:appId, baseVersion:runtime.baseVersion, screenApi:runtime.screenApi,
|
|
84
|
+
screenIds:runtime.screenIds, hostSha256:host, nativeFiles:nativeFiles,
|
|
85
|
+
screenFiles:screenFiles, functionContract:Contract.read(Contract.inside(root, options.functionContractFile)), programSha256:Contract.hash(bytes)
|
|
86
|
+
]), 'UTF-8')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
abstract class RecordBaseline extends DefaultTask {
|
|
91
|
+
@Internal Map options
|
|
92
|
+
@Internal def prepared
|
|
93
|
+
@Internal def artifactProvider
|
|
94
|
+
@Internal def artifactsLoader
|
|
95
|
+
@Internal String artifactType
|
|
96
|
+
@Internal String variantName
|
|
97
|
+
|
|
98
|
+
private void result(File destination, File artifact) {
|
|
99
|
+
File pointer = project.layout.buildDirectory.file("outputs/patchrelay/$variantName/$artifactType-record.json").get().asFile
|
|
100
|
+
pointer.parentFile.mkdirs()
|
|
101
|
+
pointer.setText(JsonOutput.toJson([
|
|
102
|
+
baselineFile:project.rootDir.toPath().relativize(new File(destination, 'baseline.json').toPath()).toString().replace('\\','/'),
|
|
103
|
+
artifactFile:project.rootDir.toPath().relativize(artifact.toPath()).toString().replace('\\','/')
|
|
104
|
+
]) + '\n', 'UTF-8')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@TaskAction void record() {
|
|
108
|
+
File root = project.rootDir
|
|
109
|
+
File preparedDir = prepared.get().outputDirectory.get().asFile
|
|
110
|
+
Map baseline = Contract.read(new File(preparedDir, 'contract.json'))
|
|
111
|
+
Map runtime = Contract.read(Contract.inside(root, options.runtimeFile))
|
|
112
|
+
if (Contract.fingerprint(root, runtime.nativeInputs, runtime.textExtensions) != baseline.nativeFiles ||
|
|
113
|
+
Contract.fingerprint(root, options.screenInputs, runtime.textExtensions) != baseline.screenFiles) {
|
|
114
|
+
throw new GradleException('Sources changed during the build; no PatchRelay baseline recorded')
|
|
115
|
+
}
|
|
116
|
+
File artifact
|
|
117
|
+
if (artifactType == 'apk') {
|
|
118
|
+
def built = artifactsLoader.load(artifactProvider.get())
|
|
119
|
+
if (built == null || built.applicationId != baseline.appId || built.elements.size() != 1) throw new GradleException('PatchRelay currently requires one non-split APK per variant')
|
|
120
|
+
artifact = new File(built.elements.first().outputFile)
|
|
121
|
+
} else artifact = artifactProvider.get().asFile
|
|
122
|
+
String entryName = (artifactType == 'aab' ? 'base/' : '') + 'assets/baseline-screen.js'
|
|
123
|
+
new ZipFile(artifact).withCloseable { zip ->
|
|
124
|
+
def entry = zip.getEntry(entryName)
|
|
125
|
+
if (entry == null || entry.size < 1 || entry.size > 2 * 1024 * 1024) throw new GradleException('Built artifact has no supported bundled screen program')
|
|
126
|
+
byte[] embedded = zip.getInputStream(entry).withCloseable { it.readNBytes(2 * 1024 * 1024 + 1) }
|
|
127
|
+
if (Contract.hash(embedded) != baseline.programSha256) throw new GradleException('Built artifact contains a different screen program; baseline rejected')
|
|
128
|
+
}
|
|
129
|
+
String artifactHash = Contract.fileHash(artifact)
|
|
130
|
+
File destination = Contract.inside(root, ".patchrelay/releases/baselines/${baseline.appId}/$variantName/$artifactType-$artifactHash")
|
|
131
|
+
File existing = new File(destination, 'baseline.json')
|
|
132
|
+
if (existing.isFile()) {
|
|
133
|
+
Map saved = Contract.read(existing)
|
|
134
|
+
if (saved.artifactSha256 != artifactHash || saved.nativeFiles != baseline.nativeFiles || saved.screenFiles != baseline.screenFiles ||
|
|
135
|
+
!new File(destination, 'baseline-sources.zip').isFile() ||
|
|
136
|
+
Contract.fileHash(new File(destination, 'baseline-sources.zip')) != saved.sourcesSha256 ||
|
|
137
|
+
Contract.fileHash(new File(destination, "screen-base.$artifactType")) != artifactHash ||
|
|
138
|
+
Contract.hash(new File(destination, 'baseline-screen.js').bytes) != baseline.programSha256) {
|
|
139
|
+
throw new GradleException('Existing PatchRelay record differs or is damaged; restore it rather than overwriting')
|
|
140
|
+
}
|
|
141
|
+
logger.lifecycle("PatchRelay baseline already recorded: $destination")
|
|
142
|
+
result(destination, artifact)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
if (destination.exists()) throw new GradleException('Incomplete PatchRelay output exists; preserve it and choose a fresh build')
|
|
146
|
+
destination.mkdirs()
|
|
147
|
+
Files.copy(artifact.toPath(), new File(destination, "screen-base.$artifactType").toPath())
|
|
148
|
+
new File(destination, 'baseline-screen.js').bytes = new File(preparedDir, 'baseline-screen.js').bytes
|
|
149
|
+
File archive = new File(destination, 'baseline-sources.zip')
|
|
150
|
+
new ZipOutputStream(new FileOutputStream(archive)).withCloseable { zip ->
|
|
151
|
+
(baseline.nativeFiles.keySet() + baseline.screenFiles.keySet()).toSorted().unique().each { String path ->
|
|
152
|
+
if (path.split('/').any { it in ['.keys', '.env', 'local.properties'] } || path ==~ /(?i).*\.(key|pem|p12|jks|keystore)$/) throw new GradleException('Private credentials cannot be baseline inputs')
|
|
153
|
+
File file = Contract.inside(root, path)
|
|
154
|
+
def entry = new ZipEntry(path); entry.time = 0
|
|
155
|
+
zip.putNextEntry(entry); file.withInputStream { it.transferTo(zip) }; zip.closeEntry()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
baseline.artifactType = artifactType
|
|
159
|
+
baseline.artifactSha256 = artifactHash
|
|
160
|
+
baseline[artifactType == 'apk' ? 'apkSha256' : 'aabSha256'] = artifactHash
|
|
161
|
+
baseline.variant = variantName
|
|
162
|
+
baseline.sourcesSha256 = Contract.fileHash(archive)
|
|
163
|
+
Files.writeString(existing.toPath(), JsonOutput.prettyPrint(JsonOutput.toJson(baseline)) + '\n', java.nio.file.StandardOpenOption.CREATE_NEW)
|
|
164
|
+
result(destination, artifact)
|
|
165
|
+
logger.lifecycle("PatchRelay baseline recorded: $destination\nBack up the entire directory privately; patch selection remains explicit.")
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
class PatchRelayPlugin implements Plugin<Project> {
|
|
170
|
+
void apply(Project project) {
|
|
171
|
+
def extension = project.extensions.create('patchrelayBaseline', BaselineOptions, project)
|
|
172
|
+
project.plugins.withId('com.android.application') {
|
|
173
|
+
project.extensions.getByName('androidComponents').onVariants(
|
|
174
|
+
project.extensions.getByName('androidComponents').selector().withBuildType('release')) { variant ->
|
|
175
|
+
def options = extension.values()
|
|
176
|
+
String cap = variant.name.capitalize()
|
|
177
|
+
def prepare = project.tasks.register("preparePatchRelay$cap", PrepareBaseline) {
|
|
178
|
+
it.options = options; it.namespaceValue = variant.namespace; it.applicationId = variant.applicationId
|
|
179
|
+
outputDirectory.set(project.layout.buildDirectory.dir("generated/patchrelay/${variant.name}"))
|
|
180
|
+
dependsOn(options.compileTasks)
|
|
181
|
+
outputs.upToDateWhen { false }
|
|
182
|
+
notCompatibleWithConfigurationCache('PatchRelay snapshots declared project sources at execution time')
|
|
183
|
+
}
|
|
184
|
+
variant.sources.java.addGeneratedSourceDirectory(prepare, { it.outputDirectory })
|
|
185
|
+
project.tasks.matching { it.name == "compile${cap}Kotlin" }.configureEach {
|
|
186
|
+
dependsOn(prepare)
|
|
187
|
+
inputs.dir(prepare.flatMap { it.outputDirectory })
|
|
188
|
+
}
|
|
189
|
+
variant.buildConfigFields.put('HOST_SHA256', project.providers.provider {
|
|
190
|
+
new BuildConfigField('String', "${variant.namespace.get()}.patchrelay.PatchRelayBuild.HOST_SHA256", null)
|
|
191
|
+
})
|
|
192
|
+
project.tasks.matching { it.name == "merge${cap}Assets" || it.name == "generate${cap}BuildConfig" }.configureEach { dependsOn(prepare) }
|
|
193
|
+
['apk','aab'].each { type ->
|
|
194
|
+
String buildName = type == 'apk' ? "assemble$cap" : "bundle$cap"
|
|
195
|
+
def provider = variant.artifacts.get(type == 'apk' ? SingleArtifact.APK.INSTANCE : SingleArtifact.BUNDLE.INSTANCE)
|
|
196
|
+
def record = project.tasks.register("recordPatchRelay${cap}${type.toUpperCase()}", RecordBaseline) {
|
|
197
|
+
it.options = options; it.prepared = prepare; artifactProvider = provider
|
|
198
|
+
artifactsLoader = variant.artifacts.getBuiltArtifactsLoader()
|
|
199
|
+
artifactType = type; variantName = variant.name
|
|
200
|
+
dependsOn(prepare, provider)
|
|
201
|
+
notCompatibleWithConfigurationCache('PatchRelay writes immutable release records outside build/')
|
|
202
|
+
onlyIf {
|
|
203
|
+
def build = project.tasks.findByName(buildName)
|
|
204
|
+
build != null && build.state.executed && build.state.failure == null
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
project.tasks.matching { it.name == buildName }.configureEach { build ->
|
|
208
|
+
build.finalizedBy(record)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
class BaselineOptions {
|
|
217
|
+
final Project project
|
|
218
|
+
String runtimeFile = 'runtime.json'
|
|
219
|
+
String programFile = 'screens/build/compileSync/js/main/productionExecutable/kotlin/PatchRelayScreens.js'
|
|
220
|
+
String baselineAsset
|
|
221
|
+
String functionContractFile = 'screens/build/generated/patchrelay/functions.json'
|
|
222
|
+
List<String> screenInputs = ['screens/src/commonMain']
|
|
223
|
+
List<String> compileTasks = [':screens:jvmTest', ':screens:compileProductionExecutableKotlinJs']
|
|
224
|
+
BaselineOptions(Project project) { this.project = project; baselineAsset = project.rootDir.toPath().relativize(new File(project.projectDir, 'src/main/assets/baseline-screen.js').toPath()).toString().replace('\\','/') }
|
|
225
|
+
Map values() { [functionContractFile:functionContractFile, runtimeFile:runtimeFile, programFile:programFile, baselineAsset:baselineAsset, screenInputs:screenInputs, compileTasks:compileTasks] }
|
|
226
|
+
}
|
package/.patchrelay-tools/src/main/kotlin/dev/patchrelay/compiler/PatchRelayCompilerRegistrar.kt
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
package dev.patchrelay.compiler
|
|
2
|
+
|
|
3
|
+
import org.jetbrains.kotlin.backend.common.extensions.IrGenerationExtension
|
|
4
|
+
import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
|
|
5
|
+
import org.jetbrains.kotlin.backend.common.lower.DeclarationIrBuilder
|
|
6
|
+
import org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar
|
|
7
|
+
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
|
|
8
|
+
import org.jetbrains.kotlin.config.CompilerConfiguration
|
|
9
|
+
import org.jetbrains.kotlin.descriptors.DescriptorVisibilities
|
|
10
|
+
import org.jetbrains.kotlin.ir.builders.*
|
|
11
|
+
import org.jetbrains.kotlin.ir.declarations.*
|
|
12
|
+
import org.jetbrains.kotlin.ir.expressions.IrCall
|
|
13
|
+
import org.jetbrains.kotlin.ir.expressions.IrExpression
|
|
14
|
+
import org.jetbrains.kotlin.ir.expressions.impl.IrCallImpl
|
|
15
|
+
import org.jetbrains.kotlin.ir.util.*
|
|
16
|
+
import org.jetbrains.kotlin.ir.visitors.IrElementTransformerVoid
|
|
17
|
+
import org.jetbrains.kotlin.name.CallableId
|
|
18
|
+
import org.jetbrains.kotlin.name.FqName
|
|
19
|
+
import org.jetbrains.kotlin.name.Name
|
|
20
|
+
|
|
21
|
+
@OptIn(ExperimentalCompilerApi::class)
|
|
22
|
+
class PatchRelayCompilerRegistrar : CompilerPluginRegistrar() {
|
|
23
|
+
override val supportsK2 = false
|
|
24
|
+
override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
|
|
25
|
+
IrGenerationExtension.registerExtension(SuspendInterception())
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Declaration instrumentation also covers callers compiled in other JVM/Android modules. */
|
|
30
|
+
private class SuspendInterception : IrGenerationExtension {
|
|
31
|
+
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
|
|
32
|
+
val originals = moduleFragment.files.flatMap { it.declarations.filterIsInstance<IrSimpleFunction>() }
|
|
33
|
+
.filter { it.isSuspend && it.hasAnnotation(FqName("PatchFunction")) }
|
|
34
|
+
val fallbacks = originals.associateWith { original ->
|
|
35
|
+
original.deepCopyWithSymbols(original.parent).apply {
|
|
36
|
+
name = Name.identifier("__patchrelay_original_${original.name}")
|
|
37
|
+
visibility = DescriptorVisibilities.INTERNAL
|
|
38
|
+
annotations = emptyList()
|
|
39
|
+
attributeOwnerId = this
|
|
40
|
+
metadata = null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
val fallbackCalls = object : IrElementTransformerVoid() {
|
|
44
|
+
override fun visitCall(expression: IrCall): IrExpression {
|
|
45
|
+
expression.transformChildrenVoid()
|
|
46
|
+
val fallback = fallbacks[expression.symbol.owner] ?: return expression
|
|
47
|
+
return IrCallImpl.fromSymbolOwner(expression.startOffset, expression.endOffset, expression.type, fallback.symbol).apply {
|
|
48
|
+
for (i in 0 until expression.valueArgumentsCount) putValueArgument(i, expression.getValueArgument(i))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
for ((original, fallback) in fallbacks) {
|
|
53
|
+
fallback.body?.transformChildren(fallbackCalls, null)
|
|
54
|
+
(original.parent as IrFile).declarations.add(fallback)
|
|
55
|
+
val wrapper = pluginContext.referenceFunctions(CallableId(FqName.ROOT,
|
|
56
|
+
Name.identifier("patchRelayIntercept_${original.name}"))).singleOrNull()
|
|
57
|
+
?: error("PatchRelay: missing generated suspend bridge for ${original.name}; apply dev.patchrelay.functions")
|
|
58
|
+
original.body = DeclarationIrBuilder(pluginContext, original.symbol).irBlockBody {
|
|
59
|
+
+irReturn(irCall(wrapper).apply {
|
|
60
|
+
original.valueParameters.forEachIndexed { index, parameter -> putValueArgument(index, irGet(parameter)) }
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Generated callbacks own native fallback: they must bypass the instrumented entrypoint.
|
|
65
|
+
moduleFragment.files.filter { it.fileEntry.name.endsWith("/PatchRelayFunctions.kt") ||
|
|
66
|
+
it.fileEntry.name.endsWith("\\PatchRelayFunctions.kt") }.forEach {
|
|
67
|
+
it.transformChildren(fallbackCalls, null)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dev.patchrelay.compiler.PatchRelayCompilerRegistrar
|