From a23fb18f2386c62850bf71685fe20f6eb14c5261 Mon Sep 17 00:00:00 2001 From: BestLux <55964117+bestlux@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:57:42 -0500 Subject: [PATCH] feat: add configurable file receive flow --- crates/adapter-ipc-grpc/src/v2.rs | 67 ++++++++++--- crates/app-services/src/app.rs | 14 ++- crates/app-services/src/commands.rs | 7 ++ crates/app-services/src/queries.rs | 9 ++ crates/cli/src/commands.rs | 63 +++++++++++-- crates/cli/src/main.rs | 45 +++++++-- crates/daemon/src/config.rs | 94 +++++++++++++++++++ crates/daemon/src/control_plane_app.rs | 54 +++++++++-- crates/daemon/src/state.rs | 5 +- crates/daemon/src/state/clipboard_ops.rs | 14 ++- crates/daemon/src/state/config_ops.rs | 20 ++++ crates/daemon/src/state/core_ops.rs | 10 +- .../src/state/tests/input_and_outgoing.rs | 70 ++++++++++++++ crates/ipc-api/proto/boundless.proto | 18 ++++ crates/tray/src/dashboard/layout.rs | 48 ++++++++++ crates/tray/src/dashboard/model.rs | 11 +++ crates/tray/src/dashboard/task_runner.rs | 56 +++++++++++ crates/tray/src/dashboard/workflow.rs | 67 +++++++++++++ crates/tray/src/dashboard_test_support.rs | 7 ++ crates/tray/src/main.rs | 88 ++++++++++++++++- 20 files changed, 714 insertions(+), 53 deletions(-) diff --git a/crates/adapter-ipc-grpc/src/v2.rs b/crates/adapter-ipc-grpc/src/v2.rs index 07eede5..a403173 100644 --- a/crates/adapter-ipc-grpc/src/v2.rs +++ b/crates/adapter-ipc-grpc/src/v2.rs @@ -3,8 +3,9 @@ use std::{path::PathBuf, time::Duration}; use app_services::{ SharedControlPlaneApp, commands as app_commands, queries::{ - AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, StatusSnapshot, - TransportEventSnapshot, UiDiscoveredPeer, UiPairedPeer, UiPendingRequest, UiSnapshot, + AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, + FileTransferConfigSnapshot, StatusSnapshot, TransportEventSnapshot, UiDiscoveredPeer, + UiPairedPeer, UiPendingRequest, UiSnapshot, }, }; use tokio::{sync::mpsc, time}; @@ -14,16 +15,17 @@ use tonic::{Request, Response, Status}; use ipc_api::boundless::v1::{ AntiIdleConfigReply, AntiIdleSetRequest, AntiIdleStatusReply, ConsoleSnapshotReply, DiagnosticsDumpReply, DiagnosticsDumpRequest, DiscoveredPeerInfo, Empty, FeatureListReply, - FeatureSetRequest, HotkeySetRequest, HotkeyTriggerRequest, ImportTrustBundleRequest, - InputCaptureTargetReply, InputCaptureTargetRequest, InputOwnerReply, InputOwnerRequest, - LayoutReply, LayoutSetRequest, NearbyJoinStartRequest, NearbyJoinStatusReply, - NearbyJoinStatusRequest, NearbyPairingCompletionReply, NearbyPairingDecisionRequest, - NearbyPairingRequestInfo, NearbyRequestCodeStartReply, NearbyRequestCodeStartRequest, - NearbySubmitCodeRequest, OperationReply, PairCreateCodeReply, PairCreateCodeRequest, - PairJoinReply, PairJoinRequest, PeerInfo, PeerListReply, RemovePeerRequest, SafeResetRequest, - SendClipboardImageRequest, SendClipboardTextRequest, SendFileRequest, SendInputKeyRequest, - SendInputMoveRequest, StatusReply, StatusRequest, TransportEvent, TransportEventsReply, - TrustBundleReply, UiSnapshotReply, + FeatureSetRequest, FileTransferConfigReply, FileTransferSetRequest, HotkeySetRequest, + HotkeyTriggerRequest, ImportTrustBundleRequest, InputCaptureTargetReply, + InputCaptureTargetRequest, InputOwnerReply, InputOwnerRequest, LayoutReply, LayoutSetRequest, + NearbyJoinStartRequest, NearbyJoinStatusReply, NearbyJoinStatusRequest, + NearbyPairingCompletionReply, NearbyPairingDecisionRequest, NearbyPairingRequestInfo, + NearbyRequestCodeStartReply, NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, + OperationReply, PairCreateCodeReply, PairCreateCodeRequest, PairJoinReply, PairJoinRequest, + PeerInfo, PeerListReply, RemovePeerRequest, SafeResetRequest, SendClipboardImageRequest, + SendClipboardTextRequest, SendFileRequest, SendInputKeyRequest, SendInputMoveRequest, + StatusReply, StatusRequest, TransportEvent, TransportEventsReply, TrustBundleReply, + UiSnapshotReply, control_plane_service_server::{ControlPlaneService, ControlPlaneServiceServer}, }; @@ -286,6 +288,37 @@ impl ControlPlaneService for ControlPlaneApi { })) } + async fn get_file_transfer_config( + &self, + _request: Request, + ) -> Result, Status> { + let snapshot = + self.app.file_transfer_config().await.map_err(|error| { + Status::internal(format!("build file-transfer config: {error:#}")) + })?; + Ok(Response::new(map_file_transfer_config(snapshot))) + } + + async fn set_file_transfer_config( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let reply = self + .app + .set_file_transfer_config(app_commands::SetFileTransferConfigCommand { + receive_dir: request.receive_dir, + organize_by_peer: request.organize_by_peer, + auto_accept_trusted_peers: request.auto_accept_trusted_peers, + }) + .await + .map_err(|error| Status::invalid_argument(error.to_string()))?; + Ok(Response::new(OperationReply { + ok: reply.ok, + message: reply.message, + })) + } + async fn set_hotkey( &self, request: Request, @@ -838,6 +871,7 @@ fn map_ui_snapshot(snapshot: UiSnapshot) -> UiSnapshotReply { .collect(), anti_idle_config: Some(map_anti_idle_config(snapshot.anti_idle_config)), anti_idle_status: Some(map_anti_idle_status(snapshot.anti_idle_status)), + file_transfer_config: Some(map_file_transfer_config(snapshot.file_transfer_config)), } } @@ -862,6 +896,7 @@ fn map_console_snapshot(snapshot: ConsoleSnapshot) -> ConsoleSnapshotReply { local_display_name: snapshot.local_display_name, anti_idle_config: Some(map_anti_idle_config(snapshot.anti_idle_config)), anti_idle_status: Some(map_anti_idle_status(snapshot.anti_idle_status)), + file_transfer_config: Some(map_file_transfer_config(snapshot.file_transfer_config)), } } @@ -904,6 +939,14 @@ fn map_anti_idle_status(snapshot: AntiIdleStatusSnapshot) -> AntiIdleStatusReply } } +fn map_file_transfer_config(snapshot: FileTransferConfigSnapshot) -> FileTransferConfigReply { + FileTransferConfigReply { + receive_dir: snapshot.receive_dir, + organize_by_peer: snapshot.organize_by_peer, + auto_accept_trusted_peers: snapshot.auto_accept_trusted_peers, + } +} + fn map_peer_info(peer: UiPairedPeer) -> PeerInfo { PeerInfo { peer_id: peer.peer_id, diff --git a/crates/app-services/src/app.rs b/crates/app-services/src/app.rs index dc94ede..83452b9 100644 --- a/crates/app-services/src/app.rs +++ b/crates/app-services/src/app.rs @@ -12,12 +12,13 @@ use crate::{ NearbyRequestCodeCommand, NearbySubmitCodeCommand, OperationReply, PairJoinCommand, PairJoinReply, PairingCodeReply, PairingCodeRequest, RemovePeerCommand, SafeResetCommand, SendClipboardImageCommand, SendClipboardTextCommand, SendFileCommand, SendInputKeyCommand, - SendInputMoveCommand, SetAntiIdleConfigCommand, + SendInputMoveCommand, SetAntiIdleConfigCommand, SetFileTransferConfigCommand, }, queries::{ - AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, NearbyJoinStatusSnapshot, - NearbyPairingCompletionSnapshot, NearbyRequestCodeStartSnapshot, StatusSnapshot, - TransportEventSnapshot, TrustBundleSnapshot, UiSnapshot, + AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, + FileTransferConfigSnapshot, NearbyJoinStatusSnapshot, NearbyPairingCompletionSnapshot, + NearbyRequestCodeStartSnapshot, StatusSnapshot, TransportEventSnapshot, + TrustBundleSnapshot, UiSnapshot, }, }; @@ -40,6 +41,11 @@ pub trait ControlPlaneApp: Send + Sync { &self, command: SetAntiIdleConfigCommand, ) -> Result; + async fn file_transfer_config(&self) -> Result; + async fn set_file_transfer_config( + &self, + command: SetFileTransferConfigCommand, + ) -> Result; async fn set_hotkey(&self, command: HotkeySetCommand) -> Result; async fn trigger_hotkey_action(&self, command: HotkeyTriggerCommand) -> Result; async fn export_trust_bundle(&self) -> Result; diff --git a/crates/app-services/src/commands.rs b/crates/app-services/src/commands.rs index d4b6848..87cf23b 100644 --- a/crates/app-services/src/commands.rs +++ b/crates/app-services/src/commands.rs @@ -38,6 +38,13 @@ pub struct SetAntiIdleConfigCommand { pub keep_display_on: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetFileTransferConfigCommand { + pub receive_dir: String, + pub organize_by_peer: bool, + pub auto_accept_trusted_peers: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct HotkeySetCommand { pub action: String, diff --git a/crates/app-services/src/queries.rs b/crates/app-services/src/queries.rs index 7b1a943..aa622d3 100644 --- a/crates/app-services/src/queries.rs +++ b/crates/app-services/src/queries.rs @@ -18,6 +18,13 @@ pub struct AntiIdleStatusSnapshot { pub reason: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileTransferConfigSnapshot { + pub receive_dir: String, + pub organize_by_peer: bool, + pub auto_accept_trusted_peers: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StatusSnapshot { pub daemon_version: String, @@ -73,6 +80,7 @@ pub struct UiSnapshot { pub pending_requests: Vec, pub anti_idle_config: AntiIdleConfigSnapshot, pub anti_idle_status: AntiIdleStatusSnapshot, + pub file_transfer_config: FileTransferConfigSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -90,6 +98,7 @@ pub struct ConsoleSnapshot { pub local_display_name: String, pub anti_idle_config: AntiIdleConfigSnapshot, pub anti_idle_status: AntiIdleStatusSnapshot, + pub file_transfer_config: FileTransferConfigSnapshot, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/cli/src/commands.rs b/crates/cli/src/commands.rs index 174fb36..6b99bc6 100644 --- a/crates/cli/src/commands.rs +++ b/crates/cli/src/commands.rs @@ -953,6 +953,40 @@ pub(super) async fn anti_idle_set( Ok(()) } +pub(super) async fn file_transfer_config(endpoint: &str) -> Result<()> { + let mut client = connect_control_plane(endpoint).await?; + let config = client + .get_file_transfer_config(Empty {}) + .await? + .into_inner(); + + println!( + "receive_dir={} organize_by_peer={} auto_accept_trusted_peers={}", + config.receive_dir, config.organize_by_peer, config.auto_accept_trusted_peers + ); + Ok(()) +} + +pub(super) async fn file_transfer_set_receive_dir( + endpoint: &str, + path: String, + organize_by_peer: bool, + auto_accept_trusted_peers: bool, +) -> Result<()> { + let mut client = connect_control_plane(endpoint).await?; + let response = client + .set_file_transfer_config(FileTransferSetRequest { + receive_dir: path, + organize_by_peer, + auto_accept_trusted_peers, + }) + .await? + .into_inner(); + + println!("ok={} message={}", response.ok, response.message); + Ok(()) +} + pub(super) async fn hotkey_set(endpoint: &str, action: String, combo: String) -> Result<()> { let mut client = connect_control_plane(endpoint).await?; let response = client @@ -996,21 +1030,30 @@ pub(super) async fn transport_send_image( Ok(()) } -pub(super) async fn transport_send_file( +pub(super) async fn transport_send_files( endpoint: &str, peer_id: String, - path: String, + paths: Vec, ) -> Result<()> { let mut client = connect_control_plane(endpoint).await?; - let response = client - .send_file(SendFileRequest { - peer_id, - file_path: path, - }) - .await? - .into_inner(); + let total = paths.len(); + for path in paths { + let response = client + .send_file(SendFileRequest { + peer_id: peer_id.clone(), + file_path: path.clone(), + }) + .await? + .into_inner(); - println!("ok={} message={}", response.ok, response.message); + println!( + "path={} ok={} message={}", + path, response.ok, response.message + ); + } + if total > 1 { + println!("queued_files={total}"); + } Ok(()) } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 9354ae5..7380b0a 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -13,9 +13,9 @@ use std::{ use tokio::time::Instant; use ipc_api::boundless::v1::{ - AntiIdleSetRequest, DiagnosticsDumpRequest, Empty, FeatureSetRequest, HotkeySetRequest, - HotkeyTriggerRequest, ImportTrustBundleRequest, InputCaptureTargetRequest, InputOwnerRequest, - LayoutSetRequest, NearbyJoinStartRequest, NearbyJoinStatusRequest, + AntiIdleSetRequest, DiagnosticsDumpRequest, Empty, FeatureSetRequest, FileTransferSetRequest, + HotkeySetRequest, HotkeyTriggerRequest, ImportTrustBundleRequest, InputCaptureTargetRequest, + InputOwnerRequest, LayoutSetRequest, NearbyJoinStartRequest, NearbyJoinStatusRequest, NearbyPairingDecisionRequest, NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, PairCreateCodeRequest, PairJoinRequest, RemovePeerRequest, SafeResetRequest, SendClipboardImageRequest, SendClipboardTextRequest, SendFileRequest, SendInputKeyRequest, @@ -85,6 +85,10 @@ enum Command { #[command(subcommand)] command: AntiIdleCommand, }, + FileTransfer { + #[command(subcommand)] + command: FileTransferCommand, + }, Transport { #[command(subcommand)] command: TransportCommand, @@ -228,6 +232,18 @@ enum AntiIdleCommand { }, } +#[derive(Debug, Subcommand)] +enum FileTransferCommand { + Config, + SetReceiveDir { + path: String, + #[arg(long)] + organize_by_peer: bool, + #[arg(long, default_value_t = true)] + auto_accept_trusted_peers: bool, + }, +} + #[derive(Debug, Subcommand)] enum TransportCommand { SendText { @@ -240,7 +256,8 @@ enum TransportCommand { }, SendFile { peer_id: String, - path: String, + #[arg(required = true)] + paths: Vec, }, Events { #[arg(long, default_value_t = 50)] @@ -423,6 +440,22 @@ async fn main() -> Result<()> { .await } }, + Command::FileTransfer { command } => match command { + FileTransferCommand::Config => file_transfer_config(&cli.endpoint).await, + FileTransferCommand::SetReceiveDir { + path, + organize_by_peer, + auto_accept_trusted_peers, + } => { + file_transfer_set_receive_dir( + &cli.endpoint, + path, + organize_by_peer, + auto_accept_trusted_peers, + ) + .await + } + }, Command::Transport { command } => match command { TransportCommand::SendText { peer_id, text } => { transport_send_text(&cli.endpoint, peer_id, text).await @@ -430,8 +463,8 @@ async fn main() -> Result<()> { TransportCommand::SendImage { peer_id, path } => { transport_send_image(&cli.endpoint, peer_id, path).await } - TransportCommand::SendFile { peer_id, path } => { - transport_send_file(&cli.endpoint, peer_id, path).await + TransportCommand::SendFile { peer_id, paths } => { + transport_send_files(&cli.endpoint, peer_id, paths).await } TransportCommand::Events { limit } => transport_events(&cli.endpoint, limit).await, }, diff --git a/crates/daemon/src/config.rs b/crates/daemon/src/config.rs index 698f133..c42109e 100644 --- a/crates/daemon/src/config.rs +++ b/crates/daemon/src/config.rs @@ -73,6 +73,8 @@ pub struct RuntimeConfig { pub features: BTreeMap, #[serde(default)] pub anti_idle: AntiIdleConfig, + #[serde(default)] + pub file_transfer: FileTransferConfig, pub hotkeys: BTreeMap, pub peers: Vec, pub updated_at: DateTime, @@ -92,6 +94,16 @@ pub struct AntiIdleConfig { pub pulse_interval_secs: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FileTransferConfig { + #[serde(default = "default_file_receive_dir")] + pub receive_dir: String, + #[serde(default)] + pub organize_by_peer: bool, + #[serde(default = "default_file_auto_accept_trusted_peers")] + pub auto_accept_trusted_peers: bool, +} + impl Default for AntiIdleConfig { fn default() -> Self { Self { @@ -104,6 +116,16 @@ impl Default for AntiIdleConfig { } } +impl Default for FileTransferConfig { + fn default() -> Self { + Self { + receive_dir: default_file_receive_dir(), + organize_by_peer: false, + auto_accept_trusted_peers: default_file_auto_accept_trusted_peers(), + } + } +} + impl Default for RuntimeConfig { fn default() -> Self { let now = Utc::now(); @@ -145,6 +167,7 @@ impl Default for RuntimeConfig { anti_idle: AntiIdleConfig::default(), hotkeys, peers: Vec::new(), + file_transfer: FileTransferConfig::default(), updated_at: now, } } @@ -186,6 +209,19 @@ fn default_pulse_interval_secs() -> u32 { DEFAULT_ANTI_IDLE_PULSE_INTERVAL_SECS } +fn default_file_auto_accept_trusted_peers() -> bool { + true +} + +fn default_file_receive_dir() -> String { + dirs::download_dir() + .or_else(dirs::data_local_dir) + .unwrap_or_else(|| PathBuf::from(".")) + .join("Boundless") + .display() + .to_string() +} + pub fn config_path() -> PathBuf { if let Ok(path) = std::env::var("BOUNDLESS_CONFIG_PATH") { return PathBuf::from(path); @@ -256,6 +292,13 @@ pub fn load_or_create_config_at(path: &Path) -> Result { ); } + if config.file_transfer.receive_dir.trim().is_empty() { + bail!( + "invalid config: file_transfer.receive_dir must not be empty in `{}`", + path.display() + ); + } + Ok(config) } @@ -305,6 +348,13 @@ fn migrate_config_value(path: &Path, value: &mut serde_json::Value) -> Result<() .context("serialize anti_idle default")?, ); } + if !object.contains_key("file_transfer") { + object.insert( + "file_transfer".to_string(), + serde_json::to_value(FileTransferConfig::default()) + .context("serialize file_transfer default")?, + ); + } Ok(()) } "2" | "3" => { @@ -321,6 +371,11 @@ fn migrate_config_value(path: &Path, value: &mut serde_json::Value) -> Result<() serde_json::to_value(AntiIdleConfig::default()) .context("serialize anti_idle default")?, ); + object.insert( + "file_transfer".to_string(), + serde_json::to_value(FileTransferConfig::default()) + .context("serialize file_transfer default")?, + ); let migrated: RuntimeConfig = serde_json::from_value(serde_json::Value::Object(object.clone())) @@ -483,6 +538,45 @@ mod tests { ); } + #[test] + fn default_config_uses_visible_boundless_receive_folder() { + let config = RuntimeConfig::default(); + + assert!(config.file_transfer.receive_dir.ends_with("Boundless")); + assert!(!config.file_transfer.receive_dir.trim().is_empty()); + assert!(!config.file_transfer.organize_by_peer); + assert!(config.file_transfer.auto_accept_trusted_peers); + } + + #[test] + fn current_config_without_file_transfer_gets_default_receive_folder() { + let root = std::env::temp_dir().join(format!( + "boundless-config-file-transfer-default-test-{}", + uuid::Uuid::new_v4() + )); + let path = root.join("config.json"); + std::fs::create_dir_all(&root).expect("create temp root"); + + let mut value = + serde_json::to_value(RuntimeConfig::default()).expect("serialize default config"); + value + .as_object_mut() + .expect("config must be object") + .remove("file_transfer"); + std::fs::write( + &path, + serde_json::to_string_pretty(&value).expect("serialize"), + ) + .expect("write seeded config"); + + let config = load_or_create_config_at(&path).expect("load config with defaulted transfer"); + assert!(config.file_transfer.receive_dir.ends_with("Boundless")); + assert!(!config.file_transfer.organize_by_peer); + assert!(config.file_transfer.auto_accept_trusted_peers); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn load_or_create_migrates_v2_config_with_default_anti_idle() { let root = diff --git a/crates/daemon/src/control_plane_app.rs b/crates/daemon/src/control_plane_app.rs index 0cd751c..c2bed1a 100644 --- a/crates/daemon/src/control_plane_app.rs +++ b/crates/daemon/src/control_plane_app.rs @@ -11,19 +11,23 @@ use app_services::{ NearbyRequestCodeCommand, NearbySubmitCodeCommand, OperationReply, PairJoinCommand, PairJoinReply, PairingCodeReply, PairingCodeRequest, RemovePeerCommand, SafeResetCommand, SendClipboardImageCommand, SendClipboardTextCommand, SendFileCommand, SendInputKeyCommand, - SendInputMoveCommand, SetAntiIdleConfigCommand, + SendInputMoveCommand, SetAntiIdleConfigCommand, SetFileTransferConfigCommand, }, queries::{ - AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, NearbyJoinStatusSnapshot, - NearbyPairingCompletionSnapshot, NearbyRequestCodeStartSnapshot, StatusSnapshot, - TransportEventSnapshot, TrustBundleSnapshot, UiDiscoveredPeer, UiPairedPeer, - UiPendingRequest, UiSnapshot, + AntiIdleConfigSnapshot, AntiIdleStatusSnapshot, ConsoleSnapshot, + FileTransferConfigSnapshot, NearbyJoinStatusSnapshot, NearbyPairingCompletionSnapshot, + NearbyRequestCodeStartSnapshot, StatusSnapshot, TransportEventSnapshot, + TrustBundleSnapshot, UiDiscoveredPeer, UiPairedPeer, UiPendingRequest, UiSnapshot, }, }; use async_trait::async_trait; use core_security::TrustBundle; -use crate::{config::ApiTransport, pairing_wire, state::AppState}; +use crate::{ + config::{ApiTransport, FileTransferConfig}, + pairing_wire, + state::AppState, +}; #[derive(Clone)] pub struct DaemonControlPlaneApp { @@ -159,6 +163,32 @@ impl ControlPlaneApp for DaemonControlPlaneApp { }) } + async fn file_transfer_config(&self) -> Result { + Ok(build_file_transfer_config_snapshot( + self.state.file_transfer_config().await, + )) + } + + async fn set_file_transfer_config( + &self, + command: SetFileTransferConfigCommand, + ) -> Result { + self.state + .update_file_transfer_config(FileTransferConfig { + receive_dir: command.receive_dir.clone(), + organize_by_peer: command.organize_by_peer, + auto_accept_trusted_peers: command.auto_accept_trusted_peers, + }) + .await?; + Ok(OperationReply { + ok: true, + message: format!( + "file_transfer receive_dir={} organize_by_peer={} auto_accept_trusted_peers={}", + command.receive_dir, command.organize_by_peer, command.auto_accept_trusted_peers + ), + }) + } + async fn set_hotkey(&self, command: HotkeySetCommand) -> Result { self.state .set_hotkey(command.action.clone(), command.combo.clone()) @@ -553,6 +583,7 @@ async fn build_ui_snapshot(state: &AppState) -> Result { pending_requests, anti_idle_config: build_anti_idle_config_snapshot(bundle.anti_idle_config), anti_idle_status: build_anti_idle_status_snapshot(bundle.anti_idle_runtime), + file_transfer_config: build_file_transfer_config_snapshot(bundle.config.file_transfer), }) } @@ -595,6 +626,7 @@ fn build_console_snapshot_from_bundle( local_display_name: bundle.config.device_name, anti_idle_config: build_anti_idle_config_snapshot(bundle.anti_idle_config), anti_idle_status: build_anti_idle_status_snapshot(bundle.anti_idle_runtime), + file_transfer_config: build_file_transfer_config_snapshot(bundle.config.file_transfer), } } @@ -621,6 +653,16 @@ fn build_anti_idle_status_snapshot( } } +fn build_file_transfer_config_snapshot( + config: crate::config::FileTransferConfig, +) -> FileTransferConfigSnapshot { + FileTransferConfigSnapshot { + receive_dir: config.receive_dir, + organize_by_peer: config.organize_by_peer, + auto_accept_trusted_peers: config.auto_accept_trusted_peers, + } +} + fn build_paired_peers(bundle: &crate::state::ControlPlaneSnapshotBundle) -> Vec { bundle .peers diff --git a/crates/daemon/src/state.rs b/crates/daemon/src/state.rs index 22d0661..6dfb1da 100644 --- a/crates/daemon/src/state.rs +++ b/crates/daemon/src/state.rs @@ -34,8 +34,8 @@ use core_security::{ use core_transfer::{resolve_conflict_path, validate_transfer_size}; use crate::config::{ - AntiIdleConfig, ApiTransport, PeerConfig, RuntimeConfig, config_path, load_or_create_config_at, - save_config_at, + AntiIdleConfig, ApiTransport, FileTransferConfig, PeerConfig, RuntimeConfig, config_path, + load_or_create_config_at, save_config_at, }; const MAX_PENDING_REMOTE_CLIPBOARD_ITEMS: usize = 64; @@ -198,7 +198,6 @@ pub struct AppState { security_paths: Arc, identity: Arc, device_fingerprint: Arc, - inbox_root: Arc, parsed_layout_matrix_cache: Arc>>, input_capture_wake: Arc, input_inject_wake: Arc, diff --git a/crates/daemon/src/state/clipboard_ops.rs b/crates/daemon/src/state/clipboard_ops.rs index cb93df3..e514f62 100644 --- a/crates/daemon/src/state/clipboard_ops.rs +++ b/crates/daemon/src/state/clipboard_ops.rs @@ -917,7 +917,7 @@ impl AppState { validate_transfer_size(bytes.len() as u64)?; let sanitized_name = sanitize_incoming_file_name(file_name)?; - let peer_dir = self.inbox_root.join(peer_id); + let peer_dir = self.receive_dir_for_peer(peer_id).await; tokio::fs::create_dir_all(&peer_dir).await?; let final_path = resolve_conflict_path(&peer_dir, &sanitized_name); @@ -948,7 +948,7 @@ impl AppState { validate_transfer_size(size_bytes)?; let sanitized_name = sanitize_incoming_file_name(file_name)?; - let peer_dir = self.inbox_root.join(peer_id); + let peer_dir = self.receive_dir_for_peer(peer_id).await; tokio::fs::create_dir_all(&peer_dir).await?; let final_path = resolve_conflict_path(&peer_dir, &sanitized_name); @@ -976,6 +976,16 @@ impl AppState { Ok(final_path) } + async fn receive_dir_for_peer(&self, peer_id: &str) -> PathBuf { + let file_transfer = self.config.read().await.file_transfer.clone(); + let receive_dir = PathBuf::from(file_transfer.receive_dir); + if file_transfer.organize_by_peer { + receive_dir.join(peer_id) + } else { + receive_dir + } + } + pub async fn record_outgoing_file(&self, peer_id: &str, file_name: &str, size_bytes: u64) { self.record_transport_event(TransportEventRecord { timestamp: Utc::now(), diff --git a/crates/daemon/src/state/config_ops.rs b/crates/daemon/src/state/config_ops.rs index 83f8da2..eab0638 100644 --- a/crates/daemon/src/state/config_ops.rs +++ b/crates/daemon/src/state/config_ops.rs @@ -29,6 +29,26 @@ impl AppState { save_config_at(&self.config_path, &config) } + pub async fn file_transfer_config(&self) -> FileTransferConfig { + self.config.read().await.file_transfer.clone() + } + + pub async fn update_file_transfer_config( + &self, + file_transfer: FileTransferConfig, + ) -> Result<()> { + if file_transfer.receive_dir.trim().is_empty() { + anyhow::bail!("file transfer receive directory must not be empty"); + } + + let receive_dir = PathBuf::from(&file_transfer.receive_dir); + tokio::fs::create_dir_all(&receive_dir).await?; + + let mut config = self.config.write().await; + config.file_transfer = file_transfer; + save_config_at(&self.config_path, &config) + } + pub async fn set_discovered_endpoint( &self, machine_id: &str, diff --git a/crates/daemon/src/state/core_ops.rs b/crates/daemon/src/state/core_ops.rs index cbae272..011ab87 100644 --- a/crates/daemon/src/state/core_ops.rs +++ b/crates/daemon/src/state/core_ops.rs @@ -31,14 +31,7 @@ impl AppState { }, )?; - let inbox_root = if let Ok(path) = std::env::var("BOUNDLESS_INBOX_ROOT") { - PathBuf::from(path) - } else { - dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("Boundless") - .join("inbox") - }; + let inbox_root = PathBuf::from(&config.file_transfer.receive_dir); std::fs::create_dir_all(&inbox_root)?; let fingerprint = fingerprint(&secret); @@ -65,7 +58,6 @@ impl AppState { security_paths: Arc::new(paths), identity: Arc::new(identity), device_fingerprint: Arc::new(fingerprint), - inbox_root: Arc::new(inbox_root), parsed_layout_matrix_cache: Arc::new(RwLock::new(None)), input_capture_wake: Arc::new(RuntimeWakeSignal::default()), input_inject_wake: Arc::new(RuntimeWakeSignal::default()), diff --git a/crates/daemon/src/state/tests/input_and_outgoing.rs b/crates/daemon/src/state/tests/input_and_outgoing.rs index 695d858..4c554eb 100644 --- a/crates/daemon/src/state/tests/input_and_outgoing.rs +++ b/crates/daemon/src/state/tests/input_and_outgoing.rs @@ -24,6 +24,76 @@ async fn store_incoming_file_rejects_unsafe_name() { let _ = std::fs::remove_dir_all(&root); } +#[tokio::test] +async fn store_incoming_file_uses_configured_receive_dir() { + let root = std::env::temp_dir().join(format!( + "boundless-incoming-file-receive-dir-test-{}", + uuid::Uuid::new_v4() + )); + let config_path = root.join("config.json"); + let security_root = root.join("security"); + let receive_dir = root.join("received"); + + let mut config = RuntimeConfig::default(); + config.file_transfer.receive_dir = receive_dir.display().to_string(); + save_config_at(&config_path, &config).expect("seed config"); + + let state = + AppState::load_or_create_with_paths(config_path, security_root).expect("load state"); + + let final_path = state + .store_incoming_file("peer-a", "report.txt", b"payload".to_vec()) + .await + .expect("store incoming file"); + + assert_eq!(final_path.parent(), Some(receive_dir.as_path())); + assert_eq!( + final_path.file_name().and_then(|name| name.to_str()), + Some("report.txt") + ); + assert_eq!( + std::fs::read(&final_path).expect("read stored file"), + b"payload" + ); + + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn update_file_transfer_config_persists_receive_dir() { + let root = std::env::temp_dir().join(format!( + "boundless-file-transfer-config-update-test-{}", + uuid::Uuid::new_v4() + )); + let config_path = root.join("config.json"); + let security_root = root.join("security"); + let receive_dir = root.join("custom-receive"); + + let state = AppState::load_or_create_with_paths(config_path.clone(), security_root) + .expect("load state"); + + let mut config = state.file_transfer_config().await; + config.receive_dir = receive_dir.display().to_string(); + state + .update_file_transfer_config(config) + .await + .expect("update file transfer config"); + + let final_path = state + .store_incoming_file("peer-a", "report.txt", b"payload".to_vec()) + .await + .expect("store incoming file"); + assert_eq!(final_path.parent(), Some(receive_dir.as_path())); + + let reloaded = load_or_create_config_at(&config_path).expect("reload config"); + assert_eq!( + reloaded.file_transfer.receive_dir, + receive_dir.display().to_string() + ); + + let _ = std::fs::remove_dir_all(&root); +} + #[tokio::test] async fn disconnect_clears_inbound_sequence_state_for_reconnect() { let root = std::env::temp_dir().join(format!( diff --git a/crates/ipc-api/proto/boundless.proto b/crates/ipc-api/proto/boundless.proto index ef1ec6b..09c3aa0 100644 --- a/crates/ipc-api/proto/boundless.proto +++ b/crates/ipc-api/proto/boundless.proto @@ -132,6 +132,18 @@ message AntiIdleSetRequest { bool keep_display_on = 4; } +message FileTransferConfigReply { + string receive_dir = 1; + bool organize_by_peer = 2; + bool auto_accept_trusted_peers = 3; +} + +message FileTransferSetRequest { + string receive_dir = 1; + bool organize_by_peer = 2; + bool auto_accept_trusted_peers = 3; +} + message HotkeySetRequest { string action = 1; string combo = 2; @@ -235,6 +247,7 @@ message UiSnapshotReply { repeated NearbyPairingRequestInfo pending_requests = 7; AntiIdleConfigReply anti_idle_config = 8; AntiIdleStatusReply anti_idle_status = 9; + FileTransferConfigReply file_transfer_config = 10; } message ConsoleSnapshotReply { @@ -249,6 +262,7 @@ message ConsoleSnapshotReply { string local_display_name = 9; AntiIdleConfigReply anti_idle_config = 10; AntiIdleStatusReply anti_idle_status = 11; + FileTransferConfigReply file_transfer_config = 12; } message NearbyRequestCodeStartRequest { @@ -323,6 +337,8 @@ service ControlPlaneService { rpc GetAntiIdleConfig(Empty) returns (AntiIdleConfigReply); rpc GetAntiIdleStatus(Empty) returns (AntiIdleStatusReply); rpc SetAntiIdleConfig(AntiIdleSetRequest) returns (OperationReply); + rpc GetFileTransferConfig(Empty) returns (FileTransferConfigReply); + rpc SetFileTransferConfig(FileTransferSetRequest) returns (OperationReply); rpc SetHotkey(HotkeySetRequest) returns (OperationReply); rpc TriggerHotkeyAction(HotkeyTriggerRequest) returns (OperationReply); rpc ExportTrustBundle(Empty) returns (TrustBundleReply); @@ -369,6 +385,8 @@ service TopologyService { service FeatureService { rpc SetFeature(FeatureSetRequest) returns (OperationReply); rpc ListFeatures(Empty) returns (FeatureListReply); + rpc GetFileTransferConfig(Empty) returns (FileTransferConfigReply); + rpc SetFileTransferConfig(FileTransferSetRequest) returns (OperationReply); rpc SetHotkey(HotkeySetRequest) returns (OperationReply); } diff --git a/crates/tray/src/dashboard/layout.rs b/crates/tray/src/dashboard/layout.rs index 1d95124..9723cbf 100644 --- a/crates/tray/src/dashboard/layout.rs +++ b/crates/tray/src/dashboard/layout.rs @@ -440,6 +440,54 @@ impl DashboardApp { } } + let dropped_file_paths = ctx.input(|input| { + input + .raw + .dropped_files + .iter() + .filter_map(|file| file.path.as_ref()) + .filter(|path| path.is_file()) + .map(|path| path.display().to_string()) + .collect::>() + }); + if !dropped_file_paths.is_empty() && let Some(pos) = ctx.pointer_hover_pos() { + let mut target_peer_id = None; + for (rect, x, y) in &cell_rects { + if rect.contains(pos) + && let Some(peer_id) = self.layout_grid.get(&(*x, *y)).cloned() + { + target_peer_id = Some(peer_id); + break; + } + } + + match target_peer_id { + Some(peer_id) if peer_id == local_id => { + self.push_toast("Drop files on a connected peer to send them".to_string(), true); + } + Some(peer_id) if is_peer_connected(&peer_id) => { + self.task_runner().send_files_to_peer( + self.tx.clone(), + self.ctx.endpoint.clone(), + peer_id, + dropped_file_paths, + ); + } + Some(peer_id) => { + self.push_toast( + format!("{} is offline; connect it before sending files", get_display_name(&peer_id)), + true, + ); + } + None => { + self.push_toast( + "Drop files directly on a connected peer tile to send them".to_string(), + true, + ); + } + } + } + // ── Drag-drop resolution ──────────────────────────────────────── if drag_stopped && let Some((peer_id, old_pos)) = self.dragging_peer.take() { if let Some(pos) = pointer_pos_at_drop { diff --git a/crates/tray/src/dashboard/model.rs b/crates/tray/src/dashboard/model.rs index cc62923..203568f 100644 --- a/crates/tray/src/dashboard/model.rs +++ b/crates/tray/src/dashboard/model.rs @@ -75,6 +75,8 @@ pub(super) struct DashboardApp { pub(super) dragging_peer: Option<(String, (i32, i32))>, pub(super) last_layout_matrix: String, pub(super) last_layout_peer_ids: Vec, + pub(super) file_receive_dir_edit: String, + pub(super) file_receive_dir_last_snapshot: String, // Undo: stash previous layout state before each drag/action pub(super) prev_layout_grid: Option>, @@ -161,6 +163,8 @@ impl DashboardApp { dragging_peer: None, last_layout_matrix: String::new(), last_layout_peer_ids: Vec::new(), + file_receive_dir_edit: String::new(), + file_receive_dir_last_snapshot: String::new(), prev_layout_grid: None, prev_layout_unassigned: None, confirm_apply_pending: false, @@ -240,6 +244,13 @@ impl DashboardApp { pub(super) fn apply_app_msg(&mut self, msg: AppMsg) { match msg { AppMsg::SnapshotUpdated(snap) => { + let receive_dir = snap.file_transfer_config.receive_dir.clone(); + if self.file_receive_dir_edit.trim().is_empty() + || self.file_receive_dir_edit == self.file_receive_dir_last_snapshot + { + self.file_receive_dir_edit = receive_dir.clone(); + } + self.file_receive_dir_last_snapshot = receive_dir; self.snapshot = snap; if should_offer_first_run_onboarding(&self.snapshot) && !self.onboarding_focus_shown { diff --git a/crates/tray/src/dashboard/task_runner.rs b/crates/tray/src/dashboard/task_runner.rs index f3fc49b..2c0ec2f 100644 --- a/crates/tray/src/dashboard/task_runner.rs +++ b/crates/tray/src/dashboard/task_runner.rs @@ -232,6 +232,62 @@ impl DashboardTaskRunner { }); } + pub(super) fn set_file_transfer_config( + &self, + tx: Sender, + endpoint: String, + receive_dir: String, + organize_by_peer: bool, + auto_accept_trusted_peers: bool, + ) { + Self::spawn(move || { + match set_file_transfer_config_blocking( + &endpoint, + receive_dir, + organize_by_peer, + auto_accept_trusted_peers, + ) { + Ok(msg) => { + let _ = tx.send(AppMsg::ActionComplete(msg)); + } + Err(error) => { + let _ = tx.send(AppMsg::ActionFailed(error.to_string())); + } + } + }); + } + + pub(super) fn open_receive_folder(&self, tx: Sender, receive_dir: String) { + Self::spawn(move || { + let result = ProcessCommand::new("explorer") + .arg(&receive_dir) + .spawn() + .map(|_| format!("Opened receive folder: {receive_dir}")) + .map_err(|error| format!("Failed to open receive folder: {error}")); + let _ = tx.send(match result { + Ok(message) => AppMsg::ActionComplete(message), + Err(error) => AppMsg::ActionFailed(error), + }); + }); + } + + pub(super) fn send_files_to_peer( + &self, + tx: Sender, + endpoint: String, + peer_id: String, + paths: Vec, + ) { + Self::spawn(move || match send_files_to_peer_blocking(&endpoint, peer_id, paths) { + Ok(msg) => { + let _ = tx.send(AppMsg::ActionComplete(msg)); + } + Err(error) => { + let _ = tx.send(AppMsg::ActionFailed(error.to_string())); + } + }); + } + pub(super) fn reconnect_all_peers(&self, tx: Sender, endpoint: String) { Self::spawn(move || match trigger_hotkey_action_blocking(&endpoint, "reconnect") { Ok(msg) => { diff --git a/crates/tray/src/dashboard/workflow.rs b/crates/tray/src/dashboard/workflow.rs index 7646696..a8c8f57 100644 --- a/crates/tray/src/dashboard/workflow.rs +++ b/crates/tray/src/dashboard/workflow.rs @@ -390,6 +390,73 @@ impl DashboardApp { ui.add_space(16.0); ui.separator(); + // ── File Transfer ────────────────────────────────────────── + ui.add_space(8.0); + ui.heading("File Transfer"); + ui.add_space(4.0); + let file_transfer_config = self.snapshot.file_transfer_config.clone(); + ui.label(egui::RichText::new("Received files are saved to this folder.").weak()); + ui.horizontal(|ui| { + ui.label("Receive folder:"); + ui.text_edit_singleline(&mut self.file_receive_dir_edit); + }); + let mut organize_by_peer = file_transfer_config.organize_by_peer; + if ui + .checkbox(&mut organize_by_peer, "Organize received files by sender") + .clicked() + { + self.task_runner().set_file_transfer_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + self.file_receive_dir_edit.clone(), + !file_transfer_config.organize_by_peer, + file_transfer_config.auto_accept_trusted_peers, + ); + } + let mut auto_accept_trusted = file_transfer_config.auto_accept_trusted_peers; + if ui + .checkbox(&mut auto_accept_trusted, "Auto-accept files from trusted peers") + .clicked() + { + self.task_runner().set_file_transfer_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + self.file_receive_dir_edit.clone(), + file_transfer_config.organize_by_peer, + !file_transfer_config.auto_accept_trusted_peers, + ); + } + ui.horizontal(|ui| { + let receive_dir_changed = + self.file_receive_dir_edit != file_transfer_config.receive_dir; + if ui + .add_enabled(receive_dir_changed, egui::Button::new("Save Folder")) + .on_hover_text("Persist this receive folder for future incoming files") + .clicked() + { + self.task_runner().set_file_transfer_config( + self.tx.clone(), + self.ctx.endpoint.clone(), + self.file_receive_dir_edit.clone(), + file_transfer_config.organize_by_peer, + file_transfer_config.auto_accept_trusted_peers, + ); + } + if ui + .button("Open Folder") + .on_hover_text("Open the current receive folder in Explorer") + .clicked() + { + self.task_runner().open_receive_folder( + self.tx.clone(), + file_transfer_config.receive_dir.clone(), + ); + } + }); + + ui.add_space(16.0); + ui.separator(); + // ── Peer Availability ───────────────────────────────────── ui.add_space(8.0); ui.heading("Peer Availability"); diff --git a/crates/tray/src/dashboard_test_support.rs b/crates/tray/src/dashboard_test_support.rs index 54c7e33..a26a9e4 100644 --- a/crates/tray/src/dashboard_test_support.rs +++ b/crates/tray/src/dashboard_test_support.rs @@ -38,6 +38,8 @@ pub(super) fn test_app() -> DashboardApp { dragging_peer: None, last_layout_matrix: String::new(), last_layout_peer_ids: Vec::new(), + file_receive_dir_edit: String::new(), + file_receive_dir_last_snapshot: String::new(), prev_layout_grid: None, prev_layout_unassigned: None, confirm_apply_pending: false, @@ -99,6 +101,11 @@ pub(super) fn sample_first_run_snapshot() -> UiSnapshot { display_required: false, reason: "none".to_string(), }, + file_transfer_config: UiFileTransferConfig { + receive_dir: r"C:\Users\Test\Downloads\Boundless".to_string(), + organize_by_peer: false, + auto_accept_trusted_peers: true, + }, } } diff --git a/crates/tray/src/main.rs b/crates/tray/src/main.rs index 066d037..27fbd0b 100644 --- a/crates/tray/src/main.rs +++ b/crates/tray/src/main.rs @@ -27,12 +27,14 @@ mod windows_app { use eframe::icon_data; use image::ImageFormat; use ipc_api::boundless::v1::{ - AntiIdleSetRequest, Empty, HotkeyTriggerRequest, LayoutSetRequest, + AntiIdleSetRequest, Empty, FileTransferSetRequest, HotkeyTriggerRequest, LayoutSetRequest, NearbyPairingDecisionRequest, NearbyRequestCodeStartRequest, NearbySubmitCodeRequest, + SendFileRequest, }; use serde::Deserialize; use std::{ future::Future, + process::Command as ProcessCommand, time::{Duration, Instant}, }; use tray_icon::{ @@ -79,6 +81,7 @@ mod windows_app { pending_requests: Vec, anti_idle_config: UiAntiIdleConfig, anti_idle_status: UiAntiIdleStatus, + file_transfer_config: UiFileTransferConfig, } #[derive(Debug, Clone, Deserialize, Default)] @@ -98,6 +101,13 @@ mod windows_app { reason: String, } + #[derive(Debug, Clone, Deserialize, Default)] + struct UiFileTransferConfig { + receive_dir: String, + organize_by_peer: bool, + auto_accept_trusted_peers: bool, + } + #[derive(Debug, Clone, Deserialize)] struct UiDiscoveredPeer { machine_id: String, @@ -270,6 +280,14 @@ mod windows_app { reason: status.reason, }) .unwrap_or_default(), + file_transfer_config: snapshot + .file_transfer_config + .map(|config| UiFileTransferConfig { + receive_dir: config.receive_dir, + organize_by_peer: config.organize_by_peer, + auto_accept_trusted_peers: config.auto_accept_trusted_peers, + }) + .unwrap_or_default(), })?; } Ok(()) @@ -342,6 +360,20 @@ mod windows_app { )) } + fn set_file_transfer_config_blocking( + endpoint: &str, + receive_dir: String, + organize_by_peer: bool, + auto_accept_trusted_peers: bool, + ) -> Result { + block_on_result(set_file_transfer_config( + endpoint, + receive_dir, + organize_by_peer, + auto_accept_trusted_peers, + )) + } + fn ensure_daemon_available_blocking(ctx: &AppContext) -> Result> { block_on_result(ensure_daemon_available( &ctx.endpoint, @@ -402,6 +434,60 @@ mod windows_app { Ok(response.message) } + async fn set_file_transfer_config( + endpoint: &str, + receive_dir: String, + organize_by_peer: bool, + auto_accept_trusted_peers: bool, + ) -> Result { + let mut client = connect_control_plane(endpoint).await?; + let response = client + .set_file_transfer_config(FileTransferSetRequest { + receive_dir, + organize_by_peer, + auto_accept_trusted_peers, + }) + .await? + .into_inner(); + if !response.ok { + bail!(response.message); + } + Ok(response.message) + } + + fn send_files_to_peer_blocking( + endpoint: &str, + peer_id: String, + paths: Vec, + ) -> Result { + block_on_result(send_files_to_peer(endpoint, peer_id, paths)) + } + + async fn send_files_to_peer( + endpoint: &str, + peer_id: String, + paths: Vec, + ) -> Result { + let mut client = connect_control_plane(endpoint).await?; + let total = paths.len(); + for path in paths { + let response = client + .send_file(SendFileRequest { + peer_id: peer_id.clone(), + file_path: path, + }) + .await? + .into_inner(); + if !response.ok { + bail!(response.message); + } + } + Ok(format!( + "Queued {total} file{} for transfer", + if total == 1 { "" } else { "s" } + )) + } + async fn ensure_daemon_available( endpoint: &str, start_daemon: bool,