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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file.
- A portable copy started on a computer that also has Portkey Drop installed now offers to bring the installed copy's configuration across on first launch: a list of what to copy -- your sites, known hosts, and settings -- and, separately, your saved passwords. The installed copy is read and left exactly as it is. Passwords need the separate question because an installed copy keeps them in the computer's keyring, which a portable copy on a different machine cannot read, so without copying them into the portable copy's own encrypted vault the sites would arrive with every password blank. Both questions are asked once.
- The Modified column can show how long ago a file changed -- "3 days ago" rather than a date and time -- which is much shorter to listen to when skimming a folder. The exact stamp is still there for comparing two files; choose between them in Settings.
- The Site Manager has its Browse button back for choosing a private key file, and it opens where the current path points rather than at your home folder.
- A "Waiting to connect" cue now loops while an SFTP connection is held up waiting for your SSH agent to approve the key. Agents such as Bitwarden show that approval in a box that can open behind the Portkey Drop window with nothing to say it is there; the sound fills that gap and stops the moment the connection succeeds or fails. Give it a sound by adding a `connect_waiting` entry to a sound pack, and mute it in Settings like any other cue.

### Fixed
- Backspace, Alt+Left, and Alt+Up in a file pane now go to the parent directory. Those keys were bound to a list event that never reported which key was pressed, so they did nothing in either pane. Ctrl+Up and Ctrl+[ do the same (Command+Up and Command+[ on a Mac, matching Finder).
Expand Down
15 changes: 10 additions & 5 deletions crates/portkeydrop-core/src/protocols/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub use model::{
ConnectionInfo, HostKeyDecision, HostKeyPolicy, Protocol, RemoteFile, UnknownProtocol,
SUPPORTED_PROTOCOL_VALUES,
};
pub use sftp::{HostKeyPrompt, SftpClient};
pub use sftp::{AgentAuthNotice, HostKeyPrompt, SftpClient};
pub use webdav::WebdavClient;

/// Errors any protocol client can raise.
Expand Down Expand Up @@ -185,9 +185,14 @@ pub trait TransferClient: Send {
pub fn create_client(
info: ConnectionInfo,
host_key_prompt: Option<HostKeyPrompt>,
agent_notice: Option<AgentAuthNotice>,
) -> Result<Box<dyn TransferClient>> {
match info.protocol {
Protocol::Sftp => Ok(Box::new(SftpClient::new(info, host_key_prompt))),
Protocol::Sftp => Ok(Box::new(SftpClient::with_hooks(
info,
host_key_prompt,
agent_notice,
))),
Protocol::Ftp | Protocol::Ftps => Ok(Box::new(FtpClient::new(info))),
Protocol::Webdav => Ok(Box::new(WebdavClient::new(info))),
Protocol::Scp => Err(ProtocolError::Unsupported(
Expand All @@ -206,7 +211,7 @@ mod tests {
protocol: Protocol::Scp,
..Default::default()
};
let Err(error) = create_client(info, None) else {
let Err(error) = create_client(info, None, None) else {
panic!("scp should not build a client");
};
assert!(matches!(error, ProtocolError::Unsupported(_)));
Expand All @@ -221,7 +226,7 @@ mod tests {
host: "example.com".into(),
..Default::default()
};
let client = create_client(info, None).expect("client for {name}");
let client = create_client(info, None, None).expect("client for {name}");
// A freshly built client has not connected yet.
assert!(!client.is_connected());
}
Expand All @@ -234,7 +239,7 @@ mod tests {
protocol,
..Default::default()
};
let client = create_client(info, None).unwrap();
let client = create_client(info, None, None).unwrap();
assert_eq!(client.protocol(), protocol);
}
}
Expand Down
38 changes: 36 additions & 2 deletions crates/portkeydrop-core/src/protocols/sftp/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ const TRANSFER_CHUNK: usize = 32 * 1024;
/// Receives `(host, key algorithm, fingerprint)` and returns the decision.
pub type HostKeyPrompt = Arc<dyn Fn(&str, &str, &str) -> HostKeyDecision + Send + Sync + 'static>;

/// Called once, on the connect worker thread, just before SFTP authentication
/// asks the SSH agent to sign.
///
/// An external agent (Bitwarden, a smartcard) may pop a confirmation dialog at
/// that point, and for a screen-reader user it can open behind the main
/// window. The UI uses this to start an audible "waiting" cue; it stops the
/// cue itself once the connection attempt resolves.
pub type AgentAuthNotice = Arc<dyn Fn() + Send + Sync + 'static>;

/// What the handler observed about the server's key.
#[derive(Debug, Default, Clone)]
struct OfferedKey {
Expand Down Expand Up @@ -109,6 +118,7 @@ impl client::Handler for ClientHandler {
pub struct SftpClient {
info: ConnectionInfo,
host_key_prompt: Option<HostKeyPrompt>,
agent_notice: Option<AgentAuthNotice>,
runtime: Option<tokio::runtime::Runtime>,
session: Option<Arc<SftpSession>>,
handle: Option<Handle<ClientHandler>>,
Expand All @@ -119,9 +129,19 @@ pub struct SftpClient {
impl SftpClient {
/// Build a client. No network activity happens until [`TransferClient::connect`].
pub fn new(info: ConnectionInfo, host_key_prompt: Option<HostKeyPrompt>) -> Self {
Self::with_hooks(info, host_key_prompt, None)
}

/// Build a client, also wiring an [`AgentAuthNotice`] callback.
pub fn with_hooks(
info: ConnectionInfo,
host_key_prompt: Option<HostKeyPrompt>,
agent_notice: Option<AgentAuthNotice>,
) -> Self {
Self {
info,
host_key_prompt,
agent_notice,
runtime: None,
session: None,
handle: None,
Expand Down Expand Up @@ -204,6 +224,7 @@ impl SftpClient {

let info = self.info.clone();
let prompt_used = self.host_key_prompt.is_some();
let agent_notice = self.agent_notice.clone();
let connect_result: Result<(Handle<ClientHandler>, Arc<SftpSession>, String)> = runtime
.block_on(async move {
let handle =
Expand All @@ -217,7 +238,7 @@ impl SftpClient {
})?
.map_err(map_ssh_error)?;

authenticate(handle, &info, prompt_used).await
authenticate(handle, &info, prompt_used, agent_notice).await
});

let observed = offered
Expand Down Expand Up @@ -274,6 +295,7 @@ async fn authenticate(
mut handle: Handle<ClientHandler>,
info: &ConnectionInfo,
_prompt_used: bool,
agent_notice: Option<AgentAuthNotice>,
) -> Result<(Handle<ClientHandler>, Arc<SftpSession>, String)> {
let username = if info.username.is_empty() {
whoami::username()
Expand Down Expand Up @@ -303,7 +325,7 @@ async fn authenticate(
// and it never prompts.
if ssh_agent::is_agent_available() || cfg!(windows) {
attempted.push("SSH agent".to_string());
match authenticate_with_agent(&mut handle, &username).await {
match authenticate_with_agent(&mut handle, &username, agent_notice.as_ref()).await {
Ok(true) => authenticated = true,
Ok(false) => {}
Err(err) => log::debug!("SSH agent authentication unavailable: {err}"),
Expand Down Expand Up @@ -364,9 +386,14 @@ async fn authenticate(
}

/// Try every identity the SSH agent holds.
///
/// `notice`, if given, is called once immediately before the first signature
/// request: that is when an external agent (Bitwarden, a smartcard) may put up
/// a confirmation dialog.
async fn authenticate_with_agent(
handle: &mut Handle<ClientHandler>,
username: &str,
notice: Option<&AgentAuthNotice>,
) -> Result<bool> {
#[cfg(unix)]
let mut agent = russh::keys::agent::client::AgentClient::connect_env()
Expand All @@ -384,10 +411,17 @@ async fn authenticate_with_agent(
.await
.map_err(|err| ProtocolError::Other(err.to_string()))?;

let mut notified = false;
for identity in identities {
let russh::keys::agent::AgentIdentity::PublicKey { key, .. } = identity else {
continue;
};
if !notified {
if let Some(notice) = notice {
notice();
}
notified = true;
}
let hash_alg = hash_alg_for_algorithm(key.algorithm().as_str());
match handle
.authenticate_publickey_with(username, key, hash_alg, &mut agent)
Expand Down
2 changes: 2 additions & 0 deletions crates/portkeydrop-core/src/sound_events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub const SOUND_EVENT_SECTIONS: &[SoundEventSection] = &[
title: "Connections",
description: "Server connection lifecycle sounds.",
events: &[
("connect_waiting", "Waiting to connect"),
("connect_success", "Connected"),
("connect_failed", "Connection failed"),
("disconnect", "Disconnected"),
Expand Down Expand Up @@ -133,6 +134,7 @@ mod tests {
#[test]
fn the_catalogue_covers_the_documented_events() {
assert!(is_known_sound_event("transfer_complete"));
assert!(is_known_sound_event("connect_waiting"));
assert!(is_known_sound_event("connect_failed"));
assert!(is_known_sound_event("folder_create_failed"));
assert!(is_known_sound_event("exit"));
Expand Down
5 changes: 4 additions & 1 deletion crates/portkeydrop-core/src/soundpacks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ use std::path::{Path, PathBuf};

pub use install::{is_safe_archive_name, InstallError, PackInstaller};
pub use manifest::{PackManifest, SoundEntry};
pub use player::{can_decode, play_sound_file, wait_for_playback, SoundPlayer, EXIT_SOUND_TIMEOUT};
pub use player::{
can_decode, play_looping_sound_file, play_sound_file, wait_for_playback, LoopHandle,
SoundPlayer, EXIT_SOUND_TIMEOUT,
};

/// Directory name of the built-in pack.
pub const DEFAULT_PACK: &str = "default";
Expand Down
Loading