A project can produce more than one APK
tdep-survey is one checkout with a backend and two independent Android clients, so a project has to be able to declare two Apk components. It could not: APK_PATTERNS was anchored at the project root, which reached the first client and stopped, and package, strip, the variant list and the download all came from AppEntry::apk_component()'s first match -- correct only while a project produced one APK. Each component's builds are now found under its own cwd, which is what cwd already meant everywhere else: the directory the build command runs in, the subtree staleness is scoped to, a server's WorkingDirectory. A component that declares none sits at the project root, so nothing about the single-APK case changes. The alternative -- naming the file on the component -- would have made a component a file path, and an app being a project path rather than a file path is this project's central invariant. Everything derived from an APK follows it onto the component: package, previousPackage, strip, size, mtime, variants and the rename note, in a nested `apk` block that a Server simply doesn't have. Two clients install over different packages, so first-wins would have checked the installed state of one and reported it as the other's -- which looks exactly like a correct answer. For the same reason a download naming no component is refused rather than guessed; naming none still answers for a project with one, which is what lets the frozen /self/apk keep working. On the phone the per-device state is keyed by project and component, so the variant picker moved inside the component's own card, beside the build it picks, and the installed state, size and icon are each their own component's. A project building two clients shows no single icon of its own rather than borrowing the first one's. Measured against the real thing: pointed at tdep-survey, the two clients resolve to their own builds (15 MB and 153 MB), strip applies only to the one that asked for it (153 MB served as 52 MB), pressing Install on one row installed that row's package and left the other row offering Install, and a download with no component named answers 400 saying which flag to pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
db47972a25
commit
c4e19c2e2b
9 files changed
+740
-289
No files matched your search
@@ -427,10 +427,46 @@ mutable at runtime from the phone.
|
|||||||
unaccepted, since taking commits runs git rather than the project's
|
unaccepted, since taking commits runs git rather than the project's
|
||||||
command, and it is how the new request arrives to be read.
|
command, and it is how the new request arrives to be read.
|
||||||
- **Which build variant to serve is the phone's choice, not the server's.**
|
- **Which build variant to serve is the phone's choice, not the server's.**
|
||||||
It arrives as `?variant=` on the download and is validated against the
|
It arrives as `?variant=` on the download, beside the `?component=` that
|
||||||
|
says whose build it is, and is validated against *that component's*
|
||||||
discovered builds -- an unvalidated path would let a phone name any file
|
discovered builds -- an unvalidated path would let a phone name any file
|
||||||
on disk to be served. Storing it server-side meant one enrolled device
|
on disk to be served. Storing it server-side meant one enrolled device
|
||||||
silently changing what another was offered.
|
silently changing what another was offered. The stored choice is keyed
|
||||||
|
by project *and* component on the device, so pinning one client to a
|
||||||
|
release build says nothing about the other.
|
||||||
|
|
||||||
|
- **A project can produce more than one APK, and each component's builds
|
||||||
|
are found under its own `cwd`.** `APK_PATTERNS` is anchored at
|
||||||
|
`project.join(cwd)` rather than at the project root, which is what
|
||||||
|
`cwd` already meant everywhere else -- the directory the build command
|
||||||
|
runs in, the subtree `subtree_head` scopes staleness to, a server's
|
||||||
|
`WorkingDirectory`. A component that declares none sits at the root,
|
||||||
|
which is what every single-APK project has always meant, so nothing
|
||||||
|
about that case changed. tdep-survey is the project that needed it: one
|
||||||
|
checkout, a backend and *two* independent Android clients, where the
|
||||||
|
root-anchored patterns reached the first and stopped.
|
||||||
|
The alternative -- naming the file on the component, `apk: "path"` --
|
||||||
|
was rejected because it makes a component a file path, and **an app
|
||||||
|
being a project path rather than a file path** is the invariant at the
|
||||||
|
top of this document.
|
||||||
|
Everything downstream is per component in consequence: `package`,
|
||||||
|
`strip`, the variant list, the size, the mtime and the rename note.
|
||||||
|
Those were all `apk_component()`'s *first* match before, which was
|
||||||
|
correct only while a project had one. Two clients install over
|
||||||
|
different packages and differ in whether their symbols are worth
|
||||||
|
carrying to a phone, so first-wins would have checked the installed
|
||||||
|
state of one app and reported it as the other's -- the expensive kind
|
||||||
|
of wrong, because it looks exactly like an answer.
|
||||||
|
**A download that names no component is refused, not guessed**
|
||||||
|
(`ApiError::AmbiguousApk`), for that reason. Naming none still answers
|
||||||
|
for a project with one, which is nearly all of them and is what lets
|
||||||
|
the frozen `/self/apk` keep working -- it cannot carry a component, and
|
||||||
|
the project it describes has a single APK.
|
||||||
|
The measurement carried across an acceptance is keyed by name *and*
|
||||||
|
`cwd` (`registry::component_id`): once the directory decides which
|
||||||
|
builds a component has, a reused name is not the same APK, and handing
|
||||||
|
it the old one's package would be wrong until that component happened
|
||||||
|
to be downloaded.
|
||||||
- **The self entry's project is the working directory itself**, so this
|
- **The self entry's project is the working directory itself**, so this
|
||||||
server has to be started from the root of its own checkout -- which is
|
server has to be started from the root of its own checkout -- which is
|
||||||
where everything else here is driven from, and what the service unit
|
where everything else here is driven from, and what the service unit
|
||||||
|
|||||||
@@ -23,21 +23,30 @@ private const val DOWNLOAD_READ_TIMEOUT_MS = 15000
|
|||||||
fun downloadApk(
|
fun downloadApk(
|
||||||
context: Context,
|
context: Context,
|
||||||
entry: ManifestEntry,
|
entry: ManifestEntry,
|
||||||
|
component: String,
|
||||||
onProgress: (bytesRead: Long, total: Long) -> Unit,
|
onProgress: (bytesRead: Long, total: Long) -> Unit,
|
||||||
): File {
|
): File {
|
||||||
// The chosen build travels with the request rather than being
|
// Which component's APK, always said rather than left to the server:
|
||||||
|
// a project can build two clients, and the server refuses to guess
|
||||||
|
// between them rather than serving the first, which would install the
|
||||||
|
// wrong app while looking like it worked.
|
||||||
|
//
|
||||||
|
// The chosen build travels with the request too rather than being
|
||||||
// stored on the server: it is this device's preference, and a
|
// stored on the server: it is this device's preference, and a
|
||||||
// second phone must not have its download changed by it. The
|
// second phone must not have its download changed by it. The
|
||||||
// server checks the path against the builds it can see, so a stale
|
// server checks the path against that component's builds, so a stale
|
||||||
// one falls back to the newest rather than naming a file.
|
// one falls back to its newest rather than naming a file.
|
||||||
val route =
|
val query = StringBuilder("?component=").append(encode(component))
|
||||||
when (val variant = chosenVariant(context, entry.key)) {
|
chosenVariant(context, entry.key, component)?.let {
|
||||||
null -> entry.route
|
query.append("&variant=").append(encode(it))
|
||||||
else -> "${entry.route}?variant=${URLEncoder.encode(variant, "UTF-8")}"
|
|
||||||
}
|
}
|
||||||
return downloadFromRoute(context, route, entry.key, onProgress)
|
// Named for the component as well, so two clients of one project do
|
||||||
|
// not overwrite each other's download on the way to the installer.
|
||||||
|
return downloadFromRoute(context, entry.route + query, "${entry.key}-$component", onProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches one APK from [route] into private storage, named [name], and returns the file.
|
* Fetches one APK from [route] into private storage, named [name], and returns the file.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -9,26 +9,14 @@ data class ManifestEntry(
|
|||||||
// is what lets the app list be edited at runtime from the Add screen
|
// is what lets the app list be edited at runtime from the Add screen
|
||||||
// with no rebuild here.
|
// with no rebuild here.
|
||||||
val label: String,
|
val label: String,
|
||||||
val filename: String,
|
// Where this project's APKs come from. Which of them is said with the
|
||||||
|
// request, so one route covers a project that builds two clients --
|
||||||
|
// see [ProjectComponent.apk].
|
||||||
val route: String,
|
val route: String,
|
||||||
// Null until this project has been built at least once -- there is no
|
|
||||||
// APK to read an identity out of before then, and inventing one would
|
|
||||||
// make the installed-version check compare against nothing.
|
|
||||||
val packageName: String?,
|
|
||||||
// What this project's APK used to install over, when it has been
|
|
||||||
// renamed. Android treats a renamed applicationId as an unrelated app,
|
|
||||||
// so that one is still installed and nothing will ever replace it --
|
|
||||||
// the card offers to remove it, but only while it is actually there.
|
|
||||||
val previousPackageName: String?,
|
|
||||||
// The project directory this app was added by. Shown on the card so
|
// The project directory this app was added by. Shown on the card so
|
||||||
// it's possible to tell two similarly-named apps apart, and to spot an
|
// it's possible to tell two similarly-named apps apart, and to spot an
|
||||||
// entry pointing somewhere unexpected.
|
// entry pointing somewhere unexpected.
|
||||||
val projectPath: String,
|
val projectPath: String,
|
||||||
// Epoch seconds of the raw build's mtime, straight from the server --
|
|
||||||
// these are ad hoc local rebuilds with no CI bumping a version, so
|
|
||||||
// build freshness is the only meaningful signal, not a version code.
|
|
||||||
val mtime: Double,
|
|
||||||
val size: Long,
|
|
||||||
// True for an entry with an on-demand build step (see BuildStatus.kt)
|
// True for an entry with an on-demand build step (see BuildStatus.kt)
|
||||||
// -- only then does this app call that entry's prepare/status routes,
|
// -- only then does this app call that entry's prepare/status routes,
|
||||||
// which 404 for an entry without one.
|
// which 404 for an entry without one.
|
||||||
@@ -39,13 +27,10 @@ data class ManifestEntry(
|
|||||||
// Force git's remote commands onto IPv4 for this project -- this
|
// Force git's remote commands onto IPv4 for this project -- this
|
||||||
// machine's choice, editable from the card's settings.
|
// machine's choice, editable from the card's settings.
|
||||||
val gitIpv4: Boolean,
|
val gitIpv4: Boolean,
|
||||||
// False when the project has no APK yet (never built, or cleaned).
|
// Whether anything this project produces has been built. Per component
|
||||||
// Such an entry is still listed rather than silently dropped -- it was
|
// is on the component; this is what the card's own "nothing here yet"
|
||||||
// added deliberately, so saying so beats it disappearing.
|
// line reads.
|
||||||
val built: Boolean,
|
val built: Boolean,
|
||||||
// Every build discovered under the project, so a different one can be
|
|
||||||
// selected without another round trip.
|
|
||||||
val variants: List<ApkVariant>,
|
|
||||||
// What this project produces, in build order. One is the ordinary case
|
// What this project produces, in build order. One is the ordinary case
|
||||||
// and the card stays flat; more than one is drawn as a nested list, so
|
// and the card stays flat; more than one is drawn as a nested list, so
|
||||||
// a project that also runs a server says so without every single-app
|
// a project that also runs a server says so without every single-app
|
||||||
@@ -144,6 +129,9 @@ data class ProjectComponent(
|
|||||||
// The last is the ordinary case and not a fault.
|
// The last is the ordinary case and not a fault.
|
||||||
val resourcesChecking: Boolean,
|
val resourcesChecking: Boolean,
|
||||||
val resourcesError: String?,
|
val resourcesError: String?,
|
||||||
|
// What there is to install, for a component that produces one. Null
|
||||||
|
// for a server, which builds nothing this phone installs.
|
||||||
|
val apk: ComponentApk?,
|
||||||
) {
|
) {
|
||||||
// Only "behind" is worth saying. "Current" is what a card already
|
// Only "behind" is worth saying. "Current" is what a card already
|
||||||
// implies, and "unknown" said out loud would be on most rows most of
|
// implies, and "unknown" said out loud would be on most rows most of
|
||||||
@@ -167,6 +155,39 @@ data class ProjectComponent(
|
|||||||
get() = state == "failed"
|
get() = state == "failed"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The installable half of a component.
|
||||||
|
//
|
||||||
|
// Per component rather than per project, because a project can build two
|
||||||
|
// clients: they install over different packages, are different sizes, and
|
||||||
|
// have their own builds to choose between. Read off the project, the
|
||||||
|
// answer would be the first component's wearing the project's name -- and
|
||||||
|
// nothing on screen would say so.
|
||||||
|
data class ComponentApk(
|
||||||
|
// What the download is saved as on this device.
|
||||||
|
val filename: String,
|
||||||
|
// Null until this component has been built at least once -- there is
|
||||||
|
// no APK to read an identity out of before then, and inventing one
|
||||||
|
// would make the installed-version check compare against nothing.
|
||||||
|
val packageName: String?,
|
||||||
|
// What this component used to install over, when it has been renamed.
|
||||||
|
// Android treats a renamed applicationId as an unrelated app, so that
|
||||||
|
// one is still installed and nothing will ever replace it -- the card
|
||||||
|
// offers to remove it, but only while it is actually there.
|
||||||
|
val previousPackageName: String?,
|
||||||
|
// False when this component has no APK yet (never built, or cleaned).
|
||||||
|
// Its row is still drawn rather than silently dropped -- it was
|
||||||
|
// declared deliberately, so saying so beats it disappearing.
|
||||||
|
val built: Boolean,
|
||||||
|
// Epoch seconds of the raw build's mtime, straight from the server --
|
||||||
|
// these are ad hoc local rebuilds with no CI bumping a version, so
|
||||||
|
// build freshness is the only meaningful signal, not a version code.
|
||||||
|
val mtime: Double,
|
||||||
|
val size: Long,
|
||||||
|
// Every build discovered under this component, so a different one can
|
||||||
|
// be selected without another round trip.
|
||||||
|
val variants: List<ApkVariant>,
|
||||||
|
)
|
||||||
|
|
||||||
// One discovered build of an app. `variant` is the Gradle-style build
|
// One discovered build of an app. `variant` is the Gradle-style build
|
||||||
// variant name ("debug", "freeRelease") taken from the output directory.
|
// variant name ("debug", "freeRelease") taken from the output directory.
|
||||||
data class ApkVariant(
|
data class ApkVariant(
|
||||||
@@ -191,14 +212,14 @@ data class Manifest(
|
|||||||
|
|
||||||
// In the units PackageInfo.lastUpdateTime reports, which is what this is
|
// In the units PackageInfo.lastUpdateTime reports, which is what this is
|
||||||
// ever compared against (see InstalledBuilds.kt).
|
// ever compared against (see InstalledBuilds.kt).
|
||||||
fun ManifestEntry.mtimeMillis(): Long = (mtime * 1000).toLong()
|
fun ComponentApk.mtimeMillis(): Long = (mtime * 1000).toLong()
|
||||||
|
|
||||||
// When this device has pinned a build, that build's timestamp is the one
|
// When this device has pinned a build, that build's timestamp is the one
|
||||||
// freshness is about -- the newest build being newer than the installed
|
// freshness is about -- the newest build being newer than the installed
|
||||||
// copy says nothing when the newest is not what would be installed. Falls
|
// copy says nothing when the newest is not what would be installed. Falls
|
||||||
// back to the entry's own when the pinned one is gone, which is the same
|
// back to this component's own when the pinned one is gone, which is the
|
||||||
// build the server would fall back to serving.
|
// same build the server would fall back to serving.
|
||||||
fun ManifestEntry.mtimeMillisFor(chosenVariantPath: String?): Long =
|
fun ComponentApk.mtimeMillisFor(chosenVariantPath: String?): Long =
|
||||||
variants.firstOrNull { it.path == chosenVariantPath }?.let { (it.mtime * 1000).toLong() }
|
variants.firstOrNull { it.path == chosenVariantPath }?.let { (it.mtime * 1000).toLong() }
|
||||||
?: mtimeMillis()
|
?: mtimeMillis()
|
||||||
|
|
||||||
@@ -290,17 +311,11 @@ fun fetchApp(key: String): ManifestEntry =
|
|||||||
/** One app as the server describes it, shared by both reads above. */
|
/** One app as the server describes it, shared by both reads above. */
|
||||||
private fun readEntry(entry: JSONObject): ManifestEntry {
|
private fun readEntry(entry: JSONObject): ManifestEntry {
|
||||||
val components = entry.optJSONArray("components")
|
val components = entry.optJSONArray("components")
|
||||||
val variants = entry.getJSONArray("variants")
|
|
||||||
return ManifestEntry(
|
return ManifestEntry(
|
||||||
key = entry.getString("key"),
|
key = entry.getString("key"),
|
||||||
label = entry.getString("label"),
|
label = entry.getString("label"),
|
||||||
filename = entry.getString("filename"),
|
|
||||||
route = entry.getString("route"),
|
route = entry.getString("route"),
|
||||||
packageName = entry.optString("package").ifEmpty { null },
|
|
||||||
previousPackageName = entry.optString("previousPackage").ifEmpty { null },
|
|
||||||
projectPath = entry.getString("projectPath"),
|
projectPath = entry.getString("projectPath"),
|
||||||
mtime = entry.getDouble("mtime"),
|
|
||||||
size = entry.getLong("size"),
|
|
||||||
needsBuild = entry.getBoolean("needsBuild"),
|
needsBuild = entry.getBoolean("needsBuild"),
|
||||||
builtIn = entry.getBoolean("builtIn"),
|
builtIn = entry.getBoolean("builtIn"),
|
||||||
gitIpv4 = entry.getBoolean("gitIpv4"),
|
gitIpv4 = entry.getBoolean("gitIpv4"),
|
||||||
@@ -339,8 +354,22 @@ private fun readEntry(entry: JSONObject): ManifestEntry {
|
|||||||
configPresent = component.optBoolean("configPresent", false),
|
configPresent = component.optBoolean("configPresent", false),
|
||||||
resourcesChecking = component.optBoolean("resourcesChecking", false),
|
resourcesChecking = component.optBoolean("resourcesChecking", false),
|
||||||
resourcesError = component.optString("resourcesError").ifEmpty { null },
|
resourcesError = component.optString("resourcesError").ifEmpty { null },
|
||||||
|
apk = component.optJSONObject("apk")?.let(::readApk),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The installable half of one component, absent for a server. */
|
||||||
|
private fun readApk(apk: JSONObject): ComponentApk {
|
||||||
|
val variants = apk.getJSONArray("variants")
|
||||||
|
return ComponentApk(
|
||||||
|
filename = apk.getString("filename"),
|
||||||
|
packageName = apk.optString("package").ifEmpty { null },
|
||||||
|
previousPackageName = apk.optString("previousPackage").ifEmpty { null },
|
||||||
|
built = apk.getBoolean("built"),
|
||||||
|
mtime = apk.getDouble("mtime"),
|
||||||
|
size = apk.getLong("size"),
|
||||||
variants =
|
variants =
|
||||||
(0 until variants.length()).map { j ->
|
(0 until variants.length()).map { j ->
|
||||||
val variant = variants.getJSONObject(j)
|
val variant = variants.getJSONObject(j)
|
||||||
|
|||||||
@@ -422,12 +422,17 @@ private fun AppListScreen(
|
|||||||
// two effects that follow -- so a fresh manifest, a package-change
|
// two effects that follow -- so a fresh manifest, a package-change
|
||||||
// broadcast, and a return from the system installer all go through the
|
// broadcast, and a return from the system installer all go through the
|
||||||
// same one path rather than each refreshing these their own way.
|
// same one path rather than each refreshing these their own way.
|
||||||
var installedTimes by remember { mutableStateOf<Map<String, Long?>>(emptyMap()) }
|
//
|
||||||
var installedSizes by remember { mutableStateOf<Map<String, Long?>>(emptyMap()) }
|
// Two levels: a project, then a component of it. A project can build
|
||||||
// Which build each project is pinned to on *this* device, read from
|
// two clients, and they install over different packages -- one map
|
||||||
|
// keyed by project alone would answer for whichever was asked about
|
||||||
|
// last, on both rows.
|
||||||
|
var installedTimes by remember { mutableStateOf<Map<String, Map<String, Long?>>>(emptyMap()) }
|
||||||
|
var installedSizes by remember { mutableStateOf<Map<String, Map<String, Long?>>>(emptyMap()) }
|
||||||
|
// Which build each component is pinned to on *this* device, read from
|
||||||
// local storage rather than the manifest. Held as state so picking one
|
// local storage rather than the manifest. Held as state so picking one
|
||||||
// redraws the card without a round trip.
|
// redraws the card without a round trip.
|
||||||
var chosenVariants by remember { mutableStateOf<Map<String, String>>(emptyMap()) }
|
var chosenVariants by remember { mutableStateOf<Map<String, Map<String, String>>>(emptyMap()) }
|
||||||
// The project whose pull came back saying its checkout and its remote
|
// The project whose pull came back saying its checkout and its remote
|
||||||
// share no history, waiting on an answer about throwing that history
|
// share no history, waiting on an answer about throwing that history
|
||||||
// away. One slot rather than one per card: it is a modal, so only one
|
// away. One slot rather than one per card: it is a modal, so only one
|
||||||
@@ -710,7 +715,7 @@ private fun AppListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startUpdate(entry: ManifestEntry) {
|
fun startUpdate(entry: ManifestEntry, component: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (entry.needsBuild) {
|
if (entry.needsBuild) {
|
||||||
cardStates = cardStates + (entry.key to CardState.Preparing(null))
|
cardStates = cardStates + (entry.key to CardState.Preparing(null))
|
||||||
@@ -741,7 +746,7 @@ private fun AppListScreen(
|
|||||||
val file =
|
val file =
|
||||||
try {
|
try {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
downloadApk(context, entry) { read, total ->
|
downloadApk(context, entry, component) { read, total ->
|
||||||
val progress = if (total > 0) read.toFloat() / total else null
|
val progress = if (total > 0) read.toFloat() / total else null
|
||||||
cardStates = cardStates + (entry.key to CardState.Downloading(progress))
|
cardStates = cardStates + (entry.key to CardState.Downloading(progress))
|
||||||
}
|
}
|
||||||
@@ -814,20 +819,32 @@ private fun AppListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateInstalledState(entries: List<ManifestEntry>) {
|
fun updateInstalledState(entries: List<ManifestEntry>) {
|
||||||
// A project with no build yet has no package, so there is nothing
|
// A component with no build yet has no package, so there is
|
||||||
// installed to ask about -- which is exactly what null already
|
// nothing installed to ask about -- which is exactly what null
|
||||||
// means to the callers of these two maps.
|
// already means to the callers of these two maps.
|
||||||
installedTimes = entries.associate { entry ->
|
fun <T> byComponent(read: (String) -> T?): Map<String, Map<String, T?>> =
|
||||||
entry.key to entry.packageName?.let { installedLastUpdateTimeMillis(context, it) }
|
entries.associate { entry ->
|
||||||
|
entry.key to
|
||||||
|
entry.components
|
||||||
|
.filter { !it.isServer }
|
||||||
|
.associate { component ->
|
||||||
|
component.name to component.apk?.packageName?.let(read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installedTimes = byComponent { installedLastUpdateTimeMillis(context, it) }
|
||||||
|
installedSizes = byComponent { installedApkSizeBytes(context, it) }
|
||||||
|
chosenVariants = entries.associate { entry ->
|
||||||
|
entry.key to
|
||||||
|
entry.components
|
||||||
|
.filter { !it.isServer }
|
||||||
|
.mapNotNull { component ->
|
||||||
|
chosenVariant(context, entry.key, component.name)?.let {
|
||||||
|
component.name to it
|
||||||
}
|
}
|
||||||
installedSizes = entries.associate { entry ->
|
|
||||||
entry.key to entry.packageName?.let { installedApkSizeBytes(context, it) }
|
|
||||||
}
|
}
|
||||||
chosenVariants =
|
|
||||||
entries
|
|
||||||
.mapNotNull { entry -> chosenVariant(context, entry.key)?.let { entry.key to it } }
|
|
||||||
.toMap()
|
.toMap()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) { refresh() }
|
LaunchedEffect(Unit) { refresh() }
|
||||||
|
|
||||||
@@ -902,7 +919,11 @@ private fun AppListScreen(
|
|||||||
val receiver =
|
val receiver =
|
||||||
registerPackageChangeReceiver(context) { packageName ->
|
registerPackageChangeReceiver(context) { packageName ->
|
||||||
currentEntries.value
|
currentEntries.value
|
||||||
?.takeIf { entries -> entries.any { it.packageName == packageName } }
|
?.takeIf { entries ->
|
||||||
|
entries.any { entry ->
|
||||||
|
entry.components.any { it.apk?.packageName == packageName }
|
||||||
|
}
|
||||||
|
}
|
||||||
?.let(::updateInstalledState)
|
?.let(::updateInstalledState)
|
||||||
}
|
}
|
||||||
onDispose { context.unregisterReceiver(receiver) }
|
onDispose { context.unregisterReceiver(receiver) }
|
||||||
@@ -1002,12 +1023,20 @@ private fun AppListScreen(
|
|||||||
cardStates[entry.key].isBuilding() ||
|
cardStates[entry.key].isBuilding() ||
|
||||||
!entry.built ||
|
!entry.built ||
|
||||||
entry.newCommits ||
|
entry.newCommits ||
|
||||||
|
// Any client of the project being
|
||||||
|
// behind is the project being
|
||||||
|
// behind: a card with one of two
|
||||||
|
// apps waiting has something
|
||||||
|
// waiting.
|
||||||
|
entry.components.any { component ->
|
||||||
|
val apk = component.apk ?: return@any false
|
||||||
!isUpToDate(
|
!isUpToDate(
|
||||||
entry,
|
apk,
|
||||||
installedTimes[entry.key],
|
installedTimes[entry.key]?.get(component.name),
|
||||||
chosenVariants[entry.key],
|
chosenVariants[entry.key]?.get(component.name),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The two groups render identically; only the up-to-date one
|
// The two groups render identically; only the up-to-date one
|
||||||
// gets a heading above it, since the ones needing an update
|
// gets a heading above it, since the ones needing an update
|
||||||
@@ -1022,11 +1051,13 @@ private fun AppListScreen(
|
|||||||
group.forEach { entry ->
|
group.forEach { entry ->
|
||||||
AppCard(
|
AppCard(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
installedLastUpdateTimeMillis = installedTimes[entry.key],
|
installedTimes = installedTimes[entry.key] ?: emptyMap(),
|
||||||
chosenVariantPath = chosenVariants[entry.key],
|
chosenVariants = chosenVariants[entry.key] ?: emptyMap(),
|
||||||
installedSizeBytes = installedSizes[entry.key],
|
installedSizes = installedSizes[entry.key] ?: emptyMap(),
|
||||||
cardState = cardStates[entry.key],
|
cardState = cardStates[entry.key],
|
||||||
onUpdate = { startUpdate(it) },
|
onUpdate = { updated, component ->
|
||||||
|
startUpdate(updated, component)
|
||||||
|
},
|
||||||
onPull = { startPull(entry) },
|
onPull = { startPull(entry) },
|
||||||
onRebuild = { startRebuild(entry) },
|
onRebuild = { startRebuild(entry) },
|
||||||
onRefresh = { refreshOne(entry) },
|
onRefresh = { refreshOne(entry) },
|
||||||
@@ -1037,7 +1068,7 @@ private fun AppListScreen(
|
|||||||
manage(entry) { approveDeclaration(entry.key) }
|
manage(entry) { approveDeclaration(entry.key) }
|
||||||
},
|
},
|
||||||
onRemove = {
|
onRemove = {
|
||||||
forgetVariant(context, entry.key)
|
forgetVariants(context, entry.key)
|
||||||
manage(entry, removes = true) { removeApp(entry.key) }
|
manage(entry, removes = true) { removeApp(entry.key) }
|
||||||
},
|
},
|
||||||
// Written here rather than sent to the server:
|
// Written here rather than sent to the server:
|
||||||
@@ -1045,14 +1076,23 @@ private fun AppListScreen(
|
|||||||
// reload token is what redraws the card with
|
// reload token is what redraws the card with
|
||||||
// the new choice and the mtime that goes with
|
// the new choice and the mtime that goes with
|
||||||
// it.
|
// it.
|
||||||
onSelectVariant = { variant ->
|
onSelectVariant = { component, variant ->
|
||||||
chooseVariant(context, entry.key, variant?.path)
|
chooseVariant(
|
||||||
|
context,
|
||||||
|
entry.key,
|
||||||
|
component,
|
||||||
|
variant?.path,
|
||||||
|
)
|
||||||
|
val forProject = chosenVariants[entry.key] ?: emptyMap()
|
||||||
chosenVariants =
|
chosenVariants =
|
||||||
|
chosenVariants +
|
||||||
|
(entry.key to
|
||||||
when (variant) {
|
when (variant) {
|
||||||
null -> chosenVariants - entry.key
|
null -> forProject - component
|
||||||
else ->
|
else ->
|
||||||
chosenVariants + (entry.key to variant.path)
|
forProject +
|
||||||
}
|
(component to variant.path)
|
||||||
|
})
|
||||||
},
|
},
|
||||||
serviceBusy = serviceBusy[entry.key],
|
serviceBusy = serviceBusy[entry.key],
|
||||||
onServiceAction = { component, action, purge ->
|
onServiceAction = { component, action, purge ->
|
||||||
@@ -1151,25 +1191,25 @@ private fun ForcePullDialog(entry: ManifestEntry, onDismiss: () -> Unit, onForce
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AppCard(
|
private fun AppCard(
|
||||||
entry: ManifestEntry,
|
entry: ManifestEntry,
|
||||||
installedLastUpdateTimeMillis: Long?,
|
/** By component name, for the project's own APKs. */
|
||||||
installedSizeBytes: Long?,
|
installedTimes: Map<String, Long?>,
|
||||||
|
installedSizes: Map<String, Long?>,
|
||||||
|
chosenVariants: Map<String, String>,
|
||||||
cardState: CardState?,
|
cardState: CardState?,
|
||||||
onUpdate: (ManifestEntry) -> Unit,
|
onUpdate: (ManifestEntry, component: String) -> Unit,
|
||||||
onPull: () -> Unit,
|
onPull: () -> Unit,
|
||||||
onRebuild: () -> Unit,
|
onRebuild: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onSettings: (gitIpv4: Boolean) -> Unit,
|
onSettings: (gitIpv4: Boolean) -> Unit,
|
||||||
onApprove: () -> Unit,
|
onApprove: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
chosenVariantPath: String?,
|
onSelectVariant: (component: String, ApkVariant?) -> Unit,
|
||||||
onSelectVariant: (ApkVariant?) -> Unit,
|
|
||||||
// Which component this card is running a service action for, if any --
|
// Which component this card is running a service action for, if any --
|
||||||
// so the one being acted on is the one that shows it, rather than
|
// so the one being acted on is the one that shows it, rather than
|
||||||
// every row going quiet together.
|
// every row going quiet together.
|
||||||
serviceBusy: String?,
|
serviceBusy: String?,
|
||||||
onServiceAction: (component: String, action: String, purge: Purge) -> Unit,
|
onServiceAction: (component: String, action: String, purge: Purge) -> Unit,
|
||||||
) {
|
) {
|
||||||
val upToDate = isUpToDate(entry, installedLastUpdateTimeMillis, chosenVariantPath)
|
|
||||||
var settingsOpen by remember { mutableStateOf(false) }
|
var settingsOpen by remember { mutableStateOf(false) }
|
||||||
// Until the build step this project asks for has been accepted, the
|
// Until the build step this project asks for has been accepted, the
|
||||||
// card is about that request and nothing else: no size, no components,
|
// card is about that request and nothing else: no size, no components,
|
||||||
@@ -1178,18 +1218,6 @@ private fun AppCard(
|
|||||||
// decline by getting rid of the card.
|
// decline by getting rid of the card.
|
||||||
val awaitingApproval = entry.pendingDeclaration != null
|
val awaitingApproval = entry.pendingDeclaration != null
|
||||||
|
|
||||||
// What the download would cost, and what it replaces. Belongs to the
|
|
||||||
// APK the same way the Update button does, and travels with it.
|
|
||||||
val sizeText =
|
|
||||||
if (awaitingApproval) null
|
|
||||||
else
|
|
||||||
when {
|
|
||||||
!entry.built -> null
|
|
||||||
!upToDate && installedSizeBytes != null ->
|
|
||||||
"${formatSize(installedSizeBytes)} \u2192 ${formatSize(entry.size)}"
|
|
||||||
else -> formatSize(entry.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(Modifier.fillMaxWidth()) {
|
Card(Modifier.fillMaxWidth()) {
|
||||||
Column(Modifier.padding(16.dp)) {
|
Column(Modifier.padding(16.dp)) {
|
||||||
// The two corner controls belong to the card, not to its title,
|
// The two corner controls belong to the card, not to its title,
|
||||||
@@ -1207,7 +1235,18 @@ private fun AppCard(
|
|||||||
// edge with nothing in the way.
|
// edge with nothing in the way.
|
||||||
Box(Modifier.fillMaxWidth()) {
|
Box(Modifier.fillMaxWidth()) {
|
||||||
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.Top, modifier = Modifier.fillMaxWidth()) {
|
||||||
AppIcon(entry.packageName, Modifier.align(Alignment.CenterVertically))
|
// The project's icon is its client's, and only when
|
||||||
|
// it has exactly one: a project building two of them
|
||||||
|
// has no single answer, and showing the first would
|
||||||
|
// label the whole card with one of its two apps.
|
||||||
|
// AppIcon draws its own placeholder for null, which is
|
||||||
|
// what a project with two gets -- the same mark a
|
||||||
|
// project with nothing built yet gets, because in both
|
||||||
|
// cases there is no one icon to show.
|
||||||
|
AppIcon(
|
||||||
|
entry.components.mapNotNull { it.apk?.packageName }.singleOrNull(),
|
||||||
|
Modifier.align(Alignment.CenterVertically),
|
||||||
|
)
|
||||||
Spacer(Modifier.width(10.dp))
|
Spacer(Modifier.width(10.dp))
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
// The one line that makes room for them: they are beside
|
// The one line that makes room for them: they are beside
|
||||||
@@ -1335,14 +1374,34 @@ private fun AppCard(
|
|||||||
entry.components
|
entry.components
|
||||||
.sortedBy { it.isServer }
|
.sortedBy { it.isServer }
|
||||||
.forEach { component ->
|
.forEach { component ->
|
||||||
|
val installed = installedTimes[component.name]
|
||||||
|
val installedSize = installedSizes[component.name]
|
||||||
|
val chosenVariantPath = chosenVariants[component.name]
|
||||||
|
val upToDate =
|
||||||
|
component.apk?.let {
|
||||||
|
isUpToDate(it, installed, chosenVariantPath)
|
||||||
|
} == true
|
||||||
ComponentCard(
|
ComponentCard(
|
||||||
entryKey = entry.key,
|
entryKey = entry.key,
|
||||||
component = component,
|
component = component,
|
||||||
packageName = entry.packageName,
|
packageName = component.apk?.packageName,
|
||||||
// An APK's size sits where a server's state
|
// What the download would cost, and what it
|
||||||
// does: the one thing worth knowing about it
|
// replaces. An APK's size sits where a
|
||||||
// besides its name.
|
// server's state does: the one thing worth
|
||||||
sizeText = sizeText.takeIf { !component.isServer },
|
// knowing about it besides its name.
|
||||||
|
sizeText =
|
||||||
|
component.apk
|
||||||
|
?.takeIf { it.built }
|
||||||
|
?.let { apk ->
|
||||||
|
when {
|
||||||
|
!upToDate && installedSize != null ->
|
||||||
|
"${formatSize(installedSize)} \u2192 " +
|
||||||
|
formatSize(apk.size)
|
||||||
|
else -> formatSize(apk.size)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chosenVariantPath = chosenVariantPath,
|
||||||
|
onSelectVariant = { onSelectVariant(component.name, it) },
|
||||||
// This app reaches the server through this server.
|
// This app reaches the server through this server.
|
||||||
// Stopping or uninstalling it is the one action
|
// Stopping or uninstalling it is the one action
|
||||||
// here that cannot be undone from the phone.
|
// here that cannot be undone from the phone.
|
||||||
@@ -1370,12 +1429,12 @@ private fun AppCard(
|
|||||||
// how far along it is: a bar reports on
|
// how far along it is: a bar reports on
|
||||||
// the control above it.
|
// the control above it.
|
||||||
UpdateButton(
|
UpdateButton(
|
||||||
built = entry.built,
|
built = component.apk?.built == true,
|
||||||
needsBuild = entry.needsBuild,
|
needsBuild = entry.needsBuild,
|
||||||
installed = installedLastUpdateTimeMillis != null,
|
installed = installed != null,
|
||||||
upToDate = upToDate,
|
upToDate = upToDate,
|
||||||
cardState = cardState,
|
cardState = cardState,
|
||||||
onUpdate = { onUpdate(entry) },
|
onUpdate = { onUpdate(entry, component.name) },
|
||||||
onPull = onPull,
|
onPull = onPull,
|
||||||
)
|
)
|
||||||
ApkProgress(cardState)
|
ApkProgress(cardState)
|
||||||
@@ -1483,26 +1542,6 @@ private fun AppCard(
|
|||||||
BuildProgress("Building", cardState.status)
|
BuildProgress("Building", cardState.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only while the old app is actually still there. The server
|
|
||||||
// remembers the name it was renamed from; whether anything
|
|
||||||
// answers to it is this device's question, and asking it here
|
|
||||||
// is what saves the server needing to be told when it stops
|
|
||||||
// being true.
|
|
||||||
val cardContext = LocalContext.current
|
|
||||||
val orphan = entry.previousPackageName?.takeIf { isInstalled(cardContext, it) }
|
|
||||||
if (orphan != null) {
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
"Renamed from $orphan. Android treats that as a different " +
|
|
||||||
"app, so it is still installed and nothing will replace it.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
TextButton(onClick = { cardContext.startActivity(uninstallIntent(orphan)) }) {
|
|
||||||
Text("Remove the old app")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// What is left to say about the card once its components have
|
// What is left to say about the card once its components have
|
||||||
// said their own part: a failure, or that there is no build to
|
// said their own part: a failure, or that there is no build to
|
||||||
// talk about yet. Progress is not here -- it belongs beside the
|
// talk about yet. Progress is not here -- it belongs beside the
|
||||||
@@ -1545,23 +1584,6 @@ private fun AppCard(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only worth a row when there is actually a choice, which is
|
|
||||||
// rare -- the usual case is a single debug build, and an empty
|
|
||||||
// row here was leaving a band of space at the foot of every
|
|
||||||
// card for a control almost none of them have.
|
|
||||||
//
|
|
||||||
// And not while a declaration is waiting either: this picks
|
|
||||||
// which build gets installed, on a card that is offering no
|
|
||||||
// way to install one.
|
|
||||||
if (entry.variants.size > 1 && !awaitingApproval) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
VariantPicker(entry.variants, chosenVariantPath, onSelectVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1918,6 +1940,9 @@ private fun ComponentCard(
|
|||||||
/** The package an APK component installs, for its icon. */
|
/** The package an APK component installs, for its icon. */
|
||||||
packageName: String?,
|
packageName: String?,
|
||||||
sizeText: String?,
|
sizeText: String?,
|
||||||
|
/** Which of this component's builds this device is pinned to, if any. */
|
||||||
|
chosenVariantPath: String? = null,
|
||||||
|
onSelectVariant: (ApkVariant?) -> Unit = {},
|
||||||
isOwnServer: Boolean,
|
isOwnServer: Boolean,
|
||||||
/** This component's part of a build in progress, if it has one. */
|
/** This component's part of a build in progress, if it has one. */
|
||||||
build: ComponentBuild?,
|
build: ComponentBuild?,
|
||||||
@@ -2148,6 +2173,49 @@ private fun ComponentCard(
|
|||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
ComponentBuildProgress(it)
|
ComponentBuildProgress(it)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only while the old app is actually still there. The build
|
||||||
|
// machine remembers the name this component was renamed from;
|
||||||
|
// whether anything answers to it is this device's question,
|
||||||
|
// and asking it here is what saves the server needing to be
|
||||||
|
// told when it stops being true.
|
||||||
|
//
|
||||||
|
// In this component's card rather than the project's: with two
|
||||||
|
// clients, only one of them was renamed, and the offer has to
|
||||||
|
// sit with the one it is about.
|
||||||
|
val cardContext = LocalContext.current
|
||||||
|
val orphan = component.apk?.previousPackageName?.takeIf { isInstalled(cardContext, it) }
|
||||||
|
if (orphan != null) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"Renamed from $orphan. Android treats that as a different " +
|
||||||
|
"app, so it is still installed and nothing will replace it.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
TextButton(onClick = { cardContext.startActivity(uninstallIntent(orphan)) }) {
|
||||||
|
Text("Remove the old app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only worth a row when there is actually a choice, which is
|
||||||
|
// rare -- the usual case is a single debug build, and an empty
|
||||||
|
// row here was leaving a band of space at the foot of every
|
||||||
|
// card for a control almost none of them have.
|
||||||
|
//
|
||||||
|
// Beside the build it picks, which is what makes it answerable
|
||||||
|
// for a project with two clients: the choice is this
|
||||||
|
// component's, and a picker at the foot of the card could only
|
||||||
|
// have been the project's.
|
||||||
|
val variants = component.apk?.variants.orEmpty()
|
||||||
|
if (variants.size > 1) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
VariantPicker(variants, chosenVariantPath, onSelectVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2515,12 +2583,12 @@ private fun CardState?.isBuilding(): Boolean =
|
|||||||
this is CardState.Pulling || this is CardState.Preparing || this is CardState.Rebuilding
|
this is CardState.Pulling || this is CardState.Preparing || this is CardState.Rebuilding
|
||||||
|
|
||||||
private fun isUpToDate(
|
private fun isUpToDate(
|
||||||
entry: ManifestEntry,
|
apk: ComponentApk,
|
||||||
installedLastUpdateTimeMillis: Long?,
|
installedLastUpdateTimeMillis: Long?,
|
||||||
chosenVariantPath: String?,
|
chosenVariantPath: String?,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
installedLastUpdateTimeMillis != null &&
|
installedLastUpdateTimeMillis != null &&
|
||||||
installedLastUpdateTimeMillis >= entry.mtimeMillisFor(chosenVariantPath)
|
installedLastUpdateTimeMillis >= apk.mtimeMillisFor(chosenVariantPath)
|
||||||
|
|
||||||
internal fun formatSize(bytes: Long): String {
|
internal fun formatSize(bytes: Long): String {
|
||||||
val mb = bytes / 1024.0 / 1024.0
|
val mb = bytes / 1024.0 / 1024.0
|
||||||
|
|||||||
@@ -11,8 +11,14 @@ import android.content.Context
|
|||||||
* the choice lives here and travels with the download request, rather than
|
* the choice lives here and travels with the download request, rather than
|
||||||
* being written into the server's config.
|
* being written into the server's config.
|
||||||
*
|
*
|
||||||
* Keyed by the project key, which the server promises never to change: it
|
* Keyed by the project *and the component*, because a project can build
|
||||||
* is the same identifier the downloaded file is named after.
|
* two clients: pinning one of them to a release build has nothing to say
|
||||||
|
* about the other, and a key with only the project in it would have made
|
||||||
|
* the second component inherit the first's choice.
|
||||||
|
*
|
||||||
|
* The project key is the identifier the server promises never to change,
|
||||||
|
* and a component's name is fixed by the declaration this machine
|
||||||
|
* accepted, so a stored choice keeps meaning what it meant.
|
||||||
*
|
*
|
||||||
* The path is the server's, not this device's, and is checked there against
|
* The path is the server's, not this device's, and is checked there against
|
||||||
* the builds it can actually see. Nothing here can name a file into
|
* the builds it can actually see. Nothing here can name a file into
|
||||||
@@ -22,22 +28,38 @@ import android.content.Context
|
|||||||
|
|
||||||
private const val PREFS_NAME = "variants"
|
private const val PREFS_NAME = "variants"
|
||||||
|
|
||||||
/** The build [key] is pinned to on this device, or null for "the newest". */
|
/**
|
||||||
fun chosenVariant(context: Context, key: String): String? =
|
* One stored preference's name. A component's name cannot contain a slash -- it is a RON identifier
|
||||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE).getString(key, null)
|
* -- so nothing else can collide with a project key that contains one.
|
||||||
|
*/
|
||||||
|
private fun slot(key: String, component: String) = "$key/$component"
|
||||||
|
|
||||||
|
/** The build one component is pinned to on this device, or null for "the newest". */
|
||||||
|
fun chosenVariant(context: Context, key: String, component: String): String? =
|
||||||
|
context
|
||||||
|
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
.getString(slot(key, component), null)
|
||||||
|
|
||||||
/** Passing null goes back to "whatever is newest", which is the default. */
|
/** Passing null goes back to "whatever is newest", which is the default. */
|
||||||
fun chooseVariant(context: Context, key: String, path: String?) {
|
fun chooseVariant(context: Context, key: String, component: String, path: String?) {
|
||||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
if (path == null) {
|
if (path == null) {
|
||||||
prefs.edit().remove(key).apply()
|
prefs.edit().remove(slot(key, component)).apply()
|
||||||
} else {
|
} else {
|
||||||
prefs.edit().putString(key, path).apply()
|
prefs.edit().putString(slot(key, component), path).apply()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Forgets a project's choice, for one being removed -- otherwise a key reused by a later project
|
* Forgets every choice made for a project being removed -- otherwise a key reused by a later
|
||||||
* would inherit a preference nobody set.
|
* project would inherit preferences nobody set.
|
||||||
|
*
|
||||||
|
* Every component at once, because the card that is going away is the only thing that knew which
|
||||||
|
* components it had.
|
||||||
*/
|
*/
|
||||||
fun forgetVariant(context: Context, key: String) = chooseVariant(context, key, null)
|
fun forgetVariants(context: Context, key: String) {
|
||||||
|
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
val editor = prefs.edit()
|
||||||
|
prefs.all.keys.filter { it == key || it.startsWith("$key/") }.forEach(editor::remove)
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
@@ -310,6 +310,13 @@ impl Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether to serve a stripped copy of this component's build. Only
|
||||||
|
/// an `Apk` has anything to strip, so a `Server` is always false
|
||||||
|
/// rather than this being an option only one variant carries.
|
||||||
|
pub fn strip(&self) -> bool {
|
||||||
|
matches!(self, Self::Apk { strip: true, .. })
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cwd(&self) -> Option<&Path> {
|
pub fn cwd(&self) -> Option<&Path> {
|
||||||
match self {
|
match self {
|
||||||
Self::Apk { cwd, .. } | Self::Server { cwd, .. } => cwd.as_deref(),
|
Self::Apk { cwd, .. } | Self::Server { cwd, .. } => cwd.as_deref(),
|
||||||
|
|||||||
+25
-9
@@ -333,10 +333,15 @@ async fn main() -> Result<()> {
|
|||||||
// out from a bare 404 in a phone browser -- which is where this
|
// out from a bare 404 in a phone browser -- which is where this
|
||||||
// flag is used and where there is least to go on.
|
// flag is used and where there is least to go on.
|
||||||
let self_entry = state.entry(registry::SELF_KEY);
|
let self_entry = state.entry(registry::SELF_KEY);
|
||||||
match self_entry
|
// Named as no component, because this listener is half of the
|
||||||
.as_ref()
|
// frozen rescue contract and cannot say one -- and this server's
|
||||||
.and_then(|entry| entry.resolve_apk(None))
|
// own project produces a single APK, which is what makes that
|
||||||
{
|
// safe. Two would answer `None` here rather than pick.
|
||||||
|
match self_entry.as_ref().and_then(|entry| {
|
||||||
|
entry
|
||||||
|
.apk_component(None)
|
||||||
|
.and_then(|component| entry.resolve_apk(component, None))
|
||||||
|
}) {
|
||||||
// The age is here because this listener serves the file already
|
// The age is here because this listener serves the file already
|
||||||
// on disk and builds nothing, so an old APK installs in silence.
|
// on disk and builds nothing, so an old APK installs in silence.
|
||||||
// That one is unusually expensive to land on: a fresh install is
|
// That one is unusually expensive to land on: a fresh install is
|
||||||
@@ -386,14 +391,25 @@ async fn main() -> Result<()> {
|
|||||||
tracing::info!("scanning for projects under {}", root.display());
|
tracing::info!("scanning for projects under {}", root.display());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One line per APK, not per project: a project producing two clients
|
||||||
|
// has two answers here, and folding them into one would hide exactly
|
||||||
|
// the case where a component's builds are not where somebody expected.
|
||||||
for entry in state.entries() {
|
for entry in state.entries() {
|
||||||
match entry.resolve_apk(None) {
|
for component in entry.apk_components() {
|
||||||
Some(apk) => tracing::info!(" {} -> {}", entry.key, apk.path.display()),
|
match entry.resolve_apk(component, None) {
|
||||||
None => tracing::warn!(
|
Some(apk) => tracing::info!(
|
||||||
" {} -> no build found under {} (it will show as not built)",
|
" {}/{} -> {}",
|
||||||
entry.key,
|
entry.key,
|
||||||
entry.project_path.display(),
|
component.name(),
|
||||||
|
apk.path.display()
|
||||||
),
|
),
|
||||||
|
None => tracing::warn!(
|
||||||
|
" {}/{} -> no build found under {} (it will show as not built)",
|
||||||
|
entry.key,
|
||||||
|
component.name(),
|
||||||
|
entry.component_dir(component).display(),
|
||||||
|
),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+208
-51
@@ -61,30 +61,60 @@ pub struct AppEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AppEntry {
|
impl AppEntry {
|
||||||
/// The APK component this entry serves, if it has one.
|
/// Every APK this project produces, in declaration order.
|
||||||
///
|
pub fn apk_components(&self) -> impl Iterator<Item = &Component> {
|
||||||
/// One for now, and the first wins if a project ever declares two: the
|
|
||||||
/// download route serves a project, and which of two APKs it meant
|
|
||||||
/// would need saying. That is a question for whoever adds the second.
|
|
||||||
fn apk_component(&self) -> Option<&Component> {
|
|
||||||
self.components
|
self.components
|
||||||
.iter()
|
.iter()
|
||||||
.find(|component| matches!(component, Component::Apk { .. }))
|
.filter(|component| matches!(component, Component::Apk { .. }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The package this project's APK installs over, once a build has been
|
/// The APK component a request means: the one it names, or the only
|
||||||
/// read. `None` until there has been one -- a project can be added
|
/// one when it names none.
|
||||||
/// before it has ever been built.
|
///
|
||||||
pub fn package(&self) -> Option<&str> {
|
/// Deliberately not first-wins for a project with two. Two APKs differ
|
||||||
self.apk_component().and_then(Component::package)
|
/// in the package they install over and in whether their symbols are
|
||||||
|
/// worth carrying to a phone, so picking one for a request that didn't
|
||||||
|
/// say would answer a question nobody asked -- and it would look
|
||||||
|
/// exactly like a correct answer, which is the expensive kind of
|
||||||
|
/// wrong. `None` is the caller's cue to say which.
|
||||||
|
///
|
||||||
|
/// Naming none stays right for the projects that produce one, which is
|
||||||
|
/// nearly all of them, and for the frozen `/self` contract: it cannot
|
||||||
|
/// carry a component name, and the project it describes has a single
|
||||||
|
/// APK.
|
||||||
|
pub fn apk_component(&self, named: Option<&str>) -> Option<&Component> {
|
||||||
|
match named {
|
||||||
|
Some(name) => self
|
||||||
|
.apk_components()
|
||||||
|
.find(|component| component.name() == name),
|
||||||
|
None => {
|
||||||
|
let mut components = self.apk_components();
|
||||||
|
let only = components.next()?;
|
||||||
|
components.next().is_none().then_some(only)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether to serve a stripped copy. Declared, not detected.
|
/// Where one component's builds are.
|
||||||
pub fn strip(&self) -> bool {
|
///
|
||||||
matches!(
|
/// Its own directory, which is what `cwd` already means everywhere
|
||||||
self.apk_component(),
|
/// else: the directory its build command runs in, the subtree its
|
||||||
Some(Component::Apk { strip: true, .. })
|
/// staleness and its recorded commit are scoped to, and a server's
|
||||||
)
|
/// working directory. A component that doesn't say one sits at the
|
||||||
|
/// project root, which is what a project with a single APK has always
|
||||||
|
/// meant -- so nothing about the one-APK case changes.
|
||||||
|
///
|
||||||
|
/// This is what lets one project produce two APKs: the patterns are
|
||||||
|
/// anchored per component rather than at the root, so the second
|
||||||
|
/// client's build is reachable and each component's builds are its
|
||||||
|
/// own.
|
||||||
|
pub fn component_dir(&self, component: &Component) -> PathBuf {
|
||||||
|
match component.cwd() {
|
||||||
|
// `join` on an absolute path yields that path, as everywhere
|
||||||
|
// else a cwd is resolved.
|
||||||
|
Some(cwd) => self.project_path.join(cwd),
|
||||||
|
None => self.project_path.clone(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The APK to serve: `requested` if it is still one of this project's
|
/// The APK to serve: `requested` if it is still one of this project's
|
||||||
@@ -101,8 +131,12 @@ impl AppEntry {
|
|||||||
/// rather than failing -- a `./gradlew clean` shouldn't take an app out
|
/// rather than failing -- a `./gradlew clean` shouldn't take an app out
|
||||||
/// of the list, and the fallback is the answer this would have given
|
/// of the list, and the fallback is the answer this would have given
|
||||||
/// before any variant was chosen.
|
/// before any variant was chosen.
|
||||||
pub fn resolve_apk(&self, requested: Option<&Path>) -> Option<ApkCandidate> {
|
pub fn resolve_apk(
|
||||||
let variants = self.variants();
|
&self,
|
||||||
|
component: &Component,
|
||||||
|
requested: Option<&Path>,
|
||||||
|
) -> Option<ApkCandidate> {
|
||||||
|
let variants = self.variants(component);
|
||||||
if let Some(requested) = requested
|
if let Some(requested) = requested
|
||||||
&& let Some(found) = variants
|
&& let Some(found) = variants
|
||||||
.iter()
|
.iter()
|
||||||
@@ -115,8 +149,8 @@ impl AppEntry {
|
|||||||
|
|
||||||
/// Every build found under this project, newest first -- what the app
|
/// Every build found under this project, newest first -- what the app
|
||||||
/// offers when letting the user switch variants.
|
/// offers when letting the user switch variants.
|
||||||
pub fn variants(&self) -> Vec<ApkCandidate> {
|
pub fn variants(&self, component: &Component) -> Vec<ApkCandidate> {
|
||||||
discover::find_apks(&self.project_path)
|
discover::find_apks(&self.component_dir(component))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The build step this project is asking for that nobody has accepted
|
/// The build step this project is asking for that nobody has accepted
|
||||||
@@ -223,25 +257,37 @@ impl AppEntry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The packages already read for each component, by name -- what an
|
/// The packages already read for each component -- what an acceptance has
|
||||||
/// acceptance has to carry across.
|
/// to carry across.
|
||||||
fn measured_packages(components: &[Component]) -> HashMap<String, String> {
|
///
|
||||||
|
/// Keyed by name *and* directory, because a name alone stopped
|
||||||
|
/// identifying an APK once each component's builds came from its own
|
||||||
|
/// `cwd`: a declared component that happens to reuse a name while
|
||||||
|
/// pointing somewhere else is a different app, and handing it the package
|
||||||
|
/// read from the old one would make the card check the installed state of
|
||||||
|
/// something else -- silently, and looking like an answer, until that
|
||||||
|
/// component was downloaded once and re-read.
|
||||||
|
type ComponentId = (String, Option<PathBuf>);
|
||||||
|
|
||||||
|
fn component_id(component: &Component) -> ComponentId {
|
||||||
|
(
|
||||||
|
component.name().to_string(),
|
||||||
|
component.cwd().map(Path::to_path_buf),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn measured_packages(components: &[Component]) -> HashMap<ComponentId, String> {
|
||||||
components
|
components
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|component| {
|
.filter_map(|component| Some((component_id(component), component.package()?.to_string())))
|
||||||
Some((
|
|
||||||
component.name().to_string(),
|
|
||||||
component.package()?.to_string(),
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Records a freshly read package on whichever component is the APK.
|
/// Records a freshly read package on the APK component that produced it.
|
||||||
fn set_measured_package(components: &mut [Component], package: String) {
|
fn set_measured_package(components: &mut [Component], name: &str, package: String) {
|
||||||
if let Some(component) = components
|
if let Some(component) = components
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|component| matches!(component, Component::Apk { .. }))
|
.find(|component| matches!(component, Component::Apk { .. }) && component.name() == name)
|
||||||
{
|
{
|
||||||
component.set_package(package);
|
component.set_package(package);
|
||||||
}
|
}
|
||||||
@@ -291,7 +337,10 @@ pub struct AppState {
|
|||||||
/// It needs no clearing protocol: the phone knows what is installed,
|
/// It needs no clearing protocol: the phone knows what is installed,
|
||||||
/// so it only shows the offer while the old package is actually there.
|
/// so it only shows the offer while the old package is actually there.
|
||||||
/// Dropped when the project is, so this can't outlive it.
|
/// Dropped when the project is, so this can't outlive it.
|
||||||
previous_packages: Mutex<HashMap<String, String>>,
|
/// Keyed by project *and* component: a project with two clients can
|
||||||
|
/// rename either of them, and one card would otherwise offer to
|
||||||
|
/// remove the other's old package.
|
||||||
|
previous_packages: Mutex<HashMap<(String, String), String>>,
|
||||||
config_path: PathBuf,
|
config_path: PathBuf,
|
||||||
registry: RwLock<Registry>,
|
registry: RwLock<Registry>,
|
||||||
}
|
}
|
||||||
@@ -336,10 +385,14 @@ impl AppState {
|
|||||||
.cloned()
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What this project's APK used to install over, if it has been
|
/// What one component's APK used to install over, if it has been
|
||||||
/// renamed since this server started. See [`Self::previous_packages`].
|
/// renamed since this server started. See [`Self::previous_packages`].
|
||||||
pub fn previous_package(&self, key: &str) -> Option<String> {
|
pub fn previous_package(&self, key: &str, component: &str) -> Option<String> {
|
||||||
self.previous_packages.lock().unwrap().get(key).cloned()
|
self.previous_packages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&(key.to_string(), component.to_string()))
|
||||||
|
.cloned()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-reads what `apk` installs over and records it if it has changed.
|
/// Re-reads what `apk` installs over and records it if it has changed.
|
||||||
@@ -350,9 +403,10 @@ impl AppState {
|
|||||||
/// bytes are asked for. Runs off the request so the download is not
|
/// bytes are asked for. Runs off the request so the download is not
|
||||||
/// held up by a process spawn -- the answer is wanted by the *next*
|
/// held up by a process spawn -- the answer is wanted by the *next*
|
||||||
/// manifest, not this one.
|
/// manifest, not this one.
|
||||||
pub fn refresh_package(self: &Arc<Self>, key: &str, apk: PathBuf) {
|
pub fn refresh_package(self: &Arc<Self>, key: &str, component: &str, apk: PathBuf) {
|
||||||
let state = Arc::clone(self);
|
let state = Arc::clone(self);
|
||||||
let key = key.to_string();
|
let key = key.to_string();
|
||||||
|
let component = component.to_string();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
let Ok(info) = crate::apkinfo::read(&apk) else {
|
let Ok(info) = crate::apkinfo::read(&apk) else {
|
||||||
return;
|
return;
|
||||||
@@ -360,12 +414,16 @@ impl AppState {
|
|||||||
let Some(entry) = state.entry(&key) else {
|
let Some(entry) = state.entry(&key) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if entry.package() == Some(info.package.as_str()) {
|
let known = entry
|
||||||
|
.apk_component(Some(&component))
|
||||||
|
.and_then(Component::package)
|
||||||
|
.map(str::to_string);
|
||||||
|
if known.as_deref() == Some(info.package.as_str()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(previous) = entry.package() {
|
if let Some(previous) = known {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"{} now installs {} rather than {previous}",
|
"{}/{component} now installs {} rather than {previous}",
|
||||||
entry.label,
|
entry.label,
|
||||||
info.package,
|
info.package,
|
||||||
);
|
);
|
||||||
@@ -373,11 +431,11 @@ impl AppState {
|
|||||||
.previous_packages
|
.previous_packages
|
||||||
.lock()
|
.lock()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.insert(key.clone(), previous.to_string());
|
.insert((key.clone(), component.clone()), previous);
|
||||||
}
|
}
|
||||||
let update = state.update(|config| {
|
let update = state.update(|config| {
|
||||||
if let Some(project) = config.projects.iter_mut().find(|p| p.key == key) {
|
if let Some(project) = config.projects.iter_mut().find(|p| p.key == key) {
|
||||||
set_measured_package(&mut project.components, info.package);
|
set_measured_package(&mut project.components, &component, info.package);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -547,7 +605,10 @@ impl AppState {
|
|||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
// Nothing left for either to be about.
|
// Nothing left for either to be about.
|
||||||
self.previous_packages.lock().unwrap().remove(key);
|
self.previous_packages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|(project, _), _| project != key);
|
||||||
self.service_checks.forget(key);
|
self.service_checks.forget(key);
|
||||||
self.resource_checks.forget(key);
|
self.resource_checks.forget(key);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -616,7 +677,7 @@ impl AppState {
|
|||||||
project.resources = declared.resources;
|
project.resources = declared.resources;
|
||||||
project.components = declared.components;
|
project.components = declared.components;
|
||||||
for component in &mut project.components {
|
for component in &mut project.components {
|
||||||
if let Some(package) = measured.get(component.name()) {
|
if let Some(package) = measured.get(&component_id(component)) {
|
||||||
component.set_package(package.clone());
|
component.set_package(package.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -674,10 +735,15 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
|
|||||||
} else {
|
} else {
|
||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
if !components
|
// Named rather than assumed: which component is the APK is the
|
||||||
|
// declaration's business, and only the fallback below gets to pick a
|
||||||
|
// name for it.
|
||||||
|
let apk_name = match components
|
||||||
.iter()
|
.iter()
|
||||||
.any(|component| matches!(component, Component::Apk { .. }))
|
.find(|component| matches!(component, Component::Apk { .. }))
|
||||||
{
|
{
|
||||||
|
Some(component) => component.name().to_string(),
|
||||||
|
None => {
|
||||||
components.push(Component::Apk {
|
components.push(Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::default(),
|
build: crate::config::Command::default(),
|
||||||
@@ -687,8 +753,10 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
|
|||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
});
|
});
|
||||||
|
"app".to_string()
|
||||||
}
|
}
|
||||||
set_measured_package(&mut components, SELF_PACKAGE.to_string());
|
};
|
||||||
|
set_measured_package(&mut components, &apk_name, SELF_PACKAGE.to_string());
|
||||||
let existing = config
|
let existing = config
|
||||||
.projects
|
.projects
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
@@ -925,6 +993,89 @@ mod tests {
|
|||||||
.expect("the configured app")
|
.expect("the configured app")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn apk_named(name: &str, cwd: Option<&str>) -> Component {
|
||||||
|
Component::Apk {
|
||||||
|
name: name.to_string(),
|
||||||
|
build: crate::config::Command::default(),
|
||||||
|
cwd: cwd.map(PathBuf::from),
|
||||||
|
stale_when: None,
|
||||||
|
strip: false,
|
||||||
|
package: None,
|
||||||
|
built_from: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_apk(dir: &Path, relative: &str) -> PathBuf {
|
||||||
|
let path = dir.join(relative);
|
||||||
|
std::fs::create_dir_all(path.parent().expect("a parent")).expect("mkdir");
|
||||||
|
std::fs::write(&path, "not really an apk").expect("write");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A project with two clients: each component's builds are its own,
|
||||||
|
/// found under the directory the component already says it lives in.
|
||||||
|
///
|
||||||
|
/// The second APK is the case this exists for -- anchored at the
|
||||||
|
/// project root, the patterns reach the first client and stop, so the
|
||||||
|
/// second was invisible while the first looked like the project's
|
||||||
|
/// answer.
|
||||||
|
#[test]
|
||||||
|
fn each_apk_component_finds_its_own_builds() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let first = write_apk(
|
||||||
|
dir.path(),
|
||||||
|
"app/androidApp/build/outputs/apk/debug/androidApp-debug.apk",
|
||||||
|
);
|
||||||
|
let second = write_apk(
|
||||||
|
dir.path(),
|
||||||
|
"app-dioxus/target/dx/app-dioxus/debug/android/app/app/build/outputs/apk/debug/app-debug.apk",
|
||||||
|
);
|
||||||
|
|
||||||
|
let entry = entry_for(
|
||||||
|
dir.path(),
|
||||||
|
vec![
|
||||||
|
apk_named("app", Some("app")),
|
||||||
|
apk_named("app-dioxus", Some("app-dioxus")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let paths = |name: &str| {
|
||||||
|
let component = entry.apk_component(Some(name)).expect("the component");
|
||||||
|
entry
|
||||||
|
.variants(component)
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| candidate.path)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
assert_eq!(paths("app"), vec![first]);
|
||||||
|
assert_eq!(paths("app-dioxus"), vec![second]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which of two APKs a request means is a question, not something to
|
||||||
|
/// answer with whichever was declared first -- two clients install
|
||||||
|
/// over different packages, so a guess reads as a correct answer while
|
||||||
|
/// putting the wrong app on the phone.
|
||||||
|
#[test]
|
||||||
|
fn naming_no_component_answers_only_for_a_project_with_one_apk() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|
||||||
|
let one = entry_for(dir.path(), vec![apk_named("app", None)]);
|
||||||
|
assert_eq!(one.apk_component(None).map(Component::name), Some("app"));
|
||||||
|
|
||||||
|
let two = entry_for(
|
||||||
|
dir.path(),
|
||||||
|
vec![
|
||||||
|
apk_named("app", Some("app")),
|
||||||
|
apk_named("app-dioxus", Some("app-dioxus")),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert!(two.apk_component(None).is_none());
|
||||||
|
assert_eq!(
|
||||||
|
two.apk_component(Some("app-dioxus")).map(Component::name),
|
||||||
|
Some("app-dioxus")
|
||||||
|
);
|
||||||
|
assert!(two.apk_component(Some("nothing-by-that-name")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
/// The point of the whole mechanism: a project asking for a command
|
/// The point of the whole mechanism: a project asking for a command
|
||||||
/// does not thereby get to run one.
|
/// does not thereby get to run one.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1091,7 +1242,10 @@ mod tests {
|
|||||||
assert_eq!(entry.label, "Declared Name");
|
assert_eq!(entry.label, "Declared Name");
|
||||||
// Its own package is this server's, not something a file may
|
// Its own package is this server's, not something a file may
|
||||||
// claim -- it is the one entry that is this program.
|
// claim -- it is the one entry that is this program.
|
||||||
assert_eq!(entry.package(), Some(SELF_PACKAGE));
|
assert_eq!(
|
||||||
|
entry.apk_component(None).and_then(Component::package),
|
||||||
|
Some(SELF_PACKAGE)
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
entry
|
entry
|
||||||
.build
|
.build
|
||||||
@@ -1114,7 +1268,10 @@ mod tests {
|
|||||||
|
|
||||||
let entry = self_entry(&app);
|
let entry = self_entry(&app);
|
||||||
assert_eq!(entry.label, SELF_LABEL);
|
assert_eq!(entry.label, SELF_LABEL);
|
||||||
assert_eq!(entry.package(), Some(SELF_PACKAGE));
|
assert_eq!(
|
||||||
|
entry.apk_component(None).and_then(Component::package),
|
||||||
|
Some(SELF_PACKAGE)
|
||||||
|
);
|
||||||
assert!(entry.built_in);
|
assert!(entry.built_in);
|
||||||
// Nothing declares how to build it, so nothing is guessed.
|
// Nothing declares how to build it, so nothing is guessed.
|
||||||
assert!(entry.build.is_none());
|
assert!(entry.build.is_none());
|
||||||
|
|||||||
+188
-81
@@ -127,6 +127,14 @@ enum ApiError {
|
|||||||
UnknownApp(String),
|
UnknownApp(String),
|
||||||
#[error("{0} has no build yet")]
|
#[error("{0} has no build yet")]
|
||||||
NotBuilt(String),
|
NotBuilt(String),
|
||||||
|
/// A project that produces more than one APK, asked for "the" APK.
|
||||||
|
/// Refused rather than answered with the first: which of two clients
|
||||||
|
/// somebody meant is not something to guess, and a guess here would
|
||||||
|
/// install the wrong app while looking like it worked.
|
||||||
|
#[error("{0} builds more than one app -- say which with ?component=")]
|
||||||
|
AmbiguousApk(String),
|
||||||
|
#[error("{0} has no component named {1}")]
|
||||||
|
UnknownComponent(String, String),
|
||||||
#[error("{0} has no on-demand build step configured")]
|
#[error("{0} has no on-demand build step configured")]
|
||||||
NoBuildStep(String),
|
NoBuildStep(String),
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
@@ -140,7 +148,11 @@ enum ApiError {
|
|||||||
impl IntoResponse for ApiError {
|
impl IntoResponse for ApiError {
|
||||||
fn into_response(self) -> Response {
|
fn into_response(self) -> Response {
|
||||||
let status = match self {
|
let status = match self {
|
||||||
Self::UnknownApp(_) | Self::NotBuilt(_) | Self::NoBuildStep(_) => StatusCode::NOT_FOUND,
|
Self::UnknownApp(_)
|
||||||
|
| Self::NotBuilt(_)
|
||||||
|
| Self::NoBuildStep(_)
|
||||||
|
| Self::UnknownComponent(..) => StatusCode::NOT_FOUND,
|
||||||
|
Self::AmbiguousApk(_) => StatusCode::BAD_REQUEST,
|
||||||
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
Self::BadRequest(_) => StatusCode::BAD_REQUEST,
|
||||||
Self::RangeNotSatisfiable => StatusCode::RANGE_NOT_SATISFIABLE,
|
Self::RangeNotSatisfiable => StatusCode::RANGE_NOT_SATISFIABLE,
|
||||||
Self::Internal(err) => {
|
Self::Internal(err) => {
|
||||||
@@ -162,6 +174,56 @@ fn bad_request(err: anyhow::Error) -> ApiError {
|
|||||||
ApiError::BadRequest(format!("{err:#}"))
|
ApiError::BadRequest(format!("{err:#}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The installable half of a component, absent for a `Server`.
|
||||||
|
///
|
||||||
|
/// Nested rather than flattened onto the component with every field
|
||||||
|
/// optional, because "this component has an APK" is one fact rather than
|
||||||
|
/// six: a server has no build to install, no package to replace and no
|
||||||
|
/// variants to choose between, and saying that once is what stops the
|
||||||
|
/// phone having to work it out from a size of zero.
|
||||||
|
///
|
||||||
|
/// Per component and not per project. Two clients built from one checkout
|
||||||
|
/// install over different packages and are worth stripping to different
|
||||||
|
/// degrees, so a project-level answer would be the first component's,
|
||||||
|
/// presented as the project's.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ManifestApk {
|
||||||
|
/// What the download is saved as on the device.
|
||||||
|
filename: String,
|
||||||
|
/// Absent until a build has been read for it -- a project can be added
|
||||||
|
/// before it has ever been built, and claiming a package before then
|
||||||
|
/// would be inventing one.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
package: Option<String>,
|
||||||
|
/// What this component used to install over, when it has been renamed
|
||||||
|
/// since this server started.
|
||||||
|
///
|
||||||
|
/// Android treats a renamed `applicationId` as an unrelated app, so
|
||||||
|
/// the old one is still installed and nothing will ever replace it.
|
||||||
|
/// The phone offers to remove it -- and knows whether it is still
|
||||||
|
/// there, which is why nothing here has to be cleared.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
previous_package: Option<String>,
|
||||||
|
/// False when this component has no APK yet (never built, or cleaned).
|
||||||
|
/// It is still drawn -- it was declared deliberately, and a row saying
|
||||||
|
/// so is a better answer than one that silently vanished -- with
|
||||||
|
/// `mtime`/`size` at zero and nothing to download.
|
||||||
|
built: bool,
|
||||||
|
/// Epoch seconds of the raw build's mtime. Always the *raw* build's,
|
||||||
|
/// even when a stripped copy is what's served: that's the number that
|
||||||
|
/// actually moves when something is rebuilt, which is what the app
|
||||||
|
/// compares against the installed copy.
|
||||||
|
mtime: f64,
|
||||||
|
/// Of the file that would be served as things stand -- the slim copy
|
||||||
|
/// where one has already been produced. Close to the bytes about to be
|
||||||
|
/// downloaded rather than exactly them, because finding out exactly
|
||||||
|
/// would mean running the strip pipeline here; see
|
||||||
|
/// `strip::serveable_now`.
|
||||||
|
size: u64,
|
||||||
|
variants: Vec<ManifestVariant>,
|
||||||
|
}
|
||||||
|
|
||||||
/// One component, as the card needs it: what it is called and which kind
|
/// One component, as the card needs it: what it is called and which kind
|
||||||
/// it is. Nothing else -- what it *does* is the build step, which is not
|
/// it is. Nothing else -- what it *does* is the build step, which is not
|
||||||
/// the phone's business, and how far along it is arrives on the status.
|
/// the phone's business, and how far along it is arrives on the status.
|
||||||
@@ -246,15 +308,22 @@ struct ManifestComponent {
|
|||||||
/// project that keeps nothing.
|
/// project that keeps nothing.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
resources_error: Option<String>,
|
resources_error: Option<String>,
|
||||||
|
/// What there is to install, for a component that produces an APK.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
apk: Option<ManifestApk>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManifestComponent {
|
impl ManifestComponent {
|
||||||
fn read(
|
/// Async only for the APK's size, which is a `stat` of whatever is on
|
||||||
|
/// disk -- never a strip run to find out what the slim copy would
|
||||||
|
/// weigh, because this path is fetched on every open, resume and
|
||||||
|
/// Refresh.
|
||||||
|
async fn read(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
key: &str,
|
key: &str,
|
||||||
entry: &AppEntry,
|
entry: &AppEntry,
|
||||||
component: &crate::config::Component,
|
component: &crate::config::Component,
|
||||||
) -> Self {
|
) -> Result<Self, ApiError> {
|
||||||
let name = component.name().to_string();
|
let name = component.name().to_string();
|
||||||
let is_server = matches!(component, crate::config::Component::Server { .. });
|
let is_server = matches!(component, crate::config::Component::Server { .. });
|
||||||
// A build log is a file this server wrote, so its existence is a
|
// A build log is a file this server wrote, so its existence is a
|
||||||
@@ -271,7 +340,7 @@ impl ManifestComponent {
|
|||||||
.then(|| state.resource_checks.facts(key))
|
.then(|| state.resource_checks.facts(key))
|
||||||
.flatten()
|
.flatten()
|
||||||
.map(|facts| crate::purge::paths(&facts, &entry.project_path));
|
.map(|facts| crate::purge::paths(&facts, &entry.project_path));
|
||||||
Self {
|
Ok(Self {
|
||||||
kind: if is_server { "server" } else { "apk" },
|
kind: if is_server { "server" } else { "apk" },
|
||||||
state: is_server
|
state: is_server
|
||||||
.then(|| state.service_checks.state(key, &name))
|
.then(|| state.service_checks.state(key, &name))
|
||||||
@@ -317,8 +386,56 @@ impl ManifestComponent {
|
|||||||
resources_error: is_server
|
resources_error: is_server
|
||||||
.then(|| state.resource_checks.error(key))
|
.then(|| state.resource_checks.error(key))
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
apk: match is_server {
|
||||||
|
true => None,
|
||||||
|
false => Some(ManifestApk::read(state, key, entry, component).await?),
|
||||||
|
},
|
||||||
name,
|
name,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ManifestApk {
|
||||||
|
async fn read(
|
||||||
|
state: &AppState,
|
||||||
|
key: &str,
|
||||||
|
entry: &AppEntry,
|
||||||
|
component: &crate::config::Component,
|
||||||
|
) -> Result<Self, ApiError> {
|
||||||
|
let name = component.name();
|
||||||
|
let newest = entry.resolve_apk(component, None);
|
||||||
|
let size = match &newest {
|
||||||
|
Some(apk) => {
|
||||||
|
tokio::fs::metadata(crate::strip::serveable_now(&apk.path, component.strip()))
|
||||||
|
.await
|
||||||
|
.context("stat the apk to be served")?
|
||||||
|
.len()
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
filename: newest
|
||||||
|
.as_ref()
|
||||||
|
.map(|apk| entry.filename(&apk.path))
|
||||||
|
.unwrap_or_else(|| format!("{key}-{name}.apk")),
|
||||||
|
package: component.package().map(str::to_string),
|
||||||
|
previous_package: state.previous_package(key, name),
|
||||||
|
built: newest.is_some(),
|
||||||
|
mtime: newest
|
||||||
|
.as_ref()
|
||||||
|
.map(|apk| epoch_secs(apk.modified))
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
size,
|
||||||
|
variants: entry
|
||||||
|
.variants(component)
|
||||||
|
.into_iter()
|
||||||
|
.map(|candidate| ManifestVariant {
|
||||||
|
path: candidate.path.to_string_lossy().into_owned(),
|
||||||
|
variant: candidate.variant,
|
||||||
|
mtime: epoch_secs(candidate.modified),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,44 +458,22 @@ struct ManifestResponse {
|
|||||||
struct ManifestApp {
|
struct ManifestApp {
|
||||||
key: String,
|
key: String,
|
||||||
label: String,
|
label: String,
|
||||||
filename: String,
|
/// Where this project's APKs are fetched from. Which of them is said
|
||||||
|
/// with the request (`?component=`), so this stays one route per
|
||||||
|
/// project rather than one string per component that differs only in
|
||||||
|
/// its query.
|
||||||
route: String,
|
route: String,
|
||||||
/// Absent until a build has been read for it -- a project can be added
|
|
||||||
/// before it has ever been built, and claiming a package before then
|
|
||||||
/// would be inventing one.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
package: Option<String>,
|
|
||||||
/// What this project's APK used to install over, when it has been
|
|
||||||
/// renamed since this server started.
|
|
||||||
///
|
|
||||||
/// Android treats a renamed `applicationId` as an unrelated app, so
|
|
||||||
/// the old one is still installed and nothing will ever replace it.
|
|
||||||
/// The phone offers to remove it -- and knows whether it is still
|
|
||||||
/// there, which is why nothing here has to be cleared.
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
previous_package: Option<String>,
|
|
||||||
project_path: String,
|
project_path: String,
|
||||||
/// Epoch seconds of the raw build's mtime. Always the *raw* build's,
|
|
||||||
/// even when a stripped copy is what's served: that's the number that
|
|
||||||
/// actually moves when something is rebuilt, which is what the app
|
|
||||||
/// compares against the installed copy.
|
|
||||||
mtime: f64,
|
|
||||||
/// Of the file that would be served as things stand -- the slim copy
|
|
||||||
/// where one has already been produced. Close to the bytes about to be
|
|
||||||
/// downloaded rather than exactly them, because finding out exactly
|
|
||||||
/// would mean running the strip pipeline here; see
|
|
||||||
/// `strip::serveable_now`.
|
|
||||||
size: u64,
|
|
||||||
needs_build: bool,
|
needs_build: bool,
|
||||||
/// This machine's preferences for the project, so the card's settings
|
/// This machine's preferences for the project, so the card's settings
|
||||||
/// can show what is currently set rather than a guess at it.
|
/// can show what is currently set rather than a guess at it.
|
||||||
git_ipv4: bool,
|
git_ipv4: bool,
|
||||||
/// True for this server's own app, which has no Remove button.
|
/// True for this server's own app, which has no Remove button.
|
||||||
built_in: bool,
|
built_in: bool,
|
||||||
/// False when the project has no APK yet (never built, or cleaned).
|
/// Whether *anything* this project produces has been built. Per
|
||||||
/// Such an app is still listed -- it was added deliberately, and a card
|
/// component is on the component (`ManifestApk::built`); this is what
|
||||||
/// saying so is a better answer than one that silently vanished -- with
|
/// the card's own "nothing here yet" line reads, and what keeps a
|
||||||
/// `mtime`/`size` at zero and nothing to download.
|
/// project with one built client out of it.
|
||||||
built: bool,
|
built: bool,
|
||||||
/// Present when the project is in a git repository at all: the branch,
|
/// Present when the project is in a git repository at all: the branch,
|
||||||
/// how far behind it is as of the last fetch, and whether the tree is
|
/// how far behind it is as of the last fetch, and whether the tree is
|
||||||
@@ -422,7 +517,6 @@ struct ManifestApp {
|
|||||||
/// something the keys don't say.
|
/// something the keys don't say.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pending_declaration: Option<String>,
|
pending_declaration: Option<String>,
|
||||||
variants: Vec<ManifestVariant>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One build found under a project. Which of them a device wants is that
|
/// One build found under a project. Which of them a device wants is that
|
||||||
@@ -514,27 +608,13 @@ async fn app(
|
|||||||
/// whole list and a single card cannot come to describe the same app
|
/// whole list and a single card cannot come to describe the same app
|
||||||
/// differently.
|
/// differently.
|
||||||
async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp, ApiError> {
|
async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp, ApiError> {
|
||||||
let apk = entry.resolve_apk(None);
|
// A loop rather than a map because each component's APK is a `stat`,
|
||||||
// Whatever is on disk right now, never a strip run to find out --
|
// and they are described in declaration order -- which is build order,
|
||||||
// see `strip::serveable_now`. This path is fetched on every open,
|
// and the order the card draws them in.
|
||||||
// resume and Refresh.
|
let mut components = Vec::with_capacity(entry.components.len());
|
||||||
let size = match &apk {
|
for component in &entry.components {
|
||||||
Some(apk) => tokio::fs::metadata(crate::strip::serveable_now(&apk.path, entry.strip()))
|
components.push(ManifestComponent::read(state, &entry.key, entry, component).await?);
|
||||||
.await
|
}
|
||||||
.context("stat the apk to be served")?
|
|
||||||
.len(),
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
let variants = entry
|
|
||||||
.variants()
|
|
||||||
.into_iter()
|
|
||||||
.map(|candidate| ManifestVariant {
|
|
||||||
path: candidate.path.to_string_lossy().into_owned(),
|
|
||||||
variant: candidate.variant,
|
|
||||||
mtime: epoch_secs(candidate.modified),
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let git = crate::git::status(&entry.project_path);
|
let git = crate::git::status(&entry.project_path);
|
||||||
// An upstream is part of it: a branch that tracks nothing has nothing
|
// An upstream is part of it: a branch that tracks nothing has nothing
|
||||||
@@ -550,34 +630,22 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
|
|||||||
// Read after `new_commits`, so a check that lands between the
|
// Read after `new_commits`, so a check that lands between the
|
||||||
// two reports its answer rather than that it is still coming.
|
// two reports its answer rather than that it is still coming.
|
||||||
check_pending: can_pull && state.remote_checks.is_checking(&entry.project_path),
|
check_pending: can_pull && state.remote_checks.is_checking(&entry.project_path),
|
||||||
components: entry
|
built: components
|
||||||
.components
|
|
||||||
.iter()
|
.iter()
|
||||||
.map(|component| ManifestComponent::read(state, &entry.key, entry, component))
|
.any(|component| component.apk.as_ref().is_some_and(|apk| apk.built)),
|
||||||
.collect(),
|
components,
|
||||||
check_error: can_pull
|
check_error: can_pull
|
||||||
.then(|| state.remote_checks.error(&entry.project_path))
|
.then(|| state.remote_checks.error(&entry.project_path))
|
||||||
.flatten(),
|
.flatten(),
|
||||||
can_pull,
|
can_pull,
|
||||||
git,
|
git,
|
||||||
route: format!("/apps/{}/apk", entry.key),
|
route: format!("/apps/{}/apk", entry.key),
|
||||||
filename: apk
|
|
||||||
.as_ref()
|
|
||||||
.map(|apk| entry.filename(&apk.path))
|
|
||||||
.unwrap_or_else(|| format!("{}.apk", entry.key)),
|
|
||||||
key: entry.key.clone(),
|
key: entry.key.clone(),
|
||||||
label: entry.label.clone(),
|
label: entry.label.clone(),
|
||||||
package: entry.package().map(str::to_string),
|
|
||||||
previous_package: state.previous_package(&entry.key),
|
|
||||||
// Shown on a phone, where the home prefix is the least
|
// Shown on a phone, where the home prefix is the least
|
||||||
// interesting part of a long path. expand_tilde accepts this
|
// interesting part of a long path. expand_tilde accepts this
|
||||||
// form back, so it stays copy-pasteable into "add by path".
|
// form back, so it stays copy-pasteable into "add by path".
|
||||||
project_path: crate::config::contract_tilde(&entry.project_path),
|
project_path: crate::config::contract_tilde(&entry.project_path),
|
||||||
mtime: apk
|
|
||||||
.as_ref()
|
|
||||||
.map(|apk| epoch_secs(apk.modified))
|
|
||||||
.unwrap_or(0.0),
|
|
||||||
size,
|
|
||||||
// False while something is waiting to be accepted: the command
|
// False while something is waiting to be accepted: the command
|
||||||
// won't run until it is, so offering to build would be a
|
// won't run until it is, so offering to build would be a
|
||||||
// button that does nothing.
|
// button that does nothing.
|
||||||
@@ -588,11 +656,9 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
|
|||||||
.is_some_and(|build| build.has_command()),
|
.is_some_and(|build| build.has_command()),
|
||||||
git_ipv4: entry.git_ipv4,
|
git_ipv4: entry.git_ipv4,
|
||||||
built_in: entry.built_in,
|
built_in: entry.built_in,
|
||||||
built: apk.is_some(),
|
|
||||||
pending_declaration: pending
|
pending_declaration: pending
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|components| crate::config::render_request(components).ok()),
|
.and_then(|components| crate::config::render_request(components).ok()),
|
||||||
variants,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -747,7 +813,10 @@ async fn add_app(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
"added {} ({}) from {}",
|
"added {} ({}) from {}",
|
||||||
entry.key,
|
entry.key,
|
||||||
entry.package().unwrap_or("package not read yet"),
|
entry
|
||||||
|
.apk_component(None)
|
||||||
|
.and_then(crate::config::Component::package)
|
||||||
|
.unwrap_or("package not read yet"),
|
||||||
entry.project_path.display()
|
entry.project_path.display()
|
||||||
);
|
);
|
||||||
Ok(Json(AddedResponse {
|
Ok(Json(AddedResponse {
|
||||||
@@ -1038,10 +1107,19 @@ struct PullQuery {
|
|||||||
force: bool,
|
force: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which build a download wants. Absent means the newest, which is what
|
/// Which build a download wants, and whose.
|
||||||
/// every phone gets until it says otherwise.
|
///
|
||||||
|
/// `component` names the APK on a project that produces more than one;
|
||||||
|
/// absent is the only APK, which is every project with one and the frozen
|
||||||
|
/// `/self/apk`, which cannot say a component. A project with two and a
|
||||||
|
/// request that names neither is refused rather than served the first --
|
||||||
|
/// see [`ApiError::AmbiguousApk`].
|
||||||
|
///
|
||||||
|
/// `variant` absent means the newest, which is what every phone gets until
|
||||||
|
/// it says otherwise.
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct VariantQuery {
|
struct VariantQuery {
|
||||||
|
component: Option<String>,
|
||||||
variant: Option<String>,
|
variant: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1334,8 +1412,12 @@ struct SelfBuild {
|
|||||||
/// the file and answers two numbers.
|
/// the file and answers two numbers.
|
||||||
async fn self_build(State(state): State<Arc<AppState>>) -> Result<Json<SelfBuild>, ApiError> {
|
async fn self_build(State(state): State<Arc<AppState>>) -> Result<Json<SelfBuild>, ApiError> {
|
||||||
let entry = lookup(&state, None)?;
|
let entry = lookup(&state, None)?;
|
||||||
|
// No component named, because this route is frozen and cannot carry
|
||||||
|
// one -- and this server's own project produces exactly one APK,
|
||||||
|
// which is what makes that an answer rather than a guess.
|
||||||
|
let component = apk_component(&entry, None)?;
|
||||||
let apk = entry
|
let apk = entry
|
||||||
.resolve_apk(None)
|
.resolve_apk(component, None)
|
||||||
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
|
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
|
||||||
let size = tokio::fs::metadata(&apk.path)
|
let size = tokio::fs::metadata(&apk.path)
|
||||||
.await
|
.await
|
||||||
@@ -1347,6 +1429,28 @@ async fn self_build(State(state): State<Arc<AppState>>) -> Result<Json<SelfBuild
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The APK component a request is about, or why there isn't one.
|
||||||
|
///
|
||||||
|
/// The single place a name from a phone becomes a component, so the
|
||||||
|
/// download and anything that follows it cannot disagree about which APK
|
||||||
|
/// was meant.
|
||||||
|
fn apk_component<'a>(
|
||||||
|
entry: &'a AppEntry,
|
||||||
|
named: Option<&str>,
|
||||||
|
) -> Result<&'a crate::config::Component, ApiError> {
|
||||||
|
if let Some(component) = entry.apk_component(named) {
|
||||||
|
return Ok(component);
|
||||||
|
}
|
||||||
|
Err(match named {
|
||||||
|
Some(name) => ApiError::UnknownComponent(entry.label.clone(), name.to_string()),
|
||||||
|
// Nothing named, and not one obvious answer: either the project
|
||||||
|
// builds no APK at all, or it builds several and the request has
|
||||||
|
// to say which.
|
||||||
|
None if entry.apk_components().next().is_none() => ApiError::NotBuilt(entry.label.clone()),
|
||||||
|
None => ApiError::AmbiguousApk(entry.label.clone()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn serve_apk(
|
async fn serve_apk(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
key: Option<UrlPath<String>>,
|
key: Option<UrlPath<String>>,
|
||||||
@@ -1355,20 +1459,23 @@ async fn serve_apk(
|
|||||||
Query(query): Query<VariantQuery>,
|
Query(query): Query<VariantQuery>,
|
||||||
) -> Result<Response, ApiError> {
|
) -> Result<Response, ApiError> {
|
||||||
let entry = lookup(&state, key)?;
|
let entry = lookup(&state, key)?;
|
||||||
|
let component = apk_component(&entry, query.component.as_deref())?;
|
||||||
// Which build this device wants, if it has a preference. Its own
|
// Which build this device wants, if it has a preference. Its own
|
||||||
// preference, travelling with the request: two phones enrolled against
|
// preference, travelling with the request: two phones enrolled against
|
||||||
// one server must not change what the other gets.
|
// one server must not change what the other gets. Validated against
|
||||||
|
// *this component's* builds, so naming another one's path is a
|
||||||
|
// fallback to this one's newest rather than a way to be served it.
|
||||||
let requested = query.variant.map(PathBuf::from);
|
let requested = query.variant.map(PathBuf::from);
|
||||||
let apk = entry
|
let apk = entry
|
||||||
.resolve_apk(requested.as_deref())
|
.resolve_apk(component, requested.as_deref())
|
||||||
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
|
.ok_or_else(|| ApiError::NotBuilt(entry.label.clone()))?;
|
||||||
// A fresh download, not a range continuation: the one moment this
|
// A fresh download, not a range continuation: the one moment this
|
||||||
// server can notice that a local rebuild changed what the APK installs
|
// server can notice that a local rebuild changed what the APK installs
|
||||||
// over. Off the request, so it costs the download nothing.
|
// over. Off the request, so it costs the download nothing.
|
||||||
if headers.get(header::RANGE).is_none() {
|
if headers.get(header::RANGE).is_none() {
|
||||||
state.refresh_package(&entry.key, apk.path.clone());
|
state.refresh_package(&entry.key, component.name(), apk.path.clone());
|
||||||
}
|
}
|
||||||
let resolved = resolve_serveable_path(&apk.path, entry.strip()).await?;
|
let resolved = resolve_serveable_path(&apk.path, component.strip()).await?;
|
||||||
let size = tokio::fs::metadata(&resolved)
|
let size = tokio::fs::metadata(&resolved)
|
||||||
.await
|
.await
|
||||||
.context("stat resolved apk")?
|
.context("stat resolved apk")?
|
||||||
|
|||||||
Reference in new issue
Block a user