diff --git a/README.md b/README.md index 574252e..6e76725 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ -tinyclient is a chat client written in Rust. It signs in to Slack, Discord, Telegram, Microsoft Teams, Signal, and Android Messages, and shows them in one window with one design. It runs entirely on your machine. It talks to each network directly. There is no tinyclient server, and nothing you type is sent anywhere else. It is built for macOS, Linux, and Windows. +tinyclient is a chat client written in Rust. It signs in to Slack, Discord, Telegram, Microsoft Teams, Signal, and Android Messages, and shows them in one window with one design. It runs entirely on your machine. It talks to each of those networks directly. There is no tinyclient server, and nothing you type is sent anywhere else. You can also add one [Tiny server](https://github.com/itz4blitz/tinyserver) you already run; that sign-in talks only to that server. It is built for macOS, Linux, and Windows. The project is early. The goal is a client that is a pleasure to use and does everything the official apps do, on every network, while staying small. Talk with us on [Discord](https://discord.gg/kJHK7BNvJP), or [come help](#contributing). @@ -72,6 +72,7 @@ Current release: [v0.2.5](https://github.com/itz4blitz/tinyclient/releases/tag/v | **Telegram** | QR code from your phone, or a phone number and code, plus a 2FA password | Chats, folders, topics, photos, pins | Text, files, reactions, edit, delete, mute, leave | Calls open in Telegram | | **Signal** | Linked as a device. Scan a QR code from your phone. | Direct and group chats, replies, mentions, reactions, edits, deletes. Photos and files appear as a labelled line. Nothing from before the link. Your own chat is **Note to Self**, including messages sent from your phone. | Text, replies, reactions, edit and delete your own | Not built | | **Messages** | Paired from the Android phone, the way [messages.google.com](https://messages.google.com) is. The phone stays the account and has to stay online. | Texts in the chats the phone already has. Photos are shown. Other files are named and can be opened. | Text, one file at a time, reactions. A large photo may be sent as a smaller copy. | Opens in Messages | +| **Tiny server** | Server address, email, and password, in the app. One server. | Not yet: no conversations | Not yet | Not yet | A few behaviours that are easy to miss: @@ -262,7 +263,7 @@ flowchart LR mediad["mediad
one call"] db[("chat.sqlite
media/")] keys[("system
credential store")] - nets(["Slack · Discord · Teams
Telegram · Signal · Messages"]) + nets(["Slack · Discord · Teams
Telegram · Signal · Messages
a Tiny server you run"]) voice(["Discord voice"]) shell <-->|"local socket
length-prefixed JSON"| chatd @@ -279,7 +280,7 @@ flowchart LR | [`protocol`](crates/protocol) | | The contract: every request, answer, and change, as serde types. Frames are a 4-byte length, then JSON, at most 1 MiB, over a Unix socket (a named pipe on Windows). Also the panic hook, credential redaction, and how the shell starts chatd. | | [`chatd`](crates/chatd) | `chatd` | Always on, one per data directory. One adapter per account speaks that network's protocol. Everything lands in SQLite and the media cache. Spawns `tinyclient-login` for a sign-in and `mediad` for a call. Never decodes media and never fetches a link from a message. | | [`mediad`](crates/mediad) | `mediad` | Voice: Discord's voice gateway, RTP over UDP, Opus, DAVE, audio devices through cpal. Speaks only to chatd over stdin and stdout, and exits when the call ends or chatd closes its stdin. | -| [`login`](crates/login) | `tinyclient-login` | The sign-in page for Slack, Discord, and Teams in a native web view (WKWebView on macOS, wry elsewhere). Prints the session on stdout and exits. Signal, Telegram, and Messages pair in the main window. | +| [`login`](crates/login) | `tinyclient-login` | The sign-in page for Slack, Discord, and Teams in a native web view (WKWebView on macOS, wry elsewhere). Prints the session on stdout and exits. Signal, Telegram, Messages, and a Tiny server sign in in the main window. | | [`tinyclient`](crates/tinyclient) | `tinyclient` | The shell. Draws what chatd reports and decodes images for rows on screen. GPUI is behind the `gui` feature so tests and the daemons never build a GPU stack. Never talks to a network or reads the database. | The shell learns about changes by long-polling. `Wait {since}` blocks for up to 30 seconds and answers every change newer than `since`. The full contract, the data each process may hold, and what an adapter must do are in [`docs/architecture.md`](docs/architecture.md). diff --git a/crates/chatd/src/adapters/mod.rs b/crates/chatd/src/adapters/mod.rs index 95d1c32..bfbc112 100644 --- a/crates/chatd/src/adapters/mod.rs +++ b/crates/chatd/src/adapters/mod.rs @@ -24,6 +24,7 @@ pub mod signal; pub(crate) mod slack; pub(crate) mod teams; pub mod telegram; +pub mod tiny; use crate::hub::{lock, now, Hub}; use crate::session; @@ -1169,6 +1170,7 @@ pub fn supported(network: Network) -> bool { Network::Telegram => telegram::supported(), Network::Signal => signal::supported(), Network::Messages => messages::supported(), + Network::Tiny => tiny::supported(), } } @@ -1187,6 +1189,7 @@ pub fn accounts(network: Network, session: &str) -> Result, String> Network::Teams => teams::accounts(session)?, Network::Signal => signal::accounts(session)?, Network::Messages => messages::accounts(session)?, + Network::Tiny => tiny::accounts(session)?, }; match found.iter().find(|signed| !safe(&signed.account)) { Some(bad) => Err(format!("{}: unusable account id {:?}", network.label(), bad.account)), @@ -1204,6 +1207,7 @@ pub fn without(network: Network, session: &str, account: &str) -> Option Network::Teams => teams::without(session, account), Network::Signal => signal::without(session, account), Network::Messages => messages::without(session, account), + Network::Tiny => tiny::without(session, account), } } @@ -1228,6 +1232,7 @@ pub fn start(account: AccountKey, session: &str, hub: Arc) -> Result teams::start(ctx.clone(), session, inbox), Network::Signal => signal::start(ctx.clone(), session, inbox), Network::Messages => messages::start(ctx.clone(), session, inbox), + Network::Tiny => tiny::start(ctx.clone(), session, inbox), }; if let Err(why) = started { ctx.link(Link::SignedOut); diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs new file mode 100644 index 0000000..8e7d41a --- /dev/null +++ b/crates/chatd/src/adapters/tiny.rs @@ -0,0 +1,426 @@ +//! 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. + +pub mod signin; + +use super::{serve_commands, Command, Context, Signed}; +use protocol::Link; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::mpsc::Receiver; +use std::time::Duration; +use ureq::tls::{TlsConfig, TlsProvider}; + +/// How long one request may take. A redirect is not followed, so this is one host. +const TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Clone, Serialize, Deserialize)] +struct Saved { + base_url: String, + token: String, + user_id: String, + email: String, + display_name: String, + role: String, +} + +struct Me { + display_name: String, + email: String, +} + +enum Fault { + Unauthorized, + Unreachable, + BadAnswer, +} + +fn safe_id(id: &str) -> bool { + !id.is_empty() + && id != "." + && id != ".." + && id + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) +} + +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) { + return Err("the Tiny server session is incomplete".to_owned()); + } + if !matches!(saved.role.as_str(), "owner" | "admin" | "member") { + return Err("the Tiny server session has an unknown role".to_owned()); + } + Ok(saved) +} + +pub fn supported() -> bool { + true +} + +pub fn accounts(session: &str) -> Result, String> { + let saved = parse(session)?; + Ok(vec![Signed { + account: saved.user_id.clone(), + name: saved.display_name, + session: session.to_owned(), + }]) +} + +pub fn without(session: &str, account: &str) -> Option { + match parse(session) { + Ok(saved) if saved.user_id == account => None, + _ => Some(session.to_owned()), + } +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .tls_config( + TlsConfig::builder() + .provider(TlsProvider::NativeTls) + .build(), + ) + .timeout_global(Some(TIMEOUT)) + .http_status_as_error(false) + .max_redirects(0) + .max_redirects_will_error(false) + .user_agent("tinyclient") + .build() + .into() +} + +/// One request. A 3xx is a failure: the password and the bearer stay on the host that was asked. +fn call( + method: &str, + url: &str, + token: Option<&str>, + body: Option, +) -> Result<(u16, Value), Fault> { + let agent = agent(); + let bearer = token.map(|token| format!("Bearer {token}")); + let sent = match method { + "GET" => { + let mut request = agent.get(url); + if let Some(bearer) = bearer.as_deref() { + request = request.header("Authorization", bearer); + } + request.call() + } + "DELETE" => { + let mut request = agent.delete(url); + if let Some(bearer) = bearer.as_deref() { + request = request.header("Authorization", bearer); + } + request.call() + } + "POST" => { + let mut request = agent.post(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)?; + let status = response.status().as_u16(); + if (300..400).contains(&status) { + return Err(Fault::Unreachable); + } + if status == 204 { + return Ok((status, Value::Null)); + } + // The body is not logged: a 200 may hold the bearer. + let parsed = response.body_mut().read_json().unwrap_or(Value::Null); + Ok((status, parsed)) +} + +fn endpoint(base: &str, path: &str) -> String { + format!("{base}{path}") +} + +fn post_session(base: &str, email: &str, password: &str) -> Result { + let (status, body) = call( + "POST", + &endpoint(base, "/v1/session"), + None, + Some(serde_json::json!({ "email": email, "password": password })), + )?; + if status == 401 { + return Err(Fault::Unauthorized); + } + if status != 200 { + return Err(Fault::Unreachable); + } + let answer: SessionAnswer = serde_json::from_value(body).map_err(|_| Fault::BadAnswer)?; + if answer.token.is_empty() + || !safe_id(&answer.user_id) + || !matches!(answer.role.as_str(), "owner" | "admin" | "member") + { + return Err(Fault::BadAnswer); + } + Ok(Saved { + base_url: base.to_owned(), + token: answer.token, + user_id: answer.user_id, + email: answer.email, + display_name: answer.display_name, + role: answer.role, + }) +} + +#[derive(Deserialize)] +struct SessionAnswer { + token: String, + user_id: String, + email: String, + display_name: String, + role: String, +} + +#[derive(Deserialize, Default)] +struct MeAnswer { + #[serde(default)] + display_name: String, + #[serde(default)] + email: String, +} + +fn get_me(saved: &Saved) -> Result { + let (status, body) = call( + "GET", + &endpoint(&saved.base_url, "/v1/me"), + Some(&saved.token), + None, + )?; + if status == 401 { + return Err(Fault::Unauthorized); + } + if status != 200 { + return Err(Fault::Unreachable); + } + let answer: MeAnswer = serde_json::from_value(body).unwrap_or_default(); + Ok(Me { + display_name: answer.display_name, + email: answer.email, + }) +} + +fn delete_session(saved: &Saved) { + let _ = call( + "DELETE", + &endpoint(&saved.base_url, "/v1/session"), + Some(&saved.token), + None, + ); +} + +pub fn start(ctx: Context, session: &str, inbox: Receiver) -> Result<(), String> { + let saved = parse(session)?; + if saved.user_id != ctx.account.account { + return Err("the Tiny server session is for another account".to_owned()); + } + let (probe, probe_ctx) = (saved.clone(), ctx.clone()); + std::thread::Builder::new() + .name("tiny-link".into()) + .spawn(move || match get_me(&probe) { + Ok(me) => { + let _ = probe_ctx.describe(&me.display_name, &me.email); + probe_ctx.link(Link::Connected); + } + Err(Fault::Unauthorized) => probe_ctx.link(Link::Expired), + Err(_) => { + eprintln!( + "chatd: {}: could not reach the server", + probe_ctx.account.network.label() + ); + probe_ctx.link(Link::Offline); + } + }) + .map_err(|err| err.to_string())?; + std::thread::Builder::new() + .name("tiny-commands".into()) + .spawn(move || { + serve_commands( + &ctx, + inbox, + Duration::from_millis(200), + |_| {}, + |command| match command { + Command::SignOut(reply) => { + delete_session(&saved); + let _ = reply.send(Ok(())); + } + other => other.unsupported(&ctx), + }, + ); + }) + .map_err(|err| err.to_string())?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn session() -> &'static str { + r#"{"base_url":"http://127.0.0.1:8080","token":"t","user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"owner"}"# + } + + #[test] + fn a_session_is_one_account_and_a_bad_one_is_an_error() { + let signed = accounts(session()).unwrap(); + assert_eq!(signed.len(), 1); + assert_eq!(signed[0].account, "11111111-1111-1111-1111-111111111111"); + assert_eq!(signed[0].name, "Ada"); + assert_eq!(signed[0].session, session()); + assert!(without(session(), &signed[0].account).is_none()); + assert_eq!( + without(session(), "someone-else").as_deref(), + Some(session()) + ); + assert!(accounts("not json").is_err()); + assert!(accounts(r#"{"base_url":"http://127.0.0.1:8080","token":"t","user_id":"../x","email":"a@b.co","display_name":"A","role":"owner"}"#).is_err()); + let saved = parse(session()).unwrap(); + assert_eq!(saved.role, "owner"); + assert_eq!(saved.base_url, "http://127.0.0.1:8080"); + assert_eq!(saved.email, "ada@example.com"); + } + + /// Speaks the session routes on a local socket: wrong password, open, me, delete, and a + /// redirect that must not be followed. + #[test] + fn session_routes_against_a_local_server() { + let base = serve(|request| { + let first = request.lines().next().unwrap_or(""); + if first.starts_with("POST /v1/session ") { + if request.contains("\"password\": \"nope\"") { + return http(401, "Unauthorized", r#"{"error":"invalid_credentials"}"#); + } + return http( + 200, + "OK", + r#"{"token":"tok_value","user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"owner"}"#, + ); + } + if first.starts_with("GET /v1/me ") { + if request.contains("Bearer tok_value") { + return http( + 200, + "OK", + r#"{"user_id":"11111111-1111-1111-1111-111111111111","email":"ada@example.com","display_name":"Ada","role":"owner"}"#, + ); + } + return http(401, "Unauthorized", r#"{"error":"unauthorized"}"#); + } + if first.starts_with("DELETE /v1/session ") { + return http(204, "No Content", ""); + } + if first.starts_with("POST /v1/bounce ") { + b"HTTP/1.1 302 Found\r\nLocation: http://127.0.0.1:1/stolen\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec() + } else { + http(404, "Not Found", "{}") + } + }); + + assert!(matches!( + post_session(&base, "ada@example.com", "nope"), + Err(Fault::Unauthorized) + )); + let saved = match post_session(&base, "ada@example.com", "correct-password") { + Ok(saved) => saved, + Err(_) => panic!("open failed"), + }; + assert_eq!(saved.user_id, "11111111-1111-1111-1111-111111111111"); + assert_eq!(saved.role, "owner"); + assert_eq!(saved.email, "ada@example.com"); + assert_eq!(saved.base_url, base); + assert_eq!(saved.token, "tok_value"); + + let me = match get_me(&saved) { + Ok(me) => me, + Err(_) => panic!("me failed"), + }; + assert_eq!(me.display_name, "Ada"); + assert_eq!(me.email, "ada@example.com"); + delete_session(&saved); + + let bounced = call( + "POST", + &endpoint(&base, "/v1/bounce"), + None, + Some(serde_json::json!({"email": "ada@example.com", "password": "correct-password"})), + ); + assert!(matches!(bounced, Err(Fault::Unreachable))); + } + + /// Against a tinyserver that is already serving. Not part of `cargo test`: it needs + /// `TINY_LIVE_BASE`, `TINY_LIVE_EMAIL`, and `TINY_LIVE_PASSWORD`. + #[test] + #[ignore] + fn live_owner_round_trip() { + let base = std::env::var("TINY_LIVE_BASE").expect("TINY_LIVE_BASE"); + let email = std::env::var("TINY_LIVE_EMAIL").expect("TINY_LIVE_EMAIL"); + let password = std::env::var("TINY_LIVE_PASSWORD").expect("TINY_LIVE_PASSWORD"); + let saved = match post_session(&base, &email, &password) { + Ok(saved) => saved, + Err(_) => panic!("open failed"), + }; + assert_eq!(saved.role, "owner"); + assert_eq!(saved.email, email.trim().to_ascii_lowercase()); + let me = match get_me(&saved) { + Ok(me) => me, + Err(_) => panic!("me failed"), + }; + assert_eq!(me.email, saved.email); + delete_session(&saved); + assert!(matches!(get_me(&saved), Err(Fault::Unauthorized))); + } + + 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() + ) + .into_bytes() + } + + 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 || { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + listener.set_nonblocking(true).expect("nonblocking"); + while std::time::Instant::now() < deadline { + match listener.accept() { + Ok((mut stream, _)) => { + 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]; + while got.windows(4).all(|window| window != b"\r\n\r\n") { + 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)); + } + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(_) => break, + } + } + }); + format!("http://127.0.0.1:{port}") + } +} diff --git a/crates/chatd/src/adapters/tiny/signin.rs b/crates/chatd/src/adapters/tiny/signin.rs new file mode 100644 index 0000000..6a6619a --- /dev/null +++ b/crates/chatd/src/adapters/tiny/signin.rs @@ -0,0 +1,368 @@ +//! Signing in to a Tiny server the owner already runs: the address, then the email, then the +//! 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 crate::hub::Hub; +use protocol::{AccountKey, Network, SignInInput, SignInProblem, SignInStep}; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// Keeps a finished session and starts its account. +pub type Adopt = Box Result, String> + Send + Sync>; + +struct Flow { + server: String, + email: String, + step: SignInStep, + adopt: Adopt, +} + +static FLOW: Mutex> = Mutex::new(None); + +fn current() -> MutexGuard<'static, Option> { + FLOW.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// An http or https origin: no user, no query, no fragment, no path. A trailing slash is dropped. +pub(crate) fn base_url(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() || raw.chars().any(char::is_whitespace) { + return None; + } + let (scheme, rest) = raw.split_once("://")?; + if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") { + return None; + } + if rest.contains(['@', '?', '#', '\\']) { + return None; + } + let rest = rest.strip_suffix('/').unwrap_or(rest); + if rest.is_empty() || rest.contains('/') { + return None; + } + let ok = if let Some(v6) = rest.strip_prefix('[') { + let (addr, after) = v6.split_once(']')?; + !addr.is_empty() + && addr + .chars() + .all(|c| c.is_ascii_hexdigit() || c == ':' || c == '.') + && match after { + "" => true, + port => port_ok(port.strip_prefix(':')?), + } + } else { + match rest.rsplit_once(':') { + Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => { + host_ok(host) && port_ok(port) + } + Some(_) => false, + None => host_ok(rest), + } + }; + ok.then(|| format!("{}://{rest}", scheme.to_ascii_lowercase())) +} + +fn host_ok(host: &str) -> bool { + !host.is_empty() + && !host.starts_with(['.', '-']) + && !host.ends_with('.') + && host + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') +} + +fn port_ok(port: &str) -> bool { + port.parse::().is_ok_and(|port| port > 0) +} + +/// One `@`, something on each side, and no whitespace. +pub(crate) fn email_ok(raw: &str) -> bool { + let email = raw.trim(); + if email.is_empty() || email.chars().any(char::is_whitespace) { + return false; + } + let Some((local, domain)) = email.split_once('@') else { + return false; + }; + !local.is_empty() && !domain.is_empty() && !domain.contains('@') +} + +/// Begins a sign-in, or with `input` answers the step on show. +pub fn step( + _hub: &Arc, + input: Option, + adopt: Adopt, +) -> Result { + let mut held = current(); + let Some(input) = input else { + if held.is_none() { + *held = Some(Flow { + server: String::new(), + email: String::new(), + step: SignInStep::Server { problem: None }, + adopt, + }); + } + return Ok(held + .as_ref() + .map(|flow| flow.step.clone()) + .unwrap_or(SignInStep::Server { problem: None })); + }; + let Some(flow) = held.as_mut() else { + return Ok(SignInStep::Failed { + reason: "That sign-in is over. Start again.".to_owned(), + }); + }; + let next = flow.answer(input); + if matches!(next, SignInStep::Done { .. } | SignInStep::Failed { .. }) { + *held = None; + } + Ok(next) +} + +/// The user closed the sheet. Nothing was kept. +pub fn cancel() { + *current() = None; +} + +impl Flow { + fn answer(&mut self, input: SignInInput) -> SignInStep { + let next = match (&self.step, input) { + (SignInStep::Server { .. }, SignInInput::Server { url }) => match base_url(&url) { + Some(server) => { + self.server = server.clone(); + SignInStep::Email { + server, + problem: None, + } + } + None => SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid), + }, + }, + (SignInStep::Email { .. }, SignInInput::Email { email }) => { + if email_ok(&email) { + self.email = email.trim().to_owned(); + SignInStep::Password { + hint: String::new(), + problem: None, + } + } else { + SignInStep::Email { + server: self.server.clone(), + problem: Some(SignInProblem::EmailInvalid), + } + } + } + (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) + } + _ => self.step.clone(), + }; + self.step = next.clone(); + next + } + + fn submit(&self, password: &str) -> SignInStep { + let again = |problem| SignInStep::Password { + hint: String::new(), + 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(), + }, + } + } + Err(Fault::Unauthorized) => again(SignInProblem::PasswordInvalid), + Err(Fault::Unreachable) => again(SignInProblem::Unreachable), + Err(Fault::BadAnswer) => SignInStep::Failed { + reason: "The server's answer could not be read.".to_owned(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::Store; + + fn hub() -> Arc { + Arc::new(Hub::new( + Store::open(std::path::Path::new(":memory:")).expect("a store in memory"), + )) + } + + fn adopt() -> Adopt { + Box::new(|_session| Ok(Vec::new())) + } + + #[test] + fn a_bad_address_stays_on_the_server_step_and_a_bad_email_stays_on_email() { + cancel(); + let hub = hub(); + assert_eq!( + step(&hub, None, adopt()).unwrap(), + SignInStep::Server { problem: None } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Server { + url: "not a url".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Server { + url: "http://user:pw@127.0.0.1:8080".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Server { + url: "http://127.0.0.1:8080/v1".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Server { + problem: Some(SignInProblem::UrlInvalid) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Server { + url: "http://127.0.0.1:8080/".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Email { + server: "http://127.0.0.1:8080".into(), + problem: None + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Email { + email: "not an email".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Email { + server: "http://127.0.0.1:8080".into(), + problem: Some(SignInProblem::EmailInvalid) + } + ); + assert_eq!( + step( + &hub, + Some(SignInInput::Email { + email: "ada@example.com".into() + }), + adopt() + ) + .unwrap(), + SignInStep::Password { + hint: String::new(), + problem: None + } + ); + assert_eq!( + step(&hub, Some(SignInInput::Back), adopt()).unwrap(), + SignInStep::Email { + server: "http://127.0.0.1:8080".into(), + problem: None + } + ); + assert_eq!( + step(&hub, Some(SignInInput::Back), adopt()).unwrap(), + SignInStep::Server { problem: None } + ); + cancel(); + } + + #[test] + fn the_address_is_an_origin_and_the_email_has_one_at() { + assert_eq!( + base_url(" https://Example.com/ ").as_deref(), + Some("https://Example.com") + ); + assert_eq!( + base_url("http://[::1]:8080").as_deref(), + Some("http://[::1]:8080") + ); + assert_eq!(base_url("http://127.0.0.1:8080?x=1"), None); + assert_eq!(base_url("http://127.0.0.1:8080#top"), None); + assert_eq!(base_url("ftp://127.0.0.1:8080"), None); + assert_eq!(base_url("http://127.0.0.1:0"), None); + assert!(email_ok("ada@example.com")); + assert!(email_ok(" a@b ")); + assert!(!email_ok("ada")); + assert!(!email_ok("a@b@c")); + assert!(!email_ok("a @b.co")); + assert!(!email_ok("@b.co")); + assert!(!format!( + "{:?}", + SignInInput::Server { + url: "http://secret.example".into() + } + ) + .contains("secret")); + assert!(!format!( + "{:?}", + SignInInput::Email { + email: "ada@example.com".into() + } + ) + .contains("ada")); + assert!(!format!( + "{:?}", + SignInInput::Password { + password: "hunter2".into() + } + ) + .contains("hunter2")); + } +} diff --git a/crates/chatd/src/blobs/fetch.rs b/crates/chatd/src/blobs/fetch.rs index c4508b2..492e18e 100644 --- a/crates/chatd/src/blobs/fetch.rs +++ b/crates/chatd/src/blobs/fetch.rs @@ -61,6 +61,8 @@ impl Hosts { Network::Signal => (&[], &[]), // Media is encrypted for the phone. The adapter fetches it itself, later. Network::Messages => (&[], &[]), + // A Tiny server has no files in this slice. + Network::Tiny => (&[], &[]), }; if session.iter().any(|name| under(&host, name)) { Some(Trust::Session) diff --git a/crates/chatd/src/demo.rs b/crates/chatd/src/demo.rs index 0051b2a..374bba5 100644 --- a/crates/chatd/src/demo.rs +++ b/crates/chatd/src/demo.rs @@ -96,6 +96,7 @@ fn me_of(account: &AccountKey) -> &'static str { Network::Discord => discord::ME, Network::Signal => signal::ME, Network::Messages => "messages-me", + Network::Tiny => "tiny-me", } } @@ -231,7 +232,7 @@ fn link_of(account: &AccountKey, id: &str) -> String { Network::Teams => format!("https://teams.microsoft.com/l/chat/0/0?users={id}"), Network::Discord => format!("discord://-/users/{id}"), // Signal opens a person only by their phone number or username, which are not ids. - Network::Signal | Network::Messages => String::new(), + Network::Signal | Network::Messages | Network::Tiny => String::new(), } } diff --git a/crates/chatd/src/demo/adapter.rs b/crates/chatd/src/demo/adapter.rs index 98e7539..97d89f0 100644 --- a/crates/chatd/src/demo/adapter.rs +++ b/crates/chatd/src/demo/adapter.rs @@ -97,7 +97,7 @@ impl Demo { Network::Teams => format!("19:{}_robin@unq.gbl.spaces", number.parse::().unwrap_or(0)), Network::Discord => format!("500000000000000{number}"), // A Signal chat with a person is named by the person. - Network::Signal | Network::Messages => person_id.to_owned(), + Network::Signal | Network::Messages | Network::Tiny => person_id.to_owned(), } } @@ -330,7 +330,7 @@ impl Demo { } } // Signal tells a linked device nothing more about a person. - Network::Signal | Network::Messages => {} + Network::Signal | Network::Messages | Network::Tiny => {} } about } @@ -479,7 +479,7 @@ impl Demo { peer: Some(person_id.to_owned()), call: match self.ctx.account.network { Network::Discord => CallSupport::Native, - Network::Signal => CallSupport::None, + Network::Signal | Network::Tiny => CallSupport::None, _ => CallSupport::Handoff, }, ..ConversationRow::new(id.clone(), ConversationKind::Direct, person.name) @@ -605,6 +605,7 @@ impl Demo { Network::Teams => crate::adapters::teams::message::reaction_rules(), Network::Signal => crate::adapters::signal::reaction_rules(), Network::Messages => crate::adapters::messages::reaction_rules(), + Network::Tiny => crate::adapters::messages::reaction_rules(), } } @@ -676,6 +677,7 @@ impl Demo { let link = match demo.ctx.account.network { Network::Slack => format!("https://app.slack.com/huddle/{account}/{conversation_id}"), Network::Messages => "https://messages.google.com/web/".to_owned(), + Network::Tiny => String::new(), _ => format!("https://discord.com/channels/@me/{conversation_id}"), }; let _ = reply.send(Ok(link)); diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index f155a2e..1c97b57 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -1201,6 +1201,10 @@ impl Chat { if network == Network::Signal { return error(ErrorCode::Unsupported, "Signal signs in by linking from your phone, not in a window."); } + // Tiny signs in through the sheet (`SignInBegin`), never the browser helper. + if network == Network::Tiny { + return error(ErrorCode::Unsupported, "A Tiny server signs in in the sheet, not in a window."); + } // A scratch directory signs nobody in. A test may still name a helper of its own, to // see what it is run with and how its answer is told; its session is never kept. if !session::enabled() && std::env::var_os("TINYCLIENT_LOGIN").is_none() { @@ -1250,6 +1254,7 @@ impl Chat { Network::Telegram => adapters::telegram::signin::step(&self.hub, input, Box::new(adopt)), Network::Signal => adapters::signal::signin::step(&self.hub, input, Box::new(adopt)), Network::Messages => adapters::messages::signin::step(&self.hub, input, Box::new(adopt)), + Network::Tiny => adapters::tiny::signin::step(&self.hub, input, Box::new(adopt)), _ => Err(format!("{} signs in in its own window.", network.label())), }; match step { @@ -1631,6 +1636,7 @@ impl Chat { Network::Telegram => adapters::telegram::signin::cancel(), Network::Signal => adapters::signal::signin::cancel(), Network::Messages => adapters::messages::signin::cancel(), + Network::Tiny => adapters::tiny::signin::cancel(), _ => {} } Response::Ok diff --git a/crates/chatd/src/store.rs b/crates/chatd/src/store.rs index 817b666..e5efcff 100644 --- a/crates/chatd/src/store.rs +++ b/crates/chatd/src/store.rs @@ -1522,10 +1522,12 @@ impl Store { let pattern = format!("%{}%", like_prefix(query)); self.db .prepare( - "SELECT m.network, m.account, m.conversation, m.id, m.sent, m.text, m.body, c.title, c.space_title + "SELECT m.network, m.account, m.conversation, m.id, m.sent, m.text, m.body, c.title, + COALESCE(s.name, '') FROM message m JOIN conversation c ON c.network = m.network AND c.account = m.account AND c.id = m.conversation + LEFT JOIN space s ON s.network = c.network AND s.account = c.account AND s.id = c.space WHERE m.pending = 0 AND m.text LIKE ?8 ESCAPE '\\' AND (?1 IS NULL OR m.network = ?1) AND (?2 IS NULL OR (m.network = ?2 AND m.account = ?3 AND m.conversation = ?4)) @@ -2638,10 +2640,14 @@ mod tests { store.ingest(&Key::new(&account, "guilded"), &said("1", "world of the guild", 10), false).unwrap(); store.ingest(&Key::new(&account, "loose"), &said("2", "world outside", 20), false).unwrap(); + store + .replace_spaces(&account, &[SpaceRow { id: "guild".into(), name: "Moss & Lichen".into(), icon: None, position: 0 }]) + .unwrap(); let space = Key::new(&account, "guild"); let hits = store.search_messages(None, None, Some(&space), "world", 10).unwrap(); assert_eq!(hits.iter().map(|hit| hit.message_id.as_str()).collect::>(), ["1"]); assert_eq!(hits[0].title, "#in the space"); + assert_eq!(hits[0].space_title, "Moss & Lichen"); // The same space id on another network or account holds other rows: nothing answers. let elsewhere = Key::new(&AccountKey { network: Network::Slack, account: "me".into() }, "guild"); assert!(store.search_messages(None, None, Some(&elsewhere), "world", 10).unwrap().is_empty()); diff --git a/crates/chatd/src/upload.rs b/crates/chatd/src/upload.rs index 7dfcac1..cd6e854 100644 --- a/crates/chatd/src/upload.rs +++ b/crates/chatd/src/upload.rs @@ -698,6 +698,8 @@ fn uploads_to(network: Network, url: &str) -> bool { // Encrypted before it leaves, by the adapter: nothing goes through this helper. Network::Signal => &[], Network::Messages => &[], + // Nothing is uploaded to a Tiny server in this slice. + Network::Tiny => &[], }; hosts.iter().any(|name| super::fetch::under(&host, name)) } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 64a6ea9..0d47c8a 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -70,16 +70,19 @@ pub enum Network { Signal, /// Android Messages: Google Messages on the phone, paired to this computer. Messages, + /// A Tiny server the owner already runs. Optional, and separate from the others. + Tiny, } impl Network { - pub const ALL: [Network; 6] = [ + pub const ALL: [Network; 7] = [ Network::Telegram, Network::Discord, Network::Slack, Network::Teams, Network::Signal, Network::Messages, + Network::Tiny, ]; /// The label the shell shows on a row. The network is a label, never a different layout. @@ -91,6 +94,7 @@ impl Network { Network::Teams => "Teams", Network::Signal => "Signal", Network::Messages => "Messages", + Network::Tiny => "Tiny server", } } } diff --git a/crates/protocol/src/settings.rs b/crates/protocol/src/settings.rs index 344cccf..bf6c222 100644 --- a/crates/protocol/src/settings.rs +++ b/crates/protocol/src/settings.rs @@ -116,6 +116,7 @@ fn network_word(network: Network) -> &'static str { Network::Teams => "teams", Network::Signal => "signal", Network::Messages => "messages", + Network::Tiny => "tiny", } } diff --git a/crates/protocol/src/signin.rs b/crates/protocol/src/signin.rs index 2b7a11e..524c1fb 100644 --- a/crates/protocol/src/signin.rs +++ b/crates/protocol/src/signin.rs @@ -34,6 +34,10 @@ pub enum SignInProblem { CodeInvalid, CodeExpired, PasswordInvalid, + /// Not an http or https origin: a user, a query, a fragment, or a path. + UrlInvalid, + /// Not one address: missing `@`, more than one, or whitespace. + EmailInvalid, /// The network will not take another try before `until` (unix seconds). TooMany { until: i64 }, /// Anything else, in words the user can read. @@ -75,12 +79,24 @@ pub enum SignInStep { problem: Option, }, /// The account has a second password. `hint` is the user's own, as the network keeps it. + /// A Tiny server uses this step for the account password; `hint` is empty there. Password { #[serde(default, skip_serializing_if = "String::is_empty")] hint: String, #[serde(default, skip_serializing_if = "Option::is_none")] problem: Option, }, + /// Ask for a Tiny server's address. `problem` is skipped when there is none. + Server { + #[serde(default, skip_serializing_if = "Option::is_none")] + problem: Option, + }, + /// Ask for the email on that server. `server` is the address that was accepted. + Email { + 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")] @@ -101,6 +117,10 @@ pub enum SignInInput { Phone { number: String }, Code { code: String }, Password { password: String }, + /// A Tiny server's address, as typed. + Server { url: String }, + /// The email on that server, as typed. + Email { email: 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. @@ -116,6 +136,8 @@ impl fmt::Debug for SignInInput { SignInInput::Phone { .. } => "Phone { .. }", SignInInput::Code { .. } => "Code { .. }", SignInInput::Password { .. } => "Password { .. }", + SignInInput::Server { .. } => "Server { .. }", + SignInInput::Email { .. } => "Email { .. }", SignInInput::Resend => "Resend", SignInInput::Back => "Back", }) @@ -131,7 +153,17 @@ mod tests { let typed = SignInInput::Password { password: "hunter2".into() }; assert!(!format!("{typed:?}").contains("hunter2")); assert!(!format!("{:?}", SignInInput::Code { code: "12345".into() }).contains("12345")); + let url = SignInInput::Server { url: "http://127.0.0.1:8080".into() }; + let email = SignInInput::Email { email: "ada@example.com".into() }; + assert_eq!(format!("{url:?}"), "Server { .. }"); + assert_eq!(format!("{email:?}"), "Email { .. }"); + assert!(!format!("{url:?}").contains("127.0.0.1")); + assert!(!format!("{email:?}").contains("ada@")); + assert!(!format!("{email:?}").contains("example.com")); // 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 }; + assert_eq!(serde_json::to_string(&email_step).unwrap(), r#"{"step":"email","server":"http://127.0.0.1:8080"}"#); assert_eq!(serde_json::from_str::(r#"{"step":"phone"}"#).unwrap(), SignInStep::Phone { problem: None }); assert_eq!( serde_json::from_str::(r#"{"step":"qr"}"#).unwrap(), diff --git a/crates/tinyclient/assets/networks/tiny.svg b/crates/tinyclient/assets/networks/tiny.svg new file mode 100644 index 0000000..efc78ad --- /dev/null +++ b/crates/tinyclient/assets/networks/tiny.svg @@ -0,0 +1 @@ + diff --git a/crates/tinyclient/src/gui/app/prefs.rs b/crates/tinyclient/src/gui/app/prefs.rs index e97bead..6243613 100644 --- a/crates/tinyclient/src/gui/app/prefs.rs +++ b/crates/tinyclient/src/gui/app/prefs.rs @@ -132,6 +132,7 @@ fn network_word(network: Network) -> &'static str { Network::Teams => "teams", Network::Signal => "signal", Network::Messages => "messages", + Network::Tiny => "tiny", } } diff --git a/crates/tinyclient/src/gui/assets.rs b/crates/tinyclient/src/gui/assets.rs index 92c8c76..ce844cd 100644 --- a/crates/tinyclient/src/gui/assets.rs +++ b/crates/tinyclient/src/gui/assets.rs @@ -17,6 +17,7 @@ assets!( "networks/microsoft-teams.svg", "networks/signal.svg", "networks/messages.svg", + "networks/tiny.svg", "icons/arrow-down.svg", "icons/arrow-left.svg", "icons/arrow-right.svg", @@ -142,6 +143,7 @@ pub fn network_asset(network: Network, points: f32) -> SharedString { Network::Teams => "microsoft-teams", Network::Signal => "signal", Network::Messages => "messages", + Network::Tiny => "tiny", }; format!("networks/{name}.svg@{}", points.ceil()).into() } diff --git a/crates/tinyclient/src/gui/composer.rs b/crates/tinyclient/src/gui/composer.rs index cde941c..8e9d5b9 100644 --- a/crates/tinyclient/src/gui/composer.rs +++ b/crates/tinyclient/src/gui/composer.rs @@ -161,7 +161,7 @@ fn sends_typing(account: &protocol::AccountKey) -> bool { /// How often a network needs to hear that we are still typing for its line to stay up. fn typing_interval(network: Network) -> Duration { Duration::from_secs(match network { - Network::Discord | Network::Signal | Network::Messages => 8, + Network::Discord | Network::Signal | Network::Messages | Network::Tiny => 8, Network::Teams => 6, Network::Slack | Network::Telegram => 4, }) diff --git a/crates/tinyclient/src/gui/composer/mention.rs b/crates/tinyclient/src/gui/composer/mention.rs index e45f594..e64d74c 100644 --- a/crates/tinyclient/src/gui/composer/mention.rs +++ b/crates/tinyclient/src/gui/composer/mention.rs @@ -55,7 +55,7 @@ pub fn wire(text: &str, tokens: &[Token], network: Network) -> String { // Slack takes the name as typed. (Network::Slack, Kind::Emoji) => out.push_str(&token.label), // No adapter yet says what these take, so the name goes as it reads. - (Network::Telegram | Network::Teams | Network::Signal | Network::Messages, _) => out.push_str(&token.label), + (Network::Telegram | Network::Teams | Network::Signal | Network::Messages | Network::Tiny, _) => out.push_str(&token.label), } at = token.range.end; } diff --git a/crates/tinyclient/src/gui/composer/niceties.rs b/crates/tinyclient/src/gui/composer/niceties.rs index 8427616..1a1727e 100644 --- a/crates/tinyclient/src/gui/composer/niceties.rs +++ b/crates/tinyclient/src/gui/composer/niceties.rs @@ -59,7 +59,7 @@ pub fn broadcast(text: &str, network: Network) -> Option { let words: &[(&str, Broadcast)] = match network { Network::Discord => &[("@everyone", Broadcast::All), ("@here", Broadcast::Here)], Network::Slack => &[("@channel", Broadcast::All), ("@everyone", Broadcast::All), ("@here", Broadcast::Here)], - Network::Telegram | Network::Teams | Network::Signal | Network::Messages => &[], + Network::Telegram | Network::Teams | Network::Signal | Network::Messages | Network::Tiny => &[], }; let found = |word: &str| { text.match_indices(word).any(|(at, _)| { diff --git a/crates/tinyclient/src/gui/conversation/actions.rs b/crates/tinyclient/src/gui/conversation/actions.rs index 091ab9b..82a4736 100644 --- a/crates/tinyclient/src/gui/conversation/actions.rs +++ b/crates/tinyclient/src/gui/conversation/actions.rs @@ -108,7 +108,7 @@ pub fn message_link(conversation: &protocol::Key, space: Option<&str>, message: Some(format!("https://discord.com/channels/{place}/{channel}/{id}")) } Network::Slack => space.map(|team| format!("slack://channel?team={team}&id={channel}&message={id}")), - Network::Telegram | Network::Teams | Network::Signal | Network::Messages => None, + Network::Telegram | Network::Teams | Network::Signal | Network::Messages | Network::Tiny => None, } } diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index 6e79757..effcfca 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -30,9 +30,9 @@ pub const WINDOW_MIN: (f32, f32) = (540., 440.); /// Height of the strip that starts every region, level with the traffic lights. pub const TOP_BAR: f32 = 52.; /// Networks in the order the rail and first launch list them. -pub const NETWORK_ORDER: [Network; 6] = [Network::Discord, Network::Slack, Network::Telegram, Network::Teams, Network::Signal, Network::Messages]; +pub const NETWORK_ORDER: [Network; 7] = [Network::Discord, Network::Slack, Network::Telegram, Network::Teams, Network::Signal, Network::Messages, Network::Tiny]; /// The networks tinyclient signs in to today. The others are shown as coming later. -pub const AVAILABLE: [Network; 6] = NETWORK_ORDER; +pub const AVAILABLE: [Network; 7] = NETWORK_ORDER; /// Lets a strip of chrome move the window, as a title bar would. pub fn drags_window(strip: Div) -> Div { @@ -483,6 +483,7 @@ impl Shell { (_, _, Network::Teams) => "Work and school accounts; calls open in Teams".into(), (_, _, Network::Signal) => "Link this computer from your phone; text for now".into(), (_, _, Network::Messages) => "Pair from your Android phone. It has to stay online.".into(), + (_, _, Network::Tiny) => "A server you already run".into(), }; let failed = self.sign_in_failed.as_ref().filter(|(failed, _)| *failed == network); let action = match (available, waiting) { diff --git a/crates/tinyclient/src/gui/people/list.rs b/crates/tinyclient/src/gui/people/list.rs index 9ef3a6f..967dcd3 100644 --- a/crates/tinyclient/src/gui/people/list.rs +++ b/crates/tinyclient/src/gui/people/list.rs @@ -816,6 +816,7 @@ impl PeopleList { Network::Teams => "Teams", Network::Signal => "Signal contacts", Network::Messages => "Messages", + Network::Tiny => "Tiny server", }; let shell = self.shell.clone(); let link = div() @@ -995,6 +996,7 @@ impl PeopleList { (Chip::Online, Some(Network::Discord)) => ("No friends online".to_owned(), "Friends who come online show up here.".to_owned()), (_, Some(Network::Discord)) => ("No friends on Discord yet".to_owned(), "Friends you add in Discord appear here.".to_owned()), (_, Some(Network::Telegram)) => ("No contacts yet".to_owned(), "Type a @username to find someone.".to_owned()), + (_, Some(Network::Tiny)) => ("Nothing here yet".to_owned(), String::new()), (_, Some(_)) => ("No direct messages yet".to_owned(), "Type a name to find anyone.".to_owned()), (_, None) => ("Nobody's around right now".to_owned(), "People you talk to show up here.".to_owned()), } diff --git a/crates/tinyclient/src/gui/people/status.rs b/crates/tinyclient/src/gui/people/status.rs index 4e8364b..c9bf2cf 100644 --- a/crates/tinyclient/src/gui/people/status.rs +++ b/crates/tinyclient/src/gui/people/status.rs @@ -47,12 +47,12 @@ pub fn states(network: Network) -> &'static [Presence] { Network::Discord | Network::Teams => &[Presence::Online, Presence::Idle, Presence::Dnd, Presence::Offline], Network::Slack => &[Presence::Online, Presence::Idle], // Signal shows no presence at all. - Network::Telegram | Network::Signal | Network::Messages => &[], + Network::Telegram | Network::Signal | Network::Messages | Network::Tiny => &[], } } fn has_status_text(network: Network) -> bool { - !matches!(network, Network::Telegram | Network::Signal | Network::Messages) + !matches!(network, Network::Telegram | Network::Signal | Network::Messages | Network::Tiny) } /// The word for a state of our own: choosing to look offline has the network's name for it. diff --git a/crates/tinyclient/src/gui/picker/emoji.rs b/crates/tinyclient/src/gui/picker/emoji.rs index 6232542..88f4c42 100644 --- a/crates/tinyclient/src/gui/picker/emoji.rs +++ b/crates/tinyclient/src/gui/picker/emoji.rs @@ -360,6 +360,7 @@ pub(super) fn account_word(account: &AccountKey) -> String { Network::Teams => "teams", Network::Signal => "signal", Network::Messages => "messages", + Network::Tiny => "tiny", }; format!("{network}:{}", account.account) } diff --git a/crates/tinyclient/src/gui/profile/facts.rs b/crates/tinyclient/src/gui/profile/facts.rs index e7f1c30..fcb3d8d 100644 --- a/crates/tinyclient/src/gui/profile/facts.rs +++ b/crates/tinyclient/src/gui/profile/facts.rs @@ -401,8 +401,8 @@ pub fn place_word(network: Network, count: u32) -> &'static str { (Network::Teams, _) => "teams", (Network::Telegram, 1) => "group", (Network::Telegram, _) => "groups", - (Network::Signal | Network::Messages, 1) => "group", - (Network::Signal | Network::Messages, _) => "groups", + (Network::Signal | Network::Messages | Network::Tiny, 1) => "group", + (Network::Signal | Network::Messages | Network::Tiny, _) => "groups", } } @@ -641,7 +641,7 @@ pub fn conversation_link(conversation: &Conversation, info: Option<&Conversation Network::Slack => conversation.space.as_ref().map(|team| format!("slack://channel?team={team}&id={id}")), Network::Telegram | Network::Teams => told, // Signal opens a chat only by a phone number or username, which a row does not carry. - Network::Signal | Network::Messages => None, + Network::Signal | Network::Messages | Network::Tiny => None, } } @@ -649,7 +649,7 @@ pub fn space_link(space: &protocol::Key) -> Option { match space.network { Network::Discord => Some(format!("https://discord.com/channels/{}", space.id)), Network::Slack => Some(format!("slack://open?team={}", space.id)), - Network::Telegram | Network::Teams | Network::Signal | Network::Messages => None, + Network::Telegram | Network::Teams | Network::Signal | Network::Messages | Network::Tiny => None, } } diff --git a/crates/tinyclient/src/gui/profile/shared.rs b/crates/tinyclient/src/gui/profile/shared.rs index 36aef84..8f710e0 100644 --- a/crates/tinyclient/src/gui/profile/shared.rs +++ b/crates/tinyclient/src/gui/profile/shared.rs @@ -67,6 +67,7 @@ pub fn source(network: Network, tab: Tab, page: &Page) -> Source { // CONTRACT: Teams has no confirmed route to a chat's files, so its list is never searched. match network { Network::Teams => Source { line, search: false, open_in: true }, + Network::Tiny => Source { line, search: false, open_in: false }, _ => Source { line, search: true, open_in: false }, } } diff --git a/crates/tinyclient/src/gui/rail.rs b/crates/tinyclient/src/gui/rail.rs index e413383..61903c2 100644 --- a/crates/tinyclient/src/gui/rail.rs +++ b/crates/tinyclient/src/gui/rail.rs @@ -124,7 +124,7 @@ impl Shell { let workspaces = snapshot.accounts_of(network); let name = match (link, workspaces) { (Link::Connected, 0 | 1) => network.label().to_owned(), - (Link::Connected, n) => format!("{} · {n} {}", network.label(), if matches!(network, Network::Discord | Network::Telegram | Network::Signal | Network::Messages) { "accounts" } else { "workspaces" }), + (Link::Connected, n) => format!("{} · {n} {}", network.label(), if matches!(network, Network::Discord | Network::Telegram | Network::Signal | Network::Messages | Network::Tiny) { "accounts" } else { "workspaces" }), (other, _) => format!("{} · {}", network.label(), super::components::link_word(other)), }; let trouble = snapshot.accounts.iter().any(|a| a.key.network == network && (a.link == Link::Expired || !a.required.is_empty())); diff --git a/crates/tinyclient/src/gui/settings/pages.rs b/crates/tinyclient/src/gui/settings/pages.rs index 379acb3..142d0d0 100644 --- a/crates/tinyclient/src/gui/settings/pages.rs +++ b/crates/tinyclient/src/gui/settings/pages.rs @@ -627,7 +627,7 @@ impl SettingsWindow { ("P1", Network::Telegram) | ("P2", Network::Telegram) => (Some("Always on"), Some("Telegram does not let an app turn this off.")), ("P1", Network::Signal) | ("P2", Network::Signal) => (Some("Set on your phone"), Some("Signal takes this from your phone's settings.")), ("P1", Network::Messages) => (Some("Sent to the phone"), Some("Messages shows typing on your other devices.")), - ("P2", Network::Discord | Network::Slack) => continue, + ("P2", Network::Discord | Network::Slack) | ("P1" | "P2", Network::Tiny) => continue, _ => (None, None), }; let name = if account.name.is_empty() { network_name(network).to_owned() } else { account.name.clone() }; diff --git a/crates/tinyclient/src/gui/sidebar.rs b/crates/tinyclient/src/gui/sidebar.rs index afe0d0f..8bd1460 100644 --- a/crates/tinyclient/src/gui/sidebar.rs +++ b/crates/tinyclient/src/gui/sidebar.rs @@ -1435,6 +1435,8 @@ fn network_app(network: Network) -> &'static str { // Signal has no web app: what only it can do is done on the phone. Network::Signal => "https://signal.org/download/", Network::Messages => "https://messages.google.com/web/", + // Nothing only a Tiny server's own app can do is asked from here. + Network::Tiny => "", } } diff --git a/crates/tinyclient/src/gui/sidebar/home_view.rs b/crates/tinyclient/src/gui/sidebar/home_view.rs index 1e303e1..aba1b27 100644 --- a/crates/tinyclient/src/gui/sidebar/home_view.rs +++ b/crates/tinyclient/src/gui/sidebar/home_view.rs @@ -97,7 +97,7 @@ impl Shell { } } Network::Teams | Network::Signal | Network::Messages => read.chats = whole(network, None, Shelf::Main)?.0, - Network::Slack => {} + Network::Slack | Network::Tiny => {} } Ok(read) }; diff --git a/crates/tinyclient/src/gui/sidebar/switcher.rs b/crates/tinyclient/src/gui/sidebar/switcher.rs index fd21f02..f232541 100644 --- a/crates/tinyclient/src/gui/sidebar/switcher.rs +++ b/crates/tinyclient/src/gui/sidebar/switcher.rs @@ -127,7 +127,7 @@ fn space_word(network: Network) -> &'static str { Network::Teams => "Team", Network::Telegram => "Folder", // Signal has no Spaces; this is never asked of it. - Network::Signal | Network::Messages => "Group", + Network::Signal | Network::Messages | Network::Tiny => "Group", } } diff --git a/crates/tinyclient/src/gui/signin.rs b/crates/tinyclient/src/gui/signin.rs index 131882c..53e65b7 100644 --- a/crates/tinyclient/src/gui/signin.rs +++ b/crates/tinyclient/src/gui/signin.rs @@ -1,8 +1,9 @@ -//! The Telegram and Signal sign-in sheet (`docs/design/08-first-run-settings.md`, section 2). -//! Neither has a web page to show, so tinyclient draws the sign-in itself. Telegram: a code to +//! The Telegram, Signal, Messages, and Tiny server sign-in sheet (`docs/design/08-first-run-settings.md`, section 2). +//! None of them has a web page to show, so tinyclient draws the sign-in itself. Telegram: a code to //! 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. +//! 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. //! //! 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 @@ -84,6 +85,10 @@ pub struct Sheet { digits: Entity, /// The field behind the password's bullets. password: Entity, + /// A Tiny server's address. + url: Entity, + /// The email on that server. + email: 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. @@ -112,7 +117,7 @@ fn holder(cx: &mut App) -> Entity { /// Opens the sheet for a network that signs in here. `false` for one that signs in through its /// own web page, so the caller goes on to the helper window. pub fn open(network: Network, cx: &mut Context) -> bool { - if !matches!(network, Network::Telegram | Network::Signal | Network::Messages) { + if !matches!(network, Network::Telegram | Network::Signal | Network::Messages | Network::Tiny) { return false; } holder(cx).update(cx, |sheet, cx| sheet.open(network, cx)); @@ -144,14 +149,19 @@ fn now() -> i64 { /// The words for a problem, naming the network that is signing in. fn problem_words(network: Network, problem: &SignInProblem) -> String { let name = network.label(); + let tiny = network == Network::Tiny; match problem { + SignInProblem::Unreachable if tiny => "Couldn't reach that server. Check the address and your connection.".to_owned(), SignInProblem::Unreachable => format!("Couldn't reach {name}. Check your connection."), SignInProblem::PhoneInvalid => "That doesn't look like a phone number.".to_owned(), SignInProblem::PhoneUnknown => format!("No {name} account uses this number. Create one in the {name} app first."), SignInProblem::PhoneBanned => format!("{name} has blocked this number."), SignInProblem::CodeInvalid => "That code isn't right. Check it and type it again.".to_owned(), SignInProblem::CodeExpired => "That code has expired.".to_owned(), + SignInProblem::PasswordInvalid if tiny => "That email or password isn't right.".to_owned(), SignInProblem::PasswordInvalid => "That password isn't right.".to_owned(), + SignInProblem::UrlInvalid => "That doesn't look like a server address.".to_owned(), + SignInProblem::EmailInvalid => "That doesn't look like an email address.".to_owned(), SignInProblem::TooMany { until } => format!("Too many tries. {name} allows another at {}.", clock(*until)), SignInProblem::Other { reason } => reason.clone(), } @@ -214,7 +224,9 @@ fn step_problem(step: &SignInStep) -> Option<&SignInProblem> { SignInStep::Qr { problem, .. } | SignInStep::Phone { problem } | SignInStep::Code { problem, .. } - | SignInStep::Password { problem, .. } => problem.as_ref(), + | SignInStep::Password { problem, .. } + | SignInStep::Server { problem } + | SignInStep::Email { problem, .. } => problem.as_ref(), SignInStep::Done { .. } | SignInStep::Failed { .. } => None, } } @@ -223,6 +235,22 @@ fn waiting() -> SignInStep { SignInStep::Qr { link: String::new(), expires: 0, problem: None } } +/// What the sheet draws before chatd has answered. Tiny starts on the address, not a code. +fn waiting_for(network: Network) -> SignInStep { + if network == Network::Tiny { + SignInStep::Server { problem: None } + } else { + waiting() + } +} + +/// `TINYCLIENT_SIGNIN` names a sheet. Tiny's label is "Tiny server"; `tiny` opens it too. +fn asked_network(word: &str) -> Option { + [Network::Telegram, Network::Signal, Network::Messages, Network::Tiny] + .into_iter() + .find(|network| network.label().eq_ignore_ascii_case(word) || (*network == Network::Tiny && word.eq_ignore_ascii_case("tiny"))) +} + fn failed(reason: &str) -> Face { Face::Step(SignInStep::Failed { reason: reason.to_owned() }) } @@ -235,6 +263,8 @@ impl Sheet { let number = cx.new(|cx| TextInput::new("Phone number", cx)); let digits = cx.new(|cx| TextInput::new("", cx)); let password = cx.new(|cx| TextInput::new("", cx)); + let url = cx.new(|cx| TextInput::new("Server URL", cx)); + let email = cx.new(|cx| TextInput::new("Email", cx)); for input in [&country, &number] { cx.subscribe(input, |this, _, event: &InputEvent, cx| match event { InputEvent::Submit => this.send_phone(cx), @@ -253,8 +283,18 @@ impl Sheet { InputEvent::Changed => cx.notify(), }) .detach(); + cx.subscribe(&url, |this, _, event: &InputEvent, cx| match event { + InputEvent::Submit => this.send_url(cx), + InputEvent::Changed => cx.notify(), + }) + .detach(); + cx.subscribe(&email, |this, _, event: &InputEvent, cx| match event { + InputEvent::Submit => this.send_email(cx), + InputEvent::Changed => cx.notify(), + }) + .detach(); // Escape in a field is the field's key; it hands it on. - for input in [&country, &number, &digits, &password] { + for input in [&country, &number, &digits, &password, &url, &email] { cx.subscribe(input, |this, _, event: &InputKey, cx| { if matches!(event, InputKey::Escape) { this.close(true, cx); @@ -275,6 +315,8 @@ impl Sheet { number, digits, password, + url, + email, focus: cx.focus_handle(), refocus: false, before: None, @@ -283,16 +325,16 @@ impl Sheet { } } - /// Once, at the first frame: `TINYCLIENT_SIGNIN=telegram` (or `signal`) opens the sheet, which is - /// how it is reached while the first-run row still says "Not available yet". A screenshot - /// build may ask for a made-up step instead. + /// Once, at the first frame: `TINYCLIENT_SIGNIN=telegram` (or `signal`, `messages`, `tiny`) opens + /// the sheet, which is how it is reached while the first-run row still says "Not available yet". + /// A screenshot build may ask for a made-up step instead. fn launch(&mut self, cx: &mut Context) { #[cfg(feature = "gui-shot")] if let Some(canned) = shot::canned() { self.show_canned(canned, cx); return; } - let asked = std::env::var("TINYCLIENT_SIGNIN").ok().and_then(|word| [Network::Telegram, Network::Signal, Network::Messages].into_iter().find(|n| n.label().eq_ignore_ascii_case(&word))); + let asked = std::env::var("TINYCLIENT_SIGNIN").ok().as_deref().and_then(asked_network); if let Some(network) = asked { self.open(network, cx); } @@ -322,7 +364,7 @@ impl Sheet { self.network = network; self.clear_fields(cx); // The tile is up at once, saying a code is on its way. - self.face = Some(Face::Step(waiting())); + self.face = Some(Face::Step(waiting_for(network))); self.refocus = true; self.begin(cx); cx.notify(); @@ -351,6 +393,8 @@ impl Sheet { self.clear_typed(cx); self.number.update(cx, |input, cx| input.set_text("", cx)); 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)); } /// Drops a typed code and password. They live nowhere else. @@ -536,7 +580,7 @@ impl Sheet { self.local = None; self.code = None; self.clear_typed(cx); - self.face = Some(Face::Step(waiting())); + self.face = Some(Face::Step(waiting_for(self.network))); self.refocus = true; // Bumped now, so an answer still on its way to the old sign-in is dropped. self.epoch += 1; @@ -605,12 +649,36 @@ impl Sheet { self.answer(SignInInput::Password { password }, cx); } + fn send_url(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let url = self.url.read(cx).text().to_owned(); + if url.trim().is_empty() { + return; + } + self.answer(SignInInput::Server { url }, cx); + } + + fn send_email(&mut self, cx: &mut Context) { + if self.busy { + return; + } + let email = self.email.read(cx).text().to_owned(); + if email.trim().is_empty() { + return; + } + self.answer(SignInInput::Email { email }, cx); + } + /// Where typing goes on the step on show. fn target(&self, cx: &App) -> FocusHandle { match &self.face { Some(Face::Step(SignInStep::Phone { .. })) => self.number.focus_handle(cx), Some(Face::Step(SignInStep::Code { .. })) => self.digits.focus_handle(cx), 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), _ => self.focus.clone(), } } @@ -684,11 +752,12 @@ impl Sheet { (None, Some((_, code))) => code_tile(code.clone()), (None, None) => quiet_tile().child(div().text_center().child(format!("Getting a code from {}", self.network.label()))), }; - let phone_only = matches!(self.network, Network::Signal | Network::Messages); + let phone_only = matches!(self.network, Network::Signal | Network::Messages | Network::Tiny); let steps = match self.network { Network::Signal => ["Open Signal on your phone.", "Go to Settings > Linked devices > Link new device.", "Point the phone at this code."], Network::Messages => ["Open Messages on your Android phone.", "Go to Settings > Device pairing > QR code scanner.", "Point the phone at this code."], - _ => ["Open Telegram on your phone.", "Go to Settings > Devices > Link Desktop Device.", "Point the phone at this code."], + Network::Tiny => ["The address of a server you already run.", "Then the email on that account.", "Then the password."], + Network::Telegram | Network::Discord | Network::Slack | Network::Teams => ["Open Telegram on your phone.", "Go to Settings > Devices > Link Desktop Device.", "Point the phone at this code."], }; // Signal and Messages link a computer only from the phone: there is no other way in to offer. let other_way = (!phone_only).then(|| self.answer_link("signin-use-phone", "Use a phone number instead", || SignInInput::UsePhone, cx)); @@ -854,27 +923,58 @@ impl Sheet { ) .child(hidden(&self.password, 12., 10., 64.)) .child(bullets); + 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.") + } 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.") + }; div() .flex() .flex_col() - .child(self.header("Two-step verification password", true, cx)) + .child(heading) .child(div().mt(px(16.)).child(secret)) .children(problem.map(|problem| error_line(problem_words(self.network, problem)).mt(px(6.)))) // `text_faint` does not sit on `overlay` (`01-system.md`, rule 7): one level up. - .children((!hint.is_empty()).then(|| div().mt(px(6.)).type_small().text_color(theme::text_soft()).child(format!("Hint from Telegram: {hint}")))) - .child( - footer(22.) - // Words in a link's color, not a link: the reset happens in Telegram's own app. - .child(div().type_ui().text_color(theme::accent_text()).child("Forgot it? Reset it in the Telegram app.")) - .child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_password, cx))), - ) + .children((!tiny && !hint.is_empty()).then(|| div().mt(px(6.)).type_small().text_color(theme::text_soft()).child(format!("Hint from Telegram: {hint}")))) + .child(footer(22.).child(aside).child(buttons().child(self.cancel("Cancel", cx)).child(self.primary("Continue", Sheet::send_password, cx)))) + } + + fn server_step(&self, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { + let focused = self.url.focus_handle(cx).is_focused(window); + let input = tall_field(focused, problem.is_some()).child(self.url.clone()); + div() + .flex() + .flex_col() + .child(self.header("Server", false, cx)) + .child(div().mt(px(6.)).mb(px(16.)).text_color(theme::text_soft()).child("The address of a server you already run.")) + .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)))) + } + + fn email_step(&self, server: &str, problem: Option<&SignInProblem>, window: &mut Window, cx: &Context) -> Div { + let focused = self.email.focus_handle(cx).is_focused(window); + let input = tall_field(focused, problem.is_some()).child(self.email.clone()); + div() + .flex() + .flex_col() + .child(self.header("Email", 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_email, cx)))) } fn title(&self) -> &'static str { match self.network { + Network::Telegram => "Sign in to Telegram", Network::Signal => "Sign in to Signal", Network::Messages => "Sign in to Messages", - _ => "Sign in to Telegram", + Network::Tiny => "Sign in to Tiny server", + Network::Discord | Network::Slack | Network::Teams => "Sign in to Telegram", } } @@ -1081,6 +1181,8 @@ impl Render for Sheet { self.code_step(*via, *length, phone, *next, *resend_at, problem, window, cx) } 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::Failed { reason } => self.failed_step(reason, cx), SignInStep::Done { .. } => div(), } @@ -1173,6 +1275,12 @@ mod tests { assert_eq!(words(SignInProblem::TooMany { until }), format!("Too many tries. Telegram allows another at {}.", clock(until))); // Signal is named for itself. assert_eq!(problem_words(Network::Signal, &SignInProblem::Unreachable), "Couldn't reach Signal. Check your connection."); + assert_eq!(problem_words(Network::Tiny, &SignInProblem::UrlInvalid), "That doesn't look like a server address."); + assert_eq!(problem_words(Network::Tiny, &SignInProblem::EmailInvalid), "That doesn't look like an email address."); + assert_eq!(problem_words(Network::Tiny, &SignInProblem::PasswordInvalid), "That email or password isn't right."); + assert_eq!(problem_words(Network::Tiny, &SignInProblem::Unreachable), "Couldn't reach that server. Check the address and your connection."); + assert_eq!(asked_network("tiny"), Some(Network::Tiny)); + assert_eq!(asked_network("Tiny server"), Some(Network::Tiny)); } #[test]