bw-menu/src/main.rs

361 lines
12 KiB
Rust

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<String>,
},
}
#[derive(Serialize, Deserialize)]
struct History {
history: Vec<String>,
}
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<String> {
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<String>) {
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::<i32>().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<String, model::response::ListObjectItemsDataData> {
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<String, String> = 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<String, String> = 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<String, model::response::ListObjectItemsDataData> = 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<String, model::response::ListObjectItemsDataData>) {
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<String, String>, collections: &HashMap<String, String>) -> 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),
}
}