~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app can require an update, and says when to sign in again

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/update-and-reauth
.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
ios/Knotless/API/AppUpdate.swiftnew+76−0
ios/Knotless/API/Models.swift+10−0
ios/Knotless/App.swift+42−14
ios/ModelCheck/main.swift+1−0
ios/look-answers.json+1−0
ios/README.md+21−0
lexicons/dev/knotless/describeServer.json+12−2