chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app can require an update, and says when to sign in again
.knotless/workflows/deploy.yml+4−0
| @@ -86,6 +86,10 @@ env: | ||
# again under a name deleted within the day can have its pushes refused. | ||
KNOTLESS_CLIENT_MINIMUM: 0.6.0 | ||
KNOTLESS_CLIENT_MINIMUM_REASON: "knotless 0.6.0 changed how deleted branches are stored, and older clients can have their pushes refused: run `knotless upgrade`." | ||
# The oldest build of the iOS app to let run, none for now. To raise it, | ||
# once the newer build is in people's reach (ios/README.md): | ||
# KNOTLESS_IOS_MINIMUM_BUILD: "5" | ||
# KNOTLESS_IOS_MINIMUM_REASON: "This version can lose what you write." | ||
# Labels issues with Clef, as its own account, whose labels count only | ||
# for the scopes the owner names it a triager of (knotless triager add): | ||
# here, and in every repository that names it. | ||
crates/knotless-serve/src/appview.rs+7−1
| @@ -113,7 +113,7 @@ pub async fn list_checks( | ||
| ||
/// What this appview offers the clients that use it: the runner a new | ||
/// public repository names unless its owner names others, and the oldest | ||
/// client version it asks for. | ||
/// client version and iOS build it asks for. | ||
pub async fn describe_server(State(app): State<Arc<App>>) -> Json<Value> { | ||
let runners = app.default_runner.iter().map(|did| json!({"did": did})); | ||
let mut out = json!({"runners": runners.collect::<Vec<_>>()}); | ||
| @@ -123,6 +123,12 @@ pub async fn describe_server(State(app): State<Arc<App>>) -> Json<Value> { | ||
out["client"]["reason"] = reason.as_str().into(); | ||
} | ||
} | ||
if let Some((build, reason)) = &app.ios_minimum { | ||
out["ios"] = json!({"minimumBuild": build}); | ||
if let Some(reason) = reason { | ||
out["ios"]["reason"] = reason.as_str().into(); | ||
} | ||
} | ||
Json(out) | ||
} | ||
| ||
crates/knotless-serve/src/lib.rs+26−1
| @@ -161,6 +161,9 @@ pub struct App { | ||
/// The oldest client version this appview asks for, and why, as | ||
/// `dev.knotless.describeServer` says. | ||
pub client_minimum: Option<(String, Option<String>)>, | ||
/// The oldest build of the iOS app this appview lets run, and why, as | ||
/// `dev.knotless.describeServer` says. | ||
pub ios_minimum: Option<(u64, Option<String>)>, | ||
/// The output of the checks running now, and the spaces written to, | ||
/// for whoever follows them. | ||
pub live: live::Live, | ||
| @@ -276,6 +279,7 @@ impl App { | ||
spent: Mutex::default(), | ||
default_runner: None, | ||
client_minimum: None, | ||
ios_minimum: None, | ||
live: live::Live::default(), | ||
orgs: Mutex::default(), | ||
}) | ||
| @@ -1292,6 +1296,10 @@ pub struct Options { | ||
/// touches, and why: clients older refuse to run until upgraded. | ||
pub client_minimum: Option<String>, | ||
pub client_minimum_reason: Option<String>, | ||
/// The oldest build of the iOS app to let run, once a newer one is in | ||
/// people's reach, and why: builds older show only a way to update. | ||
pub ios_minimum_build: Option<u64>, | ||
pub ios_minimum_reason: Option<String>, | ||
/// The SvelteKit app that draws the web pages, which this server hands | ||
/// every request it does not answer itself. | ||
pub web: Option<String>, | ||
| @@ -1349,6 +1357,8 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
default_runner: None, | ||
client_minimum: None, | ||
client_minimum_reason: None, | ||
ios_minimum_build: None, | ||
ios_minimum_reason: None, | ||
web: None, | ||
releases: None, | ||
stale: None, | ||
| @@ -1418,6 +1428,18 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
"--client-minimum-reason" => { | ||
opts.client_minimum_reason = Some(value()?).filter(|v| !v.is_empty()) | ||
} | ||
"--ios-minimum-build" => { | ||
let build = value()?; | ||
let number = build.parse::<u64>(); | ||
let number = number.map_err(|_| "--ios-minimum-build takes a build number"); | ||
opts.ios_minimum_build = match build.is_empty() { | ||
true => None, | ||
false => Some(number?), | ||
}; | ||
} | ||
"--ios-minimum-reason" => { | ||
opts.ios_minimum_reason = Some(value()?).filter(|v| !v.is_empty()) | ||
} | ||
// Gone: the pages read private repositories as whoever logs | ||
// in to them. | ||
"--private" => return Err(PRIVATE_GONE.into()), | ||
| @@ -1442,7 +1464,7 @@ pub fn parse_args(args: &[String]) -> Result<Options, String> { | ||
"--code-index" => opts.code_index = Some(value()?.into()), | ||
other => { | ||
return Err(format!( | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--oauth-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--relay <https://relay>] [--labeler <did>]... [--default-runner <did>] [--client-minimum <version> [--client-minimum-reason <text>]] [--web <http://app>] [--releases <dir or knotless://repo>] [--stale <secs>] [--usage <file>] [--logs <file> [--logs-keep <lines>]] [--check-index <file>] [--metrics <addr>] [--bots <dir>] [--sign-up-pds <https://pds>] [--code-index <dir>] [--apns-key <file> --apns-key-id <id> --apns-team <id>] [--pages-domain <domain>]" | ||
"unexpected {other}\nusage: knotless-serve [--listen <addr>] [--ttl <secs>] [--firehose <wss://relay or pds>] [--jetstream <wss://host>] [--public-url <https://host>] [--oauth-url <https://host>] [--index <file>] [--backfill <https://pds or relay>] [--relay <https://relay>] [--labeler <did>]... [--default-runner <did>] [--client-minimum <version> [--client-minimum-reason <text>]] [--ios-minimum-build <build> [--ios-minimum-reason <text>]] [--web <http://app>] [--releases <dir or knotless://repo>] [--stale <secs>] [--usage <file>] [--logs <file> [--logs-keep <lines>]] [--check-index <file>] [--metrics <addr>] [--bots <dir>] [--sign-up-pds <https://pds>] [--code-index <dir>] [--apns-key <file> --apns-key-id <id> --apns-team <id>] [--pages-domain <domain>]" | ||
)); | ||
} | ||
} | ||
| @@ -1530,6 +1552,8 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
let mut app = App::build(ctx, opts.ttl, gateway, index); | ||
let reason = opts.client_minimum_reason.clone(); | ||
let client_minimum = opts.client_minimum.clone().map(|m| (m, reason)); | ||
let reason = opts.ios_minimum_reason.clone(); | ||
let ios_minimum = opts.ios_minimum_build.map(|b| (b, reason)); | ||
if let Some(app) = Arc::get_mut(&mut app) { | ||
app.stale = opts.stale; | ||
app.disk = Some(app.ctx.env.cache_dir.clone()); | ||
| @@ -1537,6 +1561,7 @@ pub async fn run(opts: Options, ctx: Ctx) -> Result<()> { | ||
app.logs = logs.clone(); | ||
app.default_runner = opts.default_runner.clone(); | ||
app.client_minimum = client_minimum; | ||
app.ios_minimum = ios_minimum; | ||
app.code = code; | ||
} | ||
// An index built anew is filled from the relay, unless asked otherwise. | ||
crates/knotless-serve/tests/serve.rs+19−0
| @@ -375,6 +375,10 @@ fn binary_lifecycle() { | ||
"0.1.0", | ||
"--client-minimum-reason", | ||
"Compaction lost objects.", | ||
"--ios-minimum-build", | ||
"7", | ||
"--ios-minimum-reason", | ||
"Older builds lose drafts.", | ||
]) | ||
.stdout(std::process::Stdio::piped()) | ||
.stderr(std::process::Stdio::piped()) | ||
| @@ -406,6 +410,10 @@ fn binary_lifecycle() { | ||
described["client"], | ||
serde_json::json!({"minimum": "0.1.0", "reason": "Compaction lost objects."}) | ||
); | ||
assert_eq!( | ||
described["ios"], | ||
serde_json::json!({"minimumBuild": 7, "reason": "Older builds lose drafts."}) | ||
); | ||
// Each request is counted, by its route, and given its trace. | ||
assert_eq!(get(&format!("{base}/healthz")).2["x-trace-id"].len(), 32); | ||
// Or the trace a traceparent names, as the pages hand theirs on. | ||
| @@ -607,6 +615,17 @@ fn arguments() { | ||
assert_eq!(o.client_minimum, None); | ||
assert_eq!(o.client_minimum_reason.as_deref(), Some("Why")); | ||
assert!(args(&["--client-minimum", "soon"]).is_err()); | ||
let o = args(&["--ios-minimum-build", "5", "--ios-minimum-reason", ""]).unwrap(); | ||
assert_eq!(o.ios_minimum_build, Some(5)); | ||
assert_eq!(o.ios_minimum_reason, None); | ||
let o = args(&["--ios-minimum-build", "", "--ios-minimum-reason", "Why"]).unwrap(); | ||
assert_eq!(o.ios_minimum_build, None); | ||
assert_eq!(o.ios_minimum_reason.as_deref(), Some("Why")); | ||
let refused = args(&["--ios-minimum-build", "soon"]).err(); | ||
assert_eq!( | ||
refused.as_deref(), | ||
Some("--ios-minimum-build takes a build number") | ||
); | ||
let o = args(&["--web", "http://127.0.0.1:2591"]).unwrap(); | ||
assert_eq!(o.web.as_deref(), Some("http://127.0.0.1:2591")); | ||
assert_eq!(o.releases, None); | ||
deploy/deploy.sh+4−0
| @@ -43,6 +43,8 @@ | ||
# names unless its owner names others: the runner host's account; | ||
# KNOTLESS_CLIENT_MINIMUM and KNOTLESS_CLIENT_MINIMUM_REASON, the oldest | ||
# client version to ask for and why, when one harms repositories; | ||
# KNOTLESS_IOS_MINIMUM_BUILD and KNOTLESS_IOS_MINIMUM_REASON, the oldest | ||
# build of the iOS app to let run and why (ios/README.md); | ||
# KNOTLESS_LOG, what serve and the runner log, as RUST_LOG takes it (info). | ||
# | ||
# knotless-run too, if KNOTLESS_RUN_REPOS names repositories to watch | ||
| @@ -253,6 +255,8 @@ fi | ||
[ -z "${KNOTLESS_DEFAULT_RUNNER_DID:-}" ] || echo "KNOTLESS_DEFAULT_RUNNER_DID=$KNOTLESS_DEFAULT_RUNNER_DID" | ||
[ -z "${KNOTLESS_CLIENT_MINIMUM:-}" ] || echo "KNOTLESS_CLIENT_MINIMUM=$KNOTLESS_CLIENT_MINIMUM" | ||
[ -z "${KNOTLESS_CLIENT_MINIMUM_REASON:-}" ] || echo "KNOTLESS_CLIENT_MINIMUM_REASON=$KNOTLESS_CLIENT_MINIMUM_REASON" | ||
[ -z "${KNOTLESS_IOS_MINIMUM_BUILD:-}" ] || echo "KNOTLESS_IOS_MINIMUM_BUILD=$KNOTLESS_IOS_MINIMUM_BUILD" | ||
[ -z "${KNOTLESS_IOS_MINIMUM_REASON:-}" ] || echo "KNOTLESS_IOS_MINIMUM_REASON=$KNOTLESS_IOS_MINIMUM_REASON" | ||
[ -z "${KNOTLESS_ADMIN_DIDS:-}" ] || echo "KNOTLESS_ADMIN_DIDS=$KNOTLESS_ADMIN_DIDS" | ||
[ -z "${KNOTLESS_BOT_DIDS:-}" ] || echo "KNOTLESS_BOT_DIDS=$KNOTLESS_BOT_DIDS" | ||
[ -z "${KNOTLESS_LOG:-}" ] || echo "KNOTLESS_LOG=$KNOTLESS_LOG" | ||
deploy/docker-compose.yml+6−0
| @@ -111,6 +111,12 @@ services: | ||
- ${KNOTLESS_CLIENT_MINIMUM:-} | ||
- --client-minimum-reason | ||
- ${KNOTLESS_CLIENT_MINIMUM_REASON:-} | ||
# The oldest build of the iOS app to let run, and why: older builds | ||
# show only a way to update. Empty, any runs (ios/README.md). | ||
- --ios-minimum-build | ||
- ${KNOTLESS_IOS_MINIMUM_BUILD:-} | ||
- --ios-minimum-reason | ||
- ${KNOTLESS_IOS_MINIMUM_REASON:-} | ||
environment: | ||
# The gateway's OAuth client key and the sessions owners delegate | ||
# are in the config directory; mirrors and identities in the cache. | ||
docs/design/serve.md+6−2
| @@ -385,7 +385,9 @@ asking the PDS nothing; without one it is read from the PDS every time, as a | ||
kept profile could have changed unseen. `dev.knotless.getActorRepos` does the | ||
same with an account's repositories, which the index has already. | ||
`dev.knotless.describeServer` needs none either: it says which runner | ||
(`--default-runner`) a client names in a new public repository. | ||
(`--default-runner`) a client names in a new public repository, and the oldest | ||
client version and iOS build it asks for (`--client-minimum`, | ||
`--ios-minimum-build`). | ||
| ||
Push claims are the reflog, and the PDS may compact them away, so the index | ||
keeps each as a row of its own: the history of the reflog. A new ref has no | ||
| @@ -794,7 +796,9 @@ what was asked rather than what was granted keeps a PDS that drops a scope | ||
it does not know, such as spaces, from asking forever. A write a PDS | ||
refuses for want of scope all the same is answered in a sentence that says | ||
how to grant it, not in the PDS's words: log in again, or, when it was the | ||
gateway's grant that fell short, delegate again. | ||
gateway's grant that fell short, delegate again. The iOS app reads `reauth` | ||
as the pages do, whenever it comes to the front, and shows the same note over | ||
every tab. Delegating stays on the pages, so it doesn't read `redelegate`. | ||
| ||
The problem is keeping a login for the pages from becoming something else. | ||
A session kept as the gateway's would let the gateway push as that | ||
docs/reference/api.md+4−1
| @@ -197,7 +197,10 @@ looked up is looked up first, once, and kept. | ||
No parameters. Answers `runners`, each a `did`: the one | ||
`--default-runner` names, which a client names in a new public | ||
repository's record unless its owner names others. Empty when it names | ||
none. It needs no index. | ||
none. `client`, with `--client-minimum`, is the oldest client version it | ||
asks for, as `minimum` and `reason`. `ios`, with `--ios-minimum-build`, is | ||
the oldest build of the iOS app it lets run, as `minimumBuild` and | ||
`reason`. It needs no index. | ||
| ||
### `dev.knotless.getForge` | ||
| ||
docs/reference/cli.md+2−0
| @@ -544,6 +544,8 @@ for `themes`) are as they were. | ||
| `--default-runner <did>` | | the runner a new public repository names unless its owner names others, as `dev.knotless.describeServer` answers; one only, as each runner a repository names runs all its checks (`--default-runner names one runner`); empty, none; anything else not a DID is refused (`--default-runner takes a DID, not <value>`) | | ||
| `--client-minimum <version>` | | the oldest client version this appview asks for, as `dev.knotless.describeServer` answers: older clients refuse to run until upgraded. For a release found to harm repositories; anything not a version is refused; empty, none | | ||
| `--client-minimum-reason <text>` | | why, which those clients print with their refusal | | ||
| `--ios-minimum-build <build>` | | the oldest build of the iOS app this appview lets run, as `dev.knotless.describeServer` answers: older builds show only a way to update. Anything not a number is refused; empty, none | | ||
| `--ios-minimum-reason <text>` | | why, which those builds show | | ||
| `--web <http://app>` | | the SvelteKit app that draws the pages | | ||
| `--releases <dir>` | | client builds to hand out under `/releases`, with `/install.sh` | | ||
| `--stale <secs>` | 120 | how long a feed may be silent before `/_status` calls it dead; whole seconds | | ||
ios/Knotless/API/Account.swift+59−2
| @@ -60,6 +60,9 @@ struct PKCE { | ||
final class Account { | ||
private(set) var person: Person? | ||
private(set) var checking = true | ||
/// The session asked for less than the app asks for now, so a write it | ||
/// wasn't granted would fail: signing in again grants the rest. | ||
private(set) var reauth = false | ||
var failure: String? | ||
/// Moved by following or unfollowing, so every profile is read again: the | ||
/// one followed, for its followers, and your own, for who you follow. | ||
| @@ -79,14 +82,29 @@ final class Account { | ||
private func loadViewer() async { | ||
do { | ||
let viewer: Viewer = try await Client.shared.query("getViewer") | ||
person = viewer.actor | ||
if person == nil { SessionStore.clear() } | ||
hold(viewer) | ||
} catch { | ||
// Offline isn't signed out: the token stays for next time. | ||
failure = error.localizedDescription | ||
} | ||
} | ||
| ||
private func hold(_ viewer: Viewer) { | ||
person = viewer.actor | ||
reauth = viewer.reauth == true | ||
if person == nil { SessionStore.clear() } | ||
} | ||
| ||
/// Reads the session again as the app comes to the front, for what changed | ||
/// while it was away. Nothing to say if that fails, and nothing to do if the | ||
/// account signed in or out meanwhile, as this answer is of the session before. | ||
func refresh() async { | ||
let asked = SessionStore.token | ||
guard !checking, person != nil else { return } | ||
guard let viewer: Viewer = try? await Client.shared.query("getViewer"), SessionStore.token == asked else { return } | ||
hold(viewer) | ||
} | ||
| ||
/// Logs in through the browser, as `handle`. | ||
func signIn(handle: String) async { | ||
failure = nil | ||
| @@ -157,6 +175,45 @@ private final class BrowserAnchor: NSObject, ASWebAuthenticationPresentationCont | ||
} | ||
} | ||
| ||
/// Says over every tab that the session is behind what the app asks for now, as | ||
/// the pages' note does, with a button that signs in again as the same account. | ||
struct ReauthNote: View { | ||
@Environment(Account.self) private var account | ||
@State private var busy = false | ||
| ||
var body: some View { | ||
VStack(alignment: .leading, spacing: Space.s2_5) { | ||
Text("Knotless can do more now than when you signed in. Sign in again to allow it.") | ||
.font(.subheadline) | ||
.fixedSize(horizontal: false, vertical: true) | ||
if let failure = account.failure { | ||
Text(failure).font(.footnote).foregroundStyle(Color.danger) | ||
} | ||
Button { | ||
guard let did = account.person?.did else { return } | ||
busy = true | ||
Task { | ||
await account.signIn(handle: did) | ||
busy = false | ||
} | ||
} label: { | ||
Text(busy ? "Signing in…" : "Sign in again") | ||
.font(.subheadline.weight(.semibold)) | ||
.foregroundStyle(Color(.systemBackground)) | ||
.padding(.horizontal, Space.s4) | ||
.padding(.vertical, Space.s2) | ||
.background(Color.primary.opacity(busy ? 0.4 : 1), in: Capsule()) | ||
.contentShape(Capsule()) | ||
} | ||
.buttonStyle(.plain) | ||
.disabled(busy) | ||
} | ||
.padding(Space.s4) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.background(Color.accentSoft) | ||
} | ||
} | ||
| ||
/// An account the typeahead service matched. | ||
struct FoundActor: Decodable, Identifiable, Sendable { | ||
let did: String | ||