use clap::{Parser, Subcommand}; use itertools::Itertools; use log::{debug, error, info}; use serde::{Serialize, Deserialize}; use std::env; use std::fs; use std::io::Read; use std::io::Write; use std::process::Stdio; use std::{process, collections::HashMap}; use crate::bitwarden_api::model; mod bitwarden_api; // GOALS: // * `search` item via dmenu / rofi / cli // * `search --mark` parameter that remembers the marked entry // * `mark get {field}` returns the field of the marked element (id, title, folder, username, password, etc) // * "scroll" through marked history, mark as active again // * automatically execute `export BW_SESSION="$(bw unlock --raw)" && bw serve --hostname 127.0.0.1&` if required // * unlock with system keyring if possible // * autotype? #[derive(Parser)] #[command(author, version, about, long_about = None)] // Read from `Cargo.toml` struct Cli { #[command(subcommand)] command: CliCommands, } #[derive(Subcommand)] enum CliCommands { // Show the details of the item Select { title: Option, }, } #[derive(Serialize, Deserialize)] struct History { history: Vec, } fn main() { env_logger::init(); let cli = Cli::parse(); match &cli.command { CliCommands::Select{title: _} => { // the title is always ignored, we want the id which we get via the environment variables debug!("Running in select mode"); rofi(); } } } fn get_history() -> Vec { let file_path = "/tmp/bw-menu-history.json"; // TODO: use XDG paths let read_file = fs::OpenOptions::new() .read(true) .open(file_path); let json_str = match read_file { Ok(mut file) => { let mut content = String::new(); file.read_to_string(&mut content).expect("Failed to read file"); content } Err(_) => String::new(), // Return an empty string if the file cannot be opened }; // Deserialize JSON string back into a Rust struct let history: History = serde_json::from_str(&json_str).unwrap_or_else(|_| { // Return a default Person with an empty history if deserialization fails History { history: Vec::new(), } }); history.history } fn save_history(entries: Vec) { let history = History { history: entries, }; let json_str = serde_json::to_string(&history).expect("Failed to serialize to JSON!"); let file_path = "/tmp/bw-menu-history.json"; // TODO: use XDG paths // Write the JSON string to a file, overwriting it if it already exists let mut file = fs::OpenOptions::new() .write(true) .truncate(true) // Ensure the file is truncated before writing .create(true) .open(file_path) .expect("Failed to open file for writing!"); file.write_all(json_str.as_bytes()).expect("Failed to write to file!"); } const MAX_HISTORY_LEN: usize = 5; // TODO: make configurable fn add_history(entry: &String) { let mut history = get_history(); history.insert(0, entry.to_string()); if history.len() > MAX_HISTORY_LEN { history = history[0..MAX_HISTORY_LEN].to_vec(); } save_history(history); } // the flow is: // 1. bw-menu starts rofi in the script mode // 2. rofi calls bw-menu calls bw-menu to get a list of items to display // 3. the user interacts with the list // 4. rofi sets environment variables, and calls bw-menu with the title of the selected item // 5. bw-menu acts based on the environment variables // // currently using the script mode so that we can pass additional info (eg the id). // no other mode supports this, meaning we would need to match based on the title fn rofi() { let rofi_retv = env::var("ROFI_RETV"); let rofi_info = env::var("ROFI_INFO"); dbg!(&rofi_retv); dbg!(&rofi_info); match rofi_retv { Err(_) => { // this means rofi is not running yet, so let's start it debug!("starting initial rofi process"); let _ = process::Command::new("rofi") .arg("-show") .arg("bw") .arg("-modes") .arg("bw:./target/debug/bw-menu select") // todo: set the path according to argv[0] // we need to remap some default so that we can use the keys for custom binds .arg("-kb-accept-alt") .arg("F12") .arg("-kb-accept-custom") .arg("F11") // set our custom keybinds .arg("-kb-custom-1") .arg("Shift+Return") .arg("-kb-custom-2") .arg("Control+Return") .spawn() .expect("Failed to spawn child process"); // TODO: better error handling } Ok(_) => { // this means we are being called by rofi as a custom script // either generate the list, or act on input let rofi_retv = rofi_retv.unwrap_or_default().to_string().parse::().unwrap_or(0); let rofi_info = rofi_info.unwrap_or_default(); if rofi_retv == 0 { // 0: Initial call of script generate_rofi_list(get_bitwarden_logins()); } else { let item = match crate::bitwarden_api::object_item(&rofi_info) { Ok(response) => response, Err(msg) => { error!("Failed to get Bitwarden item with id {}:\n{}", rofi_info, msg); process::exit(1); } }; add_history(&rofi_info); // 1: Selected an entry if rofi_retv == 1 { info!("copy password"); if let Some(login) = item.data.login { if let Some(password) = login.password { save_to_clipboard(password); } } // 10-28: Custom keybinding 1-19 } else if rofi_retv == 10 { info!("copy username"); if let Some(login) = item.data.login { if let Some(username) = login.username { save_to_clipboard(username); } } } else if rofi_retv == 11 { info!("show item"); println!("\0prompt\x1fSelect field"); println!("\0message\x1fReturn | Copy field"); println!("{}\0icon\x1f{}\x1finfo\x1f{}", "TODO", "folder", "single-item:todo"); // TODO: not sure how to proceed here. currently pressing return leads to the "copy password" action // one option is to pass the action in the info and parsing that // if let Some(login) = item.data.login { // if let Some(username) = login.username { // println!("Username: {}", username); // } // } } // we are done here process::exit(0); } } } } fn get_bitwarden_logins() -> HashMap { match crate::bitwarden_api::status() { Ok(response) => { if response.data.template.status != "unlocked" { error!("Vault is {}, should be unlocked!", response.data.template.status); process::exit(1); } } Err(msg) => { error!("Failed to get Bitwarden status:\n{msg}"); process::exit(1); } } match crate::bitwarden_api::sync() { Ok(_) => info!("Sync'd Bitwarden Vault."), Err(msg) => { error!("Failed to sync Bitwarden Vault:\n{msg}"); process::exit(1); } } let items = match crate::bitwarden_api::list_object_items() { Ok(response) => { info!("Got list of Bitwarden items."); response.data.data } Err(msg) => { error!("Failed to get list of Bitwarden items:\n{msg}"); process::exit(1); } }; let collections: HashMap = match crate::bitwarden_api::list_object_collections() { Ok(response) => { info!("Got list of Bitwarden collections."); response.data.data.iter() .map(|item| (item.id.clone(), item.name.clone())) .collect() } Err(msg) => { error!("Failed to get list of Bitwarden collections:\n{msg}"); process::exit(1); } }; let folders: HashMap = match crate::bitwarden_api::list_object_folders() { Ok(response) => { info!("Got list of Bitwarden folders."); response.data.data.iter() .filter(|item| item.id.is_some()) .map(|item| (item.id.as_ref().unwrap().clone(), item.name.clone())) .collect() } Err(msg) => { error!("Failed to get list of Bitwarden folders:\n{msg}"); process::exit(1); } }; let mut logins: HashMap = HashMap::new(); for item in items { if item.login.is_some() { let title = get_title(&item, &folders, &collections); debug!("Login: {}", title); logins.insert(title, item); } // break; } // dbg!(&logins); logins } fn generate_rofi_list(logins: HashMap) { println!("\0prompt\x1fSelect item"); println!("\0message\x1fReturn | Copy password\rShift+Return | Copy username or selected field\rControl+Return | Show item"); println!("\0use-hot-keys\x1ftrue"); println!("\0no-custom\x1ftrue"); println!("\0markup-rows\x1ftrue"); for title in logins.keys().sorted() { print!("{title}"); let icon = "folder"; // TODO: what icons are available? ~/.icons seems to work println!("\0icon\x1f{}\x1finfo\x1f{}", icon, logins[title].id); } } fn get_title(item: &model::response::ListObjectItemsDataData, folders: &HashMap, collections: &HashMap) -> String { let mut title = String::new(); let login = item.login.as_ref().unwrap(); if item.folderId.is_some() { title.push_str(&format!("{}/", folders[item.folderId.as_ref().unwrap()])); } // TODO: how do we handle multiple collections nicely? i dont like the pipe for searching. my // fav solution right now is to display the entry multiple times, eg "000/000-p-xca01", "001/000-p-xca01" for collection_id in &item.collectionIds { title.push_str(&collections[collection_id]); } if !item.collectionIds.is_empty() { title.push('/'); } if title.is_empty() { title.push_str("ungrouped/"); } title.push_str(&item.name); if login.username.is_some() { title.push_str(&format!(" ({})", login.username.as_ref().unwrap())); } title } fn save_to_clipboard(input: String) { // using xclip blocks the app. maybe piping the output to /dev/null would fix this // let mut cmd = process::Command::new("xclip") // .arg("-in") // .arg("-selection") // .arg("clipboard") // .stdin(Stdio::piped()) // .spawn() // .expect("Failed to spawn child process"); let mut cmd = process::Command::new("xsel") .arg("--input") .arg("--clipboard") .stdin(Stdio::piped()) .spawn() .expect("Failed to spawn child process"); // TODO: better error handling let mut stdin = cmd.stdin.take().expect("Failed to open stdin."); let child_thread = std::thread::spawn(move || { stdin.write_all(input.as_bytes()).expect("Failed to write to stdin"); }); let result = child_thread.join(); match result { Ok(_) => debug!("Child thread completed successfully"), Err(e) => error!("Child thread encountered an error: {:?}", e), } }