Give each component build modes and a settings sheet
A component now declares its ways of being built in one list -- `modes: ["release", "debug"]`, the first the default -- and every command it runs is handed the mode as its last argument, so a project whose script takes `release` or `debug` names that script once. A field may instead be written per mode, which is the escape hatch for the commands that cannot take the word: cargo takes `--release` or nothing, and its profile for the unoptimised build is called `dev` while the directory it writes is called `debug`, so no single word serves as both the flag and the path. A command written per mode is not handed the word as well; it already is the answer, and a stray argument to a service binary is a process that will not start. One list rather than gathering names from whichever fields happened to mention them is what makes a mode missing from one part unsayable: every per-mode map is checked against it, so a gap is named rather than resolved to some other mode's command. Which mode to build in and whether to strip are the build machine's -- there is one checkout and one set of outputs, so a per-device mode would have two phones rebuilding over each other silently. Which of the finished builds a phone installs stays that phone's. Neither choice is part of the acceptance gate, so both have to be carried across the components list being rewritten, by acceptance and by the self entry's startup reconciliation alike; without the second a mode chosen for this server's own component would not survive the restart that applies it. A mode switch moves no commit, so `builtMode` is recorded beside `builtFrom`. Without it a component built in debug and switched to release reads as current and serves the debug build for ever -- and for an APK nothing else notices, because the "never built at all" check finds any variant under the component's directory. Each component card gains a settings sheet behind a gear at the row's right-hand end, holding the mode, which build to install, strip, and an Enrol button. The variant picker moved into it: on the card the two read as one choice, both saying debug and release, and they are not. The row's text is now bounded and truncates, so a long status can no longer push the log and settings buttons off the edge. `enroll:` is a declared command whose one line of stdout is a URL for the phone to open after installing -- generic on purpose, run per press since such a link is one-shot and carries a credential, and in the acceptance gate because it runs on the build machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0b7164bb30
commit
17d873ab7c
16 files changed
+2132
-127
No files matched your search
+33
-2
@@ -28,19 +28,50 @@ components: [
|
|||||||
// started anywhere else this server finds no checkout of its own,
|
// started anywhere else this server finds no checkout of its own,
|
||||||
// and its card silently loses the branch line, its commit count
|
// and its card silently loses the branch line, its commit count
|
||||||
// and the Pull button.
|
// and the Pull button.
|
||||||
build: "cargo build --release --manifest-path server/Cargo.toml",
|
// The ways this component can be built, declared in one place.
|
||||||
|
// Release first, because the first is the default: what this
|
||||||
|
// server serves to a phone should be the optimised build unless
|
||||||
|
// somebody says otherwise, and debug is here for the times when
|
||||||
|
// a stack trace matters more than the speed.
|
||||||
|
modes: ["release", "debug"],
|
||||||
|
// Written per mode rather than handed the mode as an argument,
|
||||||
|
// which is the escape hatch and this is what it is for: cargo
|
||||||
|
// takes `--release` or nothing, and its *profile* for the
|
||||||
|
// unoptimised build is called `dev` while the directory it
|
||||||
|
// writes is called `debug`. So no single word can serve as both
|
||||||
|
// the flag and the path, and a project that wants one would have
|
||||||
|
// to carry a wrapper script to translate.
|
||||||
|
build: {
|
||||||
|
"release": "cargo build --release --manifest-path server/Cargo.toml",
|
||||||
|
"debug": "cargo build --manifest-path server/Cargo.toml",
|
||||||
|
},
|
||||||
// Managed, like anything else that just wants its binary kept
|
// Managed, like anything else that just wants its binary kept
|
||||||
// running. Nothing about restarting *this* server lives in the
|
// running. Nothing about restarting *this* server lives in the
|
||||||
// script -- that is `restart.rs`, which defers the hand-over past
|
// script -- that is `restart.rs`, which defers the hand-over past
|
||||||
// the reply and spawns it detached. So there was nothing left for
|
// the reply and spawns it detached. So there was nothing left for
|
||||||
// a script of its own to say.
|
// a script of its own to say.
|
||||||
service: Managed("server/target/release/dev-updater"),
|
// And the same for the binary, which is the pairing that makes
|
||||||
|
// per-mode worth having at all: built with `--release` and
|
||||||
|
// started from `target/debug/`, this server would go on running
|
||||||
|
// last week's build with nothing anywhere saying so.
|
||||||
|
service: Managed({
|
||||||
|
"release": "server/target/release/dev-updater",
|
||||||
|
"debug": "server/target/debug/dev-updater",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
Apk(
|
Apk(
|
||||||
name: "app",
|
name: "app",
|
||||||
// The command resolves against the project root and `cwd` says
|
// The command resolves against the project root and `cwd` says
|
||||||
// where to run it -- two different things, which is why this is
|
// where to run it -- two different things, which is why this is
|
||||||
// not `./build-apk.sh`.
|
// not `./build-apk.sh`.
|
||||||
|
//
|
||||||
|
// Deliberately one mode. A release build of *this* app would be
|
||||||
|
// signed with a different key from the debug one, and Android
|
||||||
|
// refuses to install a differently-signed APK over an installed
|
||||||
|
// package -- which for this app means the copy on the phone can
|
||||||
|
// no longer be updated through the server it updates itself
|
||||||
|
// from. Every other project can switch freely; this is the one
|
||||||
|
// that cannot, because it is the way back.
|
||||||
build: "app/build-apk.sh",
|
build: "app/build-apk.sh",
|
||||||
cwd: "app",
|
cwd: "app",
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ mutable at runtime from the phone.
|
|||||||
list). `discover.rs` is the scanner,
|
list). `discover.rs` is the scanner,
|
||||||
`config.rs` the persisted schema and the RON both config files are in,
|
`config.rs` the persisted schema and the RON both config files are in,
|
||||||
`apkinfo.rs` the `aapt2` reads,
|
`apkinfo.rs` the `aapt2` reads,
|
||||||
`strip.rs` the slim-APK pipeline, `sdk.rs` the SDK/NDK tool lookups.
|
`strip.rs` the slim-APK pipeline, `sdk.rs` the SDK/NDK tool lookups,
|
||||||
|
`script.rs` the one runner for a project's short declared commands
|
||||||
|
(`resources:`, `enroll:`) -- stdin closed, a deadline, stdout kept.
|
||||||
- `app/` — Kotlin + Compose, a single `:androidApp` module. `UpdaterScreen.kt`
|
- `app/` — Kotlin + Compose, a single `:androidApp` module. `UpdaterScreen.kt`
|
||||||
is the list, `AddAppScreen.kt` the add/settings screen, `AppsApi.kt` the
|
is the list, `AddAppScreen.kt` the add/settings screen, `AppsApi.kt` the
|
||||||
management calls, `UpdateManifest.kt` the read side, `ApkInstaller.kt` /
|
management calls, `UpdateManifest.kt` the read side, `ApkInstaller.kt` /
|
||||||
@@ -618,6 +620,108 @@ mutable at runtime from the phone.
|
|||||||
the declaration build anyway. Pulling stays allowed while a request is
|
the declaration build anyway. Pulling stays allowed while a request is
|
||||||
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.
|
||||||
|
- **A component declares its build modes in one list, and that list is the
|
||||||
|
only place a mode is declared.** `modes: ["release", "debug"]` on the
|
||||||
|
component; the first is the default, which is why a project puts the one
|
||||||
|
it wants built by default first. Every command the component runs is
|
||||||
|
then handed the mode **as its last argument** -- so a project whose
|
||||||
|
script takes `release` or `debug` names that script once and nothing is
|
||||||
|
written twice. The escape hatch is writing a field *per mode*
|
||||||
|
(`build: {"release": ..., "debug": ...}`, `service: Managed({...})`),
|
||||||
|
and it exists because some commands cannot take the word: cargo takes
|
||||||
|
`--release` or nothing, and its profile for the unoptimised build is
|
||||||
|
called `dev` while the directory it writes is called `debug`, so no
|
||||||
|
single word serves as both the flag and the path. Gradle is the same
|
||||||
|
shape (`assembleRelease` needs capitalising).
|
||||||
|
**A command written per mode is not handed the mode as well** -- it
|
||||||
|
already is the answer, and appending the word would pass a stray
|
||||||
|
argument to a binary that never asked for one, which for a service is a
|
||||||
|
process that will not start. `ByMode::mode_argument` is the one place
|
||||||
|
that rule lives.
|
||||||
|
Having *one* list is what makes "this part has a mode the other part
|
||||||
|
never heard of" unsayable rather than something to detect: every
|
||||||
|
per-mode map is checked against it (`config::mode_problem`), so a
|
||||||
|
missing entry is named as missing instead of resolving to some other
|
||||||
|
mode's command. A map on a component that declares no modes is the same
|
||||||
|
mistake from the other side and is refused with a message saying what
|
||||||
|
to write. A declaration that fails the check is discarded exactly as an
|
||||||
|
unknown field is, and the card says so.
|
||||||
|
**`ByMode` holds a `Command` and nothing else, and that is load-bearing
|
||||||
|
rather than incidental.** Telling the single form from the map needs
|
||||||
|
`deserialize_any`, and RON reports a struct `(path: "a")` as a *map* --
|
||||||
|
so a struct-valued field behind it would read its own field names as
|
||||||
|
mode names. Worse, measured: RON **discards the variant name** under
|
||||||
|
`deserialize_any`, so `Cargo("server")` and the array command
|
||||||
|
`["server"]` arrive as the same one-element sequence. That is why the
|
||||||
|
by-mode map lives *inside* `Service` rather than around it, and why a
|
||||||
|
`Cargo(...)`-style shorthand cannot be added to `build:` beside the
|
||||||
|
bare-string form without a format migration.
|
||||||
|
|
||||||
|
- **Which mode to build in, and whether to strip, are the build
|
||||||
|
machine's choices; which finished build to install is the phone's.**
|
||||||
|
There is one checkout and one set of outputs, so a per-device mode
|
||||||
|
would have two enrolled phones rebuilding over each other with nothing
|
||||||
|
on either screen to say why -- the settings sheet says as much out
|
||||||
|
loud. They are stored on the component in `config.ron` (`mode`,
|
||||||
|
`stripHere`), set through `PUT /apps/{key}/components/{name}/settings`,
|
||||||
|
and excluded from `Component::same_declaration`: choosing one of the
|
||||||
|
declared modes is not the project asking for something new, and
|
||||||
|
including it would make every settings change re-open the acceptance
|
||||||
|
gate. Which means both halves of the carry-across have to be written --
|
||||||
|
`registry::chosen_settings`/`restore_chosen_settings`, called by
|
||||||
|
`approve_declaration` **and** `reconcile_self`. Without the second, a
|
||||||
|
mode chosen for this server's own component would last until the next
|
||||||
|
restart, and restarting is how this server is updated.
|
||||||
|
`strip` stays declarable by the project and `stripHere` overrides it,
|
||||||
|
rather than being copied in at acceptance: copied, a later change to
|
||||||
|
the declaration would be ignored on every machine, silently.
|
||||||
|
**`BuildState::matches` compares the effective mode as well as the
|
||||||
|
declaration**, because that state holds a *snapshot* of the components
|
||||||
|
it builds from -- reused across a mode change it would go on running
|
||||||
|
the old mode's command from a card reporting the new one.
|
||||||
|
|
||||||
|
- **A mode switch moves no commit, so `builtMode` is recorded beside
|
||||||
|
`builtFrom`.** Without it a component built in `debug` and switched to
|
||||||
|
`release` reads as current, offers nothing to press, and serves the
|
||||||
|
debug build for ever -- and for an `Apk` nothing else would notice,
|
||||||
|
because the "never built at all" check finds *any* variant under the
|
||||||
|
component's directory, so the debug APK sitting there is enough to
|
||||||
|
satisfy it. `Component::built_in_another_mode` answers it and both
|
||||||
|
`freshness` and `component_is_stale` ask, the first reporting `Behind`
|
||||||
|
*before* consulting the checkout at all: a clean tree at the very
|
||||||
|
commit the debug build was made from still does not make that build a
|
||||||
|
release one. It stays quiet until this server has built the component
|
||||||
|
once, so a project built by hand is not told it is out of date.
|
||||||
|
|
||||||
|
- **Each component card has a settings sheet, and the variant picker
|
||||||
|
lives in it.** The gear sits at the row's right-hand end beside the log
|
||||||
|
button, drawn unconditionally so its presence is never the signal. The
|
||||||
|
sheet holds the build mode, which of the discovered builds this phone
|
||||||
|
installs, whether to strip, and Enrol; a section with nothing to offer
|
||||||
|
says so rather than vanishing, since "this project declares one way of
|
||||||
|
building" and "we could not tell" must not look alike. The variant
|
||||||
|
picker moved here from the foot of the card because on the card the two
|
||||||
|
read as one choice -- both say `debug` and `release` -- and they are
|
||||||
|
not: one decides what the build machine *builds*, the other which
|
||||||
|
finished build this phone takes. Everything applies on Save except
|
||||||
|
Enrol, which is an action and happens on the press.
|
||||||
|
**The row's text is inside one weighted child so it can never push the
|
||||||
|
controls off the edge**, and every reading in it truncates with an
|
||||||
|
ellipsis. A control that leaves because the text grew is one the reader
|
||||||
|
cannot get back to.
|
||||||
|
|
||||||
|
- **`enroll:` is a command whose one line of stdout is a URL for the
|
||||||
|
phone to open after installing.** Deliberately "a URL to open" rather
|
||||||
|
than anything named after enrolment -- a route that knew what enrolling
|
||||||
|
was would be a special case of itself. Run **per press**
|
||||||
|
(`POST /apps/{key}/components/{name}/enroll-link`), never cached: the
|
||||||
|
link a project mints is ordinarily one-shot and carries a credential,
|
||||||
|
so a stored one would be both stale and a secret sitting in a file. In
|
||||||
|
the acceptance gate like `build`, because it runs on the build machine.
|
||||||
|
`crate::script::capture` is the shared runner it and `resources` both
|
||||||
|
use -- stdin closed, a deadline, stdout kept, and a failure naming the
|
||||||
|
command and the first line of its stderr.
|
||||||
|
|
||||||
- **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, beside the `?component=` that
|
It arrives as `?variant=` on the download, beside the `?component=` that
|
||||||
says whose build it is, and is validated against *that component's*
|
says whose build it is, and is validated against *that component's*
|
||||||
|
|||||||
@@ -203,6 +203,53 @@ data class ComponentLog(
|
|||||||
* Given the manifest's timeout rather than the default: a large log is a real request that takes
|
* Given the manifest's timeout rather than the default: a large log is a real request that takes
|
||||||
* real time, and timing it out would report the server as unreachable when it is merely reading.
|
* real time, and timing it out would report the server as unreachable when it is merely reading.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* This machine's preferences for one component: which declared build mode to build it in, and
|
||||||
|
* whether to strip what it serves.
|
||||||
|
*
|
||||||
|
* On the build machine rather than on this device, unlike the chosen variant. A mode decides what
|
||||||
|
* gets *built*, and there is one checkout there -- two phones holding different answers would
|
||||||
|
* rebuild over each other with nothing on either screen to say why. Which of the finished builds
|
||||||
|
* this phone installs stays this phone's business, and stays in SharedPreferences.
|
||||||
|
*
|
||||||
|
* A null mode means "no choice, take the first the project declared", and a null strip means the
|
||||||
|
* same about the project's own declaration. Both are real answers rather than missing fields.
|
||||||
|
*/
|
||||||
|
fun setComponentSettings(key: String, component: String, mode: String?, strip: Boolean?) {
|
||||||
|
// Explicit JSON nulls rather than omitted keys, so "put this back to
|
||||||
|
// what the project says" is something this can express at all.
|
||||||
|
val body =
|
||||||
|
JSONObject().put("mode", mode ?: JSONObject.NULL).put("strip", strip ?: JSONObject.NULL)
|
||||||
|
requestFromServer(
|
||||||
|
"/apps/$key/components/$component/settings",
|
||||||
|
method = "PUT",
|
||||||
|
jsonBody = body.toString(),
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the build machine to run this component's `enroll:` command and hand back the URL it
|
||||||
|
* printed, for this phone to open.
|
||||||
|
*
|
||||||
|
* Run on every press rather than fetched once with the manifest: the link a project mints is
|
||||||
|
* ordinarily one-shot and carries a credential, so a cached one would be both stale and a secret
|
||||||
|
* sitting in a list this app redraws constantly.
|
||||||
|
*/
|
||||||
|
fun enrollmentLink(key: String, component: String): String =
|
||||||
|
requestFromServer(
|
||||||
|
"/apps/$key/components/$component/enroll-link",
|
||||||
|
method = "POST",
|
||||||
|
readTimeoutMs = ENROLL_TIMEOUT_MS,
|
||||||
|
) { connection ->
|
||||||
|
JSONObject(connection.inputStream.bufferedReader().readText()).getString("url")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longer than an ordinary call: the command runs on the build machine and
|
||||||
|
// may have to touch a keystore or write a file on the way. Timing it out
|
||||||
|
// early would report "couldn't reach the server" about a server that is
|
||||||
|
// working.
|
||||||
|
private const val ENROLL_TIMEOUT_MS = 25000
|
||||||
|
|
||||||
fun componentLog(
|
fun componentLog(
|
||||||
key: String,
|
key: String,
|
||||||
component: String,
|
component: String,
|
||||||
|
|||||||
@@ -143,6 +143,24 @@ 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?,
|
||||||
|
// Every way this component can be built, in the order the project
|
||||||
|
// declared them, with the first the default. Empty for a component
|
||||||
|
// that declares one way of building, which is not a choice -- the
|
||||||
|
// settings sheet says so rather than drawing a picker holding one
|
||||||
|
// entry that cannot be changed.
|
||||||
|
val modes: List<String>,
|
||||||
|
// Which of them the build machine is set to build it in. Null exactly
|
||||||
|
// when [modes] is empty. Not a per-device preference like the chosen
|
||||||
|
// variant: there is one checkout and one set of build outputs on that
|
||||||
|
// machine, so this is what *everyone* gets, which is why the sheet
|
||||||
|
// says so out loud.
|
||||||
|
val mode: String?,
|
||||||
|
// The project declares a command that prints a link for this phone to
|
||||||
|
// open after installing -- an enrolment link, for the apps that need
|
||||||
|
// one. What the link does is the project's business; the button just
|
||||||
|
// opens it. False while a declaration is waiting to be accepted, when
|
||||||
|
// the command would not run.
|
||||||
|
val hasEnrollLink: Boolean,
|
||||||
// What there is to install, for a component that produces one. Null
|
// What there is to install, for a component that produces one. Null
|
||||||
// for a server, which builds nothing this phone installs.
|
// for a server, which builds nothing this phone installs.
|
||||||
val apk: ComponentApk?,
|
val apk: ComponentApk?,
|
||||||
@@ -197,6 +215,14 @@ data class ComponentApk(
|
|||||||
// build freshness is the only meaningful signal, not a version code.
|
// build freshness is the only meaningful signal, not a version code.
|
||||||
val mtime: Double,
|
val mtime: Double,
|
||||||
val size: Long,
|
val size: Long,
|
||||||
|
// Whether the build machine serves a stripped copy: what it settled
|
||||||
|
// on, which is what a download will actually do.
|
||||||
|
val strip: Boolean,
|
||||||
|
// What the *project* asks for, which differs only when somebody
|
||||||
|
// overrode it on that machine. Both are sent so the sheet can say the
|
||||||
|
// two disagree, rather than showing a switch that silently contradicts
|
||||||
|
// the checkout.
|
||||||
|
val stripDeclared: Boolean,
|
||||||
// Every build discovered under this component, so a different one can
|
// Every build discovered under this component, so a different one can
|
||||||
// be selected without another round trip.
|
// be selected without another round trip.
|
||||||
val variants: List<ApkVariant>,
|
val variants: List<ApkVariant>,
|
||||||
@@ -370,6 +396,12 @@ 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 },
|
||||||
|
modes =
|
||||||
|
component.optJSONArray("modes").let { modes ->
|
||||||
|
(0 until (modes?.length() ?: 0)).map { modes!!.getString(it) }
|
||||||
|
},
|
||||||
|
mode = component.optString("mode").ifEmpty { null },
|
||||||
|
hasEnrollLink = component.optBoolean("hasEnrollLink", false),
|
||||||
apk = component.optJSONObject("apk")?.let(::readApk),
|
apk = component.optJSONObject("apk")?.let(::readApk),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -386,6 +418,8 @@ private fun readApk(apk: JSONObject): ComponentApk {
|
|||||||
built = apk.getBoolean("built"),
|
built = apk.getBoolean("built"),
|
||||||
mtime = apk.getDouble("mtime"),
|
mtime = apk.getDouble("mtime"),
|
||||||
size = apk.getLong("size"),
|
size = apk.getLong("size"),
|
||||||
|
strip = apk.optBoolean("strip", false),
|
||||||
|
stripDeclared = apk.optBoolean("stripDeclared", false),
|
||||||
variants =
|
variants =
|
||||||
(0 until variants.length()).map { j ->
|
(0 until variants.length()).map { j ->
|
||||||
val variant = variants.getJSONObject(j)
|
val variant = variants.getJSONObject(j)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.example.devupdater
|
package com.example.devupdater
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
@@ -219,6 +221,16 @@ private sealed class ComponentState {
|
|||||||
/** [progress] is null when the response gave no length to measure against. */
|
/** [progress] is null when the response gave no length to measure against. */
|
||||||
data class Downloading(val progress: Float?) : ComponentState()
|
data class Downloading(val progress: Float?) : ComponentState()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A short call to the build machine that is neither a build nor a download -- fetching an
|
||||||
|
* enrolment link is the one so far.
|
||||||
|
*
|
||||||
|
* Carries its own words because the card has no other way to say which call it is waiting on,
|
||||||
|
* and "working" over a bar that could be any of three things is what makes a screen feel like
|
||||||
|
* it is doing something at random.
|
||||||
|
*/
|
||||||
|
data class Busy(val what: String) : ComponentState()
|
||||||
|
|
||||||
/** Why the last thing this component was asked to do stopped. */
|
/** Why the last thing this component was asked to do stopped. */
|
||||||
data class Error(val message: String) : ComponentState()
|
data class Error(val message: String) : ComponentState()
|
||||||
}
|
}
|
||||||
@@ -1013,6 +1025,61 @@ private fun AppListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same as [manage], for a call that is about one component rather than the whole project.
|
||||||
|
*
|
||||||
|
* A failure belongs where the press happened: reported on the card, one component's settings
|
||||||
|
* write would blame the project and go quiet on the row somebody was actually looking at.
|
||||||
|
*/
|
||||||
|
fun manageComponent(entry: ManifestEntry, component: String, action: () -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { action() }
|
||||||
|
setComponent(entry.key, component, null)
|
||||||
|
applyOne(entry.key)
|
||||||
|
} catch (e: DownloadServerException) {
|
||||||
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the build machine for this component's link and opens it here.
|
||||||
|
*
|
||||||
|
* Fetched on every press rather than carried on the manifest: what a project mints is
|
||||||
|
* ordinarily one-shot and carries a credential, so a link this app had been holding since the
|
||||||
|
* last refresh would be the wrong one.
|
||||||
|
*
|
||||||
|
* Nothing here inspects the URL. Which app answers it is Android's business, and a link the
|
||||||
|
* phone has nothing to open is reported as such rather than swallowed -- an action whose whole
|
||||||
|
* effect is elsewhere has to say when it did not happen.
|
||||||
|
*/
|
||||||
|
fun openEnrollmentLink(entry: ManifestEntry, component: String) {
|
||||||
|
scope.launch {
|
||||||
|
setComponent(entry.key, component, ComponentState.Busy("Getting the link"))
|
||||||
|
try {
|
||||||
|
val url = withContext(Dispatchers.IO) { enrollmentLink(entry.key, component) }
|
||||||
|
try {
|
||||||
|
context.startActivity(
|
||||||
|
Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
setComponent(entry.key, component, null)
|
||||||
|
} catch (e: ActivityNotFoundException) {
|
||||||
|
setComponent(
|
||||||
|
entry.key,
|
||||||
|
component,
|
||||||
|
ComponentState.Error(
|
||||||
|
"Nothing on this phone opens $url" + (e.message?.let { " ($it)" } ?: "")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: DownloadServerException) {
|
||||||
|
setComponent(entry.key, component, failure(e)?.let(ComponentState::Error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runs one of a server component's actions on the build machine.
|
* Runs one of a server component's actions on the build machine.
|
||||||
*
|
*
|
||||||
@@ -1356,6 +1423,24 @@ private fun AppListScreen(
|
|||||||
(component to variant.path)
|
(component to variant.path)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
// The build machine's, not this
|
||||||
|
// device's: a mode decides what
|
||||||
|
// gets built there, so it is a
|
||||||
|
// round trip and a refetch rather
|
||||||
|
// than a preference written here.
|
||||||
|
onComponentSettings = { component, mode, strip ->
|
||||||
|
manageComponent(entry, component) {
|
||||||
|
setComponentSettings(
|
||||||
|
entry.key,
|
||||||
|
component,
|
||||||
|
mode,
|
||||||
|
strip,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEnroll = { component ->
|
||||||
|
openEnrollmentLink(entry, component)
|
||||||
|
},
|
||||||
serviceBusy = serviceBusy[entry.key],
|
serviceBusy = serviceBusy[entry.key],
|
||||||
onServiceAction = { component, action, purge ->
|
onServiceAction = { component, action, purge ->
|
||||||
runServiceAction(entry, component, action, purge)
|
runServiceAction(entry, component, action, purge)
|
||||||
@@ -1471,6 +1556,10 @@ private fun AppCard(
|
|||||||
onApprove: () -> Unit,
|
onApprove: () -> Unit,
|
||||||
onRemove: () -> Unit,
|
onRemove: () -> Unit,
|
||||||
onSelectVariant: (component: String, ApkVariant?) -> Unit,
|
onSelectVariant: (component: String, ApkVariant?) -> Unit,
|
||||||
|
/** This machine's settings for one component: which mode it builds in, and whether to strip. */
|
||||||
|
onComponentSettings: (component: String, mode: String?, strip: Boolean?) -> Unit,
|
||||||
|
/** Fetch this component's link from the build machine and open it here. */
|
||||||
|
onEnroll: (component: String) -> 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.
|
||||||
@@ -1692,6 +1781,10 @@ private fun AppCard(
|
|||||||
},
|
},
|
||||||
chosenVariantPath = chosenVariantPath,
|
chosenVariantPath = chosenVariantPath,
|
||||||
onSelectVariant = { onSelectVariant(component.name, it) },
|
onSelectVariant = { onSelectVariant(component.name, it) },
|
||||||
|
onSettings = { mode, strip ->
|
||||||
|
onComponentSettings(component.name, mode, strip)
|
||||||
|
},
|
||||||
|
onEnroll = { onEnroll(component.name) },
|
||||||
// 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.
|
||||||
@@ -2020,6 +2113,12 @@ private fun ApkProgress(state: ComponentState?) {
|
|||||||
Text("Preparing the download...")
|
Text("Preparing the download...")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is ComponentState.Busy -> {
|
||||||
|
ProgressBar()
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text("${state.what}...")
|
||||||
|
}
|
||||||
|
|
||||||
is ComponentState.Downloading -> {
|
is ComponentState.Downloading -> {
|
||||||
val progress = state.progress
|
val progress = state.progress
|
||||||
if (progress == null) {
|
if (progress == null) {
|
||||||
@@ -2356,6 +2455,10 @@ private fun ComponentCard(
|
|||||||
/** Which of this component's builds this device is pinned to, if any. */
|
/** Which of this component's builds this device is pinned to, if any. */
|
||||||
chosenVariantPath: String? = null,
|
chosenVariantPath: String? = null,
|
||||||
onSelectVariant: (ApkVariant?) -> Unit = {},
|
onSelectVariant: (ApkVariant?) -> Unit = {},
|
||||||
|
/** Save this machine's settings for this component. */
|
||||||
|
onSettings: (mode: String?, strip: Boolean?) -> Unit = { _, _ -> },
|
||||||
|
/** Ask the build machine for this component's link and open it. */
|
||||||
|
onEnroll: () -> 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?,
|
||||||
@@ -2387,6 +2490,7 @@ private fun ComponentCard(
|
|||||||
// a second thing to remember to clear.
|
// a second thing to remember to clear.
|
||||||
var confirming by remember { mutableStateOf<String?>(null) }
|
var confirming by remember { mutableStateOf<String?>(null) }
|
||||||
var showingLog by remember { mutableStateOf(false) }
|
var showingLog by remember { mutableStateOf(false) }
|
||||||
|
var showingSettings by remember { mutableStateOf(false) }
|
||||||
// What Uninstall has been asked to take away as well. Logs start
|
// What Uninstall has been asked to take away as well. Logs start
|
||||||
// ticked and the other two do not: the dialog's defaults, deliberately
|
// ticked and the other two do not: the dialog's defaults, deliberately
|
||||||
// different from `Purge()`'s, which is what a caller with no dialog
|
// different from `Purge()`'s, which is what a caller with no dialog
|
||||||
@@ -2407,6 +2511,16 @@ private fun ComponentCard(
|
|||||||
// same size as a line of this text anyway, so the row comes out
|
// same size as a line of this text anyway, so the row comes out
|
||||||
// the same height without being told.
|
// the same height without being told.
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
// Everything the row *says* lives inside one weighted
|
||||||
|
// child, so it can only ever have the space the controls
|
||||||
|
// on the right are not using. Without it a long name or a
|
||||||
|
// long status pushed the log and settings buttons off the
|
||||||
|
// edge -- and a control that leaves because the text grew
|
||||||
|
// is one the reader cannot get back to.
|
||||||
|
Row(
|
||||||
|
Modifier.weight(1f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
// Both kinds get the same square, so a server's glyph and
|
// Both kinds get the same square, so a server's glyph and
|
||||||
// an app's icon are the same size as each other -- one
|
// an app's icon are the same size as each other -- one
|
||||||
// drawn smaller than the other reads as the row meaning
|
// drawn smaller than the other reads as the row meaning
|
||||||
@@ -2448,6 +2562,12 @@ private fun ComponentCard(
|
|||||||
component.name,
|
component.name,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
// One line and an ellipsis, on this and on every
|
||||||
|
// reading beside it: a row that wraps grows the
|
||||||
|
// card, and one that overflows silently loses its
|
||||||
|
// right-hand end without saying it was cut.
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
// Nothing at all until its script has been asked -- an
|
// Nothing at all until its script has been asked -- an
|
||||||
// unknown state is not a state, and a dot introducing
|
// unknown state is not a state, and a dot introducing
|
||||||
@@ -2484,6 +2604,8 @@ private fun ComponentCard(
|
|||||||
component.isFailed -> failedColor
|
component.isFailed -> failedColor
|
||||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
},
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (behind) {
|
if (behind) {
|
||||||
@@ -2494,6 +2616,8 @@ private fun ComponentCard(
|
|||||||
// The colour Pull & Build wears, because that is
|
// The colour Pull & Build wears, because that is
|
||||||
// the button this is telling you to press.
|
// the button this is telling you to press.
|
||||||
color = ActionTone.Primary.color,
|
color = ActionTone.Primary.color,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sizeText?.let {
|
sizeText?.let {
|
||||||
@@ -2502,21 +2626,34 @@ private fun ComponentCard(
|
|||||||
it,
|
it,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Outside the weighted text, so the spinner is never the
|
||||||
|
// thing that gets truncated: it is the one mark on the row
|
||||||
|
// that says something is happening right now.
|
||||||
if (working || busy || component.checking) {
|
if (working || busy || component.checking) {
|
||||||
Spacer(Modifier.width(6.dp))
|
Spacer(Modifier.width(6.dp))
|
||||||
Working()
|
Working()
|
||||||
}
|
}
|
||||||
|
// These belong to the component, not to whatever the row
|
||||||
|
// happens to say about it, so they hold the same corner
|
||||||
|
// whatever the text does. Unconditional, so the settings
|
||||||
|
// button sits in the same place whether or not there is a
|
||||||
|
// log beside it -- the log's own absence must not move it.
|
||||||
if (component.hasLogs) {
|
if (component.hasLogs) {
|
||||||
// Pushed to the far edge rather than following the
|
|
||||||
// text: it belongs to the component, not to whatever
|
|
||||||
// the row happens to say about it, and a control that
|
|
||||||
// slides about as the state changes is harder to find
|
|
||||||
// than one always in the same corner.
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true }
|
IconGlyphButton(LOG_GLYPH, "Show ${component.name}'s log") { showingLog = true }
|
||||||
}
|
}
|
||||||
|
// Always drawn, including for a component with a single
|
||||||
|
// build mode and nothing to strip: what a component can be
|
||||||
|
// told is part of what it is, and a control that comes and
|
||||||
|
// goes makes its own presence the signal. What it opens
|
||||||
|
// says which of its settings this component has.
|
||||||
|
IconGlyphButton(SETTINGS_GLYPH, "${component.name} settings") {
|
||||||
|
showingSettings = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The service script's own words about why it could not answer,
|
// The service script's own words about why it could not answer,
|
||||||
@@ -2663,25 +2800,33 @@ private fun ComponentCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only worth a row when there is actually a choice, which is
|
// The variant picker used to sit here. It moved into the
|
||||||
// rare -- the usual case is a single debug build, and an empty
|
// settings sheet, where the build mode is: on the card the two
|
||||||
// row here was leaving a band of space at the foot of every
|
// read as the same choice -- both say "debug" and "release" --
|
||||||
// card for a control almost none of them have.
|
// and they are not. One decides what the build machine
|
||||||
//
|
// *builds*; the other decides which of the finished builds
|
||||||
// Beside the build it picks, which is what makes it answerable
|
// this phone installs.
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showingSettings) {
|
||||||
|
ComponentSettingsDialog(
|
||||||
|
component = component,
|
||||||
|
chosenVariantPath = chosenVariantPath,
|
||||||
|
// A mode changes what the build machine runs, and the server
|
||||||
|
// replaces the state a running build reports through. So the
|
||||||
|
// picker goes quiet exactly while this component cannot be
|
||||||
|
// acted on anyway -- the same pair of conditions that disables
|
||||||
|
// its Update button and withholds its freshness.
|
||||||
|
modeSettled = !(busy || working || projectState.busy),
|
||||||
|
onSelectVariant = onSelectVariant,
|
||||||
|
onEnroll = onEnroll,
|
||||||
|
onApply = { mode, strip ->
|
||||||
|
showingSettings = false
|
||||||
|
onSettings(mode, strip)
|
||||||
|
},
|
||||||
|
onDismiss = { showingSettings = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showingLog) {
|
if (showingLog) {
|
||||||
@@ -2764,6 +2909,194 @@ private fun ComponentCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything one component can be told, in one sheet: how the build machine builds it, which of the
|
||||||
|
* finished builds this phone installs, whether it is stripped on the way, and the link a project
|
||||||
|
* hands over after an install.
|
||||||
|
*
|
||||||
|
* The two "debug or release" choices in here are deliberately separated and labelled, because they
|
||||||
|
* are not the same question and they used to sit on the card looking as though they were. **Build
|
||||||
|
* mode** is the build machine's: there is one checkout and one set of outputs there, so choosing it
|
||||||
|
* changes what *every* enrolled phone is offered, which is why the sheet says so rather than
|
||||||
|
* leaving it to be discovered. **Install** is this device's alone, and picks among builds that
|
||||||
|
* already exist.
|
||||||
|
*
|
||||||
|
* Applied on Save rather than as each control moves, matching the project's own settings sheet: a
|
||||||
|
* settings write is a round trip that rebuilds the entry on the server, and one per control touched
|
||||||
|
* while making up your mind is a lot of them. Enrol is the exception and is not a setting -- it is
|
||||||
|
* an action, it happens on the press, and it is drawn apart from the rest for that reason.
|
||||||
|
*
|
||||||
|
* A component with nothing to choose still gets its sections, saying so. Removing them would make
|
||||||
|
* the sheet's shape the signal, and "this project declares one way of building" and "we could not
|
||||||
|
* tell" would then look identical -- which is the whole reason the empty cases are written out here
|
||||||
|
* rather than skipped.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ComponentSettingsDialog(
|
||||||
|
component: ProjectComponent,
|
||||||
|
chosenVariantPath: String?,
|
||||||
|
/** False while a build is in flight, when the mode must not be changed underneath it. */
|
||||||
|
modeSettled: Boolean,
|
||||||
|
onSelectVariant: (ApkVariant?) -> Unit,
|
||||||
|
onEnroll: () -> Unit,
|
||||||
|
onApply: (mode: String?, strip: Boolean?) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
// Keyed on what the server last said, so reopening after a save shows
|
||||||
|
// the saved values rather than stale local ones.
|
||||||
|
var mode by remember(component.mode) { mutableStateOf(component.mode) }
|
||||||
|
var strip by remember(component.apk?.strip) { mutableStateOf(component.apk?.strip ?: false) }
|
||||||
|
// The variant is this device's, so it is applied locally on Save
|
||||||
|
// rather than sent anywhere; held here so Cancel really cancels.
|
||||||
|
var variantPath by remember(chosenVariantPath) { mutableStateOf(chosenVariantPath) }
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(component.name) },
|
||||||
|
text = {
|
||||||
|
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||||
|
SettingsHeading("Build mode")
|
||||||
|
when {
|
||||||
|
component.modes.isEmpty() ->
|
||||||
|
SettingsNote(
|
||||||
|
"This project declares one way of building ${component.name}, so there " +
|
||||||
|
"is nothing to choose."
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
SettingsNote(
|
||||||
|
"Chosen on the build machine, so it is what every phone here is " +
|
||||||
|
"offered -- not just this one."
|
||||||
|
)
|
||||||
|
// A dropdown rather than a row of radios, matching
|
||||||
|
// the build picker below it: they are the same
|
||||||
|
// shape of question -- one of a short list -- and
|
||||||
|
// two different controls for that in one sheet
|
||||||
|
// makes them look like different kinds of choice.
|
||||||
|
ModePicker(component.modes, mode, modeSettled) { mode = it }
|
||||||
|
if (!modeSettled) {
|
||||||
|
SettingsNote(
|
||||||
|
"Not while this component is busy: changing it now would leave " +
|
||||||
|
"the build that is running building the other one."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only an APK has anything below this. A server is not
|
||||||
|
// installed here, so there is no build for this phone to
|
||||||
|
// pick and nothing to strip on the way to it.
|
||||||
|
component.apk?.let { apk ->
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SettingsHeading("Install")
|
||||||
|
when {
|
||||||
|
apk.variants.isEmpty() ->
|
||||||
|
SettingsNote("Nothing is built yet, so there is nothing to install.")
|
||||||
|
apk.variants.size == 1 ->
|
||||||
|
SettingsNote(
|
||||||
|
"One build so far (${apk.variants.first().variant}), so there is " +
|
||||||
|
"nothing to choose between."
|
||||||
|
)
|
||||||
|
else -> {
|
||||||
|
SettingsNote(
|
||||||
|
"Which of the builds on the machine this phone installs. Only " +
|
||||||
|
"this phone's."
|
||||||
|
)
|
||||||
|
VariantPicker(apk.variants, variantPath) { variantPath = it?.path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SettingsHeading("Transfer")
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
"Strip debug symbols",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Switch(checked = strip, onCheckedChange = { strip = it })
|
||||||
|
}
|
||||||
|
// Said only when the two disagree. A machine that
|
||||||
|
// follows the checkout has nothing to report, and a
|
||||||
|
// line under every switch saying "as declared" is one
|
||||||
|
// more thing to read on every card.
|
||||||
|
if (strip != apk.stripDeclared) {
|
||||||
|
SettingsNote(
|
||||||
|
if (apk.stripDeclared) {
|
||||||
|
"The project asks for stripping; this machine is set not to."
|
||||||
|
} else {
|
||||||
|
"The project does not ask for stripping; this machine is set to."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (component.hasEnrollLink) {
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
SettingsHeading("Enrolment")
|
||||||
|
SettingsNote(
|
||||||
|
"Asks the build machine for a fresh link and opens it here. Do this after " +
|
||||||
|
"installing, or after reinstalling over a different signing key -- " +
|
||||||
|
"either loses whatever enrolment the app had."
|
||||||
|
)
|
||||||
|
// Outside the Save/Cancel bargain the rest of the
|
||||||
|
// sheet makes, because it is not a setting: pressing
|
||||||
|
// it does the thing, now, and there is nothing about
|
||||||
|
// it left to save.
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onDismiss()
|
||||||
|
onEnroll()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Enrol this app")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
// The variant is this device's, so it is written here
|
||||||
|
// and not sent; the other two are the build machine's.
|
||||||
|
if (variantPath != chosenVariantPath) {
|
||||||
|
onSelectVariant(
|
||||||
|
component.apk?.variants?.firstOrNull { it.path == variantPath }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onApply(mode, strip)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text("Save")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsHeading(text: String) {
|
||||||
|
Text(text, style = MaterialTheme.typography.titleSmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A line of explanation under a control, or in place of one that has nothing to offer.
|
||||||
|
*
|
||||||
|
* Its own composable so that "there is nothing to choose here" looks the same wherever it is said
|
||||||
|
* -- four sections each phrasing their empty case differently is how a reader learns to skip them.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun SettingsNote(text: String) {
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The three things Uninstall can take away besides the service.
|
* The three things Uninstall can take away besides the service.
|
||||||
*
|
*
|
||||||
@@ -2903,6 +3236,35 @@ private fun PurgeToggle(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ModePicker(
|
||||||
|
modes: List<String>,
|
||||||
|
chosen: String?,
|
||||||
|
enabled: Boolean,
|
||||||
|
onSelect: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
Box {
|
||||||
|
TextButton(onClick = { expanded = true }, enabled = enabled) {
|
||||||
|
// Never "none": unlike the build picker there is always an
|
||||||
|
// answer here, because a component with modes is always being
|
||||||
|
// built in one of them -- the first, when nobody has chosen.
|
||||||
|
Text(chosen ?: modes.firstOrNull().orEmpty())
|
||||||
|
}
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
modes.forEach { candidate ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(if (candidate == chosen) "$candidate ✓" else candidate) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelect(candidate)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun VariantPicker(
|
private fun VariantPicker(
|
||||||
variants: List<ApkVariant>,
|
variants: List<ApkVariant>,
|
||||||
|
|||||||
+166
-15
@@ -416,8 +416,18 @@ impl BuildState {
|
|||||||
self.git_pull == git_pull
|
self.git_pull == git_pull
|
||||||
&& self.git_ipv4 == git_ipv4
|
&& self.git_ipv4 == git_ipv4
|
||||||
&& self.components.len() == components.len()
|
&& self.components.len() == components.len()
|
||||||
&& std::iter::zip(&self.components, components)
|
&& std::iter::zip(&self.components, components).all(|(mine, theirs)| {
|
||||||
.all(|(mine, theirs)| mine.same_declaration(theirs))
|
// The declaration, plus the one choice that changes what
|
||||||
|
// this state would actually run. `same_declaration`
|
||||||
|
// deliberately ignores the chosen mode -- picking one is
|
||||||
|
// not the project asking for something new -- but this
|
||||||
|
// state holds a *snapshot* of the components it builds
|
||||||
|
// from, so a mode changed underneath it would go on
|
||||||
|
// running the old mode's command from a card reporting
|
||||||
|
// the new one. Strip is not here: it is decided at
|
||||||
|
// download, and nothing this state does depends on it.
|
||||||
|
mine.same_declaration(theirs) && mine.effective_mode() == theirs.effective_mode()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Idempotent: kicks off the build in the background if this app is
|
/// Idempotent: kicks off the build in the background if this app is
|
||||||
@@ -546,6 +556,15 @@ impl BuildState {
|
|||||||
/// uncommitted work and sending it to a phone would also be a
|
/// uncommitted work and sending it to a phone would also be a
|
||||||
/// surprising thing to do with work its author has not committed.
|
/// surprising thing to do with work its author has not committed.
|
||||||
pub fn freshness(&self, component: &Component) -> Freshness {
|
pub fn freshness(&self, component: &Component) -> Freshness {
|
||||||
|
// Behind for a reason no commit can express: what is on disk was
|
||||||
|
// built some other way than this component is set to build now.
|
||||||
|
// Reported before the checkout is consulted at all, because it is
|
||||||
|
// a fact rather than a comparison -- a clean tree at the very
|
||||||
|
// commit the debug build was made from still does not make that
|
||||||
|
// build a release one.
|
||||||
|
if component.built_in_another_mode() {
|
||||||
|
return Freshness::Behind;
|
||||||
|
}
|
||||||
let built = self
|
let built = self
|
||||||
.inner
|
.inner
|
||||||
.lock()
|
.lock()
|
||||||
@@ -575,6 +594,16 @@ impl BuildState {
|
|||||||
if component.build().is_empty() {
|
if component.build().is_empty() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// The same fact `freshness` reports as Behind, and it has to be
|
||||||
|
// asked here too: for an `Apk`, the "never built at all" check
|
||||||
|
// below finds *any* variant under the component's directory, so a
|
||||||
|
// debug build sitting there is enough to make a component
|
||||||
|
// switched to release look built. Nothing else would notice --
|
||||||
|
// the commit has not moved -- so Update would do nothing and the
|
||||||
|
// phone would install the debug APK from a card saying release.
|
||||||
|
if component.built_in_another_mode() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// Nothing built at all is as far behind as an output gets, and it
|
// Nothing built at all is as far behind as an output gets, and it
|
||||||
// is checked before any rule rather than after: a `staleWhen`
|
// is checked before any rule rather than after: a `staleWhen`
|
||||||
// compares two files *inside* a build, so a component that has
|
// compares two files *inside* a build, so a component that has
|
||||||
@@ -960,11 +989,18 @@ impl BuildState {
|
|||||||
// wants to read afterwards.
|
// wants to read afterwards.
|
||||||
let log = crate::logs::open_build_log(&self.key, component.name())
|
let log = crate::logs::open_build_log(&self.key, component.name())
|
||||||
.map(|file| Arc::new(Mutex::new(file)));
|
.map(|file| Arc::new(Mutex::new(file)));
|
||||||
|
// The mode, for a build command written once -- which is how a
|
||||||
|
// project with a script that takes `release` or `debug` says it,
|
||||||
|
// and why nothing has to be written twice in that case. Nothing
|
||||||
|
// for a command written per mode: that command already *is* the
|
||||||
|
// answer, and handing the word over as well would pass a stray
|
||||||
|
// argument to a build that never asked for one.
|
||||||
|
let mode = component.build_mode_argument();
|
||||||
self.run_streaming_command(
|
self.run_streaming_command(
|
||||||
component.name(),
|
component.name(),
|
||||||
component.build(),
|
component.build(),
|
||||||
component.cwd(),
|
component.cwd(),
|
||||||
&[],
|
mode.as_slice(),
|
||||||
log,
|
log,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1323,22 +1359,30 @@ mod tests {
|
|||||||
vec![
|
vec![
|
||||||
Component::Server {
|
Component::Server {
|
||||||
name: "backend".to_string(),
|
name: "backend".to_string(),
|
||||||
build: crate::config::Command::from_line("true"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
||||||
cwd: Some(PathBuf::from("server")),
|
cwd: Some(PathBuf::from("server")),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
service: None,
|
service: None,
|
||||||
|
mode: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
},
|
},
|
||||||
Component::Apk {
|
Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::from_line("true"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
||||||
cwd: Some(PathBuf::from("app")),
|
cwd: Some(PathBuf::from("app")),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1389,13 +1433,20 @@ mod tests {
|
|||||||
// pressed, and only the pull closes it.
|
// pressed, and only the pull closes it.
|
||||||
let accepted = vec![Component::Apk {
|
let accepted = vec![Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::from_line("touch built-marker"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
||||||
|
"touch built-marker",
|
||||||
|
)),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
package: None,
|
package: None,
|
||||||
|
mode: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
}];
|
}];
|
||||||
let state = state_for(&clone, accepted.clone());
|
let state = state_for(&clone, accepted.clone());
|
||||||
|
|
||||||
@@ -1447,22 +1498,34 @@ mod tests {
|
|||||||
let components = vec![
|
let components = vec![
|
||||||
Component::Server {
|
Component::Server {
|
||||||
name: "backend".to_string(),
|
name: "backend".to_string(),
|
||||||
build: crate::config::Command::from_line("touch backend-built"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
||||||
|
"touch backend-built",
|
||||||
|
)),
|
||||||
cwd: Some(PathBuf::from("server")),
|
cwd: Some(PathBuf::from("server")),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
service: None,
|
service: None,
|
||||||
|
mode: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
},
|
},
|
||||||
Component::Apk {
|
Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::from_line("touch app-built"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
||||||
|
"touch app-built",
|
||||||
|
)),
|
||||||
cwd: Some(PathBuf::from("app")),
|
cwd: Some(PathBuf::from("app")),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let state = state_for(root, components);
|
let state = state_for(root, components);
|
||||||
@@ -1527,16 +1590,21 @@ mod tests {
|
|||||||
let components = ["slow", "quick"]
|
let components = ["slow", "quick"]
|
||||||
.map(|name| Component::Apk {
|
.map(|name| Component::Apk {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
build: crate::config::Command::from_line(match name {
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
|
||||||
"slow" => "./slow.sh",
|
"slow" => "./slow.sh",
|
||||||
_ => "touch quick-built",
|
_ => "touch quick-built",
|
||||||
}),
|
})),
|
||||||
cwd: Some(PathBuf::from(name)),
|
cwd: Some(PathBuf::from(name)),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
})
|
})
|
||||||
.to_vec();
|
.to_vec();
|
||||||
let state = state_for(root, components);
|
let state = state_for(root, components);
|
||||||
@@ -1598,16 +1666,21 @@ mod tests {
|
|||||||
let components = ["broken", "fine"]
|
let components = ["broken", "fine"]
|
||||||
.map(|name| Component::Apk {
|
.map(|name| Component::Apk {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
build: crate::config::Command::from_line(match name {
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(match name {
|
||||||
"broken" => "false",
|
"broken" => "false",
|
||||||
_ => "true",
|
_ => "true",
|
||||||
}),
|
})),
|
||||||
cwd: Some(PathBuf::from(name)),
|
cwd: Some(PathBuf::from(name)),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
})
|
})
|
||||||
.to_vec();
|
.to_vec();
|
||||||
let state = state_for(root, components);
|
let state = state_for(root, components);
|
||||||
@@ -1671,23 +1744,37 @@ mod tests {
|
|||||||
let components = vec![
|
let components = vec![
|
||||||
Component::Apk {
|
Component::Apk {
|
||||||
name: "a".to_string(),
|
name: "a".to_string(),
|
||||||
build: crate::config::Command::from_line("touch a-built"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
||||||
|
"touch a-built",
|
||||||
|
)),
|
||||||
cwd: Some(PathBuf::from("a")),
|
cwd: Some(PathBuf::from("a")),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
},
|
},
|
||||||
Component::Apk {
|
Component::Apk {
|
||||||
name: "b".to_string(),
|
name: "b".to_string(),
|
||||||
build: crate::config::Command::from_line("touch b-built"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(
|
||||||
|
"touch b-built",
|
||||||
|
)),
|
||||||
cwd: Some(PathBuf::from("b")),
|
cwd: Some(PathBuf::from("b")),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let state = state_for(root, components);
|
let state = state_for(root, components);
|
||||||
@@ -1742,6 +1829,65 @@ mod tests {
|
|||||||
state.component_is_stale(component, &state.inner.lock().unwrap().built_from.clone())
|
state.component_is_stale(component, &state.inner.lock().unwrap().built_from.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The trap a per-component staleness check walks straight into once
|
||||||
|
/// modes exist. An APK's "never built at all" test finds *any* build
|
||||||
|
/// under the component's directory, so a debug APK sitting there is
|
||||||
|
/// enough to make a component switched to release look built -- and
|
||||||
|
/// switching modes moves no commit, so nothing else has an opinion.
|
||||||
|
/// Update would then do nothing and the phone would install the debug
|
||||||
|
/// build from a card saying release.
|
||||||
|
///
|
||||||
|
/// Asserted through `component_is_stale` rather than through
|
||||||
|
/// `built_in_another_mode` alone, because the rule being right is not
|
||||||
|
/// the same as the check that decides whether to build asking it.
|
||||||
|
#[test]
|
||||||
|
fn a_component_switched_to_another_mode_is_stale_even_with_a_build_on_disk() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let root = dir.path();
|
||||||
|
// A build output where an APK actually lands, so the "nothing
|
||||||
|
// built here" branch is satisfied and cannot be what answers.
|
||||||
|
let built = root.join("build/outputs/apk/debug");
|
||||||
|
std::fs::create_dir_all(&built).expect("mkdir");
|
||||||
|
std::fs::write(built.join("app-debug.apk"), b"not really an apk").expect("write");
|
||||||
|
|
||||||
|
let moded = |mode: Option<&str>| {
|
||||||
|
vec![Component::Apk {
|
||||||
|
name: "app".to_string(),
|
||||||
|
modes: vec!["release".to_string(), "debug".to_string()],
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
||||||
|
cwd: None,
|
||||||
|
stale_when: None,
|
||||||
|
also_watch: Vec::new(),
|
||||||
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
package: None,
|
||||||
|
mode: mode.map(str::to_string),
|
||||||
|
// Built here once, in debug -- without which there is no
|
||||||
|
// build of ours for the comparison to be about.
|
||||||
|
built_from: Some("abc123".to_string()),
|
||||||
|
built_mode: Some("debug".to_string()),
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
let matching = state_for(root, moded(Some("debug")));
|
||||||
|
assert!(
|
||||||
|
!stale(&matching, "app"),
|
||||||
|
"the build on disk is this mode's, so there is nothing to do",
|
||||||
|
);
|
||||||
|
|
||||||
|
let switched = state_for(root, moded(Some("release")));
|
||||||
|
assert!(
|
||||||
|
stale(&switched, "app"),
|
||||||
|
"a debug build must not satisfy a component set to build release",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
switched.freshness(&switched.components[0]),
|
||||||
|
Freshness::Behind,
|
||||||
|
"and the card has to say so rather than reading as current",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The shape of bug a unit test that only calls `component_is_stale`
|
/// The shape of bug a unit test that only calls `component_is_stale`
|
||||||
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
|
/// cannot see: `status()` and `trigger_if_needed` hold the lock that
|
||||||
/// the staleness check also wants. Taking it twice on one thread is a
|
/// the staleness check also wants. Taking it twice on one thread is a
|
||||||
@@ -1893,13 +2039,18 @@ mod tests {
|
|||||||
root,
|
root,
|
||||||
vec![Component::Apk {
|
vec![Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::from_line("true"),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line("true")),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
package: None,
|
package: None,
|
||||||
|
mode: None,
|
||||||
built_from: Some("0000000000000000000000000000000000000000".to_string()),
|
built_from: Some("0000000000000000000000000000000000000000".to_string()),
|
||||||
|
built_mode: None,
|
||||||
}],
|
}],
|
||||||
);
|
);
|
||||||
assert!(!stale(&state, "app"), "no checkout means no opinion");
|
assert!(!stale(&state, "app"), "no checkout means no opinion");
|
||||||
|
|||||||
+814
-15
File diff suppressed because it is too large.
Load diff
@@ -41,6 +41,7 @@ mod registry;
|
|||||||
mod resources;
|
mod resources;
|
||||||
mod restart;
|
mod restart;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
mod script;
|
||||||
mod sdk;
|
mod sdk;
|
||||||
mod service;
|
mod service;
|
||||||
mod shipped;
|
mod shipped;
|
||||||
|
|||||||
+180
-4
@@ -304,6 +304,39 @@ fn component_id(component: &Component) -> ComponentId {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What this machine chose about each component, for carrying across a
|
||||||
|
/// rewrite of the components list.
|
||||||
|
///
|
||||||
|
/// The mirror of [`measured_packages`], and here for the same reason:
|
||||||
|
/// both acceptance and the self entry's startup reconciliation replace
|
||||||
|
/// `components` wholesale with what the checkout declares, which is right
|
||||||
|
/// for everything the project asked for and wrong for everything somebody
|
||||||
|
/// chose here. Without it, choosing to build this server in `debug`
|
||||||
|
/// would last exactly until the next restart -- and restarting is how
|
||||||
|
/// this server is updated, so it would never last at all.
|
||||||
|
///
|
||||||
|
/// Keyed the same way for the same reason: once a component's directory
|
||||||
|
/// decides which builds are its own, a reused name is a different
|
||||||
|
/// component, and handing it the old one's mode would build something
|
||||||
|
/// nobody asked for.
|
||||||
|
fn chosen_settings(components: &[Component]) -> HashMap<ComponentId, crate::config::Choices> {
|
||||||
|
components
|
||||||
|
.iter()
|
||||||
|
.map(|component| (component_id(component), component.choices()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_chosen_settings(
|
||||||
|
components: &mut [Component],
|
||||||
|
chosen: &HashMap<ComponentId, crate::config::Choices>,
|
||||||
|
) {
|
||||||
|
for component in components {
|
||||||
|
if let Some(choices) = chosen.get(&component_id(component)) {
|
||||||
|
component.restore(choices.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn measured_packages(components: &[Component]) -> HashMap<ComponentId, String> {
|
fn measured_packages(components: &[Component]) -> HashMap<ComponentId, String> {
|
||||||
components
|
components
|
||||||
.iter()
|
.iter()
|
||||||
@@ -492,6 +525,11 @@ impl AppState {
|
|||||||
.find(|candidate| candidate.name() == component)
|
.find(|candidate| candidate.name() == component)
|
||||||
{
|
{
|
||||||
built.set_built_from(sha);
|
built.set_built_from(sha);
|
||||||
|
// Beside the commit and in the same write: the mode is
|
||||||
|
// half of "what is on disk", and a record of one without
|
||||||
|
// the other is a component that looks current in a mode
|
||||||
|
// it was never built in.
|
||||||
|
built.set_built_mode();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
@@ -604,13 +642,18 @@ impl AppState {
|
|||||||
// way to start running its commands.
|
// way to start running its commands.
|
||||||
components: vec![Component::Apk {
|
components: vec![Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::default(),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::default(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package,
|
package,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
Ok(key)
|
Ok(key)
|
||||||
@@ -661,6 +704,46 @@ impl AppState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records what somebody chose for one component from the phone:
|
||||||
|
/// which declared mode to build it in, and whether to strip it.
|
||||||
|
///
|
||||||
|
/// A whole-component write rather than a field at a time, because the
|
||||||
|
/// phone has just shown a person every setting the component has and
|
||||||
|
/// what comes back is the complete answer.
|
||||||
|
///
|
||||||
|
/// The mode is checked against what the component actually declares.
|
||||||
|
/// An unknown one is refused rather than stored: `ByMode::get` would
|
||||||
|
/// fall back to the first mode, so storing it would leave the card
|
||||||
|
/// naming a mode nothing builds in -- which reads exactly like a
|
||||||
|
/// setting that took effect.
|
||||||
|
pub fn set_component_choices(
|
||||||
|
&self,
|
||||||
|
key: &str,
|
||||||
|
component: &str,
|
||||||
|
mode: Option<String>,
|
||||||
|
strip: Option<bool>,
|
||||||
|
) -> Result<()> {
|
||||||
|
self.update(|config| {
|
||||||
|
let project = config
|
||||||
|
.projects
|
||||||
|
.iter_mut()
|
||||||
|
.find(|project| project.key == key)
|
||||||
|
.with_context(|| format!("no app named {key}"))?;
|
||||||
|
let target = project
|
||||||
|
.components
|
||||||
|
.iter_mut()
|
||||||
|
.find(|candidate| candidate.name() == component)
|
||||||
|
.with_context(|| format!("{key} has no component named {component}"))?;
|
||||||
|
if let Some(mode) = &mode
|
||||||
|
&& !target.modes().iter().any(|name| name == mode)
|
||||||
|
{
|
||||||
|
bail!("{component} has no build mode called {mode}");
|
||||||
|
}
|
||||||
|
target.choose(mode, strip);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Accepts what this project asks to have run, after a person has read
|
/// Accepts what this project asks to have run, after a person has read
|
||||||
/// it on the phone. From here on those components are ordinary
|
/// it on the phone. From here on those components are ordinary
|
||||||
/// configuration, indistinguishable from hand-written ones.
|
/// configuration, indistinguishable from hand-written ones.
|
||||||
@@ -695,6 +778,7 @@ impl AppState {
|
|||||||
.find(|project| project.key == key)
|
.find(|project| project.key == key)
|
||||||
.with_context(|| format!("{key} is built in and has nothing to accept"))?;
|
.with_context(|| format!("{key} is built in and has nothing to accept"))?;
|
||||||
let measured = measured_packages(&project.components);
|
let measured = measured_packages(&project.components);
|
||||||
|
let chosen = chosen_settings(&project.components);
|
||||||
project.git_pull = declared.git_pull;
|
project.git_pull = declared.git_pull;
|
||||||
// Everything `matches_accepted` compares has to be written
|
// Everything `matches_accepted` compares has to be written
|
||||||
// here, or accepting cannot clear the gate. `resources` joined
|
// here, or accepting cannot clear the gate. `resources` joined
|
||||||
@@ -710,6 +794,12 @@ impl AppState {
|
|||||||
component.set_package(package.clone());
|
component.set_package(package.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Accepting is about what the project asks to have run. It is
|
||||||
|
// not somebody withdrawing the mode they picked or the strip
|
||||||
|
// they turned off, so those survive it -- exactly as the
|
||||||
|
// measured package does, and for the same reason: neither is
|
||||||
|
// part of what was being agreed to.
|
||||||
|
restore_chosen_settings(&mut project.components, &chosen);
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -775,13 +865,18 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
|
|||||||
None => {
|
None => {
|
||||||
components.push(Component::Apk {
|
components.push(Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::default(),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::default(),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
|
mode: None,
|
||||||
package: None,
|
package: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
});
|
});
|
||||||
"app".to_string()
|
"app".to_string()
|
||||||
}
|
}
|
||||||
@@ -791,6 +886,14 @@ fn reconcile_self(config: &mut Config, self_project: &Path) -> bool {
|
|||||||
.projects
|
.projects
|
||||||
.iter_mut()
|
.iter_mut()
|
||||||
.find(|project| project.key == SELF_KEY);
|
.find(|project| project.key == SELF_KEY);
|
||||||
|
// Carried across the row being rebuilt, like `gitIpv4` below: what is
|
||||||
|
// *derived* here is what the checkout declares, and a mode chosen for
|
||||||
|
// this server's own component is not that. Restarting is how this
|
||||||
|
// server takes an update, so a choice that did not survive a restart
|
||||||
|
// would not survive being acted on.
|
||||||
|
if let Some(existing) = existing.as_ref() {
|
||||||
|
restore_chosen_settings(&mut components, &chosen_settings(&existing.components));
|
||||||
|
}
|
||||||
let derived = ProjectConfig {
|
let derived = ProjectConfig {
|
||||||
key: SELF_KEY.to_string(),
|
key: SELF_KEY.to_string(),
|
||||||
label: declared.label.unwrap_or_else(|| SELF_LABEL.to_string()),
|
label: declared.label.unwrap_or_else(|| SELF_LABEL.to_string()),
|
||||||
@@ -946,13 +1049,18 @@ mod tests {
|
|||||||
fn asking_for(command: &str) -> Vec<Component> {
|
fn asking_for(command: &str) -> Vec<Component> {
|
||||||
vec![Component::Apk {
|
vec![Component::Apk {
|
||||||
name: "app".to_string(),
|
name: "app".to_string(),
|
||||||
build: crate::config::Command::from_line(command),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::from_line(command)),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
package: None,
|
package: None,
|
||||||
|
mode: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -964,6 +1072,16 @@ mod tests {
|
|||||||
.expect("write");
|
.expect("write");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A component's declaration in the project file, with modes.
|
||||||
|
fn write_moded_request(project: &Path) {
|
||||||
|
std::fs::write(
|
||||||
|
project.join(crate::config::PROJECT_CONFIG_FILE),
|
||||||
|
"components: [Apk(name: \"app\", modes: [\"release\", \"debug\"], \
|
||||||
|
build: {\"release\": \"r\", \"debug\": \"d\"})],\n",
|
||||||
|
)
|
||||||
|
.expect("write");
|
||||||
|
}
|
||||||
|
|
||||||
/// A live `AppState` holding one configured app pointed at `project`,
|
/// A live `AppState` holding one configured app pointed at `project`,
|
||||||
/// with nothing accepted yet.
|
/// with nothing accepted yet.
|
||||||
///
|
///
|
||||||
@@ -1027,13 +1145,18 @@ mod tests {
|
|||||||
fn apk_named(name: &str, cwd: Option<&str>) -> Component {
|
fn apk_named(name: &str, cwd: Option<&str>) -> Component {
|
||||||
Component::Apk {
|
Component::Apk {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
build: crate::config::Command::default(),
|
modes: Vec::new(),
|
||||||
|
build: crate::config::ByMode::One(crate::config::Command::default()),
|
||||||
cwd: cwd.map(PathBuf::from),
|
cwd: cwd.map(PathBuf::from),
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
strip: false,
|
strip: false,
|
||||||
|
enroll: crate::config::Command::default(),
|
||||||
|
strip_here: None,
|
||||||
package: None,
|
package: None,
|
||||||
|
mode: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1178,6 +1301,59 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Accepting is about what the project asks to have *run*. It is not
|
||||||
|
/// somebody withdrawing the mode they picked, so the choice survives
|
||||||
|
/// -- exactly as the measured package does, and it has to be written
|
||||||
|
/// here rather than asserted from a hand-built config, because what
|
||||||
|
/// "accepted" means is whatever `approve_declaration` writes.
|
||||||
|
#[test]
|
||||||
|
fn a_chosen_mode_survives_the_declaration_being_accepted_again() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_moded_request(dir.path());
|
||||||
|
let (_home, state) = state_with(dir.path());
|
||||||
|
state.approve_declaration("demo").expect("approve");
|
||||||
|
state
|
||||||
|
.set_component_choices("demo", "app", Some("debug".to_string()), None)
|
||||||
|
.expect("choose the debug mode");
|
||||||
|
assert_eq!(
|
||||||
|
state.entry("demo").expect("entry").components[0].effective_mode(),
|
||||||
|
Some("debug"),
|
||||||
|
);
|
||||||
|
|
||||||
|
state.approve_declaration("demo").expect("approve again");
|
||||||
|
let component = &state.entry("demo").expect("entry").components[0];
|
||||||
|
assert_eq!(
|
||||||
|
component.effective_mode(),
|
||||||
|
Some("debug"),
|
||||||
|
"accepting must not silently put the build back to the default mode",
|
||||||
|
);
|
||||||
|
assert_eq!(component.build().to_line(), "d");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mode this component does not declare is refused rather than
|
||||||
|
/// stored: `ByMode::get` falls back to the first, so storing it would
|
||||||
|
/// leave the card naming a mode nothing is built in -- which reads
|
||||||
|
/// exactly like a setting that took effect.
|
||||||
|
#[test]
|
||||||
|
fn a_mode_the_component_does_not_declare_is_refused() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_moded_request(dir.path());
|
||||||
|
let (_home, state) = state_with(dir.path());
|
||||||
|
state.approve_declaration("demo").expect("approve");
|
||||||
|
let refused = state
|
||||||
|
.set_component_choices("demo", "app", Some("profile".to_string()), None)
|
||||||
|
.expect_err("there is no such mode");
|
||||||
|
assert!(
|
||||||
|
refused.to_string().contains("profile"),
|
||||||
|
"the refusal should name what was asked for: {refused}",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.entry("demo").expect("entry").components[0].effective_mode(),
|
||||||
|
Some("release"),
|
||||||
|
"and it must leave the component where it was",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The case the gate exists for, and the one a cached answer got
|
/// The case the gate exists for, and the one a cached answer got
|
||||||
/// wrong: the project file changes on a *pull*, which writes no config
|
/// wrong: the project file changes on a *pull*, which writes no config
|
||||||
/// and so rebuilds no entries. An entry built while the request
|
/// and so rebuilds no entries. An entry built while the request
|
||||||
|
|||||||
+1
-50
@@ -58,7 +58,7 @@ pub fn read(project: &Path, declaration: &Resources) -> Result<ResourceFacts, St
|
|||||||
parse(&text).map_err(|err| format!("in {}: {err}", path.display()))
|
parse(&text).map_err(|err| format!("in {}: {err}", path.display()))
|
||||||
}
|
}
|
||||||
Resources::Script(command) => {
|
Resources::Script(command) => {
|
||||||
let text = run(project, command)?;
|
let text = crate::script::capture(project, None, command, SCRIPT_TIMEOUT)?;
|
||||||
parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line()))
|
parse(&text).map_err(|err| format!("in the output of {}: {err}", command.to_line()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,55 +74,6 @@ fn parse(text: &str) -> Result<ResourceFacts, String> {
|
|||||||
wg_app_link::format::parse(text).map_err(|err| err.to_string())
|
wg_app_link::format::parse(text).map_err(|err| err.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(project: &Path, command: &crate::config::Command) -> Result<String, String> {
|
|
||||||
use std::io::Read;
|
|
||||||
|
|
||||||
let mut child = command
|
|
||||||
.to_process(project, None, &[])?
|
|
||||||
// Closed, because a script that asks a question would otherwise
|
|
||||||
// wait for an answer nobody is there to give -- the same rule the
|
|
||||||
// service scripts run under.
|
|
||||||
.stdin(std::process::Stdio::null())
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped())
|
|
||||||
.spawn()
|
|
||||||
.map_err(|err| format!("starting {}: {err}", command.to_line()))?;
|
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + SCRIPT_TIMEOUT;
|
|
||||||
loop {
|
|
||||||
match child.try_wait() {
|
|
||||||
Err(err) => return Err(format!("waiting on {}: {err}", command.to_line())),
|
|
||||||
Ok(Some(status)) => {
|
|
||||||
let mut out = String::new();
|
|
||||||
if let Some(mut pipe) = child.stdout.take() {
|
|
||||||
let _ = pipe.read_to_string(&mut out);
|
|
||||||
}
|
|
||||||
if status.success() {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
let mut err = String::new();
|
|
||||||
if let Some(mut pipe) = child.stderr.take() {
|
|
||||||
let _ = pipe.read_to_string(&mut err);
|
|
||||||
}
|
|
||||||
return Err(format!(
|
|
||||||
"{} failed ({status}): {}",
|
|
||||||
command.to_line(),
|
|
||||||
err.lines().next().unwrap_or("no output").trim()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(None) if std::time::Instant::now() >= deadline => {
|
|
||||||
let _ = child.kill();
|
|
||||||
return Err(format!(
|
|
||||||
"{} did not answer within {}s",
|
|
||||||
command.to_line(),
|
|
||||||
SCRIPT_TIMEOUT.as_secs()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(None) => std::thread::sleep(std::time::Duration::from_millis(20)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One project to read, and where from.
|
/// One project to read, and where from.
|
||||||
pub struct Target {
|
pub struct Target {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
//! services again
|
//! services again
|
||||||
//! PUT /apps/{key}/settings {gitIpv4}
|
//! PUT /apps/{key}/settings {gitIpv4}
|
||||||
//! this machine's preferences for it
|
//! this machine's preferences for it
|
||||||
|
//! PUT /apps/{key}/components/{name}/settings {mode, strip}
|
||||||
|
//! this machine's preferences for one
|
||||||
|
//! component
|
||||||
|
//! POST /apps/{key}/components/{name}/enroll-link
|
||||||
|
//! run that component's `enroll:` and
|
||||||
|
//! answer the URL it printed
|
||||||
//! GET /apps/{key}/components/{name}/logs[?lines=&generation=]
|
//! GET /apps/{key}/components/{name}/logs[?lines=&generation=]
|
||||||
//! what that component wrote
|
//! what that component wrote
|
||||||
//! POST /apps/{key}/components/{name}/{action}
|
//! POST /apps/{key}/components/{name}/{action}
|
||||||
@@ -98,6 +104,21 @@ pub fn tls_router(state: Arc<AppState>) -> Router {
|
|||||||
// action -- axum matches a literal segment ahead of a capture,
|
// action -- axum matches a literal segment ahead of a capture,
|
||||||
// but declaring it first says so to a reader too.
|
// but declaring it first says so to a reader too.
|
||||||
.route("/apps/{key}/components/{name}/logs", get(component_logs))
|
.route("/apps/{key}/components/{name}/logs", get(component_logs))
|
||||||
|
// Beside `logs` and ahead of `{action}` for the same reason, even
|
||||||
|
// though the methods already keep them apart: a reader should not
|
||||||
|
// have to check that `settings` is not an action.
|
||||||
|
.route(
|
||||||
|
"/apps/{key}/components/{name}/settings",
|
||||||
|
put(set_component_settings),
|
||||||
|
)
|
||||||
|
// Its own route rather than one more `{action}`: those drive a
|
||||||
|
// server's service and answer with what the service is doing,
|
||||||
|
// where this belongs to an APK and answers with a URL. Folding it
|
||||||
|
// in would have made the action list mean two things.
|
||||||
|
.route(
|
||||||
|
"/apps/{key}/components/{name}/enroll-link",
|
||||||
|
post(enrollment_link),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/apps/{key}/components/{name}/{action}",
|
"/apps/{key}/components/{name}/{action}",
|
||||||
post(service_action),
|
post(service_action),
|
||||||
@@ -236,6 +257,14 @@ struct ManifestApk {
|
|||||||
/// would mean running the strip pipeline here; see
|
/// would mean running the strip pipeline here; see
|
||||||
/// `strip::serveable_now`.
|
/// `strip::serveable_now`.
|
||||||
size: u64,
|
size: u64,
|
||||||
|
/// Whether a debug-symbol-stripped copy is what gets served -- what
|
||||||
|
/// this machine settled on, which is what the download will actually
|
||||||
|
/// do.
|
||||||
|
strip: bool,
|
||||||
|
/// What the *project* asks for, which is only different when somebody
|
||||||
|
/// overrode it here. Sent so the sheet can say the two differ rather
|
||||||
|
/// than showing a switch that silently disagrees with the checkout.
|
||||||
|
strip_declared: bool,
|
||||||
variants: Vec<ManifestVariant>,
|
variants: Vec<ManifestVariant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +361,21 @@ 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>,
|
||||||
|
/// This component can hand the phone a link to open after it is
|
||||||
|
/// installed, because the project declares a command that prints one.
|
||||||
|
/// What the link does is the project's business; the button just
|
||||||
|
/// opens it.
|
||||||
|
has_enroll_link: bool,
|
||||||
|
/// Every way this component can be built, in declaration order, with
|
||||||
|
/// the first the default. Empty for a component that declares one
|
||||||
|
/// way, which is not a choice -- the settings sheet says so rather
|
||||||
|
/// than drawing a picker with one entry in it.
|
||||||
|
modes: Vec<String>,
|
||||||
|
/// Which of them it is set to build in. Absent exactly when `modes`
|
||||||
|
/// is empty; when it is not, this is always one of them, including
|
||||||
|
/// the case where nobody has chosen and it is the first.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
mode: Option<String>,
|
||||||
/// What there is to install, for a component that produces an APK.
|
/// What there is to install, for a component that produces an APK.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
apk: Option<ManifestApk>,
|
apk: Option<ManifestApk>,
|
||||||
@@ -415,6 +459,12 @@ impl ManifestComponent {
|
|||||||
resources_error: is_server
|
resources_error: is_server
|
||||||
.then(|| state.resource_checks.error(key))
|
.then(|| state.resource_checks.error(key))
|
||||||
.flatten(),
|
.flatten(),
|
||||||
|
// Gated the same way `has_build` is: while a declaration is
|
||||||
|
// waiting to be accepted the command will not run, so
|
||||||
|
// offering the button would be offering one that refuses.
|
||||||
|
has_enroll_link: may_build && component.enroll().is_some(),
|
||||||
|
modes: component.modes().to_vec(),
|
||||||
|
mode: component.effective_mode().map(str::to_string),
|
||||||
apk: match is_server {
|
apk: match is_server {
|
||||||
true => None,
|
true => None,
|
||||||
false => Some(ManifestApk::read(state, key, entry, component).await?),
|
false => Some(ManifestApk::read(state, key, entry, component).await?),
|
||||||
@@ -455,6 +505,8 @@ impl ManifestApk {
|
|||||||
.map(|apk| epoch_secs(apk.modified))
|
.map(|apk| epoch_secs(apk.modified))
|
||||||
.unwrap_or(0.0),
|
.unwrap_or(0.0),
|
||||||
size,
|
size,
|
||||||
|
strip: component.strip(),
|
||||||
|
strip_declared: component.strip_declared(),
|
||||||
variants: entry
|
variants: entry
|
||||||
.variants(component)
|
.variants(component)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -1220,6 +1272,113 @@ async fn set_settings(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct EnrollmentLink {
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long a project's `enroll:` command is given before it is given up
|
||||||
|
/// on. Somebody is holding a phone waiting for it, so shorter than a
|
||||||
|
/// service action's minute -- but long enough for a command that has to
|
||||||
|
/// touch a keystore or a config file on the way.
|
||||||
|
const ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
|
||||||
|
|
||||||
|
/// Runs one component's `enroll:` and answers the URL it printed, for the
|
||||||
|
/// phone to open.
|
||||||
|
///
|
||||||
|
/// Run on every press rather than cached anywhere. The link a project
|
||||||
|
/// mints is ordinarily one-shot and carries a credential, so a stored one
|
||||||
|
/// would be both stale and a secret sitting in a file -- and a project
|
||||||
|
/// that mints a fresh token each time is exactly the shape this is for.
|
||||||
|
///
|
||||||
|
/// One line, and the first: the contract is a single URL on stdout, which
|
||||||
|
/// makes a project's diagnostics stderr's business. Trimmed rather than
|
||||||
|
/// parsed -- what a URL means is the phone's business and not this
|
||||||
|
/// server's, so nothing here inspects the scheme.
|
||||||
|
async fn enrollment_link(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
UrlPath((key, name)): UrlPath<(String, String)>,
|
||||||
|
) -> Result<Json<EnrollmentLink>, ApiError> {
|
||||||
|
let entry = state.entry(&key).ok_or(ApiError::UnknownApp(key.clone()))?;
|
||||||
|
// The same gate the build runs behind, asked here rather than
|
||||||
|
// inherited: this is a command the project declared, so a pull must
|
||||||
|
// not be able to introduce one that a press then runs.
|
||||||
|
if entry.pending_declaration().is_some() {
|
||||||
|
return Err(ApiError::BadRequest(format!(
|
||||||
|
"{} is asking to have a command accepted before anything of its own runs",
|
||||||
|
entry.label
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let component = entry
|
||||||
|
.components
|
||||||
|
.iter()
|
||||||
|
.find(|component| component.name() == name)
|
||||||
|
.ok_or_else(|| ApiError::UnknownComponent(key.clone(), name.clone()))?;
|
||||||
|
let command = component
|
||||||
|
.enroll()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ApiError::BadRequest(format!("{name} does not say how to get an enrolment link"))
|
||||||
|
})?
|
||||||
|
.clone();
|
||||||
|
let project = entry.project_path.clone();
|
||||||
|
let cwd = component.cwd().map(std::path::Path::to_path_buf);
|
||||||
|
let printed = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::script::capture(&project, cwd.as_deref(), &command, ENROLL_TIMEOUT)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.context("the enrolment command panicked")?
|
||||||
|
.map_err(|err| ApiError::Internal(anyhow::anyhow!(err)))?;
|
||||||
|
let url = printed
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if url.is_empty() {
|
||||||
|
return Err(ApiError::Internal(anyhow::anyhow!(
|
||||||
|
"{name}'s enrolment command printed nothing on stdout"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(Json(EnrollmentLink { url }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What somebody chose for one component on the settings sheet.
|
||||||
|
///
|
||||||
|
/// `mode` absent means "no choice, take the first declared one", which is
|
||||||
|
/// a real answer rather than a missing field -- it is what a component
|
||||||
|
/// reverts to and what every component starts as. `strip` absent means
|
||||||
|
/// the same about the project's own declaration.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ComponentSettingsBody {
|
||||||
|
#[serde(default)]
|
||||||
|
mode: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
strip: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This machine's preferences for one component: which declared mode to
|
||||||
|
/// build it in, and whether to strip what it serves.
|
||||||
|
///
|
||||||
|
/// On the build machine rather than on the device, unlike the variant a
|
||||||
|
/// download names: a mode decides what gets *built*, and there is one
|
||||||
|
/// checkout and one set of outputs, so two enrolled phones holding
|
||||||
|
/// different answers would rebuild over each other with nothing on either
|
||||||
|
/// screen to say why. Which of the finished builds a phone installs stays
|
||||||
|
/// that phone's business.
|
||||||
|
async fn set_component_settings(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
UrlPath((key, name)): UrlPath<(String, String)>,
|
||||||
|
Json(body): Json<ComponentSettingsBody>,
|
||||||
|
) -> Result<StatusCode, ApiError> {
|
||||||
|
mutate(state, move |state| {
|
||||||
|
state.set_component_choices(&key, &name, body.mode, body.strip)
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
Ok(StatusCode::NO_CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
async fn set_roots(
|
async fn set_roots(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(body): Json<RootsBody>,
|
Json(body): Json<RootsBody>,
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
//! Running a command a project declared and keeping what it printed.
|
||||||
|
//!
|
||||||
|
//! Two things here ask a project to answer a question by running
|
||||||
|
//! something: `resources`, which reads where a project keeps its state,
|
||||||
|
//! and the enrolment link on an `Apk` component. Both want the same
|
||||||
|
//! bargain -- stdin closed, a deadline, stdout kept, and a failure that
|
||||||
|
//! names the command and the first line of its stderr -- so the bargain
|
||||||
|
//! is written once rather than twice with the second copy quietly
|
||||||
|
//! drifting.
|
||||||
|
//!
|
||||||
|
//! Deliberately *not* what a build runs through. A build's output is
|
||||||
|
//! streamed as it arrives, because it is long and somebody is watching a
|
||||||
|
//! progress bar; these are short and their whole value is the string at
|
||||||
|
//! the end.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::config::Command;
|
||||||
|
|
||||||
|
/// Runs `command` in the project and answers its stdout.
|
||||||
|
///
|
||||||
|
/// Stdin is closed rather than inherited. A script that prompts for a
|
||||||
|
/// password gets end-of-file and fails, instead of hanging until the
|
||||||
|
/// deadline with a card stuck on whatever it was doing -- the same
|
||||||
|
/// bargain the service scripts make.
|
||||||
|
pub fn capture(
|
||||||
|
project: &Path,
|
||||||
|
cwd: Option<&Path>,
|
||||||
|
command: &Command,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
let mut child = command
|
||||||
|
.to_process(project, cwd, &[])?
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("starting {}: {err}", command.to_line()))?;
|
||||||
|
|
||||||
|
let deadline = Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
match child.try_wait() {
|
||||||
|
Err(err) => return Err(format!("waiting on {}: {err}", command.to_line())),
|
||||||
|
Ok(Some(status)) => {
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(mut pipe) = child.stdout.take() {
|
||||||
|
let _ = pipe.read_to_string(&mut out);
|
||||||
|
}
|
||||||
|
if status.success() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let mut err = String::new();
|
||||||
|
if let Some(mut pipe) = child.stderr.take() {
|
||||||
|
let _ = pipe.read_to_string(&mut err);
|
||||||
|
}
|
||||||
|
return Err(format!(
|
||||||
|
"{} failed ({status}): {}",
|
||||||
|
command.to_line(),
|
||||||
|
err.lines().next().unwrap_or("no output").trim()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(None) if Instant::now() >= deadline => {
|
||||||
|
let _ = child.kill();
|
||||||
|
return Err(format!(
|
||||||
|
"{} did not answer within {}s",
|
||||||
|
command.to_line(),
|
||||||
|
timeout.as_secs()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(None) => std::thread::sleep(Duration::from_millis(20)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-12
@@ -80,19 +80,42 @@ impl ServiceState {
|
|||||||
/// declares no service, which are the same thing to every caller: there
|
/// declares no service, which are the same thing to every caller: there
|
||||||
/// is nothing to drive.
|
/// is nothing to drive.
|
||||||
pub fn driver(key: &str, component: &Component) -> Option<Command> {
|
pub fn driver(key: &str, component: &Component) -> Option<Command> {
|
||||||
match component.service()? {
|
let service = component.service()?;
|
||||||
Service::Script(script) if !script.is_empty() => Some(script.clone()),
|
// Resolved here, once, from the component's own mode. Which binary a
|
||||||
Service::Managed(run) if !run.is_empty() => Some(Command::from_words(vec![
|
// managed service runs is exactly what a build mode changes, so a
|
||||||
|
// second place deciding the mode is a service started from the other
|
||||||
|
// mode's output -- which looks like a build that did nothing.
|
||||||
|
let mode = component.effective_mode();
|
||||||
|
let command = service.command(mode);
|
||||||
|
if command.is_empty() {
|
||||||
|
// Declared empty, which says the same as not declaring it.
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// The same rule the build follows: a command written once is told
|
||||||
|
// which mode it is running in, a command written per mode is not told
|
||||||
|
// twice. It lands *before* the subcommand the caller appends, which
|
||||||
|
// is what keeps `<command> <subcommand>` the contract every service
|
||||||
|
// script is written against.
|
||||||
|
let mode = service.by_mode().mode_argument(mode);
|
||||||
|
let with_mode = |command: &Command| match mode {
|
||||||
|
None => command.clone(),
|
||||||
|
Some(mode) => {
|
||||||
|
let mut words: Vec<String> = command.to_words();
|
||||||
|
words.push(mode.to_string());
|
||||||
|
Command::from_words(words)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match service {
|
||||||
|
Service::Script(_) => Some(with_mode(command)),
|
||||||
|
Service::Managed(_) => Some(Command::from_words(vec![
|
||||||
crate::shipped::service_default()
|
crate::shipped::service_default()
|
||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.into_owned(),
|
.into_owned(),
|
||||||
"--name".to_string(),
|
"--name".to_string(),
|
||||||
unit_name(key, component.name()),
|
unit_name(key, component.name()),
|
||||||
"--exec".to_string(),
|
"--exec".to_string(),
|
||||||
run.to_line(),
|
with_mode(command).to_line(),
|
||||||
])),
|
])),
|
||||||
// Declared empty, which says the same as not declaring it.
|
|
||||||
Service::Script(_) | Service::Managed(_) => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,19 +414,64 @@ mod tests {
|
|||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::config::ByMode;
|
||||||
|
|
||||||
fn server(service: Option<Service>) -> Component {
|
fn server(service: Option<Service>) -> Component {
|
||||||
|
server_with_modes(Vec::new(), service)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_with_modes(modes: Vec<String>, service: Option<Service>) -> Component {
|
||||||
Component::Server {
|
Component::Server {
|
||||||
name: "backend".to_string(),
|
name: "backend".to_string(),
|
||||||
build: Command::default(),
|
modes,
|
||||||
|
build: ByMode::One(Command::default()),
|
||||||
cwd: None,
|
cwd: None,
|
||||||
stale_when: None,
|
stale_when: None,
|
||||||
also_watch: Vec::new(),
|
also_watch: Vec::new(),
|
||||||
service,
|
service,
|
||||||
|
mode: None,
|
||||||
built_from: None,
|
built_from: None,
|
||||||
|
built_mode: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which binary a managed service runs is exactly what a mode
|
||||||
|
/// changes, and this is the single place that gets decided -- a
|
||||||
|
/// second one would build `release` and start `debug`, from a card
|
||||||
|
/// reporting the mode it was asked for.
|
||||||
|
#[test]
|
||||||
|
fn the_mode_decides_which_binary_the_service_runs() {
|
||||||
|
let mut component = server_with_modes(
|
||||||
|
vec!["release".to_string(), "debug".to_string()],
|
||||||
|
Some(Service::Managed(ByMode::Modes(vec![
|
||||||
|
(
|
||||||
|
"release".to_string(),
|
||||||
|
Command::from_line("target/release/backend"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"debug".to_string(),
|
||||||
|
Command::from_line("target/debug/backend"),
|
||||||
|
),
|
||||||
|
]))),
|
||||||
|
);
|
||||||
|
|
||||||
|
let exec_of = |component: &Component| {
|
||||||
|
let words = driver("app", component).expect("a command to drive it");
|
||||||
|
let (_, args) = words.split_first().expect("the script and its arguments");
|
||||||
|
let at = args
|
||||||
|
.iter()
|
||||||
|
.position(|arg| arg == "--exec")
|
||||||
|
.expect("--exec is how the built-in script is told what to run");
|
||||||
|
args[at + 1].clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nobody has chosen, so it is the first declared -- which is what
|
||||||
|
// makes declaration order worth getting right.
|
||||||
|
assert_eq!(exec_of(&component), "target/release/backend");
|
||||||
|
component.choose(Some("debug".to_string()), None);
|
||||||
|
assert_eq!(exec_of(&component), "target/debug/backend");
|
||||||
|
}
|
||||||
|
|
||||||
/// The one place the two variants become the same thing, so this is
|
/// The one place the two variants become the same thing, so this is
|
||||||
/// where it is worth pinning down what each turns into.
|
/// where it is worth pinning down what each turns into.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -412,7 +480,10 @@ mod tests {
|
|||||||
// already the thing the contract describes.
|
// already the thing the contract describes.
|
||||||
let own = Command::from_line("server/service");
|
let own = Command::from_line("server/service");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
driver("app", &server(Some(Service::Script(own.clone())))),
|
driver(
|
||||||
|
"app",
|
||||||
|
&server(Some(Service::Script(ByMode::One(own.clone()))))
|
||||||
|
),
|
||||||
Some(own)
|
Some(own)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -421,9 +492,9 @@ mod tests {
|
|||||||
// appends still lands last.
|
// appends still lands last.
|
||||||
let managed = driver(
|
let managed = driver(
|
||||||
"app",
|
"app",
|
||||||
&server(Some(Service::Managed(Command::from_line(
|
&server(Some(Service::Managed(ByMode::One(Command::from_line(
|
||||||
"target/release/ai-server --port 8080",
|
"target/release/ai-server --port 8080",
|
||||||
)))),
|
))))),
|
||||||
)
|
)
|
||||||
.expect("a managed component has a driver");
|
.expect("a managed component has a driver");
|
||||||
let (program, arguments) = managed.split_first().expect("a program");
|
let (program, arguments) = managed.split_first().expect("a program");
|
||||||
@@ -452,11 +523,17 @@ mod tests {
|
|||||||
fn nothing_to_drive_is_none_however_it_was_said() {
|
fn nothing_to_drive_is_none_however_it_was_said() {
|
||||||
assert_eq!(driver("app", &server(None)), None);
|
assert_eq!(driver("app", &server(None)), None);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
driver("app", &server(Some(Service::Script(Command::default())))),
|
driver(
|
||||||
|
"app",
|
||||||
|
&server(Some(Service::Script(ByMode::One(Command::default()))))
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
driver("app", &server(Some(Service::Managed(Command::default())))),
|
driver(
|
||||||
|
"app",
|
||||||
|
&server(Some(Service::Managed(ByMode::One(Command::default()))))
|
||||||
|
),
|
||||||
None
|
None
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ resources: Ron("resources.ron"),
|
|||||||
components: [
|
components: [
|
||||||
Server(
|
Server(
|
||||||
name: "daemon",
|
name: "daemon",
|
||||||
|
// Two modes, declared once. Both commands are written *once*
|
||||||
|
// and handed the mode as their last argument, which is the
|
||||||
|
// ordinary way -- Dev Updater's own declaration covers the other
|
||||||
|
// way, where a command cannot take the word and is written per
|
||||||
|
// mode instead. Between the two fixtures both paths are
|
||||||
|
// exercised. `release` first, since the first is the default.
|
||||||
|
modes: ["release", "debug"],
|
||||||
build: "./build-daemon.sh",
|
build: "./build-daemon.sh",
|
||||||
// Managed, so Dev Updater's own service script drives it and this
|
// Managed, so Dev Updater's own service script drives it and this
|
||||||
// project needs no systemd or OpenRC knowledge of its own. The
|
// project needs no systemd or OpenRC knowledge of its own. The
|
||||||
|
|||||||
@@ -6,16 +6,31 @@
|
|||||||
# seconds. A card with one instant component and one slow one is how you see
|
# seconds. A card with one instant component and one slow one is how you see
|
||||||
# whether the per-component timings and the concurrent build really are per
|
# whether the per-component timings and the concurrent build really are per
|
||||||
# component.
|
# component.
|
||||||
|
#
|
||||||
|
# It takes the build mode as its one argument, so that this fixture
|
||||||
|
# exercises a component with more than one way of being built. `release`
|
||||||
|
# is deliberately the slow one, which is both what a real optimised build
|
||||||
|
# is and what makes a mode switch visible on the card: a bar that takes
|
||||||
|
# eight seconds and one that takes two.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
STEPS=8
|
MODE="${1:-release}"
|
||||||
|
case "$MODE" in
|
||||||
|
release) STEPS=8 ;;
|
||||||
|
debug) STEPS=2 ;;
|
||||||
|
*)
|
||||||
|
echo "unknown build mode: $MODE (expected release or debug)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
i=0
|
i=0
|
||||||
while [ "$i" -lt "$STEPS" ]; do
|
while [ "$i" -lt "$STEPS" ]; do
|
||||||
i=$((i + 1))
|
i=$((i + 1))
|
||||||
echo "@@progress $i/$STEPS"
|
echo "@@progress $i/$STEPS"
|
||||||
echo "==> Pretending to compile part $i of $STEPS"
|
echo "==> Pretending to compile part $i of $STEPS ($MODE)"
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
chmod +x ./serve.sh
|
chmod +x ./serve.sh
|
||||||
echo "==> Daemon ready"
|
echo "==> Daemon ready ($MODE)"
|
||||||
@@ -7,12 +7,27 @@
|
|||||||
# that the directory exists to be shown in the dialog. Its *log* is not
|
# that the directory exists to be shown in the dialog. Its *log* is not
|
||||||
# written here: a managed service's output is captured by Dev Updater's
|
# written here: a managed service's output is captured by Dev Updater's
|
||||||
# service script, which is what the runtime log tab reads.
|
# service script, which is what the runtime log tab reads.
|
||||||
|
#
|
||||||
|
# Takes the build mode as its one argument, ahead of whatever subcommand
|
||||||
|
# the service contract appends -- which is how a command written once
|
||||||
|
# learns which mode it is running in. It ticks at a different rate in
|
||||||
|
# each, so that a mode switch is checkable from a phone: the runtime log
|
||||||
|
# is the only place the difference shows.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
MODE="${1:-release}"
|
||||||
|
case "$MODE" in
|
||||||
|
release) INTERVAL=10 ;;
|
||||||
|
debug) INTERVAL=2 ;;
|
||||||
|
*)
|
||||||
|
echo "unknown mode: $MODE (expected release or debug)" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/dutest-service"
|
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/dutest-service"
|
||||||
mkdir -p "$DATA"
|
mkdir -p "$DATA"
|
||||||
|
|
||||||
echo "started at $(date -Is), writing to $DATA"
|
echo "started at $(date -Is), writing to $DATA, ticking every ${INTERVAL}s in $MODE"
|
||||||
count=0
|
count=0
|
||||||
while true; do
|
while true; do
|
||||||
count=$((count + 1))
|
count=$((count + 1))
|
||||||
@@ -20,5 +35,5 @@ while true; do
|
|||||||
# Colour, so the runtime log tab has escapes to render too -- the same
|
# Colour, so the runtime log tab has escapes to render too -- the same
|
||||||
# reason breakable/build.sh writes them.
|
# reason breakable/build.sh writes them.
|
||||||
printf 'tick \033[1;32m%s\033[0m -- still here\n' "$count"
|
printf 'tick \033[1;32m%s\033[0m -- still here\n' "$count"
|
||||||
sleep 10
|
sleep "$INTERVAL"
|
||||||
done
|
done
|
||||||
Reference in new issue
Block a user