From 44e0db45b61be7ebfdaf2dd56c56ba90c45f68a4 Mon Sep 17 00:00:00 2001 From: Gollum Date: Sat, 24 Oct 2026 18:13:12 -0400 Subject: [PATCH 1/6] Let a Tiny server invite, list, and join. A signed-in account can see who is here, invite the next person, change a role, and remove someone. A code joins from the same sheet. --- crates/chatd/src/adapters/tiny.rs | 526 ++++++++++++++++++++++- crates/chatd/src/adapters/tiny/signin.rs | 370 ++++++++++++++-- crates/chatd/src/server.rs | 4 + crates/protocol/src/lib.rs | 30 ++ crates/protocol/src/signin.rs | 28 ++ crates/protocol/src/tiny.rs | 109 +++++ 6 files changed, 1029 insertions(+), 38 deletions(-) create mode 100644 crates/protocol/src/tiny.rs diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs index bc922a0..bc01abb 100644 --- a/crates/chatd/src/adapters/tiny.rs +++ b/crates/chatd/src/adapters/tiny.rs @@ -1,11 +1,16 @@ //! A Tiny server the owner already runs. One account lives in this network's keychain item. //! Sign-in is the sheet's (`signin`), never `tinyclient-login`. This slice has no conversations, -//! calls, or media, and does not load a WebRTC stack. +//! calls, or media, and does not load a WebRTC stack. A signed-in account can list members, +//! invite, change a role, remove a member, and read the audit. pub mod signin; use super::{serve_commands, Command, Context, Signed}; -use protocol::Link; +use crate::session; +use protocol::{ + tiny_can, AccountKey, ErrorCode, Link, Network, Response, TinyAudit, TinyCode, TinyCommunity, + TinyInvite, TinyMember, +}; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::mpsc::Receiver; @@ -16,7 +21,7 @@ use ureq::tls::{TlsConfig, TlsProvider}; const TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone, Serialize, Deserialize)] -struct Saved { +pub(crate) struct Saved { base_url: String, token: String, user_id: String, @@ -38,6 +43,8 @@ enum Fault { /// The server answered, and the answer was a failure of its own. Unavailable, BadAnswer, + /// Words for the sheet, already chosen. Not a password failure. + Words(&'static str), } fn safe_id(id: &str) -> bool { @@ -49,7 +56,7 @@ fn safe_id(id: &str) -> bool { .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) } -fn parse(session: &str) -> Result { +pub(crate) fn parse(session: &str) -> Result { let saved: Saved = serde_json::from_str(session) .map_err(|_| "the Tiny server session is unreadable".to_owned())?; if saved.base_url.is_empty() || saved.token.is_empty() || !safe_id(&saved.user_id) { @@ -131,6 +138,16 @@ fn call( None => request.send_empty(), } } + "PATCH" => { + let mut request = agent.patch(url); + if let Some(bearer) = bearer.as_deref() { + request = request.header("Authorization", bearer); + } + match body { + Some(body) => request.send_json(body), + None => request.send_empty(), + } + } _ => return Err(Fault::Unreachable), }; let mut response = sent.map_err(|_| Fault::Unreachable)?; @@ -158,12 +175,15 @@ fn post_session(base: &str, email: &str, password: &str) -> Result Some(serde_json::json!({ "email": email, "password": password })), )?; match status { - 200 => {} - 401 => return Err(Fault::Unauthorized), - 429 => return Err(Fault::SlowDown), - 500..=599 => return Err(Fault::Unavailable), - _ => return Err(Fault::Unreachable), + 200 => saved_from(base, body), + 401 => Err(Fault::Unauthorized), + 429 => Err(Fault::SlowDown), + 500..=599 => Err(Fault::Unavailable), + _ => Err(Fault::Unreachable), } +} + +fn saved_from(base: &str, body: Value) -> Result { let answer: SessionAnswer = serde_json::from_value(body).map_err(|_| Fault::BadAnswer)?; if answer.token.is_empty() || !safe_id(&answer.user_id) @@ -181,6 +201,34 @@ fn post_session(base: &str, email: &str, password: &str) -> Result }) } +/// Joining with a code. The same session shape as `post_session`. No bearer is sent. +fn post_accept( + base: &str, + token: &str, + email: &str, + password: &str, + display_name: &str, +) -> Result { + let (status, body) = call( + "POST", + &endpoint(base, "/v1/invites/accept"), + None, + Some(serde_json::json!({ + "token": token, + "email": email, + "password": password, + "display_name": display_name, + })), + )?; + match status { + 200 => saved_from(base, body), + 401 => Err(Fault::Unauthorized), + 429 => Err(Fault::SlowDown), + 500..=599 => Err(Fault::Unavailable), + _ => Err(Fault::Words(explain(status, &body, Missing::Code))), + } +} + #[derive(Deserialize)] struct SessionAnswer { token: String, @@ -227,6 +275,248 @@ fn delete_session(saved: &Saved) { ); } +const CHECK: &str = "That didn't work. Check it and try again."; +const UNREADABLE: &str = "The server's answer could not be read."; +const UNREACHABLE: &str = "Couldn't reach that server. Check the address and your connection."; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Missing { + Member, + Code, + Plain, +} + +fn error(code: ErrorCode, detail: impl ToString) -> Response { + Response::Error { + code, + detail: detail.to_string(), + } +} + +fn refused(detail: &str) -> Response { + error(ErrorCode::Refused, detail) +} + +fn explain(status: u16, body: &Value, missing: Missing) -> &'static str { + let code = body.get("error").and_then(Value::as_str).unwrap_or(""); + match (status, code) { + (401, _) => "Tiny server signed you out.", + (403, _) => "You can't do that.", + (409, "last_owner") => "This server needs an owner. Make someone else the owner first.", + (409, "already_member") => "That person is already on this server.", + (409, "email_taken") => "That email is already on this server.", + (404, _) if missing == Missing::Member => "That person is no longer on this server.", + (404, _) if missing == Missing::Code => { + "That code doesn't work. It may have expired or already been used." + } + (400, _) => CHECK, + (429, _) => "Too many tries. Wait a minute, then try again.", + (500..=599, _) => "The server had a problem. Try again in a moment.", + _ => UNREACHABLE, + } +} + +/// The signed-in session for `account`, or a refusal. Nothing is asked of the server here. +fn open(account: &AccountKey) -> Result { + if account.network != Network::Tiny { + return Err(refused("You can't do that.")); + } + let session = match session::read(Network::Tiny) { + session::Stored::Found(session) => session, + session::Stored::Absent => return Err(refused("You can't do that.")), + session::Stored::Unreadable(why) => { + return Err(error( + ErrorCode::Internal, + format!("The session could not be read from {}: {why}", session::STORE), + )); + } + }; + let saved = match parse(&session) { + Ok(saved) => saved, + Err(_) => return Err(refused("You can't do that.")), + }; + if saved.user_id != account.account { + return Err(refused("You can't do that.")); + } + Ok(saved) +} + +fn asked(method: &str, saved: &Saved, path: &str, body: Option) -> Result<(u16, Value), &'static str> { + match call(method, &endpoint(&saved.base_url, path), Some(&saved.token), body) { + Ok(answer) => Ok(answer), + Err(_) => Err(UNREACHABLE), + } +} + +enum Listed { + Rows(T), + /// A 403: the caller may not see this list. The load still succeeds. + Empty, + Failed(&'static str), +} + +fn listed(saved: &Saved, path: &str, parse: impl FnOnce(Value) -> Result) -> Listed { + match asked("GET", saved, path, None) { + Err(detail) => Listed::Failed(detail), + Ok((403, _)) => Listed::Empty, + Ok((200, body)) => match parse(body) { + Ok(rows) => Listed::Rows(rows), + Err(()) => Listed::Failed(UNREADABLE), + }, + Ok((status, body)) => Listed::Failed(explain(status, &body, Missing::Plain)), + } +} + +fn members_of(saved: &Saved) -> Result, &'static str> { + match asked("GET", saved, "/v1/members", None) { + Err(detail) => Err(detail), + Ok((200, body)) => serde_json::from_value(body).map_err(|_| UNREADABLE), + Ok((status, body)) => Err(explain(status, &body, Missing::Plain)), + } +} + +fn invites_of(saved: &Saved) -> Listed> { + listed(saved, "/v1/invites", |body| serde_json::from_value(body).map_err(|_| ())) +} + +#[derive(Deserialize)] +struct AuditWire { + id: i64, + #[serde(default)] + actor_id: Option, + action: String, + subject: String, + #[serde(default)] + detail: Option, + created_at: String, +} + +fn detail_text(detail: Option) -> String { + match detail { + None | Some(Value::Null) => String::new(), + Some(value) => serde_json::to_string(&value).unwrap_or_default(), + } +} + +fn audit_of(saved: &Saved) -> Listed> { + listed(saved, "/v1/audit?limit=50", |body| { + let rows: Vec = serde_json::from_value(body).map_err(|_| ())?; + Ok(rows + .into_iter() + .map(|row| TinyAudit { + id: row.id, + actor_id: row.actor_id, + action: row.action, + subject: row.subject, + detail: detail_text(row.detail), + created_at: row.created_at, + }) + .collect()) + }) +} + +pub(crate) fn community(account: AccountKey) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + let members = match members_of(&saved) { + Ok(members) => members, + Err(detail) => return refused(detail), + }; + // A 403 on invites or the audit is an empty list. Any other failure fails the load. + // The role is the one that was saved, not a role from the member list. + let invites = if tiny_can(&saved.role, "community.invites.read") { + match invites_of(&saved) { + Listed::Rows(rows) => rows, + Listed::Empty => Vec::new(), + Listed::Failed(detail) => return refused(detail), + } + } else { + Vec::new() + }; + let audit = if tiny_can(&saved.role, "community.audit.read") { + match audit_of(&saved) { + Listed::Rows(rows) => rows, + Listed::Empty => Vec::new(), + Listed::Failed(detail) => return refused(detail), + } + } else { + Vec::new() + }; + Response::TinyCommunity { + community: TinyCommunity { + role: saved.role, + members, + invites, + audit, + }, + } +} + +pub(crate) fn invite(account: AccountKey, role: String) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + if role != "admin" && role != "member" { + return refused(CHECK); + } + match asked( + "POST", + &saved, + "/v1/invites", + Some(serde_json::json!({ "role": role })), + ) { + Err(detail) => refused(detail), + Ok((201, body)) => match serde_json::from_value::(body) { + Ok(code) => Response::TinyCode { code }, + Err(_) => refused(UNREADABLE), + }, + Ok((status, body)) => refused(explain(status, &body, Missing::Plain)), + } +} + +pub(crate) fn set_role(account: AccountKey, user_id: String, role: String) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + if !safe_id(&user_id) || !matches!(role.as_str(), "owner" | "admin" | "member") { + return refused(CHECK); + } + let path = format!("/v1/members/{user_id}"); + match asked( + "PATCH", + &saved, + &path, + Some(serde_json::json!({ "role": role })), + ) { + Err(detail) => refused(detail), + Ok((200, body)) => match body.get("role").and_then(Value::as_str) { + Some(role) if matches!(role, "owner" | "admin" | "member") => Response::Ok, + _ => refused(UNREADABLE), + }, + Ok((status, body)) => refused(explain(status, &body, Missing::Member)), + } +} + +pub(crate) fn remove(account: AccountKey, user_id: String) -> Response { + let saved = match open(&account) { + Ok(saved) => saved, + Err(error) => return error, + }; + if !safe_id(&user_id) { + return refused(CHECK); + } + let path = format!("/v1/members/{user_id}"); + match asked("DELETE", &saved, &path, None) { + Err(detail) => refused(detail), + Ok((204, _)) => Response::Ok, + Ok((status, body)) => refused(explain(status, &body, Missing::Member)), + } +} + pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<(), String> { let saved = parse(session)?; if saved.user_id != ctx.account.account { @@ -272,7 +562,7 @@ pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<() } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; fn session() -> &'static str { @@ -403,7 +693,193 @@ mod tests { assert!(matches!(get_me(&saved), Err(Fault::Unauthorized))); } - fn http(status: u16, reason: &str, body: &str) -> Vec { + fn with_session(base: &str, role: &str, run: impl FnOnce(AccountKey)) { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _lock = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let name = format!("TINYCLIENT_SESSION_{}", Network::Tiny.label().to_uppercase()); + let user_id = "11111111-1111-1111-1111-111111111111"; + let session = format!( + r#"{{"base_url":{base:?},"token":"t","user_id":{user_id:?},"email":"ada@example.com","display_name":"Ada","role":{role:?}}}"# + ); + std::env::set_var("TINYCLIENT_DIR", std::env::temp_dir()); + std::env::set_var(&name, &session); + match crate::session::read(Network::Tiny) { + crate::session::Stored::Found(_) => {} + crate::session::Stored::Absent => panic!("tiny session was absent ({name})"), + crate::session::Stored::Unreadable(why) => panic!("tiny session was unreadable: {why}"), + } + run(AccountKey { + network: Network::Tiny, + account: user_id.to_owned(), + }); + } + + #[test] + fn a_member_loads_members_and_does_not_ask_for_invites_or_the_audit() { + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = log.clone(); + let base = serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + let first = request.lines().next().unwrap_or(""); + if first.starts_with("GET /v1/members ") { + return http( + 200, + "OK", + r#"[{"user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"owner","created_at":"2020-01-01T00:00:00Z"}]"#, + ); + } + http(500, "Internal Server Error", r#"{"error":"no"}"#) + }); + with_session(&base, "member", |account| match community(account) { + Response::TinyCommunity { community } => { + assert_eq!(community.role, "member"); + assert_eq!(community.members.len(), 1); + assert_eq!(community.members[0].display_name, "Ada"); + assert_eq!(community.members[0].email, "ada@example.com"); + assert_eq!(community.members[0].role, "owner"); + assert!(community.invites.is_empty()); + assert!(community.audit.is_empty()); + } + other => panic!("load: {other:?}"), + }); + let lines = log.lock().unwrap(); + assert!(lines.iter().any(|line| line.contains("GET /v1/members "))); + assert!(lines + .iter() + .all(|line| !line.contains("/v1/invites") && !line.contains("/v1/audit"))); + } + + #[test] + fn a_forbidden_invite_list_still_returns_the_members() { + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = log.clone(); + let base = serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + let first = request.lines().next().unwrap_or(""); + if first.starts_with("GET /v1/members ") { + return http( + 200, + "OK", + r#"[{"user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"member","created_at":"2020-01-01T00:00:00Z"}]"#, + ); + } + if first.starts_with("GET /v1/invites ") { + return http(403, "Forbidden", r#"{"error":"forbidden"}"#); + } + if first.starts_with("GET /v1/audit") { + return http( + 200, + "OK", + r#"[{"id":7,"actor_id":"11111111-1111-1111-1111-111111111111","action":"member.role","subject":"22222222-2222-2222-2222-222222222222","detail":{"to":"admin"},"created_at":"2020-01-02T00:00:00Z"},{"id":8,"actor_id":null,"action":"member.remove","subject":"x","detail":null,"created_at":"2020-01-03T00:00:00Z"}]"#, + ); + } + http(500, "Internal Server Error", r#"{"error":"no"}"#) + }); + with_session(&base, "admin", |account| match community(account) { + Response::TinyCommunity { community } => { + assert_eq!(community.role, "admin"); + assert_eq!(community.members.len(), 1); + assert_eq!(community.members[0].role, "member"); + assert!(community.invites.is_empty()); + assert_eq!(community.audit.len(), 2); + assert_eq!(community.audit[0].id, 7); + assert_eq!(community.audit[0].detail, r#"{"to":"admin"}"#); + assert_eq!(community.audit[1].detail, ""); + assert_eq!(community.audit[1].actor_id, None); + } + other => panic!("load: {other:?}"), + }); + let lines = log.lock().unwrap(); + assert!(lines.iter().any(|line| line.contains("GET /v1/members "))); + assert!(lines.iter().any(|line| line.contains("GET /v1/invites "))); + assert!(lines.iter().any(|line| line.contains("GET /v1/audit?limit=50"))); + } + + #[test] + fn an_invite_a_role_and_a_removal_use_the_member_routes() { + let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = log.clone(); + let base = serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + let first = request.lines().next().unwrap_or(""); + if first.starts_with("POST /v1/invites ") { + return http( + 201, + "Created", + r#"{"id":"inv1","token":"invite-token","role":"member","expires_at":"2026-01-01T00:00:00Z"}"#, + ); + } + if first.starts_with("PATCH /v1/members/last-owner ") { + return http(409, "Conflict", r#"{"error":"last_owner"}"#); + } + if first.starts_with("PATCH /v1/members/") { + return http(200, "OK", r#"{"role":"admin"}"#); + } + if first.starts_with("DELETE /v1/members/") { + return http(204, "No Content", ""); + } + http(404, "Not Found", r#"{"error":"missing"}"#) + }); + let refused = |detail: &str| Response::Error { + code: ErrorCode::Refused, + detail: detail.to_owned(), + }; + with_session(&base, "owner", |account| { + let quiet = || log.lock().unwrap().len(); + let before = quiet(); + assert_eq!( + set_role(account.clone(), "not a user".into(), "admin".into()), + refused("That didn't work. Check it and try again.") + ); + assert_eq!(quiet(), before); + assert_eq!( + invite(account.clone(), "owner".into()), + refused("That didn't work. Check it and try again.") + ); + assert_eq!(quiet(), before); + match invite(account.clone(), "member".into()) { + Response::TinyCode { code } => { + assert_eq!(code.token, "invite-token"); + assert_eq!(code.id, "inv1"); + assert_eq!(code.role, "member"); + assert_eq!(code.expires_at, "2026-01-01T00:00:00Z"); + } + other => panic!("invite: {other:?}"), + } + let posted = log.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(posted.lines().next().unwrap_or("").starts_with("POST /v1/invites ")); + assert_eq!(json_body(&posted)["role"], "member"); + let id = "22222222-2222-2222-2222-222222222222"; + assert_eq!(set_role(account.clone(), id.into(), "admin".into()), Response::Ok); + let patched = log.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(patched + .lines() + .next() + .unwrap_or("") + .starts_with(&format!("PATCH /v1/members/{id} "))); + assert_eq!(json_body(&patched)["role"], "admin"); + assert_eq!( + set_role(account.clone(), "last-owner".into(), "member".into()), + refused("This server needs an owner. Make someone else the owner first.") + ); + assert_eq!(remove(account.clone(), id.into()), Response::Ok); + let deleted = log.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(deleted.contains(&format!("DELETE /v1/members/{id} ")), "{deleted}"); + let before = quiet(); + assert_eq!( + remove(account, "a/b".into()), + refused("That didn't work. Check it and try again.") + ); + assert_eq!(quiet(), before); + }); + } + + fn json_body(request: &str) -> serde_json::Value { + let body = request.split("\r\n\r\n").nth(1).unwrap_or(""); + serde_json::from_str(body).unwrap_or_else(|err| panic!("body {err}")) + } + + pub(crate) fn http(status: u16, reason: &str, body: &str) -> Vec { format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() @@ -411,7 +887,7 @@ mod tests { .into_bytes() } - fn serve(handler: impl Fn(&str) -> Vec + Send + 'static) -> String { + pub(crate) fn serve(handler: impl Fn(&str) -> Vec + Send + 'static) -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); std::thread::spawn(move || { @@ -424,11 +900,33 @@ mod tests { let _ = stream.set_read_timeout(Some(Duration::from_millis(300))); let mut got = Vec::new(); let mut buf = [0u8; 2048]; - while got.windows(4).all(|window| window != b"\r\n\r\n") { + let header_end = loop { + if let Some(at) = got.windows(4).position(|window| window == b"\r\n\r\n") { + break Some(at + 4); + } match std::io::Read::read(&mut stream, &mut buf) { - Ok(0) | Err(_) => break, + Ok(0) | Err(_) => break None, Ok(read) => got.extend_from_slice(&buf[..read]), } + }; + if let Some(header_end) = header_end { + let head = String::from_utf8_lossy(&got[..header_end]); + let length = head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }); + if let Some(length) = length { + while got.len() < header_end + length { + match std::io::Read::read(&mut stream, &mut buf) { + Ok(0) | Err(_) => break, + Ok(read) => got.extend_from_slice(&buf[..read]), + } + } + } } let request = String::from_utf8_lossy(&got).into_owned(); let _ = std::io::Write::write_all(&mut stream, &handler(&request)); diff --git a/crates/chatd/src/adapters/tiny/signin.rs b/crates/chatd/src/adapters/tiny/signin.rs index 6f21f20..1621dda 100644 --- a/crates/chatd/src/adapters/tiny/signin.rs +++ b/crates/chatd/src/adapters/tiny/signin.rs @@ -2,7 +2,7 @@ //! password. Nothing is kept until `POST /v1/session` answers 200. The password and the bearer //! are not logged. One sign-in at a time, in memory; closing the sheet drops it. -use super::{post_session, Fault}; +use super::{post_accept, post_session, Fault}; use crate::hub::Hub; use protocol::{AccountKey, Network, SignInInput, SignInProblem, SignInStep}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -13,6 +13,9 @@ pub type Adopt = Box Result, String> + Send + Sy struct Flow { server: String, email: String, + code: String, + display_name: String, + joining: bool, step: SignInStep, adopt: Adopt, } @@ -131,6 +134,9 @@ pub fn step( *held = Some(Flow { server: String::new(), email: String::new(), + code: String::new(), + display_name: String::new(), + joining: false, step: SignInStep::Server { problem: None }, adopt, }); @@ -159,10 +165,12 @@ pub fn cancel() { impl Flow { fn answer(&mut self, input: SignInInput) -> SignInStep { + let joining = self.joining; let next = match (&self.step, input) { (SignInStep::Server { .. }, SignInInput::Server { url }) => match base_url(&url) { Some(server) => { self.server = server.clone(); + self.joining = false; SignInStep::Email { server, problem: None, @@ -172,6 +180,58 @@ impl Flow { problem: Some(SignInProblem::UrlInvalid), }, }, + (SignInStep::Server { .. }, SignInInput::HaveCode { url }) => match base_url(&url) { + Some(server) => { + self.server = server.clone(); + self.joining = true; + SignInStep::Invite { + server, + problem: None, + } + } + None => SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid), + }, + }, + (SignInStep::Invite { .. }, SignInInput::Invite { code }) => { + let code = code.trim(); + if code.is_empty() { + SignInStep::Invite { + server: self.server.clone(), + problem: Some(SignInProblem::Other { + reason: "Paste the code you were given.".to_owned(), + }), + } + } else { + self.code = code.to_owned(); + SignInStep::Name { + server: self.server.clone(), + problem: None, + } + } + } + (SignInStep::Invite { .. }, SignInInput::Back) => SignInStep::Server { problem: None }, + (SignInStep::Name { .. }, SignInInput::Name { name }) => { + let name = name.trim(); + if name.is_empty() || name.chars().count() > 80 { + SignInStep::Name { + server: self.server.clone(), + problem: Some(SignInProblem::Other { + reason: "Type the name people should see.".to_owned(), + }), + } + } else { + self.display_name = name.to_owned(); + SignInStep::Email { + server: self.server.clone(), + problem: None, + } + } + } + (SignInStep::Name { .. }, SignInInput::Back) => SignInStep::Invite { + server: self.server.clone(), + problem: None, + }, (SignInStep::Email { .. }, SignInInput::Email { email }) => { if email_ok(&email) { self.email = email.trim().to_owned(); @@ -186,13 +246,21 @@ impl Flow { } } } + (SignInStep::Email { .. }, SignInInput::Back) if joining => SignInStep::Name { + server: self.server.clone(), + problem: None, + }, (SignInStep::Email { .. }, SignInInput::Back) => SignInStep::Server { problem: None }, (SignInStep::Password { .. }, SignInInput::Back) => SignInStep::Email { server: self.server.clone(), problem: None, }, (SignInStep::Password { .. }, SignInInput::Password { password }) => { - self.submit(&password) + if joining { + self.accept(&password) + } else { + self.submit(&password) + } } _ => self.step.clone(), }; @@ -206,28 +274,7 @@ impl Flow { problem: Some(problem), }; match post_session(&self.server, &self.email, password) { - Ok(saved) => { - let account = saved.user_id.clone(); - let session = match serde_json::to_string(&saved) { - Ok(session) => session, - Err(_) => { - return SignInStep::Failed { - reason: "The server's answer could not be kept.".to_owned(), - } - } - }; - match (self.adopt)(&session) { - Ok(_) => SignInStep::Done { - account: Some(AccountKey { - network: Network::Tiny, - account, - }), - }, - Err(_) => SignInStep::Failed { - reason: "The sign-in could not be kept.".to_owned(), - }, - } - } + Ok(saved) => self.kept(saved), Err(fault) => match password_outcome(fault) { Ok(problem) => again(problem), Err(reason) => SignInStep::Failed { @@ -236,6 +283,57 @@ impl Flow { }, } } + + fn accept(&self, password: &str) -> SignInStep { + let again = |problem| SignInStep::Password { + hint: String::new(), + problem: Some(problem), + }; + let length = password.chars().count(); + if !(12..=128).contains(&length) { + return again(SignInProblem::Other { + reason: "Use 12 to 128 characters.".to_owned(), + }); + } + match post_accept( + &self.server, + &self.code, + &self.email, + password, + &self.display_name, + ) { + Ok(saved) => self.kept(saved), + Err(fault) => match password_outcome(fault) { + Ok(problem) => again(problem), + Err(reason) => SignInStep::Failed { + reason: reason.to_owned(), + }, + }, + } + } + + fn kept(&self, saved: super::Saved) -> SignInStep { + let account = saved.user_id.clone(); + let session = match serde_json::to_string(&saved) { + Ok(session) => session, + Err(_) => { + return SignInStep::Failed { + reason: "The server's answer could not be kept.".to_owned(), + } + } + }; + match (self.adopt)(&session) { + Ok(_) => SignInStep::Done { + account: Some(AccountKey { + network: Network::Tiny, + account, + }), + }, + Err(_) => SignInStep::Failed { + reason: "The sign-in could not be kept.".to_owned(), + }, + } + } } /// A failed password step stays on that step. `Err` ends the sheet: the answer could not be read. @@ -250,6 +348,9 @@ fn password_outcome(fault: Fault) -> Result { }), Fault::Unreachable => Ok(SignInProblem::Unreachable), Fault::BadAnswer => Err("The server's answer could not be read."), + Fault::Words(reason) => Ok(SignInProblem::Other { + reason: reason.to_owned(), + }), } } @@ -274,8 +375,14 @@ mod tests { Box::new(|_session| Ok(Vec::new())) } + fn one_sheet() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + #[test] fn a_bad_address_stays_on_the_server_step_and_a_bad_email_stays_on_email() { + let _one = one_sheet(); cancel(); let hub = hub(); assert_eq!( @@ -459,5 +566,220 @@ mod tests { } ) .contains("hunter2")); + assert!(!format!( + "{:?}", + SignInInput::Invite { + code: "secret-code".into() + } + ) + .contains("secret")); + assert!(!format!( + "{:?}", + SignInInput::Name { + name: "Ada Lovelace".into() + } + ) + .contains("Ada")); + } + + #[test] + fn a_code_joins_from_the_same_sheet() { + let _one = one_sheet(); + cancel(); + let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let seen = hits.clone(); + let base = super::super::tests::serve(move |request| { + seen.lock().unwrap().push(request.to_owned()); + if request.lines().next().unwrap_or("").starts_with("POST /v1/invites/accept ") { + return super::super::tests::http( + 200, + "OK", + r#"{"token":"tok_join","user_id":"22222222-2222-2222-2222-222222222222","email":"ada@example.com","display_name":"Ada","role":"member"}"#, + ); + } + super::super::tests::http(500, "Internal Server Error", r#"{"error":"no"}"#) + }); + let kept = std::sync::Arc::new(std::sync::Mutex::new(None)); + let kept_step = kept.clone(); + let hub = hub(); + assert_eq!( + step( + &hub, + None, + Box::new(move |session| { + *kept_step.lock().unwrap() = Some(session.to_owned()); + Ok(Vec::new()) + }), + ) + .unwrap(), + SignInStep::Server { problem: None } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::HaveCode { + url: "not a url".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::HaveCode { url: base.clone() }), + adopt() + ) + .unwrap(), + SignInStep::Invite { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Invite { + code: " ".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Invite { + server: base.clone(), + problem: Some(SignInProblem::Other { + reason: "Paste the code you were given.".into() + }) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Invite { + code: " join-code ".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Name { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Name { + name: "n".repeat(81) + }), + adopt() + ) + .unwrap(), + SignInStep::Name { + server: base.clone(), + problem: Some(SignInProblem::Other { + reason: "Type the name people should see.".into() + }) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Name { + name: " Ada ".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Email { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step(&hub, Some(SignInInput::Back), adopt()).unwrap(), + SignInStep::Name { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Name { name: "Ada".into() }), + adopt() + ) + .unwrap(), + SignInStep::Email { + server: base.clone(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Email { + email: "ada@example.com".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Password { + hint: String::new(), + problem: None + } + ); + assert_eq!(hits.lock().unwrap().len(), 0); + assert_eq!( + step( + &hub, + Some(SignInInput::Password { + password: "short".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Password { + hint: String::new(), + problem: Some(SignInProblem::Other { + reason: "Use 12 to 128 characters.".into() + }) + } + ); + assert_eq!(hits.lock().unwrap().len(), 0); + assert_eq!( + step( + &hub, + Some(SignInInput::Password { + password: "correct-horse".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Done { + account: Some(AccountKey { + network: Network::Tiny, + account: "22222222-2222-2222-2222-222222222222".into(), + }) + } + ); + let sent = hits.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert!(sent[0].lines().next().unwrap_or("").starts_with("POST /v1/invites/accept ")); + let body: serde_json::Value = serde_json::from_str(sent[0].split("\r\n\r\n").nth(1).unwrap_or("")).unwrap(); + assert_eq!(body["token"], "join-code"); + assert_eq!(body["email"], "ada@example.com"); + assert_eq!(body["display_name"], "Ada"); + assert_eq!(body["password"], "correct-horse"); + let session = kept.lock().unwrap().clone().expect("adopted"); + let saved: serde_json::Value = serde_json::from_str(&session).unwrap(); + assert_eq!(saved["user_id"], "22222222-2222-2222-2222-222222222222"); + assert_eq!(saved["role"], "member"); + assert_eq!(saved["base_url"], base); + assert_eq!(saved["display_name"], "Ada"); + cancel(); } } diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index 9da16c8..deeb4b2 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -1723,6 +1723,10 @@ impl Chat { Response::Ok } Request::SignOut { account } => self.sign_out(account), + Request::TinyCommunity { account } => adapters::tiny::community(account), + Request::TinyInvite { account, role } => adapters::tiny::invite(account, role), + Request::TinyRole { account, user_id, role } => adapters::tiny::set_role(account, user_id, role), + Request::TinyRemove { account, user_id } => adapters::tiny::remove(account, user_id), Request::CallStart { conversation } => self.call_start(conversation), Request::CallAccept => match self.hub.calls.accept(&self.hub) { diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 0d47c8a..68654d0 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -21,6 +21,7 @@ mod play; mod react; pub mod settings; mod signin; +mod tiny; pub use call::*; pub use crash::redact; @@ -30,6 +31,7 @@ pub use message::*; pub use play::*; pub use react::*; pub use signin::*; +pub use tiny::*; pub const CHAT_SOCK_NAME: &str = "chat.sock"; @@ -1347,6 +1349,26 @@ pub enum Request { }, /// Plays the test sound on the call speaker (V2): in the call's mediad, or one started for it. TestSound, + /// Who is on this Tiny server. An owner or an admin also gets the invites and the audit. + TinyCommunity { + account: AccountKey, + }, + /// Creates an invite for `role` (`admin` or `member`). The token is in the answer, once. + TinyInvite { + account: AccountKey, + role: String, + }, + /// Changes one member's role (`owner`, `admin`, or `member`). + TinyRole { + account: AccountKey, + user_id: String, + role: String, + }, + /// Removes one member. + TinyRemove { + account: AccountKey, + user_id: String, + }, } /// One thing that changed. The shell asks again for exactly that; the ephemeral kinds carry their @@ -1614,6 +1636,14 @@ pub enum Response { messages: u64, bytes: u64, }, + /// Who is on this Tiny server, plus the invites and the audit when this account may see them. + TinyCommunity { + community: TinyCommunity, + }, + /// An invite that was just created. `code.token` is not kept. + TinyCode { + code: TinyCode, + }, } /// `TINYCLIENT_DIR` moves the socket and database, for tests and the demo seed. diff --git a/crates/protocol/src/signin.rs b/crates/protocol/src/signin.rs index 40c4201..c816f54 100644 --- a/crates/protocol/src/signin.rs +++ b/crates/protocol/src/signin.rs @@ -97,6 +97,18 @@ pub enum SignInStep { #[serde(default, skip_serializing_if = "Option::is_none")] problem: Option, }, + /// Ask for an invite code for `server`. + Invite { + server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + problem: Option, + }, + /// Ask for the name people should see. + Name { + server: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + problem: Option, + }, /// Signed in. The account is in `Status` from now on. Done { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -121,6 +133,12 @@ pub enum SignInInput { Server { url: String }, /// The email on that server, as typed. Email { email: String }, + /// A Tiny server's address, to join it with a code. + HaveCode { url: String }, + /// The invite code, as pasted. + Invite { code: String }, + /// The name people should see. + Name { name: String }, /// Send the code again, the way `SignInStep::Code::next` says. Resend, /// One step back: from the code to the number, from the number to the QR code. @@ -138,6 +156,9 @@ impl fmt::Debug for SignInInput { SignInInput::Password { .. } => "Password { .. }", SignInInput::Server { .. } => "Server { .. }", SignInInput::Email { .. } => "Email { .. }", + SignInInput::HaveCode { .. } => "HaveCode { .. }", + SignInInput::Invite { .. } => "Invite { .. }", + SignInInput::Name { .. } => "Name { .. }", SignInInput::Resend => "Resend", SignInInput::Back => "Back", }) @@ -160,6 +181,13 @@ mod tests { assert!(!format!("{url:?}").contains("127.0.0.1")); assert!(!format!("{email:?}").contains("ada@")); assert!(!format!("{email:?}").contains("example.com")); + let invite = SignInInput::Invite { code: "secret-code".into() }; + let name = SignInInput::Name { name: "Ada Lovelace".into() }; + assert_eq!(format!("{invite:?}"), "Invite { .. }"); + assert_eq!(format!("{name:?}"), "Name { .. }"); + assert!(!format!("{invite:?}").contains("secret-code")); + assert!(!format!("{name:?}").contains("Ada")); + assert!(!format!("{name:?}").contains("Lovelace")); // A step with nothing but its name still reads: every other field is optional. assert_eq!(serde_json::from_str::(r#"{"step":"server"}"#).unwrap(), SignInStep::Server { problem: None }); let email_step = SignInStep::Email { server: "http://127.0.0.1:8080".into(), problem: None }; diff --git a/crates/protocol/src/tiny.rs b/crates/protocol/src/tiny.rs new file mode 100644 index 0000000..e734377 --- /dev/null +++ b/crates/protocol/src/tiny.rs @@ -0,0 +1,109 @@ +//! A Tiny server's members, invites, and audit. No rooms and no messages. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyMember { + pub user_id: String, + pub email: String, + pub display_name: String, + pub role: String, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyInvite { + pub id: String, + pub role: String, + pub created_by: String, + pub expires_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accepted_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub accepted_by: Option, +} + +/// The token is only in the answer to creating an invite. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyCode { + pub id: String, + pub token: String, + pub role: String, + pub expires_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyAudit { + pub id: i64, + pub actor_id: Option, + pub action: String, + pub subject: String, + /// The server's detail object, as JSON text. Empty when the server sent none. + pub detail: String, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TinyCommunity { + pub role: String, + pub members: Vec, + pub invites: Vec, + pub audit: Vec, +} + +/// Whether `role` may do `id`. An unknown id is refused. +pub fn tiny_can(role: &str, id: &str) -> bool { + match id { + "community.members.read" => matches!(role, "owner" | "admin" | "member"), + "community.invites.read" + | "community.invites.create" + | "community.members.role" + | "community.members.remove" + | "community.audit.read" => matches!(role, "owner" | "admin"), + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tiny_can_matrix() { + assert!(tiny_can("member", "community.members.read")); + assert!(tiny_can("admin", "community.members.read")); + assert!(tiny_can("owner", "community.members.read")); + assert!(!tiny_can("guest", "community.members.read")); + for id in [ + "community.invites.read", + "community.invites.create", + "community.members.role", + "community.members.remove", + "community.audit.read", + ] { + assert!(!tiny_can("member", id), "{id}"); + assert!(tiny_can("admin", id), "{id}"); + assert!(tiny_can("owner", id), "{id}"); + assert!(!tiny_can("someone", id), "{id}"); + } + assert!(!tiny_can("owner", "community.nope")); + assert!(!tiny_can("admin", "")); + assert!(!tiny_can("member", "community.invites.create")); + } + + #[test] + fn an_invite_omits_an_acceptance_it_does_not_have() { + let invite = TinyInvite { + id: "1".into(), + role: "member".into(), + created_by: "a".into(), + expires_at: "t".into(), + accepted_at: None, + accepted_by: None, + }; + let json = serde_json::to_string(&invite).unwrap(); + assert!(!json.contains("accepted")); + let back: TinyInvite = serde_json::from_str(r#"{"id":"1","role":"member","created_by":"a","expires_at":"t"}"#).unwrap(); + assert_eq!(back, invite); + } +} From fd101905ec587e595fcd92aebd7506e8d2d36051 Mon Sep 17 00:00:00 2001 From: Gollum Date: Sat, 24 Oct 2026 18:20:54 -0400 Subject: [PATCH 2/6] Show who is on a Tiny server and invite the next person. The window lists the server when Tiny is selected, and a code joins from the sign-in sheet. --- crates/tinyclient/src/gui/layout.rs | 2 + crates/tinyclient/src/gui/mod.rs | 1 + crates/tinyclient/src/gui/sidebar.rs | 20 +- crates/tinyclient/src/gui/signin.rs | 110 ++++- crates/tinyclient/src/gui/state.rs | 3 + crates/tinyclient/src/gui/tiny.rs | 652 +++++++++++++++++++++++++++ 6 files changed, 783 insertions(+), 5 deletions(-) create mode 100644 crates/tinyclient/src/gui/tiny.rs diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index effcfca..499bd8b 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -825,6 +825,7 @@ impl Render for Shell { self.note_place(window, cx); self.follow_changes(cx); self.refresh_menus(cx); + self.note_tiny_page(cx); // Before the first answer the window is still drawn: rail, strips, an empty list, and // what it waits on. Never a blank canvas, never a developer message. let placeholder; @@ -872,6 +873,7 @@ impl Render for Shell { let pane = match (&snapshot.open, self.keychain_state(window, cx)) { (_, Some(waiting)) => waiting, (Some(_), None) => self.conversation(snapshot, window, cx), + (None, None) if self.tiny_open() => self.tiny_page(window, cx), (None, None) => self.nothing_open(window, cx), }; root.child(div().flex_1().min_w_0().h_full().child(pane)) diff --git a/crates/tinyclient/src/gui/mod.rs b/crates/tinyclient/src/gui/mod.rs index 55b0b1f..a7082e8 100644 --- a/crates/tinyclient/src/gui/mod.rs +++ b/crates/tinyclient/src/gui/mod.rs @@ -23,6 +23,7 @@ mod signin; mod shortcuts; mod state; mod theme; +mod tiny; mod time; use gpui::{point, prelude::*, px, size, App, Bounds, Context, Div, KeyBinding, TitlebarOptions, WindowBounds, WindowOptions}; diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index 94d89ce..c05a06d 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -1371,6 +1371,23 @@ impl Shell { match &state.mode { Mode::People => div().into_any_element(), Mode::Inbox => self.inbox_list(window, cx), + // No rooms. People on this server are drawn in the window, not as channels. + Mode::Network(Network::Tiny) => div() + .size_full() + .px(px(24.)) + .flex() + .items_center() + .justify_center() + .child( + div() + .max_w(px(220.)) + .text_center() + .whitespace_normal() + .type_ui() + .text_color(theme::text_soft()) + .child("People on this server are in the window."), + ) + .into_any_element(), _ if state.listed() => self.home_list(snapshot, window, cx), Mode::Network(network) if state.rows.is_empty() && state.spaces_loaded => { let name = network.label(); @@ -1400,6 +1417,7 @@ impl Shell { pub(super) fn sidebar(&self, snapshot: &Snapshot, width: f32, window: &mut Window, cx: &mut Context) -> Div { let people = self.sidebar_state.mode == Mode::People; + let tiny = matches!(self.sidebar_state.mode, Mode::Network(Network::Tiny)); if !matches!(&self.sidebar_state.mode, Mode::Space(space) if space.network == Network::Discord) { self.sidebar_state.width.set(width); } @@ -1422,7 +1440,7 @@ impl Shell { .border_r_1() .border_color(theme::hairline()) .child(self.title_strip(&self.sidebar_state.mode, cx)) - .when(!people, |sidebar| sidebar.child(self.jump_strip(cx))) + .when(!people && !tiny, |sidebar| sidebar.child(self.jump_strip(cx))) .children(tabs) .when(!banners.is_empty(), |sidebar| sidebar.child(div().flex_none().px(px(theme::INSET)).pb(px(8.)).flex().flex_col().gap(px(4.)).children(banners))) .child(div().id("sidebar-list").flex_1().min_h_0().track_focus(&self.sidebar_state.focus).key_context("Sidebar").child(body)) diff --git a/crates/tinyclient/src/gui/signin.rs b/crates/tinyclient/src/gui/signin.rs index 405f503..7dcf55a 100644 --- a/crates/tinyclient/src/gui/signin.rs +++ b/crates/tinyclient/src/gui/signin.rs @@ -3,7 +3,8 @@ //! scan, or a phone number, the code Telegram sends and, for accounts that have one, the two-step //! password. Signal: only the code to scan from the phone, which links this computer as one more //! device; a number typed here would take the account from the phone, so none is asked. A Tiny -//! server asks for its address, then an email, then the password for that server. +//! server asks for its address, then an email, then a password of 12 to 128 characters. +//! A code from someone already on that server is its own step, then the name people there will see. //! //! chatd owns the sign-in. The sheet asks it to begin, draws the step it is told //! (`protocol::SignInStep`) and answers it; while the sheet is open its own loop asks `Wait` and @@ -89,6 +90,10 @@ pub struct Sheet { url: Entity, /// The email on that server. email: Entity, + /// An invite code for that server. + invite: Entity, + /// The name people on that server will see. + name: Entity, /// Has the keys when no field does, so Escape still closes the sheet. focus: FocusHandle, /// The step changed: put the keys where typing goes, on the next frame. @@ -227,7 +232,9 @@ fn step_problem(step: &SignInStep) -> Option<&SignInProblem> { | SignInStep::Code { problem, .. } | SignInStep::Password { problem, .. } | SignInStep::Server { problem } - | SignInStep::Email { problem, .. } => problem.as_ref(), + | SignInStep::Email { problem, .. } + | SignInStep::Invite { problem, .. } + | SignInStep::Name { problem, .. } => problem.as_ref(), SignInStep::Done { .. } | SignInStep::Failed { .. } => None, } } @@ -266,6 +273,8 @@ impl Sheet { let password = cx.new(|cx| TextInput::new("", cx)); let url = cx.new(|cx| TextInput::new("tiny.example.com", cx)); let email = cx.new(|cx| TextInput::new("Email", cx)); + let invite = cx.new(|cx| TextInput::new("Invite code", cx)); + let name = cx.new(|cx| TextInput::new("Name", cx)); for input in [&country, &number] { cx.subscribe(input, |this, _, event: &InputEvent, cx| match event { InputEvent::Submit => this.send_phone(cx), @@ -306,8 +315,24 @@ impl Sheet { } }) .detach(); + cx.subscribe(&invite, |this, _, event: &InputEvent, cx| match event { + InputEvent::Submit => this.send_invite(cx), + InputEvent::Changed => { + this.dismiss(); + cx.notify(); + } + }) + .detach(); + cx.subscribe(&name, |this, _, event: &InputEvent, cx| match event { + InputEvent::Submit => this.send_name(cx), + InputEvent::Changed => { + this.dismiss(); + cx.notify(); + } + }) + .detach(); // Escape in a field is the field's key; it hands it on. - for input in [&country, &number, &digits, &password, &url, &email] { + for input in [&country, &number, &digits, &password, &url, &email, &invite, &name] { cx.subscribe(input, |this, _, event: &InputKey, cx| { if matches!(event, InputKey::Escape) { this.close(true, cx); @@ -330,6 +355,8 @@ impl Sheet { password, url, email, + invite, + name, focus: cx.focus_handle(), refocus: false, before: None, @@ -408,6 +435,8 @@ impl Sheet { self.country.update(cx, |input, cx| input.set_text("+1", cx)); self.url.update(cx, |input, cx| input.set_text("", cx)); self.email.update(cx, |input, cx| input.set_text("", cx)); + self.invite.update(cx, |input, cx| input.set_text("", cx)); + self.name.update(cx, |input, cx| input.set_text("", cx)); } /// Drops a typed code and password. They live nowhere else. @@ -426,6 +455,8 @@ impl Sheet { SignInStep::Phone { problem } | SignInStep::Server { problem } | SignInStep::Email { problem, .. } + | SignInStep::Invite { problem, .. } + | SignInStep::Name { problem, .. } | SignInStep::Password { problem, .. } => *problem = None, _ => {} } @@ -707,6 +738,41 @@ impl Sheet { self.answer(SignInInput::Email { email }, cx); } + fn send_have_code(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let url = self.url.read(cx).text().to_owned(); + self.dismiss(); + self.answer(SignInInput::HaveCode { url }, cx); + } + + fn send_invite(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let code = self.invite.read(cx).text().to_owned(); + if code.trim().is_empty() { + return; + } + // A code is not kept in the field after it is sent. + self.invite.update(cx, |input, cx| input.set_text("", cx)); + self.dismiss(); + self.answer(SignInInput::Invite { code }, cx); + } + + fn send_name(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let name = self.name.read(cx).text().to_owned(); + if name.trim().is_empty() { + return; + } + self.dismiss(); + self.answer(SignInInput::Name { name }, cx); + } + /// Where typing goes on the step on show. fn target(&self, cx: &App) -> FocusHandle { match &self.face { @@ -715,6 +781,8 @@ impl Sheet { Some(Face::Step(SignInStep::Password { .. })) => self.password.focus_handle(cx), Some(Face::Step(SignInStep::Server { .. })) => self.url.focus_handle(cx), Some(Face::Step(SignInStep::Email { .. })) => self.email.focus_handle(cx), + Some(Face::Step(SignInStep::Invite { .. })) => self.invite.focus_handle(cx), + Some(Face::Step(SignInStep::Name { .. })) => self.name.focus_handle(cx), _ => self.focus.clone(), } } @@ -962,7 +1030,7 @@ impl Sheet { let tiny = self.network == Network::Tiny; let heading = if tiny { self.header("Password", true, cx) } else { self.header("Two-step verification password", true, cx) }; let aside = if tiny { - div().type_ui().text_color(theme::text_soft()).child("The password for that server.") + div().type_ui().text_color(theme::text_soft()).child("A password of 12 to 128 characters.") } else { // Words in a link's color, not a link: the reset happens in Telegram's own app. div().type_ui().text_color(theme::accent_text()).child("Forgot it? Reset it in the Telegram app.") @@ -989,6 +1057,12 @@ impl Sheet { .child(input) .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_url, cx)))) + .child(div().mt(px(12.)).child(self.have_code(cx))) + } + + /// "I have a code" reads the address already typed. `answer_link` cannot see the field. + fn have_code(&self, cx: &Context) -> Stateful
{ + link("signin-have-code", "I have a code").on_click(cx.listener(|this, _, _, cx| this.send_have_code(cx))) } fn email_step(&self, server: &str, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { @@ -1004,6 +1078,32 @@ impl Sheet { .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_email, cx)))) } + fn invite_step(&self, server: &str, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { + let focused = self.invite.focus_handle(cx).is_focused(window); + let input = tall_field(focused, problem.is_some()).child(self.invite.clone()); + div() + .flex() + .flex_col() + .child(self.header("Code", true, cx)) + .child(div().mt(px(6.)).mb(px(16.)).text_color(theme::text_soft()).child(server.to_owned())) + .child(input) + .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) + .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_invite, cx)))) + } + + fn name_step(&self, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { + let focused = self.name.focus_handle(cx).is_focused(window); + let input = tall_field(focused, problem.is_some()).child(self.name.clone()); + div() + .flex() + .flex_col() + .child(self.header("Name", true, cx)) + .child(div().mt(px(6.)).mb(px(16.)).text_color(theme::text_soft()).child("The name people on that server will see.")) + .child(input) + .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) + .child(footer(22.).child(div()).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_name, cx)))) + } + fn title(&self) -> &'static str { match self.network { Network::Telegram => "Sign in to Telegram", @@ -1219,6 +1319,8 @@ impl Render for Sheet { SignInStep::Password { hint, .. } => self.password_step(hint, problem, window, cx), SignInStep::Server { .. } => self.server_step(problem, window, cx), SignInStep::Email { server, .. } => self.email_step(server, problem, window, cx), + SignInStep::Invite { server, .. } => self.invite_step(server, problem, window, cx), + SignInStep::Name { .. } => self.name_step(problem, window, cx), SignInStep::Failed { reason } => self.failed_step(reason, cx), SignInStep::Done { .. } => div(), } diff --git a/crates/tinyclient/src/gui/state.rs b/crates/tinyclient/src/gui/state.rs index 066208c..9ebdd5b 100644 --- a/crates/tinyclient/src/gui/state.rs +++ b/crates/tinyclient/src/gui/state.rs @@ -218,6 +218,8 @@ pub struct Shell { pub(super) call_state: call::State, /// The voice message or audio file playing, across conversations. pub(super) playing_state: super::playing::State, + /// The Tiny server on show. + pub(super) tiny_state: super::tiny::State, /// chatd as the shell sees it: starting, live, reconnecting, given up; the keychain. pub(super) service: Service, @@ -281,6 +283,7 @@ impl Shell { composer_state: composer::State::default(), call_state: call::State::default(), playing_state: super::playing::State::default(), + tiny_state: super::tiny::State::default(), service: Service::new(!cfg!(feature = "gui-shot") || std::env::args().any(|arg| arg == "--start-chatd")), crashed: app::crash::relaunched_after(), restore: Some((prefs.open.clone(), prefs.mode.clone())), diff --git a/crates/tinyclient/src/gui/tiny.rs b/crates/tinyclient/src/gui/tiny.rs new file mode 100644 index 0000000..307be16 --- /dev/null +++ b/crates/tinyclient/src/gui/tiny.rs @@ -0,0 +1,652 @@ +//! The Tiny server window: who is on it, and an invite for the next person. +//! A control the account cannot use is absent, never disabled. + +use super::components::{button, button_disabled, section_header, segment, segmented, ButtonKind}; +use super::layout::{drags_window, TOP_BAR}; +use super::sidebar::Mode; +use super::state::{Shell, Snapshot}; +use super::theme::{self, Type}; +use crate::client::ask; +use gpui::{div, prelude::*, px, AnyElement, ClipboardItem, Context, Div, Window}; +use protocol::{AccountKey, Link, Network, Request, Response}; +use std::io; + +/// What the server window is holding. The invite token stays until Done and is never logged. +pub struct State { + seen: bool, + account: Option, + load_epoch: u64, + action_epoch: u64, + loading: bool, + community: Option, + error: Option, + /// `"member"` or `"admin"`. Never owner. + invite_role: String, + token: Option, + busy: bool, + confirm: Option, +} + +impl Default for State { + fn default() -> State { + State { + seen: false, + account: None, + load_epoch: 0, + action_epoch: 0, + loading: false, + community: None, + error: None, + invite_role: "member".into(), + token: None, + busy: false, + confirm: None, + } + } +} + +struct Member { + user_id: String, + name: String, + email: String, + role: String, + make_admin: bool, + make_member: bool, + make_owner: bool, + remove: bool, +} + +struct OpenInvite { + role: String, + expires: String, +} + +struct Ready { + title: &'static str, + line: Option<&'static str>, + error: Option, + token: Option, + invite_role: String, + can_invite: bool, + busy: bool, + confirm: Option, + members: Vec, + invites: Vec, + /// `None` when this account cannot read the audit. + audit: Option>, +} + +enum Page { + Loading, + Failed(String), + Ready(Ready), +} + +fn connected(snapshot: &Snapshot) -> Option { + snapshot + .accounts + .iter() + .find(|status| status.key.network == Network::Tiny && status.link == Link::Connected) + .map(|status| status.key.clone()) +} + +fn same_role(role: &str, name: &str) -> bool { + role.eq_ignore_ascii_case(name) +} + +fn known_role(role: &str) -> bool { + same_role(role, "owner") || same_role(role, "admin") || same_role(role, "member") +} + +/// A role button is drawn only when that change would be legal. +fn role_change_legal(caller: &str, target: &str, next: &str, owners: usize) -> bool { + if same_role(target, next) || !known_role(next) { + return false; + } + // The only owner cannot be demoted. + if same_role(target, "owner") && owners <= 1 { + return false; + } + // Changing an owner, or granting owner, is the owner's act alone. + if same_role(target, "owner") || same_role(next, "owner") { + return same_role(caller, "owner"); + } + if same_role(caller, "owner") { + return true; + } + // An admin may promote a member, and does not demote another admin. + same_role(caller, "admin") && same_role(target, "member") && same_role(next, "admin") +} + +fn remove_legal(caller: &str, target: &str, owners: usize) -> bool { + if same_role(target, "owner") && owners <= 1 { + return false; + } + same_role(caller, "owner") || (same_role(caller, "admin") && same_role(target, "member")) +} + +fn can_make(caller: &str, target: &str, next: &str, owners: usize) -> bool { + protocol::tiny_can(caller, "community.members.role") && role_change_legal(caller, target, next, owners) +} + +fn can_remove(caller: &str, target: &str, owners: usize) -> bool { + protocol::tiny_can(caller, "community.members.remove") && remove_legal(caller, target, owners) +} + +fn role_word(role: &str) -> String { + if same_role(role, "owner") { + "Owner".to_owned() + } else if same_role(role, "admin") { + "Admin".to_owned() + } else if same_role(role, "member") { + "Member".to_owned() + } else { + role.to_owned() + } +} + +fn person_name(display: &str, email: &str) -> (String, String) { + if !display.is_empty() { + (display.to_owned(), email.to_owned()) + } else if !email.is_empty() { + (email.to_owned(), String::new()) + } else { + ("Someone".to_owned(), String::new()) + } +} + +impl Shell { + /// Loads the server when the window is actually showing it, and again the next time it is. + pub(super) fn note_tiny_page(&mut self, cx: &mut Context) { + let Some(account) = self.tiny_shown() else { + self.tiny_state.seen = false; + return; + }; + if self.tiny_state.seen && self.tiny_state.account.as_ref() == Some(&account) { + return; + } + self.tiny_state.seen = true; + self.fetch(account, None, cx); + } + + pub(super) fn tiny_open(&self) -> bool { + self.tiny_state.seen + } + + fn tiny_shown(&self) -> Option { + if self.shown().empty_state().is_some() { + return None; + } + if !matches!(self.sidebar_state.mode, Mode::Network(Network::Tiny)) { + return None; + } + let snapshot = self.snapshot.as_ref()?; + if snapshot.open.is_some() { + return None; + } + connected(snapshot) + } + + pub(super) fn tiny_page(&self, window: &Window, cx: &mut Context) -> Div { + let page = self.page(); + let body = match &page { + Page::Loading => note_line("Loading this server.", false).into_any_element(), + Page::Failed(detail) => note_line(detail, true).into_any_element(), + Page::Ready(ready) => ready_view(ready, cx), + }; + div() + .size_full() + .flex() + .flex_col() + .child( + drags_window(div()) + .h(px(TOP_BAR)) + .flex_none() + .px(px(theme::PANE_PAD)) + .flex() + .items_center() + .child(self.strip_controls(window, cx)), + ) + .child(body) + } + + fn page(&self) -> Page { + let state = &self.tiny_state; + if state.loading && state.community.is_none() { + return Page::Loading; + } + let Some(community) = state.community.as_ref() else { + return match state.error.clone() { + Some(detail) => Page::Failed(detail), + None => Page::Loading, + }; + }; + let caller = community.role.as_str(); + let owners = community.members.iter().filter(|member| same_role(&member.role, "owner")).count(); + let can_invite = protocol::tiny_can(caller, "community.invites.create"); + let alone = community.members.len() == 1; + let members = if protocol::tiny_can(caller, "community.members.read") { + community + .members + .iter() + .map(|member| { + let (name, email) = person_name(&member.display_name, &member.email); + let role = member.role.as_str(); + Member { + user_id: member.user_id.clone(), + name, + email, + role: role_word(role), + make_admin: can_make(caller, role, "admin", owners), + make_member: can_make(caller, role, "member", owners), + make_owner: can_make(caller, role, "owner", owners), + remove: can_remove(caller, role, owners), + } + }) + .collect() + } else { + Vec::new() + }; + let invites = if protocol::tiny_can(caller, "community.invites.read") { + community + .invites + .iter() + .filter(|invite| invite.accepted_at.is_none()) + .map(|invite| OpenInvite { role: role_word(&invite.role), expires: invite.expires_at.to_string() }) + .collect() + } else { + Vec::new() + }; + let audit = protocol::tiny_can(caller, "community.audit.read").then(|| { + community.audit.iter().map(|row| format!("{} {}", row.action, row.subject)).collect() + }); + Page::Ready(Ready { + title: if alone && can_invite { "Just you so far" } else { "Tiny server" }, + line: (alone && can_invite && state.token.is_none()) + .then_some("Invite someone. They paste the code into their own tinyclient."), + error: state.error.clone().filter(|detail| !detail.is_empty()), + token: state.token.clone(), + invite_role: state.invite_role.clone(), + can_invite, + busy: state.busy, + confirm: state.confirm.clone(), + members, + invites, + audit, + }) + } + + /// `clear_busy` is the action whose button stays quiet until this read returns. + fn fetch(&mut self, account: AccountKey, clear_busy: Option, cx: &mut Context) { + let switched = self.tiny_state.account.as_ref() != Some(&account); + if switched { + self.tiny_state.community = None; + self.tiny_state.token = None; + self.tiny_state.confirm = None; + self.tiny_state.error = None; + self.tiny_state.invite_role = "member".into(); + self.tiny_state.action_epoch += 1; + self.tiny_state.busy = false; + } + self.tiny_state.account = Some(account.clone()); + if self.tiny_state.community.is_none() { + self.tiny_state.loading = true; + self.tiny_state.error = None; + } + self.tiny_state.load_epoch += 1; + let epoch = self.tiny_state.load_epoch; + self.off_thread(cx, move || ask(&Request::TinyCommunity { account }), move |this, answer, cx| { + if this.tiny_state.load_epoch != epoch { + return; + } + this.tiny_state.loading = false; + if let Some(action) = clear_busy { + if this.tiny_state.action_epoch == action { + this.tiny_state.busy = false; + } + } + match answer { + Ok(Response::TinyCommunity { community }) => { + let confirm = this.tiny_state.confirm.clone(); + if confirm.as_ref().is_some_and(|id| community.members.iter().all(|member| &member.user_id != id)) { + this.tiny_state.confirm = None; + } + this.tiny_state.error = None; + this.tiny_state.community = Some(community); + } + Ok(Response::Error { detail, .. }) => this.tiny_state.error = Some(detail), + Ok(_) => this.tiny_state.error = Some(crate::client::OTHER_ANSWER.to_owned()), + Err(err) => this.tiny_state.error = Some(crate::client::trouble_words(&err)), + } + cx.notify(); + }); + } + + fn act(&mut self, request: Request, cx: &mut Context) { + if self.tiny_state.busy { + return; + } + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + self.tiny_state.busy = true; + self.tiny_state.error = None; + self.tiny_state.action_epoch += 1; + let epoch = self.tiny_state.action_epoch; + cx.notify(); + self.off_thread(cx, move || ask(&request), move |this, answer, cx| this.acted(epoch, account, answer, cx)); + } + + fn acted(&mut self, epoch: u64, account: AccountKey, answer: io::Result, cx: &mut Context) { + if self.tiny_state.action_epoch != epoch { + return; + } + if self.tiny_state.account.as_ref() != Some(&account) { + self.tiny_state.busy = false; + cx.notify(); + return; + } + match answer { + Ok(Response::Ok) => { + self.tiny_state.confirm = None; + self.tiny_state.error = None; + self.fetch(account, Some(epoch), cx); + cx.notify(); + } + Ok(Response::TinyCode { code }) => { + self.tiny_state.token = Some(code.token); + self.tiny_state.error = None; + self.fetch(account, Some(epoch), cx); + cx.notify(); + } + Ok(Response::Error { detail, .. }) => { + self.tiny_state.busy = false; + self.tiny_state.error = Some(detail); + cx.notify(); + } + Ok(_) => { + self.tiny_state.busy = false; + self.tiny_state.error = Some(crate::client::OTHER_ANSWER.to_owned()); + cx.notify(); + } + Err(err) => { + self.tiny_state.busy = false; + self.tiny_state.error = Some(crate::client::trouble_words(&err)); + cx.notify(); + } + } + } + + fn send_invite(&mut self, cx: &mut Context) { + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + let role = if self.tiny_state.invite_role == "admin" { "admin" } else { "member" }; + self.act(Request::TinyInvite { account, role: role.to_owned() }, cx); + } + + fn pick_role(&mut self, role: &'static str, cx: &mut Context) { + if self.tiny_state.busy || (role != "member" && role != "admin") { + return; + } + self.tiny_state.invite_role = role.to_owned(); + cx.notify(); + } + + fn send_role(&mut self, user_id: String, role: &'static str, cx: &mut Context) { + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + self.act(Request::TinyRole { account, user_id, role: role.to_owned() }, cx); + } + + fn ask_remove(&mut self, user_id: String, cx: &mut Context) { + if self.tiny_state.busy { + return; + } + self.tiny_state.confirm = Some(user_id); + cx.notify(); + } + + fn cancel_remove(&mut self, cx: &mut Context) { + self.tiny_state.confirm = None; + cx.notify(); + } + + fn send_remove(&mut self, user_id: String, cx: &mut Context) { + let Some(account) = self.tiny_state.account.clone() else { + return; + }; + self.tiny_state.confirm = None; + self.act(Request::TinyRemove { account, user_id }, cx); + } + + fn copy_code(&mut self, cx: &mut Context) { + let Some(token) = self.tiny_state.token.clone() else { + return; + }; + cx.write_to_clipboard(ClipboardItem::new_string(token)); + } + + fn done_code(&mut self, cx: &mut Context) { + self.tiny_state.token = None; + cx.notify(); + } +} + +fn note_line(words: &str, bad: bool) -> Div { + let line = div().flex_1().px(px(theme::PANE_PAD)).pt(px(8.)); + if bad { + line.type_small().text_color(theme::danger()).child(words.to_owned()) + } else { + line.type_ui().text_color(theme::text_soft()).child(words.to_owned()) + } +} + +fn ready_view(ready: &Ready, cx: &mut Context) -> AnyElement { + let mut column = div().w_full().pt(px(8.)).pb(px(24.)).flex().flex_col().child( + div() + .px(px(theme::PANE_PAD)) + .flex() + .flex_col() + .gap(px(8.)) + .child(div().type_title().text_color(theme::text()).child(ready.title)) + .children(ready.line.map(|line| div().max_w(px(360.)).whitespace_normal().type_ui().text_color(theme::text_soft()).child(line))) + .children(ready.error.as_ref().map(|detail| div().type_small().text_color(theme::danger()).child(detail.clone()))) + .children(ready.token.as_ref().map(|token| code_panel(token, cx))) + .when(ready.token.is_none() && ready.can_invite, |column| column.child(invite_row(&ready.invite_role, ready.busy, cx))), + ); + if !ready.members.is_empty() { + column = column.child(div().mt(px(8.)).flex().flex_col().children(ready.members.iter().map(|member| member_row(member, ready.confirm.as_deref(), cx)))); + } + if !ready.invites.is_empty() { + column = column.child(invite_section(&ready.invites)); + } + if let Some(rows) = &ready.audit { + column = column.child(audit_section(rows)); + } + div().id("tiny-page").flex_1().min_h_0().overflow_y_scroll().child(column).into_any_element() +} + +fn invite_row(role: &str, busy: bool, cx: &mut Context) -> Div { + let invite = if busy { + button_disabled("Invite someone").into_any_element() + } else { + button("tiny-invite", ButtonKind::Primary, "Invite someone") + .on_click(cx.listener(|this, _, _, cx| this.send_invite(cx))) + .into_any_element() + }; + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child( + segmented() + .child(segment("tiny-as-member", "Member", role != "admin").on_click(cx.listener(|this, _, _, cx| this.pick_role("member", cx)))) + .child(segment("tiny-as-admin", "Admin", role == "admin").on_click(cx.listener(|this, _, _, cx| this.pick_role("admin", cx)))), + ) + .child(invite) +} + +fn code_panel(token: &str, cx: &mut Context) -> Div { + div() + .flex() + .flex_col() + .gap(px(8.)) + .child(div().whitespace_normal().type_ui().text_color(theme::text_soft()).child("Copy this code. It works for 7 days and will not be shown again.")) + .child( + div() + .px(px(12.)) + .py(px(8.)) + .rounded(px(theme::R_CONTROL)) + .bg(theme::field()) + .border_1() + .border_color(theme::border()) + .whitespace_normal() + .type_code() + .child(token.to_owned()), + ) + .child( + div() + .flex() + .flex_wrap() + .gap(px(8.)) + .child(button("tiny-copy", ButtonKind::Primary, "Copy code").on_click(cx.listener(|this, _, _, cx| this.copy_code(cx)))) + .child(button("tiny-done", ButtonKind::Secondary, "Done").on_click(cx.listener(|this, _, _, cx| this.done_code(cx)))), + ) +} + +fn member_row(member: &Member, confirm: Option<&str>, cx: &mut Context) -> Div { + let confirming = confirm == Some(member.user_id.as_str()); + let identity = div().flex_1().min_w(px(120.)).flex().flex_col().child( + div() + .min_w_0() + .flex() + .items_center() + .gap(px(6.)) + .child(div().min_w_0().truncate().type_ui().text_color(theme::text()).child(member.name.clone())) + .child(div().flex_none().type_small().text_color(theme::text_soft()).child(member.role.clone())), + ) + .when(!member.email.is_empty(), |identity| { + identity.child(div().min_w_0().truncate().type_small().text_color(theme::text_faint()).child(member.email.clone())) + }); + div().w_full().px(px(theme::PANE_PAD)).py(px(6.)).flex().flex_col().gap(px(6.)).child( + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child(identity) + .when(!confirming, |row| row.child(member_actions(member, cx))) + .when(confirming, |row| row.child(confirm_remove(&member.name, &member.user_id, cx))), + ) +} + +fn member_actions(member: &Member, cx: &mut Context) -> Div { + let mut actions = div().flex_none().flex().flex_wrap().items_center().gap(px(4.)); + if member.make_admin { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-admin-{}", member.user_id), ButtonKind::Secondary, "Make admin") + .on_click(cx.listener(move |this, _, _, cx| this.send_role(user_id.clone(), "admin", cx))), + ); + } + if member.make_member { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-member-{}", member.user_id), ButtonKind::Secondary, "Make member") + .on_click(cx.listener(move |this, _, _, cx| this.send_role(user_id.clone(), "member", cx))), + ); + } + if member.make_owner { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-owner-{}", member.user_id), ButtonKind::Secondary, "Make owner") + .on_click(cx.listener(move |this, _, _, cx| this.send_role(user_id.clone(), "owner", cx))), + ); + } + if member.remove { + let user_id = member.user_id.clone(); + actions = actions.child( + button(format!("tiny-remove-{}", member.user_id), ButtonKind::Danger, "Remove") + .on_click(cx.listener(move |this, _, _, cx| this.ask_remove(user_id.clone(), cx))), + ); + } + actions +} + +fn confirm_remove(name: &str, user_id: &str, cx: &mut Context) -> Div { + let remove_id = user_id.to_owned(); + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(8.)) + .child(div().whitespace_normal().type_ui().text_color(theme::text()).child(format!("Remove {name} from this server?"))) + .child( + button(format!("tiny-remove-yes-{user_id}"), ButtonKind::Danger, "Remove") + .on_click(cx.listener(move |this, _, _, cx| this.send_remove(remove_id.clone(), cx))), + ) + .child(button(format!("tiny-remove-no-{user_id}"), ButtonKind::Secondary, "Cancel").on_click(cx.listener(|this, _, _, cx| this.cancel_remove(cx)))) +} + +fn invite_section(invites: &[OpenInvite]) -> Div { + div().mt(px(12.)).flex().flex_col().child(section_header("Invites", true)).children(invites.iter().map(|invite| { + div() + .px(px(theme::PANE_PAD)) + .h(px(theme::ROW_ONE)) + .flex() + .items_center() + .gap(px(8.)) + .child(div().type_ui().text_color(theme::text()).child(invite.role.clone())) + .child(div().type_small().text_color(theme::text_soft()).child(format!("Expires {}", invite.expires))) + })) +} + +fn audit_section(rows: &[String]) -> Div { + let mut section = div().mt(px(12.)).flex().flex_col().child(section_header("Activity", true)); + if rows.is_empty() { + section = section.child(div().px(px(theme::PANE_PAD)).type_ui().text_color(theme::text_soft()).child("Nothing has been recorded.")); + } else { + section = section.children(rows.iter().map(|row| { + div().px(px(theme::PANE_PAD)).h(px(theme::ROW_ONE)).flex().items_center().min_w_0().truncate().type_ui().text_color(theme::text()).child(row.clone()) + })); + } + section +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_buttons_follow_who_may_change_whom() { + let owners = 1; + assert!(role_change_legal("owner", "member", "admin", owners)); + assert!(role_change_legal("owner", "member", "owner", owners)); + assert!(role_change_legal("admin", "member", "admin", owners)); + assert!(!role_change_legal("admin", "member", "owner", owners)); + assert!(!role_change_legal("admin", "admin", "member", owners)); + assert!(!role_change_legal("admin", "owner", "admin", owners)); + assert!(!role_change_legal("admin", "owner", "member", owners)); + assert!(!role_change_legal("owner", "owner", "admin", owners)); + assert!(!role_change_legal("owner", "owner", "member", owners)); + assert!(!role_change_legal("member", "member", "admin", owners)); + assert!(role_change_legal("owner", "owner", "admin", 2)); + assert!(role_change_legal("owner", "admin", "member", owners)); + assert!(!role_change_legal("owner", "admin", "admin", owners)); + } + + #[test] + fn remove_spares_the_only_owner_and_an_admin_does_not_touch_one() { + assert!(!remove_legal("owner", "owner", 1)); + assert!(remove_legal("owner", "owner", 2)); + assert!(remove_legal("owner", "admin", 1)); + assert!(remove_legal("owner", "member", 1)); + assert!(remove_legal("admin", "member", 1)); + assert!(!remove_legal("admin", "admin", 1)); + assert!(!remove_legal("admin", "owner", 2)); + assert!(!remove_legal("member", "member", 1)); + } +} From bdd462546965a1539f6cbdb71853c3034f10c9b0 Mon Sep 17 00:00:00 2001 From: Gollum Date: Sat, 24 Oct 2026 18:28:14 -0400 Subject: [PATCH 3/6] Let an empty Tiny code be answered, and don't point at a closed window. An empty invite code and an empty name still go to chatd, which stays on that step. The sidebar only says the people are in the window while the account is connected. --- crates/tinyclient/src/gui/sidebar.rs | 36 +++++++++++++++------------- crates/tinyclient/src/gui/signin.rs | 9 ++----- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index c05a06d..4c705aa 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -1372,22 +1372,26 @@ impl Shell { Mode::People => div().into_any_element(), Mode::Inbox => self.inbox_list(window, cx), // No rooms. People on this server are drawn in the window, not as channels. - Mode::Network(Network::Tiny) => div() - .size_full() - .px(px(24.)) - .flex() - .items_center() - .justify_center() - .child( - div() - .max_w(px(220.)) - .text_center() - .whitespace_normal() - .type_ui() - .text_color(theme::text_soft()) - .child("People on this server are in the window."), - ) - .into_any_element(), + Mode::Network(Network::Tiny) => match snapshot.link(Network::Tiny) { + Link::Connected => div() + .size_full() + .px(px(24.)) + .flex() + .items_center() + .justify_center() + .child( + div() + .max_w(px(220.)) + .text_center() + .whitespace_normal() + .type_ui() + .text_color(theme::text_soft()) + .child("People on this server are in the window."), + ) + .into_any_element(), + Link::Offline => empty("icons/wifi-off.svg", "Tiny server is offline", "It shows up here once it connects."), + _ => empty("icons/users.svg", "Tiny server", "Sign in to see who's here."), + }, _ if state.listed() => self.home_list(snapshot, window, cx), Mode::Network(network) if state.rows.is_empty() && state.spaces_loaded => { let name = network.label(); diff --git a/crates/tinyclient/src/gui/signin.rs b/crates/tinyclient/src/gui/signin.rs index 7dcf55a..97a23f2 100644 --- a/crates/tinyclient/src/gui/signin.rs +++ b/crates/tinyclient/src/gui/signin.rs @@ -752,10 +752,8 @@ impl Sheet { return; } let code = self.invite.read(cx).text().to_owned(); - if code.trim().is_empty() { - return; - } - // A code is not kept in the field after it is sent. + // A code is not kept in the field after it is sent. An empty one still goes to chatd, + // which stays on this step and says to paste the code. self.invite.update(cx, |input, cx| input.set_text("", cx)); self.dismiss(); self.answer(SignInInput::Invite { code }, cx); @@ -766,9 +764,6 @@ impl Sheet { return; } let name = self.name.read(cx).text().to_owned(); - if name.trim().is_empty() { - return; - } self.dismiss(); self.answer(SignInInput::Name { name }, cx); } From f8b30d0b9420b9cebc3196ab24dcb6e8ea9aaa58 Mon Sep 17 00:00:00 2001 From: Gollum Date: Sat, 24 Oct 2026 19:12:13 -0400 Subject: [PATCH 4/6] Keep a Tiny test's scratch directory out of the temp root, so a picked file can still be sent. A file in the temp directory is one the user picked, and anything under TINYCLIENT_DIR is refused. The community tests were pointing TINYCLIENT_DIR at that same directory, so the Windows run refused the file. The local test server also waits for the whole request instead of answering on a short read. --- crates/chatd/src/adapters/tiny.rs | 72 +++++++++++++++++++++++++------ 1 file changed, 60 insertions(+), 12 deletions(-) diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs index bc01abb..6439d8c 100644 --- a/crates/chatd/src/adapters/tiny.rs +++ b/crates/chatd/src/adapters/tiny.rs @@ -701,8 +701,12 @@ pub(crate) mod tests { let session = format!( r#"{{"base_url":{base:?},"token":"t","user_id":{user_id:?},"email":"ada@example.com","display_name":"Ada","role":{role:?}}}"# ); - std::env::set_var("TINYCLIENT_DIR", std::env::temp_dir()); - std::env::set_var(&name, &session); + // Not the temp root. A file there is one the user picked, and anything under + // TINYCLIENT_DIR is refused. These tests share a process with that check. + let scratch = std::env::temp_dir().join(format!("tinyclient-tiny-{}", std::process::id())); + let _ = std::fs::create_dir_all(&scratch); + let _dir = Restore::set("TINYCLIENT_DIR", &scratch); + let _session = Restore::set(&name, &session); match crate::session::read(Network::Tiny) { crate::session::Stored::Found(_) => {} crate::session::Stored::Absent => panic!("tiny session was absent ({name})"), @@ -714,6 +718,40 @@ pub(crate) mod tests { }); } + /// Puts one environment variable back when the test ends, including on a panic. + struct Restore { + key: String, + previous: Option, + } + + impl Restore { + fn set(key: &str, value: impl AsRef) -> Restore { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Restore { key: key.to_owned(), previous } + } + } + + impl Drop for Restore { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(&self.key, value), + None => std::env::remove_var(&self.key), + } + } + } + + #[test] + fn a_session_scratch_does_not_hide_a_file_the_user_picked() { + let picked = std::env::temp_dir().join(format!("tinyclient-beside-{}.txt", std::process::id())); + std::fs::write(&picked, b"hello").unwrap(); + with_session("http://127.0.0.1:9", "member", |_| { + let answer = crate::blobs::upload::allowed(&picked.to_string_lossy()); + assert!(answer.is_ok(), "{answer:?}"); + }); + let _ = std::fs::remove_file(&picked); + } + #[test] fn a_member_loads_members_and_does_not_ask_for_invites_or_the_audit() { let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); @@ -887,6 +925,23 @@ pub(crate) mod tests { .into_bytes() } + /// One read. A timeout is not the end of the request: the body can arrive in a later packet. + fn read_some(stream: &mut std::net::TcpStream, got: &mut Vec, deadline: std::time::Instant) -> bool { + let mut buf = [0u8; 2048]; + while std::time::Instant::now() < deadline { + match std::io::Read::read(stream, &mut buf) { + Ok(0) => return false, + Err(err) if err.kind() == std::io::ErrorKind::TimedOut || err.kind() == std::io::ErrorKind::WouldBlock => continue, + Err(_) => return false, + Ok(read) => { + got.extend_from_slice(&buf[..read]); + return true; + } + } + } + false + } + pub(crate) fn serve(handler: impl Fn(&str) -> Vec + Send + 'static) -> String { let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); let port = listener.local_addr().expect("addr").port(); @@ -899,14 +954,12 @@ pub(crate) mod tests { let _ = stream.set_nonblocking(false); let _ = stream.set_read_timeout(Some(Duration::from_millis(300))); let mut got = Vec::new(); - let mut buf = [0u8; 2048]; let header_end = loop { if let Some(at) = got.windows(4).position(|window| window == b"\r\n\r\n") { break Some(at + 4); } - match std::io::Read::read(&mut stream, &mut buf) { - Ok(0) | Err(_) => break None, - Ok(read) => got.extend_from_slice(&buf[..read]), + if !read_some(&mut stream, &mut got, deadline) { + break None; } }; if let Some(header_end) = header_end { @@ -920,12 +973,7 @@ pub(crate) mod tests { } }); if let Some(length) = length { - while got.len() < header_end + length { - match std::io::Read::read(&mut stream, &mut buf) { - Ok(0) | Err(_) => break, - Ok(read) => got.extend_from_slice(&buf[..read]), - } - } + while got.len() < header_end + length && read_some(&mut stream, &mut got, deadline) {} } } let request = String::from_utf8_lossy(&got).into_owned(); From 2ff82d6d7e4de2bac41ae7b32a4cf17200ed4e30 Mon Sep 17 00:00:00 2001 From: Gollum Date: Sat, 24 Oct 2026 19:15:42 -0400 Subject: [PATCH 5/6] Version 0.2.13: see who is on a Tiny server, and invite the next person. A signed-in Tiny server shows the people on it. An owner or an admin can invite, change a role, and remove a member. The code is shown once. --- Cargo.lock | 10 +++++----- Cargo.toml | 2 +- README.md | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f8fddd5..7ac437a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1015,7 +1015,7 @@ dependencies = [ [[package]] name = "chatd" -version = "0.2.12" +version = "0.2.13" dependencies = [ "aes 0.8.4", "aes-gcm", @@ -4938,7 +4938,7 @@ dependencies = [ [[package]] name = "login" -version = "0.2.12" +version = "0.2.13" dependencies = [ "block2 0.6.2", "embed-resource", @@ -5098,7 +5098,7 @@ checksum = "7ebb8d8732c6a6df3d8f032a82911cfc747e00efb95cc46e8d0acd5b5b88570c" [[package]] name = "mediad" -version = "0.2.12" +version = "0.2.13" dependencies = [ "aes-gcm", "chacha20poly1305", @@ -7017,7 +7017,7 @@ dependencies = [ [[package]] name = "protocol" -version = "0.2.12" +version = "0.2.13" dependencies = [ "interprocess", "serde", @@ -9081,7 +9081,7 @@ dependencies = [ [[package]] name = "tinyclient" -version = "0.2.12" +version = "0.2.13" dependencies = [ "embed-resource", "gpui", diff --git a/Cargo.toml b/Cargo.toml index 3369cef..91d6fca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.2.12" +version = "0.2.13" edition = "2021" license = "MIT" publish = false diff --git a/README.md b/README.md index e3cd482..fb92915 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Sizes of a macOS arm64 release build, 2026-10-14: ## What works today -Current release: [v0.2.12](https://github.com/itz4blitz/tinyclient/releases/tag/v0.2.12). This is what `main` does. The goal is full parity with each official app. Every gap is a place to help. +Current release: [v0.2.13](https://github.com/itz4blitz/tinyclient/releases/tag/v0.2.13). This is what `main` does. The goal is full parity with each official app. Every gap is a place to help. | Network | Sign-in | Reading and media | Writing | Calls | | --- | --- | --- | --- | --- | From c16e60bbd8f7b4447a06d2786f5bb49aa1d0956f Mon Sep 17 00:00:00 2001 From: Gollum Date: Sat, 24 Oct 2026 19:24:39 -0400 Subject: [PATCH 6/6] Count a playing mediad without asking PowerShell. The one-second clip ends before Windows finishes listing processes, so the test saw no mediad while it was still playing. --- crates/chatd/tests/playback.rs | 63 +++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/crates/chatd/tests/playback.rs b/crates/chatd/tests/playback.rs index bfa3fec..4181c90 100644 --- a/crates/chatd/tests/playback.rs +++ b/crates/chatd/tests/playback.rs @@ -108,12 +108,9 @@ fn start(path: &Path, at: Option, rate: u16) -> PlayRequest { fn mediads() -> usize { #[cfg(windows)] { - let script = format!( - "(Get-CimInstance Win32_Process -Filter \"Name = 'mediad.exe'\" | Where-Object {{ $_.ParentProcessId -eq {} }}).ProcessId", - std::process::id() - ); - let out = Command::new("powershell").args(["-NoProfile", "-Command", &script]).output().unwrap(); - String::from_utf8(out.stdout).unwrap().lines().filter(|line| !line.trim().is_empty()).count() + // PowerShell's process list takes longer than this one-second clip, so it reported + // none while mediad was still playing. The snapshot is the process list itself. + windows_mediads() } #[cfg(not(windows))] { @@ -122,6 +119,55 @@ fn mediads() -> usize { } } +/// `mediad.exe` whose parent is this test. A toolhelp snapshot, not PowerShell. +#[cfg(windows)] +fn windows_mediads() -> usize { + use std::ffi::c_void; + #[repr(C)] + struct Entry { + size: u32, + usage: u32, + pid: u32, + heap: usize, + module: u32, + threads: u32, + parent: u32, + priority: i32, + flags: u32, + exe: [u16; 260], + } + #[link(name = "kernel32")] + extern "system" { + fn CreateToolhelp32Snapshot(flags: u32, pid: u32) -> *mut c_void; + fn Process32FirstW(snap: *mut c_void, entry: *mut Entry) -> i32; + fn Process32NextW(snap: *mut c_void, entry: *mut Entry) -> i32; + fn CloseHandle(handle: *mut c_void) -> i32; + } + const SNAPPROCESS: u32 = 2; + unsafe { + let snap = CreateToolhelp32Snapshot(SNAPPROCESS, 0); + assert_ne!(snap as isize, -1, "could not list processes"); + let mut entry: Entry = std::mem::zeroed(); + entry.size = std::mem::size_of::() as u32; + let mut count = 0usize; + let parent = std::process::id(); + if Process32FirstW(snap, &mut entry) != 0 { + loop { + let end = entry.exe.iter().position(|unit| *unit == 0).unwrap_or(entry.exe.len()); + let name = String::from_utf16_lossy(&entry.exe[..end]); + if entry.parent == parent && name.eq_ignore_ascii_case("mediad.exe") { + count += 1; + } + if Process32NextW(snap, &mut entry) == 0 { + break; + } + } + } + CloseHandle(snap); + count + } +} + fn until_no_mediad() { let deadline = Instant::now() + PATIENCE; while mediads() > 0 { @@ -206,9 +252,8 @@ fn a_second_file_replaces_the_first_and_speed_and_seek_reach_mediad() { world.ask(second).unwrap(); let playing = world.until_state(PlayState::Playing); assert_eq!(playing.file, "d6101"); - // One file at a time: the first one's mediad went. Listing processes on Windows takes - // PowerShell a second or more, long enough for the second file to end and its mediad to go - // too, so none left counts as well. + // One file at a time: the first one's mediad went. The second file is a second long, so + // it may have ended before this looks. None left is that. world.until("at most one mediad", |_| mediads() <= 1); world.ask(PlayRequest::Stop).unwrap(); until_no_mediad();