A Rebuild on every component, and the keystore AGP actually uses
Two things, both from the same stuck download. Each component row now carries its own Rebuild, which is /build scoped to it -- the route already took ?component=, only the button was missing. Update builds only what the staleness rules call behind, and those rules cannot see a command reading undeclared files, an output changed underneath this server, or a signing key replaced since the APK was made; in all of those Update does nothing and the only force was a project-wide Rebuild that rebuilds every sibling. Same action at two scales, so it takes the project Rebuild's word and colour. hasBuild is new on the manifest component so a component with no command of its own draws none, which the project-level needsBuild could not answer. And debug_keystore_path() now resolves the preferences directory the way AGP does instead of assuming ~/.android: ANDROID_USER_HOME as-is, then ANDROID_PREFS_ROOT and ANDROID_SDK_HOME with .android appended, then $XDG_CONFIG_HOME/.android when that directory exists, then $HOME/.android. Measured by running real builds against AGP 32.3.2, including the existence test on the XDG step, which the documentation does not mention. An ordinary desktop machine with XDG_CONFIG_HOME set signs its APKs with a keystore this server never looked at, so it reported "no debug keystore" about a machine that had one and was using it -- and this VM sets no XDG_CONFIG_HOME, which is why it could not happen here. The failure now names a keystore found further down the list, since an inert one and an absent one are otherwise identical from a phone. Verified on the emulator (component Rebuild: press, that component's own progress, back to resting with no sibling moving) and against a throwaway server for both keystore branches. Reported by the tdep-survey session, which found the two keystores on the serving host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b67c70fa2f
commit
c7b3af4861
6 files changed
+348
-62
No files matched your search
@@ -255,6 +255,23 @@ mutable at runtime from the phone.
|
|||||||
that silence. It is part of the acceptance gate like every other
|
that silence. It is part of the acceptance gate like every other
|
||||||
declared field -- `same_declaration` destructures exhaustively, which is
|
declared field -- `same_declaration` destructures exhaustively, which is
|
||||||
what forced the decision when the field was added.
|
what forced the decision when the field was added.
|
||||||
|
- **The debug keystore is not `~/.android/debug.keystore`** -- that is the
|
||||||
|
last of five places, and `sdk::android_prefs_dirs` resolves it the way
|
||||||
|
AGP does, because the only thing that makes it the right key is being
|
||||||
|
the file Gradle signed with. Measured against AGP 32.3.2 by running real
|
||||||
|
builds: `$ANDROID_USER_HOME` is the preferences directory itself with no
|
||||||
|
`.android` under it, `$ANDROID_PREFS_ROOT` and `$ANDROID_SDK_HOME` get
|
||||||
|
`.android` appended, then `$XDG_CONFIG_HOME/.android` **but only when
|
||||||
|
that directory already exists** (the bytecode tests it and falls
|
||||||
|
through), then `$HOME/.android`. That fourth step is the whole bug: an
|
||||||
|
ordinary desktop Linux machine with `XDG_CONFIG_HOME` set signs its APKs
|
||||||
|
with a keystore this server never looked at, so it reported "no debug
|
||||||
|
keystore" about a machine that had one and was using it -- and this VM
|
||||||
|
sets no `XDG_CONFIG_HOME`, which is why the case could not appear here.
|
||||||
|
The failure names a keystore found further down the list, because an
|
||||||
|
inert one and an absent one look identical from a phone: mistaking one
|
||||||
|
for the other is what turned "this machine has none" into a signature
|
||||||
|
mismatch nobody could explain.
|
||||||
- **A stripped copy is only serveable if it is signed by the key the
|
- **A stripped copy is only serveable if it is signed by the key the
|
||||||
build was.** `strip.rs` re-signs with `~/.android/debug.keystore`, and
|
build was.** `strip.rs` re-signs with `~/.android/debug.keystore`, and
|
||||||
nothing about a debug keystore says which builds it made -- it is per
|
nothing about a debug keystore says which builds it made -- it is per
|
||||||
@@ -633,10 +650,28 @@ mutable at runtime from the phone.
|
|||||||
`BuildState::{trigger_if_needed,build_now,run_build,is_stale}` all take
|
`BuildState::{trigger_if_needed,build_now,run_build,is_stale}` all take
|
||||||
the same `Option<&str>` -- `None` still means the whole project, which
|
the same `Option<&str>` -- `None` still means the whole project, which
|
||||||
is what `Pull & Build`, the project-row `Rebuild`, and every project
|
is what `Pull & Build`, the project-row `Rebuild`, and every project
|
||||||
with a single component keep doing. There is deliberately no
|
with a single component keep doing. **Each component's row carries its
|
||||||
component-scoped Rebuild: forcing one component's build without
|
own Rebuild**, which is `/build` scoped to it. That used to be
|
||||||
touching the rest happens by pressing Update on it, which runs
|
deliberately absent, on the grounds that Update already builds a
|
||||||
`/prepare` scoped to that component.
|
component that needs it -- but Update runs `/prepare`, which builds only
|
||||||
|
when the staleness rules say the output is behind, and those rules are
|
||||||
|
blind to everything a commit does not describe: a command reading files
|
||||||
|
nobody declared, an output changed underneath this server, a signing key
|
||||||
|
replaced since the APK was made. In all of them nothing reads as stale,
|
||||||
|
so Update does nothing, and the only force was a project-wide Rebuild
|
||||||
|
that rebuilds every sibling too. Iris asked for the per-component
|
||||||
|
button on 2026-09-01. The two are the same action at two scales, so
|
||||||
|
they share a word and a colour, and each sits at the right-hand end of
|
||||||
|
its own row.
|
||||||
|
A component with no command of its own draws no Rebuild -- `hasBuild`
|
||||||
|
on the manifest component says so per component, which the project's
|
||||||
|
own `needsBuild` (the OR of every component's) cannot. Absent rather
|
||||||
|
than disabled, matching the project row, because having no build step
|
||||||
|
is not a state a component is in.
|
||||||
|
A server's Rebuild sits in its service row but outside the condition
|
||||||
|
that draws the service buttons: those wait for the script's answer,
|
||||||
|
while a server whose script cannot be reached is still one this machine
|
||||||
|
can compile.
|
||||||
**Where `component.dir()` belongs is now one definition**
|
**Where `component.dir()` belongs is now one definition**
|
||||||
(`Component::dir` in `config.rs`), because a second one very nearly
|
(`Component::dir` in `config.rs`), because a second one very nearly
|
||||||
shipped a real bug: `component_is_stale`'s "never built at all" check
|
shipped a real bug: `component_is_stale`'s "never built at all" check
|
||||||
|
|||||||
@@ -141,11 +141,19 @@ fun prepareBuild(key: String, component: String): BuildStatus =
|
|||||||
* button. This is also the only way a project already current with its checkout ever records what
|
* button. This is also the only way a project already current with its checkout ever records what
|
||||||
* it was built from, which is what the "out of date" signal is compared against.
|
* it was built from, which is what the "out of date" signal is compared against.
|
||||||
*
|
*
|
||||||
* Whole-project on purpose, unlike [prepareBuild]: this is the project row's Rebuild, which sits
|
* [component] is one component's own Rebuild; null is the project row's, which means the whole
|
||||||
* beside Pull & Build and means the same "the whole checkout" that one does. Forcing just one
|
* checkout the way Pull & Build does. Both exist because a component can need building with nothing
|
||||||
* component's build without touching the rest happens by pressing Update on that component, which
|
* to press: staleness is decided by commits under the paths a component declares, so a build that
|
||||||
* runs [prepareBuild] scoped to it -- there is no second, component-scoped Rebuild.
|
* reads something nobody declared, an output changed underneath this server, or a signing key
|
||||||
|
* replaced since the APK was made leaves every rule saying "current" -- and then Update, which runs
|
||||||
|
* [prepareBuild], does nothing, and the project-wide Rebuild is a heavy way to get one component
|
||||||
|
* built.
|
||||||
*/
|
*/
|
||||||
fun buildNow(key: String): BuildStatus = requestBuildStatus("/apps/$key/build", "POST")
|
fun buildNow(key: String, component: String? = null): BuildStatus =
|
||||||
|
requestBuildStatus(
|
||||||
|
"/apps/$key/build" +
|
||||||
|
(component?.let { "?component=${URLEncoder.encode(it, "UTF-8")}" } ?: ""),
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
|
||||||
fun buildStatus(key: String): BuildStatus = requestBuildStatus("/apps/$key/status", "GET")
|
fun buildStatus(key: String): BuildStatus = requestBuildStatus("/apps/$key/status", "GET")
|
||||||
@@ -110,6 +110,12 @@ data class ProjectComponent(
|
|||||||
// The last build stopped at this component, which is the one case
|
// The last build stopped at this component, which is the one case
|
||||||
// where its build log matters more than what it is doing now.
|
// where its build log matters more than what it is doing now.
|
||||||
val buildFailed: Boolean,
|
val buildFailed: Boolean,
|
||||||
|
// This component has a command and is allowed to run it, so its row
|
||||||
|
// offers to build this one alone. Per component rather than the
|
||||||
|
// project's own needsBuild, which is the OR of every component's: on
|
||||||
|
// a project where only one of two builds, that one would draw the
|
||||||
|
// button on both rows and the other would do nothing when pressed.
|
||||||
|
val hasBuild: Boolean,
|
||||||
// Where Uninstall's "remove data" and "remove config" would delete,
|
// Where Uninstall's "remove data" and "remove config" would delete,
|
||||||
// and whether anything is there. Null for an APK, which keeps nothing
|
// and whether anything is there. Null for an APK, which keeps nothing
|
||||||
// on the build machine.
|
// on the build machine.
|
||||||
@@ -348,6 +354,7 @@ private fun readEntry(entry: JSONObject): ManifestEntry {
|
|||||||
hasBuildLogs = component.optBoolean("hasBuildLogs", false),
|
hasBuildLogs = component.optBoolean("hasBuildLogs", false),
|
||||||
hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false),
|
hasRuntimeLogs = component.optBoolean("hasRuntimeLogs", false),
|
||||||
buildFailed = component.optBoolean("buildFailed", false),
|
buildFailed = component.optBoolean("buildFailed", false),
|
||||||
|
hasBuild = component.optBoolean("hasBuild", false),
|
||||||
dataPath = component.optString("dataPath").ifEmpty { null },
|
dataPath = component.optString("dataPath").ifEmpty { null },
|
||||||
configPath = component.optString("configPath").ifEmpty { null },
|
configPath = component.optString("configPath").ifEmpty { null },
|
||||||
dataPresent = component.optBoolean("dataPresent", false),
|
dataPresent = component.optBoolean("dataPresent", false),
|
||||||
|
|||||||
@@ -757,6 +757,45 @@ private fun AppListScreen(
|
|||||||
context.startActivity(installApkIntent(context, file))
|
context.startActivity(installApkIntent(context, file))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads one entry once a build has finished, whatever asked for the build.
|
||||||
|
*
|
||||||
|
* The APK's mtime is what decides "update available", so the list has to come from the server
|
||||||
|
* again rather than be guessed at here.
|
||||||
|
*
|
||||||
|
* Retried, because building this server's own project rebuilds its binary and restarts it: the
|
||||||
|
* socket goes away for a moment, and reporting that as a failure would be telling somebody
|
||||||
|
* their update broke at the moment it worked.
|
||||||
|
*
|
||||||
|
* The card stays marked as being worked on until that entry lands, which is a second or several
|
||||||
|
* while this server restarts — [settle] is what clears it, and runs only once there is a fresh
|
||||||
|
* entry to clear it against. Clearing at the end of the build instead left the card reading as
|
||||||
|
* idle against a list still describing the state before it.
|
||||||
|
*/
|
||||||
|
suspend fun readBackAfterBuilding(entry: ManifestEntry, settle: () -> Unit) {
|
||||||
|
repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt ->
|
||||||
|
try {
|
||||||
|
applyOne(entry.key)
|
||||||
|
settle()
|
||||||
|
// Building this app's own project is what produces the
|
||||||
|
// newer copy of it, and restarts the server it has to
|
||||||
|
// keep talking to. So this is the moment to offer it,
|
||||||
|
// rather than at some later launch.
|
||||||
|
if (entry.builtIn) {
|
||||||
|
onOwnProjectBuilt()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} catch (e: DownloadServerException) {
|
||||||
|
if (attempt == REFRESH_ATTEMPTS_AFTER_PULL - 1) {
|
||||||
|
settle()
|
||||||
|
failure(e)?.let { manifestState = ManifestState.Error(it) }
|
||||||
|
} else {
|
||||||
|
delay(RESTART_WAIT_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Pull acts on the build machine: fetch, fast-forward, rebuild. */
|
/** Pull acts on the build machine: fetch, fast-forward, rebuild. */
|
||||||
/**
|
/**
|
||||||
* Runs a build on the build machine and follows it to the end, whichever button asked for it.
|
* Runs a build on the build machine and follows it to the end, whichever button asked for it.
|
||||||
@@ -802,45 +841,12 @@ private fun AppListScreen(
|
|||||||
setComponent(entry.key, component.name, ComponentState.Error(it))
|
setComponent(entry.key, component.name, ComponentState.Error(it))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The APK's mtime is what decides "update available", so the
|
readBackAfterBuilding(entry) {
|
||||||
// list has to come from the server again rather than be
|
// The project's own state only. A component that failed
|
||||||
// guessed at here.
|
// inside this run has just been given its own, and
|
||||||
//
|
// clearing that here would take away the only thing
|
||||||
// Retried, because building this server's own project rebuilds
|
// saying so.
|
||||||
// its binary and restarts it: the socket goes away for a
|
|
||||||
// moment, and reporting that as a failure would be telling
|
|
||||||
// somebody their update broke at the moment it worked.
|
|
||||||
//
|
|
||||||
// The card stays marked as being worked on until that entry
|
|
||||||
// lands, which is a second or several while this server
|
|
||||||
// restarts. Clearing it at the end of the build instead left
|
|
||||||
// the card reading as idle against a list still describing the
|
|
||||||
// state before it -- the same wrong group, arrived at from the
|
|
||||||
// other side.
|
|
||||||
repeat(REFRESH_ATTEMPTS_AFTER_PULL) { attempt ->
|
|
||||||
try {
|
|
||||||
applyOne(entry.key)
|
|
||||||
// The project's own state only. A component that
|
|
||||||
// failed inside this run has just been given its own,
|
|
||||||
// and clearing that here would take away the only
|
|
||||||
// thing saying so.
|
|
||||||
setProject(entry.key, null)
|
setProject(entry.key, null)
|
||||||
// Building this app's own project is what produces the
|
|
||||||
// newer copy of it, and restarts the server it has to
|
|
||||||
// keep talking to. So this is the moment to offer it,
|
|
||||||
// rather than at some later launch.
|
|
||||||
if (entry.builtIn) {
|
|
||||||
onOwnProjectBuilt()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
} catch (e: DownloadServerException) {
|
|
||||||
if (attempt == REFRESH_ATTEMPTS_AFTER_PULL - 1) {
|
|
||||||
setProject(entry.key, null)
|
|
||||||
failure(e)?.let { manifestState = ManifestState.Error(it) }
|
|
||||||
} else {
|
|
||||||
delay(RESTART_WAIT_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e: DownloadServerException) {
|
} catch (e: DownloadServerException) {
|
||||||
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
setProject(entry.key, failure(e)?.let(ProjectState::Error))
|
||||||
@@ -877,6 +883,53 @@ private fun AppListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds one component because somebody pressed its Rebuild, and installs nothing.
|
||||||
|
*
|
||||||
|
* The component-scoped twin of [startRebuild], and the counterpart to [startUpdate] that does
|
||||||
|
* not end at the phone: Update is "get me this build", which builds only when the rules say the
|
||||||
|
* output is behind, while this is "build it again" for the cases those rules cannot see — a
|
||||||
|
* command that reads files nobody declared, an output changed underneath this server, a signing
|
||||||
|
* key replaced since the APK was made. None of them move a commit, so nothing reads as stale
|
||||||
|
* and Update does nothing at all.
|
||||||
|
*
|
||||||
|
* Recorded against the component like every other action on one, so a sibling stays pressable
|
||||||
|
* and silent throughout.
|
||||||
|
*/
|
||||||
|
fun startComponentBuild(entry: ManifestEntry, component: String) {
|
||||||
|
scope.launch {
|
||||||
|
setComponent(entry.key, component, ComponentState.Preparing(null))
|
||||||
|
try {
|
||||||
|
var status = withContext(Dispatchers.IO) { buildNow(entry.key, component) }
|
||||||
|
// This component's own step, not the project's `building`,
|
||||||
|
// for the same reason the download path reads it: a
|
||||||
|
// sibling being built at the same time says yes to that
|
||||||
|
// one.
|
||||||
|
var started = false
|
||||||
|
while (status.component(component)?.running == true) {
|
||||||
|
started = true
|
||||||
|
setComponent(entry.key, component, ComponentState.Preparing(status))
|
||||||
|
delay(BUILD_POLL_INTERVAL_MS)
|
||||||
|
status = withContext(Dispatchers.IO) { buildStatus(entry.key) }
|
||||||
|
}
|
||||||
|
val buildError = status.component(component)?.error?.takeIf { started }
|
||||||
|
if (buildError != null) {
|
||||||
|
setComponent(entry.key, component, ComponentState.Error(buildError))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
// What changed is this component's build: its size, its
|
||||||
|
// freshness, and whether the phone's copy is now the old
|
||||||
|
// one. Only the card can say, so it is read back rather
|
||||||
|
// than guessed at -- and the row stays busy until it
|
||||||
|
// lands, which for this server's own component is the
|
||||||
|
// second or two it spends restarting.
|
||||||
|
readBackAfterBuilding(entry) { setComponent(entry.key, component, null) }
|
||||||
|
} catch (e: DownloadServerException) {
|
||||||
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds one component if it needs it, downloads it, and hands it to the installer.
|
* Builds one component if it needs it, downloads it, and hands it to the installer.
|
||||||
*
|
*
|
||||||
@@ -1264,6 +1317,9 @@ private fun AppListScreen(
|
|||||||
onUpdate = { updated, component ->
|
onUpdate = { updated, component ->
|
||||||
startUpdate(updated, component)
|
startUpdate(updated, component)
|
||||||
},
|
},
|
||||||
|
onComponentBuild = { built, component ->
|
||||||
|
startComponentBuild(built, component)
|
||||||
|
},
|
||||||
onPull = { startPull(entry) },
|
onPull = { startPull(entry) },
|
||||||
onRebuild = { startRebuild(entry) },
|
onRebuild = { startRebuild(entry) },
|
||||||
onRefresh = { refreshOne(entry) },
|
onRefresh = { refreshOne(entry) },
|
||||||
@@ -1406,6 +1462,8 @@ private fun AppCard(
|
|||||||
/** What each of its components is doing, by component name. */
|
/** What each of its components is doing, by component name. */
|
||||||
componentStates: Map<String, ComponentState>,
|
componentStates: Map<String, ComponentState>,
|
||||||
onUpdate: (ManifestEntry, component: String) -> Unit,
|
onUpdate: (ManifestEntry, component: String) -> Unit,
|
||||||
|
/** Build this one component again, whatever the staleness rules make of it. */
|
||||||
|
onComponentBuild: (ManifestEntry, component: String) -> Unit,
|
||||||
onPull: () -> Unit,
|
onPull: () -> Unit,
|
||||||
onRebuild: () -> Unit,
|
onRebuild: () -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
@@ -1653,9 +1711,11 @@ private fun AppCard(
|
|||||||
?.running == true,
|
?.running == true,
|
||||||
busy = serviceBusy == component.name,
|
busy = serviceBusy == component.name,
|
||||||
state = componentState,
|
state = componentState,
|
||||||
|
projectState = projectState,
|
||||||
onAction = { action, purge ->
|
onAction = { action, purge ->
|
||||||
onServiceAction(component.name, action, purge)
|
onServiceAction(component.name, action, purge)
|
||||||
},
|
},
|
||||||
|
onBuild = { onComponentBuild(entry, component.name) },
|
||||||
controls = {
|
controls = {
|
||||||
if (!component.isServer) {
|
if (!component.isServer) {
|
||||||
// Said before the button, because it
|
// Said before the button, because it
|
||||||
@@ -1664,9 +1724,19 @@ private fun AppCard(
|
|||||||
self = component,
|
self = component,
|
||||||
others = components,
|
others = components,
|
||||||
)
|
)
|
||||||
// The button that asked for it, then
|
// The buttons that asked for it, then
|
||||||
// how far along it is: a bar reports on
|
// how far along it is: a bar reports on
|
||||||
// the control above it.
|
// the controls above it.
|
||||||
|
//
|
||||||
|
// Laid out as the project's own row is
|
||||||
|
// -- what you would press first on the
|
||||||
|
// left, Rebuild held to the right edge
|
||||||
|
// -- so the two scales of the same
|
||||||
|
// action are in the same place at both.
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
UpdateButton(
|
UpdateButton(
|
||||||
built = component.apk?.built == true,
|
built = component.apk?.built == true,
|
||||||
needsBuild = entry.needsBuild,
|
needsBuild = entry.needsBuild,
|
||||||
@@ -1676,6 +1746,14 @@ private fun AppCard(
|
|||||||
projectState = projectState,
|
projectState = projectState,
|
||||||
onUpdate = { onUpdate(entry, component.name) },
|
onUpdate = { onUpdate(entry, component.name) },
|
||||||
)
|
)
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
ComponentBuildButton(
|
||||||
|
component = component,
|
||||||
|
state = componentState,
|
||||||
|
projectState = projectState,
|
||||||
|
onBuild = { onComponentBuild(entry, component.name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
ApkProgress(componentState)
|
ApkProgress(componentState)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2016,6 +2094,42 @@ private fun UpdateButton(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds this one component again, whatever the staleness rules make of it.
|
||||||
|
*
|
||||||
|
* The project row's Rebuild at one component's scale, so it takes that button's word and its
|
||||||
|
* colour: the same consequence should look the same wherever it is, and what this leaves behind is
|
||||||
|
* no more obvious than what that one does. It sits at the right-hand end of the component's own
|
||||||
|
* action row for the same reason Rebuild sits at the right-hand end of the project's.
|
||||||
|
*
|
||||||
|
* Why it exists beside Update, which also builds: Update asks the server whether the output is
|
||||||
|
* behind and builds only then, which is right for "get me this build" and blind to everything a
|
||||||
|
* commit does not describe — a command that reads files nobody declared, an output changed
|
||||||
|
* underneath this server, a signing key replaced since the APK was made. In all of those nothing is
|
||||||
|
* stale, so Update does nothing, and before this the only force was the project-wide Rebuild.
|
||||||
|
*
|
||||||
|
* Absent rather than disabled for a component with no command, which is the one thing here that is
|
||||||
|
* not a state it is in: the project row hides its Rebuild on the same grounds, and a permanently
|
||||||
|
* dead button on every row of every hand-built project teaches nothing. Anything that *is* a state
|
||||||
|
* — busy, or the whole checkout busy — disables it instead, the same pair that disables Update.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ComponentBuildButton(
|
||||||
|
component: ProjectComponent,
|
||||||
|
state: ComponentState?,
|
||||||
|
projectState: ProjectState?,
|
||||||
|
onBuild: () -> Unit,
|
||||||
|
) {
|
||||||
|
if (!component.hasBuild) return
|
||||||
|
TextButton(
|
||||||
|
onClick = onBuild,
|
||||||
|
enabled = !state.busy && !projectState.busy,
|
||||||
|
colors = ActionTone.Caution.colors(),
|
||||||
|
) {
|
||||||
|
Text("Rebuild")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The dot between a component's name and whatever is said about it.
|
* The dot between a component's name and whatever is said about it.
|
||||||
*
|
*
|
||||||
@@ -2232,7 +2346,14 @@ private fun ComponentCard(
|
|||||||
* so on the row it was pressed in.
|
* so on the row it was pressed in.
|
||||||
*/
|
*/
|
||||||
state: ComponentState?,
|
state: ComponentState?,
|
||||||
|
/**
|
||||||
|
* What the whole project is doing, which disables this component's Rebuild as well: a pull
|
||||||
|
* rewrites the files it would build from, and the server refuses a build while one is running.
|
||||||
|
*/
|
||||||
|
projectState: ProjectState?,
|
||||||
onAction: (String, Purge) -> Unit,
|
onAction: (String, Purge) -> Unit,
|
||||||
|
/** Build this component again. A server's is drawn here, beside its service buttons. */
|
||||||
|
onBuild: () -> Unit,
|
||||||
// Controls belonging to this component that only the caller can build
|
// Controls belonging to this component that only the caller can build
|
||||||
// -- an APK's Update button, which needs the project's build state.
|
// -- an APK's Update button, which needs the project's build state.
|
||||||
// The service's own buttons are decided here, from the component.
|
// The service's own buttons are decided here, from the component.
|
||||||
@@ -2394,9 +2515,22 @@ private fun ComponentCard(
|
|||||||
// script takes rather than leaving: a row that vanishes on
|
// script takes rather than leaving: a row that vanishes on
|
||||||
// every press makes its own presence the signal, and takes the
|
// every press makes its own presence the signal, and takes the
|
||||||
// card's height with it on the way out and back.
|
// card's height with it on the way out and back.
|
||||||
if (component.isServer && component.state != null) {
|
//
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
// Rebuild is in this row but outside that condition, because
|
||||||
if (!component.isInstalled) {
|
// it is about the build rather than the service: a server
|
||||||
|
// whose script cannot be reached is still one this machine can
|
||||||
|
// compile, and hiding the way to do that until an unrelated
|
||||||
|
// question is answered would be the same absence-as-signal.
|
||||||
|
if (component.isServer && (component.state != null || component.hasBuild)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
if (component.state == null) {
|
||||||
|
// Nothing on the left yet, so the one button there
|
||||||
|
// is holds the right edge it will keep once the
|
||||||
|
// service answers.
|
||||||
|
} else if (!component.isInstalled) {
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { onAction("install", Purge()) },
|
onClick = { onAction("install", Purge()) },
|
||||||
enabled = !busy,
|
enabled = !busy,
|
||||||
@@ -2443,6 +2577,13 @@ private fun ComponentCard(
|
|||||||
Text("Uninstall")
|
Text("Uninstall")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
ComponentBuildButton(
|
||||||
|
component = component,
|
||||||
|
state = state,
|
||||||
|
projectState = projectState,
|
||||||
|
onBuild = onBuild,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-2
@@ -283,6 +283,15 @@ struct ManifestComponent {
|
|||||||
/// succeeded, which is what makes the runtime log the ordinary
|
/// succeeded, which is what makes the runtime log the ordinary
|
||||||
/// default.
|
/// default.
|
||||||
build_failed: bool,
|
build_failed: bool,
|
||||||
|
/// This component has a command to run and is allowed to run it, so
|
||||||
|
/// the card can offer to build this one alone.
|
||||||
|
///
|
||||||
|
/// Per component rather than read off the project's `needsBuild`,
|
||||||
|
/// which is the OR of every component's: a project where only one of
|
||||||
|
/// two has a build step would otherwise draw the button on both, and
|
||||||
|
/// the one that cannot build would answer a press by doing nothing
|
||||||
|
/// at all.
|
||||||
|
has_build: bool,
|
||||||
/// Whether what is built is current with the checkout.
|
/// Whether what is built is current with the checkout.
|
||||||
///
|
///
|
||||||
/// Sent for every component, including the unknown case, because
|
/// Sent for every component, including the unknown case, because
|
||||||
@@ -338,6 +347,10 @@ impl ManifestComponent {
|
|||||||
key: &str,
|
key: &str,
|
||||||
entry: &AppEntry,
|
entry: &AppEntry,
|
||||||
component: &crate::config::Component,
|
component: &crate::config::Component,
|
||||||
|
// Whether this project's build step may run at all -- false while
|
||||||
|
// a declaration is waiting to be accepted, which is the project's
|
||||||
|
// answer rather than this component's.
|
||||||
|
may_build: bool,
|
||||||
) -> Result<Self, ApiError> {
|
) -> 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 { .. });
|
||||||
@@ -376,6 +389,7 @@ impl ManifestComponent {
|
|||||||
.build
|
.build
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|build| build.build_failed(&name)),
|
.is_some_and(|build| build.build_failed(&name)),
|
||||||
|
has_build: may_build && !component.build().is_empty(),
|
||||||
freshness: entry
|
freshness: entry
|
||||||
.build
|
.build
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -626,9 +640,15 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
|
|||||||
// A loop rather than a map because each component's APK is a `stat`,
|
// A loop rather than a map because each component's APK is a `stat`,
|
||||||
// and they are described in declaration order -- which is build order,
|
// and they are described in declaration order -- which is build order,
|
||||||
// and the order the card draws them in.
|
// and the order the card draws them in.
|
||||||
|
// Read before the components, because each of them reports whether it
|
||||||
|
// can be built and none of them can while this is set: an unaccepted
|
||||||
|
// declaration is exactly the command that must not run.
|
||||||
|
let pending = entry.pending_declaration();
|
||||||
let mut components = Vec::with_capacity(entry.components.len());
|
let mut components = Vec::with_capacity(entry.components.len());
|
||||||
for component in &entry.components {
|
for component in &entry.components {
|
||||||
components.push(ManifestComponent::read(state, &entry.key, entry, component).await?);
|
components.push(
|
||||||
|
ManifestComponent::read(state, &entry.key, entry, component, pending.is_none()).await?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let git = crate::git::status(&entry.project_path);
|
let git = crate::git::status(&entry.project_path);
|
||||||
@@ -638,7 +658,6 @@ async fn describe(state: &Arc<AppState>, entry: &AppEntry) -> Result<ManifestApp
|
|||||||
// twice -- "no upstream" on the branch line, and the check's own
|
// twice -- "no upstream" on the branch line, and the check's own
|
||||||
// complaint about the same thing underneath it.
|
// complaint about the same thing underneath it.
|
||||||
let can_pull = entry.git_pull && git.as_ref().is_some_and(|git| git.upstream.is_some());
|
let can_pull = entry.git_pull && git.as_ref().is_some_and(|git| git.upstream.is_some());
|
||||||
let pending = entry.pending_declaration();
|
|
||||||
Ok(ManifestApp {
|
Ok(ManifestApp {
|
||||||
// Read from the cache a refresh populated, rather than asking here.
|
// Read from the cache a refresh populated, rather than asking here.
|
||||||
new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path),
|
new_commits: can_pull && state.remote_checks.new_commits(&entry.project_path),
|
||||||
|
|||||||
+84
-8
@@ -184,18 +184,94 @@ pub fn llvm_strip_path() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where the Android tools keep their preferences, resolved the way the
|
||||||
|
/// Android Gradle plugin resolves it -- because the only thing that makes
|
||||||
|
/// the keystore below the right one is that it is the same file Gradle
|
||||||
|
/// signed with.
|
||||||
|
///
|
||||||
|
/// `~/.android` is what this used to assume, and it is only the last of
|
||||||
|
/// five answers. Measured against AGP 32.3.2's `AbstractAndroidLocations`
|
||||||
|
/// rather than read off a document, by running real builds here:
|
||||||
|
///
|
||||||
|
/// * `$ANDROID_USER_HOME` is the preferences directory itself, with no
|
||||||
|
/// `.android` under it (`ANDROID_USER_HOME=/tmp/auh` put the keystore at
|
||||||
|
/// `/tmp/auh/debug.keystore`);
|
||||||
|
/// * `$ANDROID_PREFS_ROOT` and the older `$ANDROID_SDK_HOME` get
|
||||||
|
/// `.android` appended (`/tmp/ash` -> `/tmp/ash/.android/debug.keystore`);
|
||||||
|
/// * `$XDG_CONFIG_HOME/.android` comes next **but only when that directory
|
||||||
|
/// already exists** -- the bytecode tests it and falls through
|
||||||
|
/// otherwise, and a build here with `XDG_CONFIG_HOME` pointed at a fresh
|
||||||
|
/// directory did indeed keep using `~/.android`;
|
||||||
|
/// * `$HOME/.android` otherwise.
|
||||||
|
///
|
||||||
|
/// That fourth step is the one that cost a day. A desktop Linux machine
|
||||||
|
/// with `XDG_CONFIG_HOME` set and a `.android` under it -- which is
|
||||||
|
/// ordinary -- signs its APKs with a keystore this server never looked at,
|
||||||
|
/// so it reported "no debug keystore" about a machine that had one and
|
||||||
|
/// was using it. This VM does not set `XDG_CONFIG_HOME` at all, which is
|
||||||
|
/// why the missing case never showed up here.
|
||||||
|
fn android_prefs_dirs() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
if let Some(home) = std::env::var_os("ANDROID_USER_HOME") {
|
||||||
|
dirs.push(PathBuf::from(home));
|
||||||
|
}
|
||||||
|
for root in ["ANDROID_PREFS_ROOT", "ANDROID_SDK_HOME"] {
|
||||||
|
if let Some(root) = std::env::var_os(root) {
|
||||||
|
dirs.push(PathBuf::from(root).join(".android"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
|
||||||
|
let dir = PathBuf::from(xdg).join(".android");
|
||||||
|
// Existence is the condition AGP applies, so applying it here is
|
||||||
|
// what keeps the two agreeing about which file is in use.
|
||||||
|
if dir.is_dir() {
|
||||||
|
dirs.push(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dirs.push(home_dir().join(".android"));
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
/// The debug keystore every locally-built debug APK is already signed with,
|
/// The debug keystore every locally-built debug APK is already signed with,
|
||||||
/// reused to re-sign a stripped copy so it still installs over the original.
|
/// reused to re-sign a stripped copy so it still installs over the original.
|
||||||
|
///
|
||||||
|
/// The first candidate is the one Gradle would use, so it is the one taken
|
||||||
|
/// even if a keystore exists somewhere further down the list: a file in a
|
||||||
|
/// place AGP would not look is not the key anything here was signed with.
|
||||||
|
/// The failure names those anyway, because an inert keystore and an absent
|
||||||
|
/// one look identical from a phone, and mistaking one for the other is
|
||||||
|
/// what turned "this machine has no keystore" into a signature mismatch
|
||||||
|
/// nobody could explain.
|
||||||
pub fn debug_keystore_path() -> Result<PathBuf> {
|
pub fn debug_keystore_path() -> Result<PathBuf> {
|
||||||
let path = home_dir().join(".android/debug.keystore");
|
let candidates: Vec<PathBuf> = android_prefs_dirs()
|
||||||
if !path.is_file() {
|
.iter()
|
||||||
bail!(
|
.map(|dir| dir.join("debug.keystore"))
|
||||||
"debug keystore not found at {} -- build any Android app once to have \
|
.collect();
|
||||||
Gradle create it",
|
let (wanted, elsewhere) = candidates
|
||||||
path.display(),
|
.split_first()
|
||||||
);
|
.expect("home is always a candidate");
|
||||||
|
if wanted.is_file() {
|
||||||
|
return Ok(wanted.clone());
|
||||||
}
|
}
|
||||||
Ok(path)
|
let found: Vec<String> = elsewhere
|
||||||
|
.iter()
|
||||||
|
.filter(|path| path.is_file())
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.collect();
|
||||||
|
bail!(
|
||||||
|
"debug keystore not found at {} -- build any Android app once to have Gradle create \
|
||||||
|
it{}",
|
||||||
|
wanted.display(),
|
||||||
|
if found.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
". There is one at {}, but that is not where the Android tools look on this \
|
||||||
|
machine, so nothing here was signed with it",
|
||||||
|
found.join(", "),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Runs `cmd`, turning a nonzero exit into an error naming the program and
|
/// Runs `cmd`, turning a nonzero exit into an error naming the program and
|
||||||
|
|||||||
Reference in new issue
Block a user