A Rust backend that discovers Android projects under configured roots, builds one on request, and serves the APK over pinned TLS on a WireGuard interface; an Android client that lists what is buildable, watches a build, and installs the result. Enrolment carries the token and the CA, so the phone trusts exactly the machine that issued it and nothing else. `AGENTS.md` is the working guide and `README.md` the configuration reference. The shared tunnel-and-TLS code lives in `vendor/wg-app-link`, which ai-app uses too. History before this point was squashed away, and a stale `config.json` went with it: nothing had read that file since the config moved to RON outside the checkout, and what it still held was one machine's absolute paths and the names of projects on it.
127 lines
4.5 KiB
Kotlin
127 lines
4.5 KiB
Kotlin
plugins {
|
|
alias(libs.plugins.androidApplication)
|
|
alias(libs.plugins.composeMultiplatform)
|
|
alias(libs.plugins.composeCompiler)
|
|
alias(libs.plugins.ktfmt)
|
|
}
|
|
|
|
// See the root build script for why this style and not ktfmt's default.
|
|
ktfmt { kotlinLangStyle() }
|
|
|
|
// The CA this app pins is baked in at build time from the certificates on
|
|
// the machine doing the build -- `$XDG_CONFIG_HOME/dev-updater/certs/ca.pem`,
|
|
// which the server generates on first start. DEV_UPDATER_CA overrides it.
|
|
//
|
|
// Reading it rather than keeping a pasted copy in the source means the
|
|
// trust anchor follows the build machine, the CA's private key never has
|
|
// to exist anywhere near this repo, and regenerating a CA needs a rebuild
|
|
// instead of a paste -- so a stale constant can't quietly disagree with
|
|
// the server the app is trying to reach.
|
|
val pinnedCaPath: String =
|
|
System.getenv("DEV_UPDATER_CA")
|
|
?: "${System.getenv("XDG_CONFIG_HOME") ?: "${System.getProperty("user.home")}/.config"}" +
|
|
"/dev-updater/certs/ca.pem"
|
|
|
|
abstract class GeneratePinnedCert : DefaultTask() {
|
|
/** Where the certificate is looked for, reported in failures. */
|
|
@get:Input abstract val caPath: Property<String>
|
|
|
|
/**
|
|
* The certificate itself, set only when it exists -- so a missing one produces this task's own
|
|
* instructions rather than Gradle's "no such input file", which doesn't say what to run.
|
|
*/
|
|
@get:InputFile
|
|
@get:Optional
|
|
@get:PathSensitive(PathSensitivity.NONE)
|
|
abstract val caCertificate: RegularFileProperty
|
|
|
|
/** Wired by AGP through `addGeneratedSourceDirectory`. */
|
|
@get:OutputDirectory abstract val outputDir: DirectoryProperty
|
|
|
|
@TaskAction
|
|
fun generate() {
|
|
val path = caPath.get()
|
|
val ca = File(path)
|
|
if (!ca.isFile) {
|
|
throw GradleException(
|
|
"No CA certificate at $path.\n" +
|
|
"Start the server once on this machine first -- it generates the CA the " +
|
|
"app pins, and the certificate has to exist before an APK can embed it.\n" +
|
|
"Set DEV_UPDATER_CA=/path/to/ca.pem to build against a different one."
|
|
)
|
|
}
|
|
val pem = ca.readText().trim()
|
|
if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) {
|
|
throw GradleException("$path is not a PEM certificate.")
|
|
}
|
|
// The PEM must start immediately after the opening quotes: a
|
|
// leading newline costs Android's CertificateFactory its
|
|
// "-----BEGIN" sniff, so it tries DER instead and fails at runtime
|
|
// with an ASN.1 decode error, nowhere near this file.
|
|
val file = outputDir.get().file("PinnedCaCertificate.kt").asFile
|
|
file.parentFile.mkdirs()
|
|
file.writeText(
|
|
"""
|
|
|// Generated from $path by the generatePinnedCert task. Do not edit.
|
|
|package com.example.devupdater
|
|
|
|
|
|const val PINNED_CA_PEM = ""${'"'}$pem
|
|
|""${'"'}
|
|
|
|
|
"""
|
|
.trimMargin()
|
|
)
|
|
}
|
|
}
|
|
|
|
val generatePinnedCert =
|
|
tasks.register<GeneratePinnedCert>("generatePinnedCert") {
|
|
val ca = file(pinnedCaPath)
|
|
caPath.set(pinnedCaPath)
|
|
if (ca.isFile) {
|
|
caCertificate.set(ca)
|
|
}
|
|
}
|
|
|
|
// AGP 9 wants generated sources registered through the variant API rather
|
|
// than added to a source set, so the task dependency is carried properly.
|
|
androidComponents {
|
|
onVariants { variant ->
|
|
variant.sources.java?.addGeneratedSourceDirectory(
|
|
generatePinnedCert,
|
|
GeneratePinnedCert::outputDir,
|
|
)
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "com.example.devupdater"
|
|
compileSdk = 37
|
|
|
|
defaultConfig {
|
|
applicationId = "com.example.devupdater"
|
|
minSdk = 24
|
|
targetSdk = 37
|
|
versionCode = 1
|
|
versionName = "1.0"
|
|
}
|
|
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
|
|
buildTypes { getByName("release") { isMinifyEnabled = false } }
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_21
|
|
targetCompatibility = JavaVersion.VERSION_21
|
|
}
|
|
}
|
|
|
|
dependencies {
|
|
implementation(libs.compose.runtime)
|
|
implementation(libs.compose.foundation)
|
|
implementation(libs.compose.material3)
|
|
implementation(libs.compose.ui)
|
|
implementation(libs.androidx.activity.compose)
|
|
implementation(libs.zxing.embedded)
|
|
// The half of this app that is the same as ai-app's: pinned TLS, the
|
|
// enrollment store, and the scanner.
|
|
implementation(project(":link"))
|
|
}
|