361 lines
12 KiB
Rust
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),
|
|
}
|
|
}
|