@orbital_mcp/patchrelay 0.0.0-stage → 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/.patchrelay-tools/src/main/groovy/dev/patchrelay/gradle/PatchRelayPlugin.groovy +226 -0
- 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/LICENSE +71 -0
- package/README.md +249 -3
- package/bin/patchrelay.mjs +7 -0
- package/package.json +13 -4
- package/src/main.mjs +511 -0
- package/src/project.mjs +196 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
plugins { id 'groovy'; id 'java-gradle-plugin'; id 'org.jetbrains.kotlin.jvm' version '1.9.23' }
|
|
2
|
+
repositories { google(); mavenCentral() }
|
|
3
|
+
dependencies {
|
|
4
|
+
implementation localGroovy()
|
|
5
|
+
implementation 'org.jetbrains.kotlin:kotlin-compiler-embeddable:1.9.23'
|
|
6
|
+
testImplementation 'junit:junit:4.13.2'
|
|
7
|
+
compileOnly 'com.android.tools.build:gradle-api:8.6.0'
|
|
8
|
+
}
|
|
9
|
+
java { toolchain { languageVersion = JavaLanguageVersion.of(17) } }
|
|
10
|
+
kotlin { jvmToolchain(17) }
|
|
11
|
+
gradlePlugin {
|
|
12
|
+
plugins {
|
|
13
|
+
functions {
|
|
14
|
+
id = 'dev.patchrelay.functions'
|
|
15
|
+
implementationClass = 'dev.patchrelay.gradle.FunctionsPlugin'
|
|
16
|
+
}
|
|
17
|
+
baseline {
|
|
18
|
+
id = 'dev.patchrelay.baseline'
|
|
19
|
+
implementationClass = 'dev.patchrelay.gradle.PatchRelayPlugin'
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
rootProject.name = 'patchrelay-build-tools'
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
package dev.patchrelay.gradle
|
|
2
|
+
|
|
3
|
+
import groovy.json.JsonOutput
|
|
4
|
+
import org.gradle.api.*
|
|
5
|
+
import org.gradle.api.tasks.*
|
|
6
|
+
import org.gradle.api.file.FileCollection
|
|
7
|
+
import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment
|
|
8
|
+
import org.jetbrains.kotlin.cli.jvm.compiler.EnvironmentConfigFiles
|
|
9
|
+
import org.jetbrains.kotlin.config.CompilerConfiguration
|
|
10
|
+
import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer
|
|
11
|
+
import org.jetbrains.kotlin.psi.*
|
|
12
|
+
import org.jetbrains.kotlin.lexer.KtTokens
|
|
13
|
+
|
|
14
|
+
/** Kotlin PSI reads declarations; Kotlin/JVM and Kotlin/JS still type-check all bodies. */
|
|
15
|
+
class FunctionBindings {
|
|
16
|
+
static List<Map> inspect(Map<String, String> sources) { discover(sources).functions }
|
|
17
|
+
|
|
18
|
+
static Map discover(Map<String, String> sources, boolean compose = false) {
|
|
19
|
+
def disposable = Disposer.newDisposable()
|
|
20
|
+
try {
|
|
21
|
+
def env = KotlinCoreEnvironment.createForProduction(disposable, new CompilerConfiguration(), EnvironmentConfigFiles.JVM_CONFIG_FILES)
|
|
22
|
+
def factory = new KtPsiFactory(env.project, false)
|
|
23
|
+
def bindings = []
|
|
24
|
+
def states = []
|
|
25
|
+
def entrypoints = new HashSet<String>()
|
|
26
|
+
sources.toSorted().each { path, text ->
|
|
27
|
+
def file = factory.createFile(path, text.replace('\r\n', '\n').replace('\r', '\n'))
|
|
28
|
+
if (compose && file.importDirectives.any { it.importedFqName?.asString()?.startsWith('androidx.compose.') && !it.importedFqName.asString().startsWith('androidx.compose.runtime') })
|
|
29
|
+
throw new GradleException("PatchRelay $path: only Compose runtime and dev.patchrelay.compose widgets are supported; Android Material/UI requires a different backend")
|
|
30
|
+
if (compose && file.importDirectives.any { it.importedFqName?.asString() in ['androidx.compose.runtime.LaunchedEffect', 'androidx.compose.runtime.produceState', 'androidx.compose.runtime.rememberCoroutineScope', 'androidx.compose.runtime.snapshotFlow'] })
|
|
31
|
+
throw new GradleException("PatchRelay $path: asynchronous Compose effects are unsupported, including aliased imports; use installed host operations")
|
|
32
|
+
if (file.importDirectives.any { it.aliasName && it.importedFqName?.shortName()?.asString() == 'PatchFunction' })
|
|
33
|
+
throw new GradleException("PatchRelay $path: annotation aliases are unsupported; use @PatchFunction")
|
|
34
|
+
def visit
|
|
35
|
+
visit = { element ->
|
|
36
|
+
if (!compose && element instanceof KtNamedFunction && element.annotationEntries.any { it.shortName?.asString() == 'Composable' })
|
|
37
|
+
throw new GradleException("PatchRelay $path: ${element.name}: Compose runtime/backend is unsupported; use the shared screen-tree DSL or keep Compose in the installed native host")
|
|
38
|
+
if (compose && element instanceof KtCallExpression && element.calleeExpression?.text in ['LaunchedEffect', 'produceState', 'rememberCoroutineScope', 'snapshotFlow'])
|
|
39
|
+
throw new GradleException("PatchRelay $path: ${element.calleeExpression.text}: asynchronous Compose effects are unsupported; use installed host operations and explicit render/event transactions")
|
|
40
|
+
if (element instanceof KtNamedFunction && element.parent instanceof KtFile) {
|
|
41
|
+
if (element.name?.startsWith('patchRelayIntercept_') || element.name?.startsWith('__patchrelay_original_'))
|
|
42
|
+
throw new GradleException("PatchRelay $path: reserved compiler-generated function name: ${element.name}")
|
|
43
|
+
entrypoints.add(element.name)
|
|
44
|
+
}
|
|
45
|
+
if (element instanceof KtProperty && element.annotationEntries.any { it.shortName?.asString() == 'PatchState' }) {
|
|
46
|
+
def property = element
|
|
47
|
+
def annotation = property.annotationEntries.find { it.shortName?.asString() == 'PatchState' }
|
|
48
|
+
String target = (file.packageFqName.asString() ? file.packageFqName.asString() + '.' : '') + property.name
|
|
49
|
+
String id = target.toLowerCase(Locale.ROOT)
|
|
50
|
+
if (annotation.valueArguments) {
|
|
51
|
+
def expression = annotation.valueArguments.size() == 1 ? annotation.valueArguments[0].getArgumentExpression() : null
|
|
52
|
+
if (!(expression instanceof KtStringTemplateExpression) || expression.entries.any { !(it instanceof KtLiteralStringTemplateEntry) })
|
|
53
|
+
throw new GradleException("PatchRelay $path: state ID must be a literal string")
|
|
54
|
+
id = expression.entries.collect { it.text }.join('')
|
|
55
|
+
}
|
|
56
|
+
if (!(property.parent instanceof KtFile) || !property.isVar() || !property.initializer || property.accessors || property.hasDelegate() ||
|
|
57
|
+
property.hasModifier(KtTokens.PRIVATE_KEYWORD) || !supportedType(property.typeReference?.text, false) ||
|
|
58
|
+
!property.name?.matches('[a-zA-Z][a-zA-Z0-9_]*') || !id.matches('[a-z][a-z0-9_.-]{0,95}'))
|
|
59
|
+
throw new GradleException("PatchRelay $path: @PatchState requires a top-level mutable scalar with explicit type, initializer and no custom accessors")
|
|
60
|
+
states << [id:id, target:target, type:property.typeReference.text]
|
|
61
|
+
}
|
|
62
|
+
if (element instanceof KtNamedFunction && element.annotationEntries.any { it.shortName?.asString() == 'PatchFunction' }) {
|
|
63
|
+
def fn = element
|
|
64
|
+
def fail = { reason -> throw new GradleException("PatchRelay $path: ${fn.name}: $reason. Supported: public top-level function, 0..8 scalar arguments, explicit scalar/Unit return type") }
|
|
65
|
+
if (!(fn.parent instanceof KtFile) || fn.receiverTypeReference || fn.typeParameters ||
|
|
66
|
+
fn.hasModifier(KtTokens.PRIVATE_KEYWORD) || fn.hasModifier(KtTokens.INTERNAL_KEYWORD) || fn.hasModifier(KtTokens.EXPECT_KEYWORD) || fn.hasModifier(KtTokens.EXTERNAL_KEYWORD) || fn.hasModifier(KtTokens.INLINE_KEYWORD)) fail('unsupported function declaration (inline interception is unsupported)')
|
|
67
|
+
def parameters = fn.valueParameters.collect { it.typeReference?.text }
|
|
68
|
+
String result = fn.typeReference?.text
|
|
69
|
+
if (!fn.name?.matches('[a-zA-Z][a-zA-Z0-9_]*') || !fn.hasBody() || fn.valueParameters.size() > 8 ||
|
|
70
|
+
!supportedType(result, true) || parameters.any { !supportedType(it, false) } ||
|
|
71
|
+
fn.valueParameters.any { it.defaultValue || it.isVarArg() }) fail('use Int, finite Double, Boolean, String, nullable scalars or Unit result; no defaults or varargs')
|
|
72
|
+
def annotation = fn.annotationEntries.find { it.shortName?.asString() == 'PatchFunction' }
|
|
73
|
+
def args = annotation.valueArguments
|
|
74
|
+
String qualified = (file.packageFqName.asString() ? file.packageFqName.asString() + '.' : '') + fn.name
|
|
75
|
+
String id = qualified.toLowerCase(Locale.ROOT) + '.v1'
|
|
76
|
+
if (args) {
|
|
77
|
+
def expression = args.size() == 1 ? args[0].getArgumentExpression() : null
|
|
78
|
+
if (!(expression instanceof KtStringTemplateExpression) || expression.entries.any { !(it instanceof KtLiteralStringTemplateEntry) }) fail('ID must be a literal string')
|
|
79
|
+
id = expression.entries.collect { it.text }.join('')
|
|
80
|
+
}
|
|
81
|
+
if (!id.matches('[a-z][a-z0-9_.-]{0,95}')) fail('ID must match [a-z][a-z0-9_.-]{0,95}')
|
|
82
|
+
boolean suspending = fn.hasModifier(KtTokens.SUSPEND_KEYWORD)
|
|
83
|
+
bindings << [id:id, signature:(suspending ? 'suspend ' : '') + '(' + parameters.join(',') + ')->' + result,
|
|
84
|
+
target:qualified, name:fn.name, parameters:parameters, result:result, suspending:suspending]
|
|
85
|
+
}
|
|
86
|
+
element.children.each { visit(it) }
|
|
87
|
+
}
|
|
88
|
+
visit(file)
|
|
89
|
+
}
|
|
90
|
+
if (bindings.size() > 64) throw new GradleException('PatchRelay supports at most 64 @PatchFunction declarations')
|
|
91
|
+
if (bindings*.id.toSet().size() != bindings.size() || bindings*.name.toSet().size() != bindings.size()) throw new GradleException('PatchRelay duplicate function ID or hook name; overloads are unsupported')
|
|
92
|
+
if (entrypoints.contains('main')) throw new GradleException('PatchRelay owns the JS main entrypoint; move application initialization into the screen module')
|
|
93
|
+
if (entrypoints.contains('takeHostRequests') != entrypoints.contains('handleHostResult')) throw new GradleException('PatchRelay host protocol requires both takeHostRequests and handleHostResult')
|
|
94
|
+
if (states.size() > 32 || states*.id.toSet().size() != states.size()) throw new GradleException('PatchRelay state IDs must be unique; maximum 32 values')
|
|
95
|
+
[functions:bindings.sort { it.id }, hostCalls:entrypoints.contains('takeHostRequests'), states:states.sort { it.id }, migration:entrypoints.contains('migrateAppState')]
|
|
96
|
+
} finally { Disposer.dispose(disposable) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static boolean supportedType(String type, boolean result) {
|
|
100
|
+
type != null && (type in ['Int', 'Double', 'Boolean', 'String', 'Int?', 'Double?', 'Boolean?', 'String?'] || (result && type == 'Unit'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static String decode(String type, String value) {
|
|
104
|
+
if (type == 'Unit') return "patchRelayUnit($value)"
|
|
105
|
+
String name = type.replace('?', '')
|
|
106
|
+
type.endsWith('?') ? "patchRelayNullable($value, ::patchRelay$name)" : "patchRelay$name($value)"
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
static String encode(String type, String value) { type == 'Unit' ? "run { $value; JsonNull }" : "JsonPrimitive($value)" }
|
|
110
|
+
|
|
111
|
+
static void generate(List<Map> bindings, File output, boolean hostCalls = false, List<Map> states = [], boolean migration = false) {
|
|
112
|
+
File common = new File(output, 'common/PatchRelayFunctions.kt')
|
|
113
|
+
File js = new File(output, 'js/PatchRelayExports.kt')
|
|
114
|
+
common.parentFile.mkdirs(); js.parentFile.mkdirs()
|
|
115
|
+
String imports = bindings.withIndex().collect { b, index -> "import ${b.target} as patchRelayNative$index" }.join('\n')
|
|
116
|
+
imports += '\n' + states.withIndex().collect { state, index -> "import ${state.target} as patchRelayState$index" }.join('\n')
|
|
117
|
+
String schema = Contract.hash(JsonOutput.toJson(states.collect { [it.id, it.type] }).getBytes('UTF-8'))
|
|
118
|
+
String restore = states.withIndex().collect { state, index -> ' val state' + index + ' = ' + decode(state.type, 'values["' + state.id + '"] ?: error("Missing state")') }.join('\n') + '\n' +
|
|
119
|
+
states.withIndex().collect { state, index -> " patchRelayState$index = state$index" }.join('\n')
|
|
120
|
+
String stateCode = """
|
|
121
|
+
internal fun patchRelayExportState(): String = patchRelayText(JsonObject(mapOf(
|
|
122
|
+
"schema" to JsonPrimitive("$schema"), "values" to JsonObject(mapOf(${states.withIndex().collect { state, index -> '"' + state.id + '" to ' + encode(state.type, "patchRelayState$index") }.join(', ')})))))
|
|
123
|
+
internal fun patchRelayRestoreState(snapshot: String): String {
|
|
124
|
+
val root = patchRelayParse(snapshot) as? JsonObject ?: error("Expected state object")
|
|
125
|
+
val schema = patchRelayString(root["schema"] ?: error("Missing state schema"))
|
|
126
|
+
val oldValues = root["values"] as? JsonObject ?: error("Missing state values")
|
|
127
|
+
val values = if (schema == "$schema") oldValues else ${migration ? 'migrateAppState(schema, oldValues)' : 'return "incompatible"'}
|
|
128
|
+
require(values.keys == setOf<String>(${states.collect { '"' + it.id + '"' }.join(', ')})) { "Wrong state fields" }
|
|
129
|
+
$restore
|
|
130
|
+
return "restored"
|
|
131
|
+
}
|
|
132
|
+
"""
|
|
133
|
+
String hooks = bindings.withIndex().collect { b, index ->
|
|
134
|
+
String params = b.parameters.withIndex().collect { type, n -> "arg$n: $type" }.join(', ')
|
|
135
|
+
String args = b.parameters.indices.collect { "arg$it" }.join(', ')
|
|
136
|
+
String call = "patchRelayNative$index($args)"
|
|
137
|
+
String encodedArgs = b.parameters.withIndex().collect { type, n -> encode(type, "arg$n") }.join(', ')
|
|
138
|
+
""" fun ${b.name}(${params ? params + ', ' : ''}dispatch: PatchRelayDispatch? = null, onResult: (${b.result}) -> Unit) {
|
|
139
|
+
val native: suspend () -> ${b.result} = { $call }
|
|
140
|
+
if (dispatch == null) native.startCoroutine(Continuation(EmptyCoroutineContext) { onResult(it.getOrThrow()) })
|
|
141
|
+
else dispatch("${b.id}", patchRelayText(JsonArray(listOf($encodedArgs))), { done ->
|
|
142
|
+
native.startCoroutine(Continuation(EmptyCoroutineContext) { result -> done(result.map { value -> patchRelayText(${encode(b.result, 'value')}) }) })
|
|
143
|
+
}, { onResult(${decode(b.result, 'patchRelayParse(it)')}) })
|
|
144
|
+
}"""
|
|
145
|
+
}.join('\n')
|
|
146
|
+
String intercepted = bindings.findAll { it.suspending }.collect { b ->
|
|
147
|
+
String nativeName = 'patchRelayNative' + bindings.indexOf(b)
|
|
148
|
+
String params = b.parameters.withIndex().collect { type, n -> "arg$n: $type" }.join(', ')
|
|
149
|
+
String args = b.parameters.indices.collect { "arg$it" }.join(', ')
|
|
150
|
+
String encodedArgs = b.parameters.withIndex().collect { type, n -> encode(type, "arg$n") }.join(', ')
|
|
151
|
+
"""suspend fun patchRelayIntercept_${b.name}($params): ${b.result} {
|
|
152
|
+
val dispatch = PatchRelayFunctions.dispatcher
|
|
153
|
+
if (dispatch == null) return $nativeName($args)
|
|
154
|
+
return suspendCoroutine { continuation ->
|
|
155
|
+
var completed = false
|
|
156
|
+
val finish: (Result<String>) -> Unit = { result ->
|
|
157
|
+
if (!completed) {
|
|
158
|
+
completed = true
|
|
159
|
+
continuation.resumeWith(result.mapCatching { ${decode(b.result, 'patchRelayParse(it)')} })
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
dispatch("${b.id}", patchRelayText(JsonArray(listOf($encodedArgs))), { done ->
|
|
164
|
+
val native: suspend () -> ${b.result} = { $nativeName($args) }
|
|
165
|
+
native.startCoroutine(Continuation(EmptyCoroutineContext) { result -> done(result.mapCatching { value -> patchRelayText(${encode(b.result, 'value')}) }) })
|
|
166
|
+
}, finish)
|
|
167
|
+
} catch (failure: Throwable) { finish(Result.failure(failure)) }
|
|
168
|
+
}
|
|
169
|
+
}"""
|
|
170
|
+
}.join('\n')
|
|
171
|
+
common.setText("""// Generated by PatchRelay. Do not edit.
|
|
172
|
+
import kotlinx.serialization.json.*
|
|
173
|
+
import kotlin.coroutines.*
|
|
174
|
+
$imports
|
|
175
|
+
@Target(AnnotationTarget.FUNCTION)
|
|
176
|
+
@Retention(AnnotationRetention.BINARY)
|
|
177
|
+
annotation class PatchFunction(val id: String = "")
|
|
178
|
+
@Target(AnnotationTarget.PROPERTY)
|
|
179
|
+
@Retention(AnnotationRetention.SOURCE)
|
|
180
|
+
annotation class PatchState(val id: String = "")
|
|
181
|
+
|
|
182
|
+
typealias PatchRelayDispatch = (String, String, ((Result<String>) -> Unit) -> Unit, (String) -> Unit) -> Unit
|
|
183
|
+
typealias PatchRelaySuspendDispatch = (String, String, ((Result<String>) -> Unit) -> Unit, (Result<String>) -> Unit) -> Unit
|
|
184
|
+
|
|
185
|
+
internal fun patchRelayParse(value: String): JsonElement { require(value.length <= 8192); return Json.parseToJsonElement(value) }
|
|
186
|
+
internal fun patchRelayText(value: JsonElement): String = value.toString().also { require(it.length <= 8192) }
|
|
187
|
+
internal fun patchRelayInt(value: JsonElement): Int { require(value is JsonPrimitive && !value.isString); return value.intOrNull ?: error("Expected Int") }
|
|
188
|
+
internal fun patchRelayDouble(value: JsonElement): Double { require(value is JsonPrimitive && !value.isString); return (value.doubleOrNull ?: error("Expected Double")).also { require(it.isFinite()) } }
|
|
189
|
+
internal fun patchRelayBoolean(value: JsonElement): Boolean { require(value is JsonPrimitive && !value.isString); return value.booleanOrNull ?: error("Expected Boolean") }
|
|
190
|
+
internal fun patchRelayString(value: JsonElement): String { require(value is JsonPrimitive && value.isString); return value.content.also { require(it.length <= 4096) } }
|
|
191
|
+
internal fun patchRelayUnit(value: JsonElement) { require(value == JsonNull) }
|
|
192
|
+
internal fun <T> patchRelayNullable(value: JsonElement, read: (JsonElement) -> T): T? = if (value == JsonNull) null else read(value)
|
|
193
|
+
|
|
194
|
+
$stateCode
|
|
195
|
+
$intercepted
|
|
196
|
+
object PatchRelayFunctions {
|
|
197
|
+
@kotlin.concurrent.Volatile private var installedDispatcher: PatchRelaySuspendDispatch? = null
|
|
198
|
+
internal val dispatcher: PatchRelaySuspendDispatch? get() = installedDispatcher
|
|
199
|
+
fun installDispatcher(dispatch: PatchRelaySuspendDispatch?) { installedDispatcher = dispatch }
|
|
200
|
+
val contracts: Map<String, String> = mapOf(${bindings.collect { '"' + it.id + '" to "' + it.signature + '"' }.join(', ')})
|
|
201
|
+
val ids: Set<String> get() = contracts.keys
|
|
202
|
+
$hooks
|
|
203
|
+
}
|
|
204
|
+
""", 'UTF-8')
|
|
205
|
+
String ids = JsonOutput.toJson(bindings*.id)
|
|
206
|
+
String contracts = JsonOutput.toJson(bindings.collectEntries { [(it.id):it.signature] })
|
|
207
|
+
String cases = bindings.collect { b ->
|
|
208
|
+
String args = b.parameters.withIndex().collect { type, n -> decode(type, "args[$n]") }.join(', ')
|
|
209
|
+
' "' + b.id + '" -> { require(args.size == ' + b.parameters.size() + '); ' + encode(b.result, b.target + '(' + args + ')') + ' }'
|
|
210
|
+
}.join('\n')
|
|
211
|
+
String legacy = bindings.findAll { it.signature == '(Int,Int)->Int' }.collect {
|
|
212
|
+
' "' + it.id + '" -> ' + it.target + '(first, second).toString()'
|
|
213
|
+
}.join('\n')
|
|
214
|
+
js.setText("""@file:OptIn(kotlin.js.ExperimentalJsExport::class)
|
|
215
|
+
import kotlin.js.JsExport
|
|
216
|
+
import kotlin.js.Promise
|
|
217
|
+
import kotlin.coroutines.*
|
|
218
|
+
import kotlinx.serialization.json.*
|
|
219
|
+
@JsExport
|
|
220
|
+
fun renderScreen(screenId: String): String = renderAppScreen(screenId)
|
|
221
|
+
@JsExport
|
|
222
|
+
fun handleEvent(screenId: String, action: String, value: String? = null): String = handleAppEvent(screenId, action, value)
|
|
223
|
+
@JsExport
|
|
224
|
+
fun exportState(): String = patchRelayExportState()
|
|
225
|
+
@JsExport
|
|
226
|
+
fun restoreState(snapshot: String): String = patchRelayRestoreState(snapshot)
|
|
227
|
+
@JsExport
|
|
228
|
+
fun patchedFunctionIds(): String = ${JsonOutput.toJson(ids)}
|
|
229
|
+
@JsExport
|
|
230
|
+
fun patchedFunctionContracts(): String = ${JsonOutput.toJson(contracts)}
|
|
231
|
+
@JsExport
|
|
232
|
+
fun invokeFunction(functionId: String, arguments: String): Promise<String> = Promise { resolve, reject ->
|
|
233
|
+
val body: suspend () -> String = {
|
|
234
|
+
val args = patchRelayParse(arguments) as? JsonArray ?: error("Expected argument array")
|
|
235
|
+
require(args.size <= 8)
|
|
236
|
+
patchRelayText(when (functionId) {
|
|
237
|
+
$cases
|
|
238
|
+
else -> error("Unknown function: \$functionId")
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
body.startCoroutine(Continuation(EmptyCoroutineContext) { result -> result.fold(resolve, reject) })
|
|
242
|
+
}
|
|
243
|
+
@JsExport
|
|
244
|
+
fun invokePatch(functionId: String, first: Int, second: Int): String = when (functionId) {
|
|
245
|
+
$legacy
|
|
246
|
+
else -> error("Unknown legacy integer function: \$functionId")
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Stable SDK registration is independent of the Kotlin project's output-module name.
|
|
250
|
+
fun main() {
|
|
251
|
+
val api = js("({})")
|
|
252
|
+
api.exportState = ::exportState
|
|
253
|
+
api.restoreState = ::restoreState
|
|
254
|
+
api.renderScreen = ::renderScreen
|
|
255
|
+
api.handleEvent = ::handleEvent
|
|
256
|
+
api.patchedFunctionIds = ::patchedFunctionIds
|
|
257
|
+
api.patchedFunctionContracts = ::patchedFunctionContracts
|
|
258
|
+
api.invokeFunction = ::invokeFunction
|
|
259
|
+
api.invokePatch = ::invokePatch
|
|
260
|
+
${hostCalls ? ' api.takeHostRequests = ::takeHostRequests\n api.handleHostResult = ::handleHostResult' : ''}
|
|
261
|
+
js("globalThis").__patchrelay = api
|
|
262
|
+
}
|
|
263
|
+
""", 'UTF-8')
|
|
264
|
+
new File(output, 'functions.json').setText(JsonOutput.toJson([format:2, stateSchema:schema, state:states.collect { [id:it.id, type:it.type] }, functions:bindings.collect { [id:it.id, signature:it.signature] }]) + '\n', 'UTF-8')
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
abstract class GenerateFunctions extends DefaultTask {
|
|
269
|
+
@Input boolean compose
|
|
270
|
+
@InputDirectory File sources
|
|
271
|
+
@OutputDirectory File destination
|
|
272
|
+
@InputFiles FileCollection platformSources
|
|
273
|
+
@TaskAction void generate() {
|
|
274
|
+
if (!platformSources.empty) throw new GradleException('PatchRelay supports commonMain Kotlin only; platform implementations require a new native integration: ' + platformSources.files*.name)
|
|
275
|
+
def files = new TreeMap<String,String>()
|
|
276
|
+
sources.eachFileRecurse { if (it.isFile() && it.name.endsWith('.kt')) files[sources.toPath().relativize(it.toPath()).toString()] = it.getText('UTF-8') }
|
|
277
|
+
def program = FunctionBindings.discover(files, compose)
|
|
278
|
+
FunctionBindings.generate(program.functions, destination, program.hostCalls, program.states, program.migration)
|
|
279
|
+
def backend = new File(destination, 'common/PatchRelayCompose.kt')
|
|
280
|
+
if (compose) backend.setText(GenerateFunctions.getResourceAsStream('/patchrelay/ComposeScreens.kt').getText('UTF-8'), 'UTF-8')
|
|
281
|
+
else if (backend.exists()) backend.delete()
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
class FunctionsPlugin implements Plugin<Project> {
|
|
286
|
+
void apply(Project project) {
|
|
287
|
+
project.plugins.withId('org.jetbrains.kotlin.multiplatform') {
|
|
288
|
+
if (project.providers.gradleProperty('kotlin.js.ir.output.granularity').orNull != 'whole-program')
|
|
289
|
+
throw new GradleException('PatchRelay requires a self-contained program: add kotlin.js.ir.output.granularity=whole-program to gradle.properties')
|
|
290
|
+
def output = project.layout.buildDirectory.dir('generated/patchrelay')
|
|
291
|
+
boolean composeEnabled = project.providers.gradleProperty('patchrelay.compose').orNull == 'true'
|
|
292
|
+
def compilerJar = new File(FunctionsPlugin.protectionDomain.codeSource.location.toURI())
|
|
293
|
+
if (!compilerJar.isFile()) throw new GradleException('PatchRelay compiler plugin must run from its built JAR')
|
|
294
|
+
def task = project.tasks.register('generatePatchRelayFunctions', GenerateFunctions) {
|
|
295
|
+
sources = project.file('src/commonMain/kotlin')
|
|
296
|
+
platformSources = project.fileTree('src') { include 'jvmMain/**/*.kt', 'jsMain/**/*.kt', 'androidMain/**/*.kt' }
|
|
297
|
+
destination = output.get().asFile
|
|
298
|
+
compose = composeEnabled
|
|
299
|
+
}
|
|
300
|
+
def kotlin = project.extensions.getByName('kotlin')
|
|
301
|
+
if (kotlin.coreLibrariesVersion != '1.9.23') throw new GradleException('PatchRelay IR interception requires Kotlin 1.9.23; compiler upgrades require a matching plugin')
|
|
302
|
+
kotlin.sourceSets.getByName('commonMain').dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' }
|
|
303
|
+
if (composeEnabled) {
|
|
304
|
+
kotlin.sourceSets.getByName('commonMain').dependencies { implementation 'org.jetbrains.compose.runtime:runtime:1.6.10' }
|
|
305
|
+
def composeCompiler = project.configurations.create('patchRelayComposeCompiler')
|
|
306
|
+
project.dependencies.add(composeCompiler.name, 'org.jetbrains.compose.compiler:compiler:1.5.13.5')
|
|
307
|
+
project.tasks.matching { it.name.startsWith('compileKotlin') || it.name.startsWith('compileTestKotlin') || it.name.startsWith('compileProductionExecutable') || it.name.startsWith('compileTestDevelopmentExecutable') }.configureEach {
|
|
308
|
+
inputs.files(composeCompiler)
|
|
309
|
+
kotlinOptions.freeCompilerArgs += ['-Xplugin=' + composeCompiler.singleFile.absolutePath]
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
kotlin.sourceSets.getByName('commonMain').kotlin.srcDir(output.map { it.dir('common') })
|
|
313
|
+
// jsMain exists after the consumer declares its JS target.
|
|
314
|
+
project.afterEvaluate {
|
|
315
|
+
kotlin.sourceSets.getByName('jsMain').kotlin.srcDir(output.map { it.dir('js') })
|
|
316
|
+
}
|
|
317
|
+
project.tasks.matching { it.name.startsWith('compileKotlin') || it.name.startsWith('compileProductionExecutable') || it.name.startsWith('compileTestKotlin') }.configureEach { dependsOn(task) }
|
|
318
|
+
kotlin.targets.all { target ->
|
|
319
|
+
if (target.platformType.toString() == 'jvm') target.compilations.matching { it.name == 'main' }.all { compilation ->
|
|
320
|
+
compilation.compileTaskProvider.configure {
|
|
321
|
+
inputs.file(compilerJar)
|
|
322
|
+
kotlinOptions.freeCompilerArgs += ['-Xplugin=' + compilerJar.absolutePath]
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
@@ -0,0 +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
|
+
// 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
|
+
}
|