Skip to main content

max / goingson

Settings > Sharing: the last section that had no data to draw Five sections were held out of this screen under one stated cause, that they are "about the host rather than about the app". Four of the five turned out to need something different every time: a measurement (Email), a screen of its own (Import & Export), two facts held on AppState (About), and a count made per command instead of per file (Sync). Sharing is the one where the reason was right. group_list, group_list_members and group_list_invitations all await, so there was no local state to draw a section from at all: not a slow write and not a loud control, a screen with no data on this side of the network. It was the last consumer on quasicoherent 82273265. synckit 0.9.0 answered it by writing the group directory down, so the reads are local and the section states how old they are rather than pretending they are live. The list is a copy of the server's answer and how old the copy is decides how much to trust it. THE MEMBER SIDE IS COMPLETE HERE, which is the part worth noticing. Being added to a group takes no write from the person being added: they show their public key, an admin seals the group key to it. my_group_pubkey and my_group_fingerprint are async by declaration only, neither body awaits, so both facts a member needs are local crypto rather than a request. The admin writes are absent rather than drawn dead: creating a group, adding a member, removing one, and the invitation flow are each a conversation with a server. Same arrangement as Email's OAuth handshake and Sync's Connect. The section says where they went instead of offering controls that do nothing. Two existing tests pinned Sharing as undescribed and now pin the opposite. The not-found case moved to a section name that never will be described, rather than riding whichever one was built last.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-24 18:40 UTC
Signed with PGP, not checked
Commit: 8c1a5551a3e9467e42d16cc0d7b51174ccfc13de
Parent: 1a6c68f
3 files changed, +346 insertions, -11 deletions
@@ -52,10 +52,26 @@
52 52 //! and auth-start are synchronous. Only the server-talking actions stay out.
53 53 //! See [`sync`].
54 54 //!
55 - //! Sharing is the one still out, and its reason is neither `AppHandle` nor a
56 - //! slow write: `group_list`, `group_list_members` and `group_list_invitations`
57 - //! all await, so there is no local state to draw a section from at all. That is
58 - //! the last consumer on quasicoherent `82273265`.
55 + //! - **Sharing** left on 2026-08-24, and it is the only one of the five whose
56 + //! stated reason was correct. `group_list`, `group_list_members` and
57 + //! `group_list_invitations` all await, so there was no local state to draw a
58 + //! section from at all: not a slow write and not a loud control, a screen with
59 + //! no data on this side of the network. It was the last consumer on
60 + //! quasicoherent `82273265`.
61 + //!
62 + //! What answered it was synckit 0.9.0, and not a new capability. The sync loop
63 + //! had always fetched the group list and thrown the names away; it writes them
64 + //! down now, so the reads are local and the section states how old they are.
65 + //! The member half of the flow is complete here, because being added to a
66 + //! group takes no write from the person being added. The admin half stays
67 + //! host-bound. See [`sharing`].
68 + //!
69 + //! So the list is empty, and the single-cause explanation this header opened
70 + //! with was wrong about four of its five members. What each one actually needed
71 + //! was different every time: a measurement (Email), a screen of its own
72 + //! (Import & Export), two facts held on `AppState` (About), a count made per
73 + //! command instead of per file (Sync), and a table that did not exist
74 + //! (Sharing).
59 75 //!
60 76 //! Import & Export left that sentence entirely. It went half out on 2026-08-16,
61 77 //! as [`data`](super::data), a screen of its own: picking a file to submit is
@@ -115,6 +131,7 @@
115 131
116 132 pub(crate) mod about;
117 133 pub(crate) mod email;
134 + pub(crate) mod sharing;
118 135 pub(crate) mod sync;
119 136
120 137 #[cfg(test)]
@@ -165,7 +182,7 @@
165 182 /// absent rather than disabled, for the reason the task overview left Edit out:
166 183 /// a control that is drawn and does nothing is worse than a control that is not
167 184 /// drawn, and the module header says which and why.
168 - const SECTIONS: [Section; 7] = [
185 + const SECTIONS: [Section; 8] = [
169 186 Section {
170 187 slug: "appearance",
171 188 title: "Appearance",
@@ -212,6 +229,14 @@
212 229 title: "Sync",
213 230 at: None,
214 231 },
232 + // Added 2026-08-24, and the last of the five to leave the list. Its reason
233 + // was real rather than a miscount: there was no local group state at all
234 + // until synckit 0.9.0 wrote the directory down. See `sharing`.
235 + Section {
236 + slug: "sharing",
237 + title: "Sharing",
238 + at: None,
239 + },
215 240 ];
216 241
217 242 /// What the app falls back to when a key has never been written.
@@ -546,6 +571,7 @@
546 571 "email" => email::pane(state)?,
547 572 "about" => about::pane(state),
548 573 "sync" => sync::pane(state),
574 + "sharing" => sharing::pane(state)?,
549 575 _ => appearance(state, &config),
550 576 });
551 577
@@ -84,10 +84,15 @@
84 84 // per command rather than per file: its reads are local.
85 85 assert!(page.contains("Sync"));
86 86
87 - // Sharing is the one still absent, and absent rather than drawn as a
88 - // control that does nothing. Its reads are remote, so there is no local
89 - // state to draw a section from at all; see quasicoherent `82273265`.
90 - assert!(!page.contains("Sharing"), "should not offer: Sharing");
87 + // Sharing joined them on 2026-08-24, and it is the only one of the five
88 + // whose stated reason was right: its reads were remote, so there was no
89 + // local state to draw a section from at all. synckit 0.9.0 writes the group
90 + // directory down, so the reads are local now. quasicoherent `82273265`.
91 + assert!(page.contains("Sharing"));
92 +
93 + // Which leaves none of the eight absent, and the header's single-cause
94 + // explanation wrong about four of its five.
95 + assert_eq!(super::SECTIONS.len(), 8);
91 96 }
92 97
93 98 #[tokio::test]
@@ -120,10 +125,13 @@
120 125
121 126 #[tokio::test]
122 127 async fn a_section_that_is_not_described_is_a_not_found() {
128 + // Every section in `SECTIONS` is described now, so the case wants a name
129 + // that never will be rather than the last one to be built. It held
130 + // `/settings/sharing` until 2026-08-24.
123 131 let state = state().await;
124 132 let error = router()
125 - .handle(&state, Request::get("/settings/sharing"))
126 - .expect_err("sharing is not described");
133 + .handle(&state, Request::get("/settings/nonesuch"))
134 + .expect_err("an unknown section is not a section");
127 135 assert_eq!(error.class.http_status(), 404);
128 136 }
129 137
@@ -355,3 +363,104 @@
355 363 assert!(page.contains("&lt;script&gt;"));
356 364 assert!(!page.contains("<script>alert"));
357 365 }
366 +
367 + /// Put a group in the directory the way the sync loop does. That the loop
368 + /// writes it is synckit's test; what is under test here is a section reading it.
369 + fn known_group(state: &AppState, id: u128, name: &str, is_admin: bool) {
370 + let mut conn = state.db.conn().unwrap();
371 + synckit_client::store::directory::ensure_tables(&conn).unwrap();
372 + synckit_client::store::directory::add_group(
373 + &mut conn,
374 + &synckit_client::store::directory::KnownGroup {
375 + id: synckit_client::GroupId::new(uuid::Uuid::from_u128(id)),
376 + name: name.to_owned(),
377 + gck_version: 1,
378 + is_admin,
379 + },
380 + )
381 + .unwrap();
382 + }
383 +
384 + fn sharing(state: &AppState) -> String {
385 + html(get(state, "/settings/sharing"))
386 + }
387 +
388 + #[tokio::test]
389 + async fn sharing_is_a_section_in_the_sidebar() {
390 + let state = state().await;
391 + let page = html(get(&state, "/settings"));
392 + assert!(page.contains("Sharing"), "{page}");
393 + assert!(page.contains("/settings/sharing"), "{page}");
394 + }
395 +
396 + /// A device that has not synced since the user joined a group knows of none,
397 + /// which is a statement about what has reached it rather than about the user.
398 + #[tokio::test]
399 + async fn a_device_that_has_not_synced_says_what_it_does_not_know() {
400 + let state = state().await;
401 + let pane = sharing(&state);
402 + assert!(pane.contains("knows of no groups"), "{pane}");
403 + }
404 +
405 + #[tokio::test]
406 + async fn the_groups_this_device_knows_are_listed() {
407 + let state = state().await;
408 + known_group(&state, 1, "The Firm", false);
409 + known_group(&state, 2, "Book club", true);
410 +
411 + let pane = sharing(&state);
412 + assert!(pane.contains("The Firm"), "{pane}");
413 + assert!(pane.contains("Book club"), "{pane}");
414 + }
415 +
416 + /// Administering a group and belonging to one are different facts, and only one
417 + /// of them is about permission. The badge comes from `is_admin`, never from the
418 + /// member list being empty.
419 + #[tokio::test]
420 + async fn administering_a_group_is_marked_and_merely_belonging_is_not() {
421 + let theirs = state().await;
422 + known_group(&theirs, 1, "Someone elses", false);
423 + let pane = sharing(&theirs);
424 + assert!(pane.contains("Someone elses"), "{pane}");
425 + assert!(!pane.contains("You administer this"), "{pane}");
426 +
427 + let mine = state().await;
428 + known_group(&mine, 2, "Mine", true);
429 + let pane = sharing(&mine);
430 + assert!(pane.contains("You administer this"), "{pane}");
431 + }
432 +
433 + /// The list is a copy of the server's answer, and how old the copy is decides
434 + /// how much to trust it.
435 + #[tokio::test]
436 + async fn the_section_says_how_old_its_group_list_is() {
437 + let state = state().await;
438 + known_group(&state, 1, "The Firm", false);
439 + let pane = sharing(&state);
440 + assert!(pane.contains("Group list last updated"), "{pane}");
441 + }
442 +
443 + /// Absent rather than drawn dead. A control that does nothing is worse than one
444 + /// that is not there, and the section says where the missing half went.
445 + #[tokio::test]
446 + async fn the_admin_writes_are_absent_and_the_section_says_so() {
447 + let state = state().await;
448 + known_group(&state, 1, "The Firm", true);
449 + let pane = sharing(&state);
450 +
451 + for absent in ["Create group", "Add member", "Remove member"] {
452 + assert!(!pane.contains(absent), "{absent} is drawn: {pane}");
453 + }
454 + assert!(pane.contains("conversation with a server"), "{pane}");
455 + }
456 +
457 + /// Being added to a group takes no write from the person being added, so the
458 + /// whole member side is describable. Without sync configured there is no key,
459 + /// and that is said rather than left blank.
460 + #[tokio::test]
461 + async fn the_identity_key_says_why_it_is_missing_when_sync_is_not_set_up() {
462 + let state = state().await;
463 + let pane = sharing(&state);
464 + assert!(pane.contains("Your identity key"), "{pane}");
465 + assert!(pane.contains("no identity key"), "{pane}");
466 + }
@@ -1,0 +1,200 @@
1 + //! Groups: who you share with, and the key an admin needs to add you.
2 + //!
3 + //! <!-- wiki: quasi-overview -->
4 + //!
5 + //! # The section was the last one held out, and the reason was real
6 + //!
7 + //! [`super`]'s header listed Sharing among the sections left out, and unlike
8 + //! Email and Sync the reason was not a miscount. `group_list`,
9 + //! `group_list_members` and `group_list_invitations` all await, so there was no
10 + //! local state to draw a section from at all. That is a different problem from a
11 + //! slow write or a loud control: a screen with no data on this side of the
12 + //! network. It was the last consumer on quasicoherent `82273265`.
13 + //!
14 + //! # What changed, and it was not a new capability
15 + //!
16 + //! synckit 0.9.0 writes the group directory down. The sync loop had always asked
17 + //! the server which groups to sync, once per cycle, and always dropped the names;
18 + //! `sync_groups` and `sync_group_members` keep the answer, and
19 + //! `synckit_client::store::directory` reads it synchronously. The remote read
20 + //! still happens, on a schedule, outside the request loop, which is where a
21 + //! description can live with it.
22 + //!
23 + //! So the reads here are local, and the section states how old they are rather
24 + //! than pretending they are live.
25 + //!
26 + //! # What is here
27 + //!
28 + //! The groups this device knows you belong to, which of them you administer,
29 + //! who is in those, and your identity public key with its fingerprint.
30 + //!
31 + //! **The whole member side of the flow is here**, which is the part worth
32 + //! noticing. Being added to a group takes no write from the person being added:
33 + //! they show their public key, an admin seals the group key to it. So a member
34 + //! can do everything they need from this section, and both facts they need are
35 + //! local crypto rather than a request. `my_group_pubkey` and
36 + //! `my_group_fingerprint` are `async` by declaration only; neither body awaits.
37 + //!
38 + //! # What is not here, and it is absent rather than drawn dead
39 + //!
40 + //! Creating a group, adding a member, removing one, and the invitation flow.
41 + //! Every one is a conversation with a server, which is the half that stays
42 + //! host-bound, the same arrangement as Email's OAuth handshake and Sync's
43 + //! Connect. A control that is drawn and does nothing is worse than one that is
44 + //! not drawn.
45 + //!
46 + //! That leaves the section honest rather than whole, and the line is worth
47 + //! keeping in view: **a person can see their groups and be added to one from
48 + //! here, and cannot create one or admit anybody.** The admin half is what
49 + //! remains of goingson `7f36900b`.
50 + //!
51 + //! One write does reach a group without being here: `group_create` writes its new
52 + //! group into the directory on the way past, so a group made through the command
53 + //! is nameable immediately rather than at the next cycle.
54 +
55 + use quasi_router::screen::{Field, Row, Tag};
56 + use quasi_router::{Node, RouteError};
57 + use synckit_client::store::directory;
58 +
59 + use crate::state::AppState;
60 +
61 + /// The directory, read through the app's own pool.
62 + ///
63 + /// An empty answer is "this device knows of no groups", which is a true
64 + /// statement about what has reached it rather than a claim that the user belongs
65 + /// to none. The section says so in those words.
66 + fn known(app: &AppState) -> Result<(Vec<directory::KnownGroup>, Option<String>), RouteError> {
67 + let conn = app
68 + .db
69 + .conn()
70 + .map_err(|error| RouteError::internal(error.to_string()))?;
71 + let groups =
72 + directory::groups(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
73 + let refreshed =
74 + directory::refreshed_at(&conn).map_err(|error| RouteError::internal(error.to_string()))?;
75 + Ok((groups, refreshed))
76 + }
77 +
78 + /// One group, with the members of it this device holds.
79 + fn group_rows(app: &AppState, group: &directory::KnownGroup) -> Result<Vec<Row>, RouteError> {
80 + let mut row = Row::new(&group.name);
81 + if group.is_admin {
82 + row = row.token(Tag::badge("You administer this"));
83 + }
84 + let mut rows = vec![row];
85 +
86 + // Only for a group this user administers: the server refuses a non-admin the
87 + // member list, so for any other group there are no rows and that is correct
88 + // rather than missing. Read from `is_admin` rather than from the list being
89 + // empty, because those are different facts and only one is about permission.
90 + if group.is_admin {
91 + let conn = app
92 + .db
93 + .conn()
94 + .map_err(|error| RouteError::internal(error.to_string()))?;
95 + let members = directory::members(&conn, group.id)
96 + .map_err(|error| RouteError::internal(error.to_string()))?;
97 + if members.is_empty() {
98 + rows.push(Row::new("No members recorded yet").secondary(
99 + "The member list is fetched on a sync; this device has not had one back.",
100 + ));
101 + } else {
102 + for member in members {
103 + let mut member_row = Row::new(&member.email);
104 + if member.role == "admin" {
105 + member_row = member_row.token(Tag::badge("Admin"));
106 + }
107 + rows.push(member_row);
108 + }
109 + }
110 + }
111 + Ok(rows)
112 + }
113 +
114 + /// Your identity public key, and the fingerprint to read it out by.
115 + ///
116 + /// Both are local: `my_identity_public_key` derives from the master key and
117 + /// `fingerprint_of_base64` hashes it. The Tauri commands wrapping them are
118 + /// `async` because their siblings are, not because either awaits.
119 + ///
120 + /// The key is a field rather than text so a host that can offer a copy control
121 + /// has something to attach it to, and so the value is selectable everywhere else.
122 + fn identity(app: &AppState) -> Vec<Node> {
123 + let Some(client) = app.read_recovering() else {
124 + return vec![Node::empty(
125 + "Sync is not set up on this device, so there is no identity key yet. \
126 + A key is derived from your encryption master key.",
127 + )];
128 + };
129 +
130 + let Ok(pubkey) = client.my_identity_public_key() else {
131 + // Set up, but with no master key loaded yet.
132 + return vec![Node::empty(
133 + "Encryption is not set up yet, so there is no identity key to show.",
134 + )];
135 + };
136 +
137 + let fingerprint = synckit_client::identity::IdentityPublicKey::fingerprint_of_base64(&pubkey)
138 + .unwrap_or_else(|_| "unavailable".to_owned());
139 +
140 + vec![
141 + Node::text(
142 + "Give this to a group's admin and they can add you. It is a public key: \
143 + it admits you to nothing on its own.",
144 + ),
145 + Node::field(
146 + Field {
147 + value: Some(pubkey),
148 + ..Field::new(
149 + makeover_layout::FieldKind::Text,
150 + "identity_pubkey",
151 + "Your public key",
152 + )
153 + }
154 + .hint("Read the fingerprint below out loud to check it arrived intact."),
155 + ),
156 + Node::field(Field {
157 + value: Some(fingerprint),
158 + ..Field::new(
159 + makeover_layout::FieldKind::Text,
160 + "identity_fingerprint",
161 + "Fingerprint",
162 + )
163 + }),
164 + ]
165 + }
166 +
167 + /// The Sharing pane.
168 + pub(super) fn pane(app: &AppState) -> Result<Vec<Node>, RouteError> {
169 + let (groups, refreshed) = known(app)?;
170 +
171 + let mut nodes = vec![Node::section("Sharing")];
172 +
173 + if groups.is_empty() {
174 + nodes.push(Node::empty(
175 + "This device knows of no groups. A group list arrives with a sync, so \
176 + a device that has not synced since you joined one will not show it \
177 + yet.",
178 + ));
179 + } else {
180 + for group in &groups {
181 + nodes.push(Node::list(group_rows(app, group)?));
182 + }
183 + if let Some(at) = refreshed {
184 + // Staleness stated rather than hidden. The list is a copy of the
185 + // server's answer, and how old the copy is decides how much to
186 + // trust it.
187 + nodes.push(Node::text(format!("Group list last updated {at}.")));
188 + }
189 + }
190 +
191 + nodes.push(Node::section("Your identity key"));
192 + nodes.extend(identity(app));
193 +
194 + nodes.push(Node::text(
195 + "Creating a group, adding a member and removing one are done elsewhere: \
196 + each is a conversation with a server, which this screen does not hold.",
197 + ));
198 +
199 + Ok(nodes)
200 + }