diff --git a/crates/chatd/src/adapters/signal/vault.rs b/crates/chatd/src/adapters/signal/vault.rs index 4cbb34e..9393a6e 100644 --- a/crates/chatd/src/adapters/signal/vault.rs +++ b/crates/chatd/src/adapters/signal/vault.rs @@ -135,7 +135,7 @@ impl Vault { let vault = Vault(Arc::new(Inner { db: Mutex::new(db), cipher, path: path.to_owned() })); // A key that does not open what is there is the wrong key: say so now, not mid-message. if let Some(name) = vault.first_name().map_err(|err| err.0)? { - vault.get(&name).map_err(|_| "the Signal state does not open with the key in the keychain".to_owned())?; + vault.get(&name).map_err(|_| format!("the Signal state does not open with the key in {}", crate::session::STORE))?; } Ok(vault) } diff --git a/crates/chatd/src/adapters/telegram/real.rs b/crates/chatd/src/adapters/telegram/real.rs index 1f57e19..c1dffbe 100644 --- a/crates/chatd/src/adapters/telegram/real.rs +++ b/crates/chatd/src/adapters/telegram/real.rs @@ -294,9 +294,19 @@ async fn follow(client: Client, updates: UnboundedReceiver, events: } async fn run(api_id: i32, vault: Arc, mut jobs: UnboundedReceiver, events: Sender) { - // What the account's device list shows for this session. + // What the account's device list shows for this session, beside the system version grammers + // reads from the OS. + let device_model = if cfg!(target_os = "macos") { + "Mac" + } else if cfg!(windows) { + "Windows PC" + } else if cfg!(target_os = "linux") { + "Linux PC" + } else { + "PC" + }; let params = ConnectionParams { - device_model: "Mac".to_owned(), + device_model: device_model.to_owned(), app_version: env!("CARGO_PKG_VERSION").to_owned(), ..ConnectionParams::default() }; diff --git a/crates/chatd/src/adapters/telegram/signin.rs b/crates/chatd/src/adapters/telegram/signin.rs index 7c678fb..7dce742 100644 --- a/crates/chatd/src/adapters/telegram/signin.rs +++ b/crates/chatd/src/adapters/telegram/signin.rs @@ -529,7 +529,7 @@ impl Flow { let _ = wire::call(self.wire.as_ref(), &tl::functions::auth::LogOut {}); let keychain = why.starts_with("keychain"); SignInStep::Failed { - reason: if keychain { "tinyclient couldn't save the sign-in to your keychain.".to_owned() } else { why }, + reason: if keychain { format!("tinyclient couldn't save the sign-in to {}.", crate::session::STORE) } else { why }, } } }; diff --git a/crates/chatd/src/server.rs b/crates/chatd/src/server.rs index 17b0dd9..e0df31b 100644 --- a/crates/chatd/src/server.rs +++ b/crates/chatd/src/server.rs @@ -429,10 +429,8 @@ impl Chat { let network = row.key.network; let unread_session = match linked { Some(_) => None, - None if keychain.denied.contains(&network) => { - Some("tinyclient can't read your sign-in from the keychain. You are still signed in.") - } - None if keychain.waiting == Some(network) => Some("Waiting for permission to read your sign-in from the keychain."), + None if keychain.denied.contains(&network) => Some(session::STORE_REFUSED), + None if keychain.waiting == Some(network) => Some(session::STORE_WAITING), None if pending.contains(&network) => Some(""), None => None, }; @@ -1274,7 +1272,7 @@ impl Chat { session::Stored::Found(session) => Some(session), session::Stored::Absent => None, // Forgetting the item now would sign out every other account it holds. - session::Stored::Unreadable(why) => return error(ErrorCode::Internal, format!("The keychain did not hand over the session: {why}")), + session::Stored::Unreadable(why) => return error(ErrorCode::Internal, format!("The session could not be read from {}: {why}", session::STORE)), }; let left = stored.and_then(|session| adapters::without(account.network, &session, &account.account)); match left { diff --git a/crates/chatd/src/session.rs b/crates/chatd/src/session.rs index a0d8733..0ea9a15 100644 --- a/crates/chatd/src/session.rs +++ b/crates/chatd/src/session.rs @@ -68,9 +68,43 @@ pub fn enabled() -> bool { std::env::var_os("TINYCLIENT_DIR").is_none() } +/// The store sign-ins are kept in, named the way this system names it. +pub const STORE: &str = if cfg!(target_os = "macos") { + "the keychain" +} else if cfg!(windows) { + "Credential Manager" +} else { + "the system keyring" +}; + /// Said instead of reading or saving when this build's keyring keeps nothing past the process /// (its in-memory mock, chosen when no system store was compiled in) or past a reboot. -pub const NO_STORE: &str = "This copy of tinyclient has no keychain to keep sign-ins in, so it signs nobody in. Install a build for this system."; +pub const NO_STORE: &str = if cfg!(target_os = "macos") { + "This copy of tinyclient has no keychain to keep sign-ins in, so it signs nobody in. Install a build for this system." +} else if cfg!(windows) { + "This copy of tinyclient cannot keep sign-ins in Credential Manager, so it signs nobody in. Install a build for this system." +} else { + "This copy of tinyclient has no system keyring to keep sign-ins in, so it signs nobody in. Install a build for this system." +}; + +/// An account's line while its session is read for over a second. macOS and the Secret Service +/// may be asking the person; Credential Manager never asks, so on Windows the read is only slow. +pub const STORE_WAITING: &str = if cfg!(target_os = "macos") { + "Waiting for permission to read your sign-in from the keychain." +} else if cfg!(windows) { + "Still reading your sign-in from Credential Manager." +} else { + "Waiting for permission to read your sign-in from the system keyring." +}; + +/// An account's line when its session could not be read. +pub const STORE_REFUSED: &str = if cfg!(target_os = "macos") { + "tinyclient can't read your sign-in from the keychain. You are still signed in." +} else if cfg!(windows) { + "tinyclient can't read your sign-in from Credential Manager. You are still signed in." +} else { + "tinyclient can't read your sign-in from the system keyring. You are still signed in." +}; /// Whether sign-ins are kept until deleted: the macOS keychain, the Windows credential store, /// the Secret Service on Linux. Asked once. @@ -305,6 +339,24 @@ mod tests { // A build without a system store falls back to keyring's mock: it would find no // sessions and say nothing. `persistent` is what makes chatd refuse instead. assert!(super::persistent()); - assert!(super::NO_STORE.ends_with('.') && !super::NO_STORE.contains("keyring")); + assert!(super::NO_STORE.ends_with('.') && super::NO_STORE.contains(super::STORE.trim_start_matches("the "))); + } + + #[test] + fn each_system_names_its_own_store() { + use super::{NO_STORE, STORE, STORE_REFUSED, STORE_WAITING}; + assert!(STORE_REFUSED.contains(STORE) && STORE_REFUSED.ends_with("You are still signed in.")); + assert!(STORE_WAITING.contains(STORE)); + if cfg!(target_os = "macos") { + assert_eq!(STORE_WAITING, "Waiting for permission to read your sign-in from the keychain."); + } else { + for words in [STORE, NO_STORE, STORE_WAITING, STORE_REFUSED] { + assert!(!words.contains("keychain"), "{words}"); + } + } + if cfg!(windows) { + // Credential Manager puts up no prompt to wait on. + assert_eq!(STORE_WAITING, "Still reading your sign-in from Credential Manager."); + } } } diff --git a/crates/chatd/tests/polish.rs b/crates/chatd/tests/polish.rs index 7e14e45..fb36979 100644 --- a/crates/chatd/tests/polish.rs +++ b/crates/chatd/tests/polish.rs @@ -182,7 +182,7 @@ fn the_socket_answers_while_a_keychain_read_waits_and_the_cached_account_stays_o if keychain.waiting == Some(Network::Discord) { let (accounts, ..) = chatd.status(); let cached = accounts.iter().find(|status| status.key == account).unwrap(); - assert_eq!(cached.detail, "Waiting for permission to read your sign-in from the keychain."); + assert_eq!(cached.detail, chatd::session::STORE_WAITING); } // Requests are served while it waits. assert!(matches!(chatd.ask(Request::Inbox { network: None, filter: InboxFilter::All, offset: 0, limit: 10 }), Response::Conversations { .. })); diff --git a/crates/tinyclient/Cargo.toml b/crates/tinyclient/Cargo.toml index 7e96609..a2725a0 100644 --- a/crates/tinyclient/Cargo.toml +++ b/crates/tinyclient/Cargo.toml @@ -44,8 +44,9 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "cb13b37a [target.'cfg(windows)'.dependencies] gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "cb13b37a6d80d034119888b534aa0d9067fb5aee", optional = true, default-features = false, features = ["font-kit"] } -# `--version` and `--text` print to the terminal from a windowed program. Already in the tree. -windows-sys = { version = "0.61", optional = true, features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console"] } +# `--version` and `--text` print to the terminal from a windowed program, and a call's link +# opens through ShellExecuteW. Already in the tree. +windows-sys = { version = "0.61", optional = true, features = ["Win32_Foundation", "Win32_Security", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } # The icon, in the .exe: Explorer, the taskbar and the window (GPUI loads resource 1) show it. # Already in the tree. diff --git a/crates/tinyclient/src/gui/app/menubar.rs b/crates/tinyclient/src/gui/app/menubar.rs index 8ca1435..ad9ee4e 100644 --- a/crates/tinyclient/src/gui/app/menubar.rs +++ b/crates/tinyclient/src/gui/app/menubar.rs @@ -123,8 +123,6 @@ pub fn bind_keys(cx: &mut App) { use gpui::KeyBinding; cx.bind_keys([ KeyBinding::new("secondary-,", Settings, None), - KeyBinding::new("secondary-h", Hide, None), - KeyBinding::new("alt-secondary-h", HideOthers, None), KeyBinding::new("secondary-n", NewMessage, None), KeyBinding::new("secondary-u", AttachFile, None), KeyBinding::new("secondary-w", CloseWindow, None), @@ -139,6 +137,10 @@ pub fn bind_keys(cx: &mut App) { KeyBinding::new("secondary-m", Minimize, None), KeyBinding::new("secondary-/", KeyboardShortcuts, None), ]); + // Hiding is macOS's. Linux ignores it, and GPUI on Windows panics at Hide Others. + if cfg!(target_os = "macos") { + cx.bind_keys([KeyBinding::new("secondary-h", Hide, None), KeyBinding::new("alt-secondary-h", HideOthers, None)]); + } } /// ⌘Q. chatd is stopped by `stop_chatd_on_quit`, as for every other way out. diff --git a/crates/tinyclient/src/gui/app/mod.rs b/crates/tinyclient/src/gui/app/mod.rs index d4b48c9..5f0c305 100644 --- a/crates/tinyclient/src/gui/app/mod.rs +++ b/crates/tinyclient/src/gui/app/mod.rs @@ -55,20 +55,12 @@ pub fn read_reduce_motion() { }); } -/// Diagnostics, with what takes a process to find out: the macOS version and the window's +/// Diagnostics, with what takes a process to find out: the operating system and the window's /// memory. Off the main thread. pub(in crate::gui) fn with_memory(mut text: String) -> String { - let run = |program: &str, args: &[&str]| { - std::process::Command::new(program) - .args(args) - .stderr(std::process::Stdio::null()) - .output() - .ok() - .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) - .filter(|out| !out.is_empty()) - }; - if let Some(version) = run("/usr/bin/sw_vers", &["-productVersion"]) { - text.push_str(&format!("macOS {version}\n")); + let run = |program: &str, args: &[&str]| command_out(program, args); + if let Some(version) = os_version() { + text.push_str(&format!("{version}\n")); } let pid = std::process::id().to_string(); if let Some(kb) = run("/bin/ps", &["-o", "rss=", "-p", &pid]).and_then(|kb| kb.parse::().ok()) { @@ -77,6 +69,37 @@ pub(in crate::gui) fn with_memory(mut text: String) -> String { text } +/// A helper's trimmed output. Without a console, so Copy diagnostics flashes no window on Windows. +fn command_out(program: &str, args: &[&str]) -> Option { + protocol::launch::no_console(std::process::Command::new(program).args(args).stderr(std::process::Stdio::null())) + .output() + .ok() + .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned()) + .filter(|out| !out.is_empty()) +} + +/// The operating system line in copied diagnostics. macOS from `sw_vers`, Linux from +/// os-release, Windows from `ver`. +#[cfg(target_os = "macos")] +fn os_version() -> Option { + command_out("/usr/bin/sw_vers", &["-productVersion"]).map(|version| format!("macOS {version}")) +} + +#[cfg(target_os = "linux")] +fn os_version() -> Option { + Some(crate::host::os_release_name()) +} + +#[cfg(windows)] +fn os_version() -> Option { + command_out("cmd", &["/C", "ver"]) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +fn os_version() -> Option { + None +} + fn unix_now() -> i64 { std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64) } diff --git a/crates/tinyclient/src/gui/app/service.rs b/crates/tinyclient/src/gui/app/service.rs index 54db47c..a230c78 100644 --- a/crates/tinyclient/src/gui/app/service.rs +++ b/crates/tinyclient/src/gui/app/service.rs @@ -266,7 +266,7 @@ impl Shown { match self { Shown::Quiet | Shown::Skeleton => None, Shown::Starting => Some("Starting tinyclient…"), - Shown::KeychainWaiting => Some("Waiting for keychain access"), + Shown::KeychainWaiting => Some(crate::host::STORE_WAITING), Shown::KeychainDenied(_) => None, Shown::Reconnecting => Some("Reconnecting…"), Shown::Failed => Some("Not receiving messages."), @@ -276,11 +276,7 @@ impl Shown { /// The conversation region's empty state, when this state takes it: glyph, title, line. pub fn empty_state(&self) -> Option<(&'static str, &'static str, &'static str)> { match self { - Shown::KeychainWaiting => Some(( - "icons/key.svg", - "Waiting for keychain access", - "macOS is asking to let tinyclient use its saved sign-ins. Choose Always Allow. The prompt may be behind this window.", - )), + Shown::KeychainWaiting => Some(("icons/key.svg", crate::host::STORE_WAITING, crate::host::STORE_WAITING_LINE)), Shown::KeychainDenied(_) => Some(( "icons/key.svg", "tinyclient can't read its sign-ins", @@ -347,8 +343,8 @@ mod tests { assert!(!service.answered(100, waiting, 0)); assert_eq!(shown(&service, Instant::now()), Shown::KeychainWaiting); let (_, title, line) = Shown::KeychainWaiting.empty_state().unwrap(); - assert_eq!(title, "Waiting for keychain access"); - assert!(line.ends_with("The prompt may be behind this window.")); + assert_eq!(title, crate::host::STORE_WAITING); + assert_eq!(line, crate::host::STORE_WAITING_LINE); let denied = Keychain { denied: vec![Network::Discord], ..Keychain::default() }; service.answered(100, denied, 0); diff --git a/crates/tinyclient/src/gui/call.rs b/crates/tinyclient/src/gui/call.rs index 9b8612f..31ed410 100644 --- a/crates/tinyclient/src/gui/call.rs +++ b/crates/tinyclient/src/gui/call.rs @@ -346,10 +346,34 @@ fn open(url: &str) -> Result<(), String> { if url.starts_with('-') || !url.contains(':') { return Err(format!("Not a link: {url}")); } - match std::process::Command::new("open").arg(url).status() { + system_open(url) +} + +#[cfg(not(windows))] +fn system_open(url: &str) -> Result<(), String> { + match std::process::Command::new(if cfg!(target_os = "macos") { "open" } else { "xdg-open" }).arg(url).status() { Ok(status) if status.success() => Ok(()), - Ok(_) => Err(format!("Nothing on this Mac opens {url}")), - Err(_) => Err("This Mac could not open the link.".to_owned()), + Ok(_) => Err(crate::host::nothing_opens(url)), + Err(_) => Err(crate::host::COULD_NOT_OPEN.to_owned()), + } +} + +/// ShellExecuteW, not `cmd /C start`: cmd splits a link at its `&` +/// (`msteams:/l/channel/…?groupId=…&tenantId=…`) and runs the rest as a command. +#[cfg(windows)] +fn system_open(url: &str) -> Result<(), String> { + use windows_sys::Win32::UI::Shell::{ShellExecuteW, SE_ERR_ASSOCINCOMPLETE, SE_ERR_FNF, SE_ERR_NOASSOC, SE_ERR_PNF}; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + let wide = |text: &str| text.encode_utf16().chain([0]).collect::>(); + let (verb, file) = (wide("open"), wide(url)); + // SAFETY: both strings are NUL-terminated and outlive the call; the rest may be null. + let code = unsafe { ShellExecuteW(std::ptr::null_mut(), verb.as_ptr(), file.as_ptr(), std::ptr::null(), std::ptr::null(), SW_SHOWNORMAL) } as isize; + // Above 32 a handler took it. These say no handler is registered, or its program is gone. + let unregistered = [SE_ERR_FNF, SE_ERR_PNF, SE_ERR_ASSOCINCOMPLETE, SE_ERR_NOASSOC]; + match code { + 33.. => Ok(()), + code if unregistered.iter().any(|&known| known as isize == code) => Err(crate::host::nothing_opens(url)), + _ => Err(crate::host::COULD_NOT_OPEN.to_owned()), } } diff --git a/crates/tinyclient/src/gui/conversation/draw.rs b/crates/tinyclient/src/gui/conversation/draw.rs index 5b95ec5..4303c0f 100644 --- a/crates/tinyclient/src/gui/conversation/draw.rs +++ b/crates/tinyclient/src/gui/conversation/draw.rs @@ -1199,7 +1199,7 @@ impl Pane { ) .child( tool("reveal", Icon::Folder, 28.) - .tooltip(tip("Show in Finder", None)) + .tooltip(tip(crate::host::REVEAL, None)) .on_click(cx.listener(move |_, _, _, cx| { cx.stop_propagation(); cx.reveal_path(std::path::Path::new(&reveal)); diff --git a/crates/tinyclient/src/gui/layout.rs b/crates/tinyclient/src/gui/layout.rs index 055cf13..f58d939 100644 --- a/crates/tinyclient/src/gui/layout.rs +++ b/crates/tinyclient/src/gui/layout.rs @@ -566,7 +566,7 @@ impl Shell { .mt(px(16.)) .type_small() .text_color(theme::text_faint()) - .child("Your sign-in is kept in the macOS keychain on this Mac."), + .child(crate::host::SIGN_IN_KEPT), ), ) } diff --git a/crates/tinyclient/src/gui/mod.rs b/crates/tinyclient/src/gui/mod.rs index 286dccb..98d0609 100644 --- a/crates/tinyclient/src/gui/mod.rs +++ b/crates/tinyclient/src/gui/mod.rs @@ -458,7 +458,7 @@ mod shot { .child(banner(Tone::Attention, "Discord wants you to verify your account.").child(banner_action("ba1", "Open Discord"))) .child(banner(Tone::Warning, "Slack is reconnecting. Showing what was saved at 10:42.")) .child(banner(Tone::Danger, "Slack signed you out of Harbor Labs.").child(banner_action("ba2", "Sign in"))) - .child(banner(Tone::Keychain, "Waiting for keychain access")) + .child(banner(Tone::Keychain, crate::host::STORE_WAITING)) .child(toast(Some(("icons/circle-alert.svg", theme::danger())), "tinyclient quit unexpectedly and reopened.").child(banner_action("t1", "Copy report"))), ) .child( diff --git a/crates/tinyclient/src/gui/profile/facts.rs b/crates/tinyclient/src/gui/profile/facts.rs index da8d0b8..e7f1c30 100644 --- a/crates/tinyclient/src/gui/profile/facts.rs +++ b/crates/tinyclient/src/gui/profile/facts.rs @@ -158,7 +158,7 @@ pub fn heads_up_words(heads_up: &HeadsUp, name: &str, network: Network, now: i64 HeadsUp::Offline => HeadsUpWords { glyph: "icons/wifi-off.svg", tone: Tone::Warning, - line: format!("{net} is offline · showing what's on this Mac"), + line: crate::host::offline(net), note: None, }, HeadsUp::Flagged(warning) => { diff --git a/crates/tinyclient/src/gui/settings/accounts.rs b/crates/tinyclient/src/gui/settings/accounts.rs index e80c851..03e5837 100644 --- a/crates/tinyclient/src/gui/settings/accounts.rs +++ b/crates/tinyclient/src/gui/settings/accounts.rs @@ -120,7 +120,7 @@ impl SettingsWindow { })), ); } - pane.child(caption("Signing out removes this account's sign-in and everything tinyclient saved for it from this Mac.").pt(px(0.))) + pane.child(caption(crate::host::sign_out_caption()).pt(px(0.))) } fn sign_in(&mut self, network: Network, cx: &mut Context) { @@ -141,7 +141,7 @@ impl SettingsWindow { super::modal_panel( format!("Sign out of {name}?"), vec![ - format!("This removes from this Mac the saved sign-in and the saved messages, images and files of this account, and ends this session on {network}."), + crate::host::sign_out_body(network), format!("Nothing is deleted on {network}. Your other accounts are not touched."), ], button("sign-out-confirm", ButtonKind::Danger, "Sign out").on_click(cx.listener(move |this, _, _, cx| { diff --git a/crates/tinyclient/src/gui/settings/pages.rs b/crates/tinyclient/src/gui/settings/pages.rs index 2093fd9..379acb3 100644 --- a/crates/tinyclient/src/gui/settings/pages.rs +++ b/crates/tinyclient/src/gui/settings/pages.rs @@ -110,9 +110,9 @@ pub fn usage_words(messages: u64, files: u64) -> (String, String, String) { /// The P4 modal's sentence. pub fn retention_words(prunable: Option<(u64, u64)>) -> String { - let rest = "are removed from this Mac. They stay on Slack, Discord and the others."; + let rest = crate::host::removed_from_here(); match prunable { - Some((0, _)) => "Nothing here is that old yet. Older messages will be removed from this Mac as they age.".to_owned(), + Some((0, _)) => crate::host::retention_none_yet(), Some((messages, bytes)) => { let count = messages.to_string(); // Thousands get a comma, as everywhere else in the app. @@ -154,7 +154,7 @@ impl SettingsWindow { ], Section::Appearance => vec![ self.group(&["A2", "A3", "A4"], None, window, cx) - .child(caption("Text size is also ⌘+, ⌘− and ⌘0. Reduce Motion follows System Settings.")), + .child(caption(crate::host::APPEARANCE_CAPTION)), ], Section::Messages => vec![self.group(&["M1", "M2", "M3", "M4", "M5"], None, window, cx)], Section::Privacy => { @@ -388,13 +388,13 @@ impl SettingsWindow { ), "B1" => row( label, - Some("Version, macOS, memory and connection states. No messages, names or tokens.".into()), + Some(crate::host::DIAGNOSTICS.into()), button("B1", ButtonKind::Secondary, "Copy").on_click(cx.listener(|this, _, _, cx| this.copy_diagnostics(cx))), ), "B2" => row( label, None, - button("B2", ButtonKind::Secondary, "Show in Finder").on_click(cx.listener(|_, _, _, cx| { + button("B2", ButtonKind::Secondary, crate::host::REVEAL).on_click(cx.listener(|_, _, _, cx| { let log = protocol::support_dir().join(protocol::launch::CHATD_LOG); if log.exists() { cx.reveal_path(&log); @@ -861,8 +861,8 @@ impl SettingsWindow { div() .flex() .items_center() - .child(div().flex_1().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::text()).child(format!("{total_words} on this Mac"))) - .child(button("D1", ButtonKind::Ghost, "Show in Finder").on_click(cx.listener(|_, _, _, cx| cx.open_with_system(&protocol::support_dir())))), + .child(div().flex_1().type_ui().font_weight(theme::SEMIBOLD).text_color(theme::text()).child(crate::host::on_here(&total_words))) + .child(button("D1", ButtonKind::Ghost, crate::host::REVEAL).on_click(cx.listener(|_, _, _, cx| cx.open_with_system(&protocol::support_dir())))), ) .child( div() @@ -923,7 +923,7 @@ impl SettingsWindow { ), Confirm::Clear => modal_panel( "Clear saved messages and files?", - vec!["Everything tinyclient saved to show you quickly is deleted from this Mac. It comes back from the networks as you open conversations. Sign-ins, drafts and settings stay.".to_owned()], + vec![crate::host::clear_saved()], button("clear-confirm", ButtonKind::Danger, "Clear").on_click(cx.listener(|this, _, _, cx| { this.confirm = None; this.off_thread(cx, || crate::client::ask(&Request::Clear), |this, answer, cx| { @@ -991,7 +991,7 @@ impl SettingsWindow { pub(super) fn shortcuts(&self, query: &str) -> Div { let mut column = div().flex().flex_col(); if query.is_empty() { - column = column.child(caption("To change a shortcut, add it in System Settings › Keyboard › Keyboard Shortcuts › App Shortcuts. The call keys are in no menu, so they cannot be changed there.").pt(px(0.)).pb(px(14.))); + column = column.child(caption(crate::host::SHORTCUTS_CAPTION).pt(px(0.)).pb(px(14.))); } let found = rows::shortcuts(query); let mut menus: Vec<&str> = Vec::new(); @@ -1152,12 +1152,13 @@ mod tests { #[test] fn the_retention_question_says_how_much_goes_and_drops_what_it_does_not_know() { + let here = crate::host::HERE; assert_eq!( retention_words(Some((41_210, 12 * 1024 * 1024))), - "41,210 messages, about 12 MB, are removed from this Mac. They stay on Slack, Discord and the others." + format!("41,210 messages, about 12 MB, are removed from {here}. They stay on Slack, Discord and the others.") ); - assert_eq!(retention_words(Some((1, 300))), "1 message is removed from this Mac. It stays on Slack, Discord and the others."); - assert_eq!(retention_words(None), "Older messages are removed from this Mac. They stay on Slack, Discord and the others."); + assert_eq!(retention_words(Some((1, 300))), format!("1 message is removed from {here}. It stays on Slack, Discord and the others.")); + assert_eq!(retention_words(None), format!("Older messages are removed from {here}. They stay on Slack, Discord and the others.")); } #[test] diff --git a/crates/tinyclient/src/gui/settings/rows.rs b/crates/tinyclient/src/gui/settings/rows.rs index 9632ec9..91b217c 100644 --- a/crates/tinyclient/src/gui/settings/rows.rs +++ b/crates/tinyclient/src/gui/settings/rows.rs @@ -51,7 +51,7 @@ pub const ROWS: &[Row] = &[ row("P1", Privacy, "Let others see when I am typing", "Let others see when I am typing", &[], &["typing", "indicator", "privacy"]), row("P2", Privacy, "Let others see what I have read", "Let others see what I have read", &[], &["receipts", "read receipts", "seen", "privacy"]), row("P3", Privacy, "", "Load images and link previews automatically", &[], &["images", "pictures", "previews", "data", "tracking"]), - row("P4", Privacy, "", "Keep messages on this Mac for", &["7 days", "30 days", "90 days"], &["retention", "history", "delete", "days"]), + row("P4", Privacy, "", crate::host::KEEP_MESSAGES, &["7 days", "30 days", "90 days"], &["retention", "history", "delete", "days"]), row("V1", Audio, "Microphone", "Microphone", &[], &["input", "mic", "device", "headset"]), row("V3", Audio, "Microphone", "Input level", &[], &["test", "meter", "mic", "level"]), row("V6", Audio, "Microphone", "Sensitivity", &[], &["gate", "threshold", "noise", "voice activity"]), @@ -81,7 +81,7 @@ pub const NOT_YET: &[(&str, &str)] = &[ ("banner", "tinyclient does not post notifications yet."), ("do not disturb", "tinyclient does not post notifications yet. Do Not Disturb you set in Slack or Discord is shown on your status."), ("quiet hours", "tinyclient does not post notifications yet."), - ("badge", "tinyclient has no Dock badge setting yet."), + ("badge", crate::host::NO_BADGE), ("ringtone", "tinyclient has no ring sound yet; calls ring in the window."), ("login", "tinyclient has no open-at-login setting yet."), ("startup", "tinyclient has no open-at-login setting yet."), @@ -94,11 +94,11 @@ pub const NOT_YET: &[(&str, &str)] = &[ ("touch id", "tinyclient has no lock yet."), ("camera", "tinyclient has no camera yet."), ("push to talk", "tinyclient has no push to talk yet."), - ("menu bar", "tinyclient has no menu bar icon yet."), + ("menu bar", crate::host::NO_TRAY_ICON), ("accent", "tinyclient keeps one accent colour."), ("font", "tinyclient keeps the system font. Text size is in Appearance."), - ("language", "tinyclient follows the language of macOS."), - ("sync", "Settings stay on this Mac. Nothing is synced."), + ("language", crate::host::FOLLOWS_LANGUAGE), + ("sync", crate::host::SETTINGS_STAY), ]; /// Lower case with the accents taken off, so "Résumé" finds "resume". @@ -236,9 +236,17 @@ pub const SHORTCUTS: &[Shortcut] = &[ key("Calls", "Go to the call", "⇧⌘J"), ]; +/// Hiding an application is macOS's: GPUI ignores it on Linux and panics at Hide Others on +/// Windows, so these keys are bound and listed on macOS only (`menubar::bind_keys`). +const MAC_ONLY: &[&str] = &["Hide tinyclient", "Hide Others"]; + pub fn shortcuts(query: &str) -> Vec<&'static Shortcut> { let query = fold(query.trim()); - SHORTCUTS.iter().filter(|s| query.is_empty() || at_word_start(s.command, &query) || at_word_start(s.menu, &query)).collect() + SHORTCUTS + .iter() + .filter(|s| cfg!(target_os = "macos") || !MAC_ONLY.contains(&s.command)) + .filter(|s| query.is_empty() || at_word_start(s.command, &query) || at_word_start(s.menu, &query)) + .collect() } #[cfg(test)] @@ -298,6 +306,14 @@ mod tests { } } + #[test] + fn hiding_the_app_is_listed_only_where_it_does_something() { + let listed = shortcuts("").iter().filter(|s| MAC_ONLY.contains(&s.command)).count(); + assert_eq!(listed, if cfg!(target_os = "macos") { MAC_ONLY.len() } else { 0 }); + assert!(shortcuts("").iter().any(|s| s.command == "Quit tinyclient")); + assert_eq!(shortcuts("hide others").is_empty(), !cfg!(target_os = "macos")); + } + #[test] fn every_shortcut_names_a_menu_item_that_exists_and_every_keyed_item_is_listed() { use crate::gui::app::menubar::menus; diff --git a/crates/tinyclient/src/host.rs b/crates/tinyclient/src/host.rs new file mode 100644 index 0000000..78a8db5 --- /dev/null +++ b/crates/tinyclient/src/host.rs @@ -0,0 +1,303 @@ +//! The computer this build runs on, named the way that computer names itself. +//! +//! The window used to say "this Mac" on every system. These words are what a person reads. + +/// "this Mac", "this computer", or "this PC". +pub const HERE: &str = if cfg!(target_os = "macos") { + "this Mac" +} else if cfg!(windows) { + "this PC" +} else { + "this computer" +}; + +/// Welcome footnote: where a sign-in is kept. +pub const SIGN_IN_KEPT: &str = if cfg!(target_os = "macos") { + "Your sign-in is kept in the macOS keychain on this Mac." +} else if cfg!(windows) { + "Your sign-in is kept in Windows Credential Manager on this PC." +} else { + "Your sign-in is kept in the system keyring on this computer." +}; + +/// The button that reveals a file in the system file manager. +pub const REVEAL: &str = if cfg!(target_os = "macos") { + "Show in Finder" +} else if cfg!(windows) { + "Show in File Explorer" +} else { + "Show in folder" +}; + +/// Sidebar banner while the system store asks permission to read saved sign-ins. +pub const STORE_WAITING: &str = if cfg!(target_os = "macos") { + "Waiting for keychain access" +} else if cfg!(windows) { + "Waiting for credential access" +} else { + "Waiting for keyring access" +}; + +/// Empty-state line under [`STORE_WAITING`]. Credential Manager never asks, so on Windows the +/// read is only slow. +pub const STORE_WAITING_LINE: &str = if cfg!(target_os = "macos") { + "macOS is asking to let tinyclient use its saved sign-ins. Choose Always Allow. The prompt may be behind this window." +} else if cfg!(windows) { + "tinyclient is still reading its saved sign-ins from Windows Credential Manager." +} else { + "The system keyring is asking to let tinyclient use its saved sign-ins. The prompt may be behind this window." +}; + +/// Settings search, when someone looks for a language setting. +pub const FOLLOWS_LANGUAGE: &str = if cfg!(target_os = "macos") { + "tinyclient follows the language of macOS." +} else if cfg!(windows) { + "tinyclient follows the language of Windows." +} else { + "tinyclient follows the language of the system." +}; + +/// Settings search, when someone looks for sync. +pub const SETTINGS_STAY: &str = if cfg!(target_os = "macos") { + "Settings stay on this Mac. Nothing is synced." +} else if cfg!(windows) { + "Settings stay on this PC. Nothing is synced." +} else { + "Settings stay on this computer. Nothing is synced." +}; + +/// Settings › Privacy, the retention row. +pub const KEEP_MESSAGES: &str = if cfg!(target_os = "macos") { + "Keep messages on this Mac for" +} else if cfg!(windows) { + "Keep messages on this PC for" +} else { + "Keep messages on this computer for" +}; + +/// Settings › About, under Copy diagnostics. +pub const DIAGNOSTICS: &str = if cfg!(target_os = "macos") { + "Version, macOS, memory and connection states. No messages, names or tokens." +} else if cfg!(windows) { + "Version, Windows, memory and connection states. No messages, names or tokens." +} else { + "Version, Linux, memory and connection states. No messages, names or tokens." +}; + +/// Settings › Appearance, under text size. Reduce Motion is read from macOS only; `caption` +/// shows the keys as Ctrl elsewhere. +pub const APPEARANCE_CAPTION: &str = if cfg!(target_os = "macos") { + "Text size is also ⌘+, ⌘− and ⌘0. Reduce Motion follows System Settings." +} else { + "Text size is also ⌘+, ⌘− and ⌘0." +}; + +/// Settings › Shortcuts, above the list. Only macOS lets a person rebind an app's menu keys. +pub const SHORTCUTS_CAPTION: &str = if cfg!(target_os = "macos") { + "To change a shortcut, add it in System Settings › Keyboard › Keyboard Shortcuts › App Shortcuts. The call keys are in no menu, so they cannot be changed there." +} else { + "Shortcuts cannot be changed yet." +}; + +/// Settings search, when someone looks for a badge. +pub const NO_BADGE: &str = if cfg!(target_os = "macos") { + "tinyclient has no Dock badge setting yet." +} else if cfg!(windows) { + "tinyclient has no taskbar badge setting yet." +} else { + "tinyclient has no unread badge setting yet." +}; + +/// Settings search, when someone looks for a menu bar icon. +pub const NO_TRAY_ICON: &str = if cfg!(target_os = "macos") { + "tinyclient has no menu bar icon yet." +} else if cfg!(windows) { + "tinyclient has no system tray icon yet." +} else { + "tinyclient has no tray icon yet." +}; + +/// `open` failed before a handler was asked. +pub const COULD_NOT_OPEN: &str = if cfg!(target_os = "macos") { + "This Mac could not open the link." +} else if cfg!(windows) { + "This PC could not open the link." +} else { + "This computer could not open the link." +}; + +/// "{network} is offline · showing what's on this computer". +pub fn offline(network: &str) -> String { + format!("{network} is offline · showing what's on {HERE}") +} + +/// "496 MB on this computer". +pub fn on_here(amount: &str) -> String { + format!("{amount} on {HERE}") +} + +/// Nothing registered for a URL scheme. +pub fn nothing_opens(url: &str) -> String { + format!("Nothing on {HERE} opens {url}") +} + +/// Accounts caption under the sign-out rows. +pub fn sign_out_caption() -> String { + format!("Signing out removes this account's sign-in and everything tinyclient saved for it from {HERE}.") +} + +/// Sign-out confirmation, first paragraph. +pub fn sign_out_body(network: &str) -> String { + format!("This removes from {HERE} the saved sign-in and the saved messages, images and files of this account, and ends this session on {network}.") +} + +/// Clear-cache confirmation. +pub fn clear_saved() -> String { + format!("Everything tinyclient saved to show you quickly is deleted from {HERE}. It comes back from the networks as you open conversations. Sign-ins, drafts and settings stay.") +} + +/// P4, when nothing is old enough to delete yet. +pub fn retention_none_yet() -> String { + format!("Nothing here is that old yet. Older messages will be removed from {HERE} as they age.") +} + +/// P4, the shared tail: "are removed from this computer. They stay on …". +pub fn removed_from_here() -> String { + format!("are removed from {HERE}. They stay on Slack, Discord and the others.") +} + +/// `PRETTY_NAME` from os-release(5): `/etc/os-release`, else `/usr/lib/os-release`. +#[cfg(target_os = "linux")] +pub fn os_release_name() -> String { + let text = ["/etc/os-release", "/usr/lib/os-release"].iter().find_map(|path| std::fs::read_to_string(path).ok()); + pretty_name(text.as_deref().unwrap_or_default()) +} + +/// The last `PRETTY_NAME` in os-release text, unquoted the way a shell would. "Linux" when it is +/// missing or empty, as os-release(5) says. +#[cfg(any(target_os = "linux", test))] +fn pretty_name(text: &str) -> String { + text.lines() + .rev() + .find_map(|line| line.trim().strip_prefix("PRETTY_NAME=")) + .map(unquote) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Linux".to_owned()) +} + +/// A value in single quotes is taken as written. Otherwise its double quotes come off and `\"`, +/// `\\`, `\$` and `` \` `` stand for the character. +#[cfg(any(target_os = "linux", test))] +fn unquote(value: &str) -> String { + let value = value.trim(); + if let Some(inner) = value.strip_prefix('\'').and_then(|value| value.strip_suffix('\'')) { + return inner.to_owned(); + } + let inner = value.strip_prefix('"').and_then(|value| value.strip_suffix('"')).unwrap_or(value); + let mut out = String::with_capacity(inner.len()); + let mut chars = inner.chars().peekable(); + while let Some(c) = chars.next() { + let escaped = if c == '\\' { chars.next_if(|next| matches!(next, '"' | '\\' | '$' | '`')) } else { None }; + out.push(escaped.unwrap_or(c)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn claims_to_be_a_mac(text: &str) -> bool { + let lower = text.to_lowercase(); + lower.contains("mac") || lower.contains("finder") || lower.contains("keychain") || lower.contains("always allow") + } + + #[test] + fn the_welcome_line_names_this_computer_and_its_own_store() { + assert!(SIGN_IN_KEPT.contains(HERE)); + assert!(SIGN_IN_KEPT.starts_with("Your sign-in is kept in ")); + assert!(SIGN_IN_KEPT.ends_with('.')); + assert_eq!(KEEP_MESSAGES, format!("Keep messages on {HERE} for")); + assert!(SETTINGS_STAY.contains(HERE)); + assert!(DIAGNOSTICS.contains(if cfg!(target_os = "macos") { "macOS" } else if cfg!(windows) { "Windows" } else { "Linux" })); + assert!(COULD_NOT_OPEN.contains(if cfg!(target_os = "linux") { "computer" } else { HERE.trim_start_matches("this ") })); + assert_eq!(offline("Discord"), format!("Discord is offline · showing what's on {HERE}")); + assert_eq!(nothing_opens("discord://channels"), format!("Nothing on {HERE} opens discord://channels")); + assert!(sign_out_body("Slack").contains("Slack") && sign_out_body("Slack").contains(HERE)); + assert!(clear_saved().contains(HERE)); + assert!(retention_none_yet().contains(HERE)); + assert!(removed_from_here().starts_with("are removed from ")); + assert_eq!(STORE_WAITING_LINE.ends_with("The prompt may be behind this window."), !cfg!(windows)); + assert!(APPEARANCE_CAPTION.starts_with("Text size is also ⌘+, ⌘− and ⌘0.")); + + if cfg!(target_os = "macos") { + assert_eq!(SIGN_IN_KEPT, "Your sign-in is kept in the macOS keychain on this Mac."); + assert_eq!(REVEAL, "Show in Finder"); + assert_eq!(STORE_WAITING, "Waiting for keychain access"); + assert!(STORE_WAITING_LINE.contains("Always Allow")); + assert_eq!(FOLLOWS_LANGUAGE, "tinyclient follows the language of macOS."); + assert!(APPEARANCE_CAPTION.ends_with("Reduce Motion follows System Settings.")); + assert!(SHORTCUTS_CAPTION.contains("App Shortcuts")); + assert_eq!(NO_BADGE, "tinyclient has no Dock badge setting yet."); + assert_eq!(NO_TRAY_ICON, "tinyclient has no menu bar icon yet."); + } else { + let shown = [ + SIGN_IN_KEPT, + REVEAL, + STORE_WAITING, + STORE_WAITING_LINE, + FOLLOWS_LANGUAGE, + SETTINGS_STAY, + KEEP_MESSAGES, + DIAGNOSTICS, + APPEARANCE_CAPTION, + SHORTCUTS_CAPTION, + NO_BADGE, + NO_TRAY_ICON, + COULD_NOT_OPEN, + HERE, + ]; + for words in shown { + assert!(!claims_to_be_a_mac(words), "{words}"); + } + for words in [offline("Discord"), sign_out_caption(), sign_out_body("Slack"), clear_saved(), retention_none_yet(), removed_from_here(), on_here("12 MB")] { + assert!(!claims_to_be_a_mac(&words), "{words}"); + } + if cfg!(windows) { + assert_eq!(SIGN_IN_KEPT, "Your sign-in is kept in Windows Credential Manager on this PC."); + assert_eq!(REVEAL, "Show in File Explorer"); + assert_eq!(STORE_WAITING, "Waiting for credential access"); + assert!(STORE_WAITING_LINE.contains("Credential Manager") && !STORE_WAITING_LINE.contains("asking")); + } else { + assert_eq!(SIGN_IN_KEPT, "Your sign-in is kept in the system keyring on this computer."); + assert_eq!(REVEAL, "Show in folder"); + assert_eq!(STORE_WAITING, "Waiting for keyring access"); + assert!(STORE_WAITING_LINE.contains("system keyring")); + assert_eq!(FOLLOWS_LANGUAGE, "tinyclient follows the language of the system."); + } + } + } + + #[test] + fn os_release_names_the_system_as_a_shell_would_read_it() { + let name = |line: &str| pretty_name(&format!("NAME=Fedora\nID=fedora\n{line}\nVERSION_ID=40\n")); + assert_eq!(name(r#"PRETTY_NAME="Fedora Linux 40 (Workstation Edition)""#), "Fedora Linux 40 (Workstation Edition)"); + assert_eq!(name("PRETTY_NAME='Arch Linux'"), "Arch Linux"); + assert_eq!(name(r#"PRETTY_NAME='Costs $5, "quoted" \`as is\`'"#), r#"Costs $5, "quoted" \`as is\`"#); + assert_eq!(name(r#"PRETTY_NAME="Say \"hi\" for \$5 \`now\` \\ \n""#), r#"Say "hi" for $5 `now` \ \n"#); + assert_eq!(name(r#"PRETTY_NAME="a\\$b""#), r#"a\$b"#); + assert_eq!(name("PRETTY_NAME=Alpine"), "Alpine"); + assert_eq!(name("# PRETTY_NAME=\"Commented\""), "Linux"); + assert_eq!(name(r#"PRETTY_NAME="""#), "Linux"); + assert_eq!(pretty_name(""), "Linux"); + assert_eq!(pretty_name("PRETTY_NAME=\"First\"\nPRETTY_NAME=\"Second\"\n"), "Second"); + } + + #[cfg(target_os = "linux")] + #[test] + fn os_release_does_not_call_this_machine_a_mac() { + let name = os_release_name(); + assert!(!name.is_empty()); + assert!(!claims_to_be_a_mac(&name), "{name}"); + } +} diff --git a/crates/tinyclient/src/main.rs b/crates/tinyclient/src/main.rs index 28d9dfe..d6abdf2 100644 --- a/crates/tinyclient/src/main.rs +++ b/crates/tinyclient/src/main.rs @@ -5,6 +5,8 @@ #[cfg_attr(not(feature = "gui"), allow(dead_code))] mod client; #[cfg_attr(not(feature = "gui"), allow(dead_code))] +mod host; +#[cfg_attr(not(feature = "gui"), allow(dead_code))] mod update; #[cfg(any(feature = "gui", test))] mod frames;