Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/chatd/src/adapters/signal/vault.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
14 changes: 12 additions & 2 deletions crates/chatd/src/adapters/telegram/real.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,9 +294,19 @@ async fn follow(client: Client, updates: UnboundedReceiver<UpdatesLike>, events:
}

async fn run(api_id: i32, vault: Arc<Vault>, mut jobs: UnboundedReceiver<Job>, events: Sender<Event>) {
// 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()
};
Expand Down
2 changes: 1 addition & 1 deletion crates/chatd/src/adapters/telegram/signin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}
}
};
Expand Down
8 changes: 3 additions & 5 deletions crates/chatd/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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 {
Expand Down
56 changes: 54 additions & 2 deletions crates/chatd/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.");
}
}
}
2 changes: 1 addition & 1 deletion crates/chatd/tests/polish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. }));
Expand Down
5 changes: 3 additions & 2 deletions crates/tinyclient/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions crates/tinyclient/src/gui/app/menubar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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.
Expand Down
47 changes: 35 additions & 12 deletions crates/tinyclient/src/gui/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<u64>().ok()) {
Expand All @@ -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<String> {
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<String> {
command_out("/usr/bin/sw_vers", &["-productVersion"]).map(|version| format!("macOS {version}"))
}

#[cfg(target_os = "linux")]
fn os_version() -> Option<String> {
Some(crate::host::os_release_name())
}

#[cfg(windows)]
fn os_version() -> Option<String> {
command_out("cmd", &["/C", "ver"])
}

#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
fn os_version() -> Option<String> {
None
}

fn unix_now() -> i64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64)
}
Expand Down
12 changes: 4 additions & 8 deletions crates/tinyclient/src/gui/app/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."),
Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 27 additions & 3 deletions crates/tinyclient/src/gui/call.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<u16>>();
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()),
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/tinyclient/src/gui/conversation/draw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion crates/tinyclient/src/gui/layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
),
)
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tinyclient/src/gui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion crates/tinyclient/src/gui/profile/facts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
4 changes: 2 additions & 2 deletions crates/tinyclient/src/gui/settings/accounts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) {
Expand All @@ -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| {
Expand Down
Loading
Loading