//! `bsk upload` — stage caller-readable files and attach them to a page. use std::fs::File; use std::io::Read; use std::path::{Path, PathBuf}; use std::time::Duration; use anyhow::Context; use base64::Engine; use bsk_protocol::Method; use bsk_protocol::tools::{ TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult, TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadMode, UploadParams, UploadResult, }; use clap::{Args, ValueEnum}; use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::error::{CliError, Format}; use crate::cli::interaction::split_target; use crate::cli::navigate::parse_timeout_ms; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum UploadModeArg { #[default] Input, Drop, } impl From for UploadMode { fn from(value: UploadModeArg) -> Self { match value { UploadModeArg::Input => Self::Input, UploadModeArg::Drop => Self::Drop, } } } #[derive(Debug, Clone, Args)] pub struct UploadArgs { /// Snapshot ref (`@e3`) or CSS selector for the chooser trigger / drop target. pub target: Option, #[arg(long = "ref")] pub ref_: Option, #[arg(long = "selector")] pub selector: Option, /// Local file to upload. Repeat for a multiple-file input. #[arg(long = "file", required = true)] pub files: Vec, /// Browser mechanism used to deliver the staged files. #[arg(long, value_enum, default_value_t)] pub mode: UploadModeArg, #[arg(long)] pub session: String, #[arg(long = "tab-id")] pub tab_id: Option, #[arg(long, default_value = "2m", value_parser = parse_timeout_ms)] pub timeout: u32, } pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> { if args.files.len() > crate::daemon::file_transfer::MAX_UPLOAD_FILES { return Err(CliError::Local(anyhow::anyhow!( "upload accepts at most {} files", crate::daemon::file_transfer::MAX_UPLOAD_FILES ))); } let info = ensure_daemon().context("ensure daemon is running")?; let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?; let mut staged = Vec::new(); for path in &args.files { match stage_file(&info.sock_path, &args.session, path) { Ok(file) => staged.push(file), Err(err) => { for (id, _) in &staged { let _ = release(&info.sock_path, id); } return Err(err); } } } let params = UploadParams { session_id: args.session, ref_, selector, tab_id: args.tab_id, files: staged .iter() .map(|(id, name)| UploadFile { transfer_id: id.clone(), name: name.clone(), staged_path: None, }) .collect(), mode: args.mode.into(), timeout_ms: Some(args.timeout), }; let result = crate::cli::business_rpc::call::<_, UploadResult>( info.sock_path.clone(), "upload", Method::ToolUpload, Some(params), ipc_timeout(args.timeout), ); // Once tool.upload is dispatched, staging ownership belongs to the // session. A transport timeout cannot prove that Chrome did not attach // the file, so releasing here could invalidate a late successful attach. // Session teardown remains the single cleanup boundary after dispatch. let reply = result?; match format { Format::Json => println!("{}", serde_json::to_string_pretty(&reply).unwrap()), Format::Human => println!( "upload ok tab={} files={}", reply.tab_id, reply.file_names.join(", ") ), } Ok(()) } fn stage_file(sock: &Path, session: &str, path: &PathBuf) -> Result<(String, String), CliError> { let mut file = File::open(path) .with_context(|| format!("open upload file {}", path.display())) .map_err(CliError::Local)?; let meta = file .metadata() .with_context(|| format!("read upload file metadata {}", path.display())) .map_err(CliError::Local)?; if !meta.is_file() { return Err(CliError::Local(anyhow::anyhow!( "upload source is not a regular file: {}", path.display() ))); } let name = path .file_name() .and_then(|v| v.to_str()) .filter(|v| !v.is_empty()) .ok_or_else(|| CliError::Local(anyhow::anyhow!("upload file has no valid basename")))? .to_string(); let begin: TransferBeginResult = crate::cli::business_rpc::call( sock.to_path_buf(), "transfer-begin", Method::TransferBegin, Some(TransferBeginParams { session_id: session.to_string(), name: name.clone(), byte_size: meta.len(), }), Duration::from_secs(10), )?; let staged = (|| { let mut offset = 0u64; let mut buf = vec![0u8; begin.chunk_size as usize]; loop { let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?; if n == 0 { break; } let reply: TransferChunkResult = crate::cli::business_rpc::call( sock.to_path_buf(), "transfer-chunk", Method::TransferChunk, Some(TransferChunkParams { transfer_id: begin.transfer_id.clone(), offset, data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]), }), Duration::from_secs(30), )?; offset = reply.next_offset; } let _: TransferReadyResult = crate::cli::business_rpc::call( sock.to_path_buf(), "transfer-finish", Method::TransferFinish, Some(TransferIdParams { transfer_id: begin.transfer_id.clone(), }), Duration::from_secs(10), )?; Ok::<_, CliError>(()) })(); if let Err(err) = staged { let _ = release(sock, &begin.transfer_id); return Err(err); } Ok((begin.transfer_id, name)) } fn release(sock: &Path, id: &str) -> Result { crate::cli::business_rpc::call( sock.to_path_buf(), "transfer-release", Method::TransferRelease, Some(TransferIdParams { transfer_id: id.to_string(), }), Duration::from_secs(5), ) } fn ipc_timeout(timeout_ms: u32) -> Duration { Duration::from_millis(u64::from(timeout_ms) + 5_000) }