From 128641d99da1f76d3ec703007236d0dda679256d Mon Sep 17 00:00:00 2001 From: itz4blitz <60281870+itz4blitz@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:49:21 -0400 Subject: [PATCH 1/2] fix: stop telling Linux and Windows they are a Mac The window hardcoded the macOS keychain, Finder, and Always Allow. Those sentences now follow the computer the build runs on: the system keyring on Linux, Credential Manager on Windows. --- crates/tinyclient/src/gui/app/mod.rs | 47 +++- crates/tinyclient/src/gui/app/service.rs | 12 +- crates/tinyclient/src/gui/call.rs | 20 +- .../tinyclient/src/gui/conversation/draw.rs | 2 +- crates/tinyclient/src/gui/layout.rs | 2 +- crates/tinyclient/src/gui/mod.rs | 2 +- crates/tinyclient/src/gui/profile/facts.rs | 2 +- .../tinyclient/src/gui/settings/accounts.rs | 4 +- crates/tinyclient/src/gui/settings/pages.rs | 21 +- crates/tinyclient/src/gui/settings/rows.rs | 6 +- crates/tinyclient/src/host.rs | 233 ++++++++++++++++++ crates/tinyclient/src/main.rs | 2 + 12 files changed, 311 insertions(+), 42 deletions(-) create mode 100644 crates/tinyclient/src/host.rs diff --git a/crates/tinyclient/src/gui/app/mod.rs b/crates/tinyclient/src/gui/app/mod.rs index d4b48c9..d96bc33 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 } +fn command_out(program: &str, args: &[&str]) -> Option { + 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`. +fn os_version() -> Option { + #[cfg(target_os = "macos")] + { + return command_out("/usr/bin/sw_vers", &["-productVersion"]).map(|version| format!("macOS {version}")); + } + #[cfg(target_os = "linux")] + { + return crate::host::os_release_name(); + } + #[cfg(windows)] + { + return command_out("cmd", &["/C", "ver"]); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] + { + 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..713c278 100644 --- a/crates/tinyclient/src/gui/call.rs +++ b/crates/tinyclient/src/gui/call.rs @@ -346,10 +346,24 @@ 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() { + #[cfg(target_os = "macos")] + let mut command = std::process::Command::new("open"); + #[cfg(not(any(target_os = "macos", windows)))] + let mut command = std::process::Command::new("xdg-open"); + #[cfg(windows)] + let mut command = std::process::Command::new("cmd"); + #[cfg(windows)] + { + protocol::launch::no_console(command.args(["/C", "start", "", url])); + } + #[cfg(not(windows))] + { + command.arg(url); + } + match command.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()), } } 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..3b5d7fa 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. @@ -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| { @@ -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..089efc6 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"]), @@ -97,8 +97,8 @@ pub const NOT_YET: &[(&str, &str)] = &[ ("menu bar", "tinyclient has no menu bar icon yet."), ("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". diff --git a/crates/tinyclient/src/host.rs b/crates/tinyclient/src/host.rs new file mode 100644 index 0000000..894ef35 --- /dev/null +++ b/crates/tinyclient/src/host.rs @@ -0,0 +1,233 @@ +//! 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`]. +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) { + "Windows is asking to let tinyclient use its saved sign-ins. The prompt may be behind this window." +} 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." +}; + +/// `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." +}; + +/// The operating system a diagnostics line should name. +pub const OS_NAME: &str = if cfg!(target_os = "macos") { + "macOS" +} else if cfg!(windows) { + "Windows" +} else { + "Linux" +}; + +/// "{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 `/etc/os-release`. +#[cfg(target_os = "linux")] +pub fn os_release_name() -> Option { + let text = std::fs::read_to_string("/etc/os-release").ok()?; + for line in text.lines() { + let Some(value) = line.strip_prefix("PRETTY_NAME=") else { + continue; + }; + let value = value.trim().trim_matches('"'); + if !value.is_empty() { + return Some(value.to_owned()); + } + } + None +} + +#[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(OS_NAME)); + 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!(STORE_WAITING_LINE.ends_with("The prompt may be behind this window.")); + + 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."); + } else { + let shown = [ + SIGN_IN_KEPT, + REVEAL, + STORE_WAITING, + STORE_WAITING_LINE, + FOLLOWS_LANGUAGE, + SETTINGS_STAY, + KEEP_MESSAGES, + DIAGNOSTICS, + 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"); + } 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."); + assert_eq!(OS_NAME, "Linux"); + } + } + } + + #[cfg(target_os = "linux")] + #[test] + fn os_release_does_not_call_this_machine_a_mac() { + let name = os_release_name().expect("PRETTY_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; From 9d8f73bda48850c00793cf287602e576af65d36f Mon Sep 17 00:00:00 2001 From: Justin Scroggins Date: Tue, 20 Oct 2026 09:35:31 -0400 Subject: [PATCH 2/2] fix: open call links whole on Windows, and say each system's own words in chatd too Windows handed a call link to `cmd /C start`, which split it at the first `&`: a Teams or Telegram link opened cut short and cmd ran the rest as a command. It goes to ShellExecuteW now, whose answer still tells "nothing opens this" from "could not open". Copy diagnostics asks for the Windows version without flashing a console. Linux reads os-release as a shell would, from /usr/lib when /etc has none, and says Linux when it names nothing. Windows no longer tells of a Credential Manager prompt that never comes. The lines still written for a Mac follow the system too. The Reduce Motion and App Shortcuts captions are macOS only; the badge and menu bar icon answers name the taskbar and the tray; Hide tinyclient and Hide Others are listed and bound on macOS only, since Hide Others panicked on Windows. chatd names the keychain, Credential Manager or the system keyring, and Telegram's device list no longer calls every computer a Mac. Co-Authored-By: Claude Opus 5.5 --- crates/chatd/src/adapters/signal/vault.rs | 2 +- crates/chatd/src/adapters/telegram/real.rs | 14 ++- crates/chatd/src/adapters/telegram/signin.rs | 2 +- crates/chatd/src/server.rs | 8 +- crates/chatd/src/session.rs | 56 ++++++++- crates/chatd/tests/polish.rs | 2 +- crates/tinyclient/Cargo.toml | 5 +- crates/tinyclient/src/gui/app/menubar.rs | 6 +- crates/tinyclient/src/gui/app/mod.rs | 38 +++--- crates/tinyclient/src/gui/call.rs | 40 +++--- crates/tinyclient/src/gui/settings/pages.rs | 4 +- crates/tinyclient/src/gui/settings/rows.rs | 22 +++- crates/tinyclient/src/host.rs | 124 +++++++++++++++---- 13 files changed, 241 insertions(+), 82 deletions(-) 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 d96bc33..5f0c305 100644 --- a/crates/tinyclient/src/gui/app/mod.rs +++ b/crates/tinyclient/src/gui/app/mod.rs @@ -69,10 +69,9 @@ 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 { - std::process::Command::new(program) - .args(args) - .stderr(std::process::Stdio::null()) + 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()) @@ -81,23 +80,24 @@ fn command_out(program: &str, args: &[&str]) -> Option { /// 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 { - #[cfg(target_os = "macos")] - { - return command_out("/usr/bin/sw_vers", &["-productVersion"]).map(|version| format!("macOS {version}")); - } - #[cfg(target_os = "linux")] - { - return crate::host::os_release_name(); - } - #[cfg(windows)] - { - return command_out("cmd", &["/C", "ver"]); - } - #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] - { - None - } + 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 { diff --git a/crates/tinyclient/src/gui/call.rs b/crates/tinyclient/src/gui/call.rs index 713c278..31ed410 100644 --- a/crates/tinyclient/src/gui/call.rs +++ b/crates/tinyclient/src/gui/call.rs @@ -346,27 +346,37 @@ fn open(url: &str) -> Result<(), String> { if url.starts_with('-') || !url.contains(':') { return Err(format!("Not a link: {url}")); } - #[cfg(target_os = "macos")] - let mut command = std::process::Command::new("open"); - #[cfg(not(any(target_os = "macos", windows)))] - let mut command = std::process::Command::new("xdg-open"); - #[cfg(windows)] - let mut command = std::process::Command::new("cmd"); - #[cfg(windows)] - { - protocol::launch::no_console(command.args(["/C", "start", "", url])); - } - #[cfg(not(windows))] - { - command.arg(url); - } - match command.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(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()), + } +} + /// The dock, in the shape of the sidebar's slot (`layout::Slots::call_dock`). pub(super) fn dock(shell: &Shell, _: &mut Window, cx: &mut Context) -> Option { let snapshot = shell.snapshot.as_ref()?; diff --git a/crates/tinyclient/src/gui/settings/pages.rs b/crates/tinyclient/src/gui/settings/pages.rs index 3b5d7fa..379acb3 100644 --- a/crates/tinyclient/src/gui/settings/pages.rs +++ b/crates/tinyclient/src/gui/settings/pages.rs @@ -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 => { @@ -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(); diff --git a/crates/tinyclient/src/gui/settings/rows.rs b/crates/tinyclient/src/gui/settings/rows.rs index 089efc6..91b217c 100644 --- a/crates/tinyclient/src/gui/settings/rows.rs +++ b/crates/tinyclient/src/gui/settings/rows.rs @@ -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,7 +94,7 @@ 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", crate::host::FOLLOWS_LANGUAGE), @@ -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 index 894ef35..78a8db5 100644 --- a/crates/tinyclient/src/host.rs +++ b/crates/tinyclient/src/host.rs @@ -38,11 +38,12 @@ pub const STORE_WAITING: &str = if cfg!(target_os = "macos") { "Waiting for keyring access" }; -/// Empty-state line under [`STORE_WAITING`]. +/// 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) { - "Windows is asking to let tinyclient use its saved sign-ins. The prompt may be behind this window." + "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." }; @@ -83,6 +84,39 @@ pub const DIAGNOSTICS: &str = if cfg!(target_os = "macos") { "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." @@ -92,15 +126,6 @@ pub const COULD_NOT_OPEN: &str = if cfg!(target_os = "macos") { "This computer could not open the link." }; -/// The operating system a diagnostics line should name. -pub const OS_NAME: &str = if cfg!(target_os = "macos") { - "macOS" -} else if cfg!(windows) { - "Windows" -} else { - "Linux" -}; - /// "{network} is offline · showing what's on this computer". pub fn offline(network: &str) -> String { format!("{network} is offline · showing what's on {HERE}") @@ -141,20 +166,41 @@ pub fn removed_from_here() -> String { format!("are removed from {HERE}. They stay on Slack, Discord and the others.") } -/// `PRETTY_NAME` from `/etc/os-release`. +/// `PRETTY_NAME` from os-release(5): `/etc/os-release`, else `/usr/lib/os-release`. #[cfg(target_os = "linux")] -pub fn os_release_name() -> Option { - let text = std::fs::read_to_string("/etc/os-release").ok()?; - for line in text.lines() { - let Some(value) = line.strip_prefix("PRETTY_NAME=") else { - continue; - }; - let value = value.trim().trim_matches('"'); - if !value.is_empty() { - return Some(value.to_owned()); - } +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)); } - None + out } #[cfg(test)] @@ -173,7 +219,7 @@ mod tests { assert!(SIGN_IN_KEPT.ends_with('.')); assert_eq!(KEEP_MESSAGES, format!("Keep messages on {HERE} for")); assert!(SETTINGS_STAY.contains(HERE)); - assert!(DIAGNOSTICS.contains(OS_NAME)); + 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")); @@ -181,7 +227,8 @@ mod tests { assert!(clear_saved().contains(HERE)); assert!(retention_none_yet().contains(HERE)); assert!(removed_from_here().starts_with("are removed from ")); - assert!(STORE_WAITING_LINE.ends_with("The prompt may be behind this window.")); + 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."); @@ -189,6 +236,10 @@ mod tests { 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, @@ -199,6 +250,10 @@ mod tests { SETTINGS_STAY, KEEP_MESSAGES, DIAGNOSTICS, + APPEARANCE_CAPTION, + SHORTCUTS_CAPTION, + NO_BADGE, + NO_TRAY_ICON, COULD_NOT_OPEN, HERE, ]; @@ -212,21 +267,36 @@ mod tests { 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."); - assert_eq!(OS_NAME, "Linux"); } } } + #[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().expect("PRETTY_NAME"); + let name = os_release_name(); assert!(!name.is_empty()); assert!(!claims_to_be_a_mac(&name), "{name}"); }