diff --git a/Cargo.lock b/Cargo.lock index 0e96f9c..9814506 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1704,12 +1704,11 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ - "cfg-if", - "futures-util", + "once_cell", "wasm-bindgen", ] @@ -3487,9 +3486,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -3500,19 +3499,23 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ + "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", + "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3520,9 +3523,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -3533,9 +3536,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] @@ -3691,9 +3694,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 3c0551f..a67fc13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,10 @@ [package] name = "keepice" +description = "simple KeePass database viewer (for now)" +license = "AGPL-3.0-or-later" + version = "0.1.0" edition = "2024" -license = "AGPL-3.0-or-later" [dependencies] dirs = "6.0.0" diff --git a/REUSE.toml b/REUSE.toml index c0ac1bb..1c09bde 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,9 +1,6 @@ version = 1 [[annotations]] -path = [ - "*", - "src/*", -] +path = [ "**" ] SPDX-FileCopyrightText = "2026 Electria" SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/flake.nix b/flake.nix index 2aa1a02..2551926 100644 --- a/flake.nix +++ b/flake.nix @@ -84,8 +84,7 @@ doCheck = false; postFixup = '' - mkdir -p "$out/share/applications" - ln -s "${desktopItem}"/share/applications/* "$out/share/applications/" + cp -rs "${desktopItem}"/* "$out" ''; } ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 7de9448..eae00ff 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,13 +1,13 @@ use std::{borrow::Cow, fs}; use iced::{ - Alignment, Color, Length, Subscription, Task, application, event, + Alignment, Color, Length, Subscription, Task, application, clipboard, event, keyboard::{self, Key, key}, - theme, widget, + theme, widget, window, }; use rfd::{AsyncFileDialog, FileHandle}; -use crate::db; +use crate::db::{self, EntrySearchExt}; type Element<'a> = iced::Element<'a, Message, iced::Theme, iced::Renderer>; @@ -17,17 +17,34 @@ enum Message { PickDb, PickedDb(Option), + ChangeKeyfilePath(String), + PickKeyfile, + PickedKeyfile(Option), + ChangePassword(String), - ChangeSecurity(bool), + ToggleSecurity, OpenDb, DbOpened(Result), - SelectionChanged(usize), - SelectionUp, + ChangeSelection(usize, db::BasicField), + + SelectionLeft, SelectionDown, + SelectionUp, + SelectionRight, + + SelectionTop, + SelectionBottom, + + ChangeQuery(String), + + Copy, FocusNext, FocusPrevious, + FocusSearch, + + Quit, } #[derive(Default)] @@ -35,14 +52,18 @@ struct State { db_path: String, db_path_exists: bool, + keyfile_path: String, + keyfile_path_exists: bool, + + is_secure: bool, + password: String, + db: Option, db_view: Vec, selected_entry: usize, - - password: String, - - is_secure: bool, + selected_field: db::BasicField, + query: String, status: Cow<'static, str>, error: Cow<'static, str>, @@ -87,79 +108,173 @@ impl State { } Message::PickedDb(None) => eprintln!("no path received from picker"), + Message::ChangeKeyfilePath(keyfile_path) => { + self.keyfile_path_exists = fs::exists(&keyfile_path) + .inspect_err(|e| eprintln!("failed to check '{keyfile_path}': {e}")) + .is_ok_and(|exists| exists); + self.keyfile_path = keyfile_path + } + Message::PickKeyfile => { + return Task::perform(AsyncFileDialog::new().pick_file(), Message::PickedKeyfile); + } + Message::PickedKeyfile(Some(file)) => { + return Task::done(Message::ChangeKeyfilePath( + file.path().to_string_lossy().to_string(), + )); + } + Message::PickedKeyfile(None) => eprintln!("no path received from picker"), + Message::ChangePassword(password) => self.password = password, - Message::ChangeSecurity(bool) => self.is_secure = bool, + Message::ToggleSecurity => self.is_secure = !self.is_secure, Message::OpenDb => return self.open_db(), Message::DbOpened(result) => { match result { Ok(db) => { self.error = "".into(); - self.db_view = db - .iter_all_entries() - .map(|entry_ref| entry_ref.clone()) - .collect(); - self.db = Some(db) + self.db = Some(db); + self.populate_view(); } Err(e) => self.error = e.into(), } self.status = "".into(); } - Message::SelectionChanged(val) => self.selected_entry = val, + Message::ChangeSelection(i, field) => { + self.selected_entry = i; + self.selected_field = field; + return Task::done(Message::Copy); + } + Message::SelectionLeft => self.selected_field.previous(), + Message::SelectionDown => { + self.selected_entry = + (self.selected_entry.saturating_add(1)).clamp(0, self.db_view.len() - 1) + } Message::SelectionUp => { self.selected_entry = - dbg!((self.selected_entry.wrapping_sub(1)).clamp(0, self.db_view.len() - 1)) + (self.selected_entry.saturating_sub(1)).clamp(0, self.db_view.len() - 1) } - Message::SelectionDown => { - self.selected_entry = if self.selected_entry == self.db_view.len() - 1 { - // essentially do a wrapping_add up to the highest index in the view - 0 + Message::SelectionRight => self.selected_field.next(), + + Message::SelectionTop => self.selected_entry = 0, + Message::SelectionBottom => self.selected_entry = self.db_view.len() - 1, + + Message::ChangeQuery(str) => { + self.query = str; + self.populate_view(); + } + + Message::Copy => { + if let Some(entry_ref) = &self.db_view.get(self.selected_entry) { + if let Some(str) = match self.selected_field { + db::BasicField::Title => entry_ref.get_password(), + db::BasicField::Username => entry_ref.get_username(), + db::BasicField::Password => entry_ref.get_password(), + db::BasicField::Url => entry_ref.get_url(), + } { + self.error = "".into(); + return clipboard::write(str.to_owned()); + } else { + self.error = "nothing to copy!".into(); + } } else { - dbg!((self.selected_entry.wrapping_add(1)).clamp(0, self.db_view.len() - 1)) + self.error = "nothing selected!".into(); } } Message::FocusNext => return widget::operation::focus_next(), Message::FocusPrevious => return widget::operation::focus_previous(), + Message::FocusSearch => return widget::operation::focus("search"), + + Message::Quit => return window::latest().and_then(window::close), } Task::none() } fn view(&self) -> Element<'_> { - let main = if !self.db_view.is_empty() { - widget::scrollable( - widget::grid(self.db_view.iter().enumerate().flat_map(|(i, entry)| { - [ - widget::button(widget::text(entry.get_title().unwrap_or_default())) - .style(if i == self.selected_entry { - widget::button::primary + let main = if self.db.is_some() { + widget::column([ + widget::text_input("search query", &self.query) + .on_input(Message::ChangeQuery) + .id("search") + .into(), + widget::scrollable( + widget::grid(self.db_view.iter().enumerate().flat_map(|(i, entry)| { + [ + if let Some(title) = entry.get_title() { + let field = db::BasicField::Title; + widget::button(widget::text(title)) + .style( + if i == self.selected_entry && field == self.selected_field + { + widget::button::primary + } else { + widget::button::subtle + }, + ) + .on_press(Message::ChangeSelection(i, field)) + .into() } else { - widget::button::subtle - }) - .on_press(Message::SelectionChanged(i)) - .into(), - widget::button(widget::text(entry.get_username().unwrap_or_default())) - .style(if i == self.selected_entry { - widget::button::primary + widget::space().into() + }, + if let Some(title) = entry.get_username() { + let field = db::BasicField::Username; + widget::button(widget::text(title)) + .style( + if i == self.selected_entry && field == self.selected_field + { + widget::button::primary + } else { + widget::button::subtle + }, + ) + .on_press(Message::ChangeSelection(i, field)) + .into() } else { - widget::button::subtle - }) - .on_press(Message::SelectionChanged(i)) - .into(), - widget::button(widget::text(entry.get_url().unwrap_or_default())) - .style(if i == self.selected_entry { - widget::button::primary + widget::space().into() + }, + if entry.get_password().is_some() { + let field = db::BasicField::Password; + widget::button(widget::text("[password hidden]")) + .style( + if i == self.selected_entry && field == self.selected_field + { + widget::button::primary + } else { + widget::button::subtle + }, + ) + .on_press(Message::ChangeSelection(i, field)) + .into() } else { - widget::button::subtle - }) - .on_press(Message::SelectionChanged(i)) - .into(), - ] - })) - .height(Length::Shrink) - .columns(3), - ) + widget::space().into() + }, + if let Some(title) = entry.get_url() { + let field = db::BasicField::Url; + widget::button(widget::text(title)) + .style( + if i == self.selected_entry && field == self.selected_field + { + widget::button::primary + } else { + widget::button::subtle + }, + ) + .on_press(Message::ChangeSelection(i, field)) + .into() + } else { + widget::space().into() + }, + ] + })) + .height(Length::Shrink) + .columns(4), + ) + // I don't know why this needs to be here, + // but without it the search bar isn't shown + .height(Length::Fill) + .into(), + ]) .into() } else { widget::container( @@ -168,7 +283,30 @@ impl State { widget::text_input("database path", &self.db_path) .on_input(Message::ChangeDbPath) .into(), - widget::button("pick file").on_press(Message::PickDb).into(), + widget::button("pick db") + .style(if self.db_path_exists { + widget::button::primary + } else { + widget::button::danger + }) + .on_press(Message::PickDb) + .into(), + ]) + .into(), + widget::row([ + widget::text_input("keyfile path", &self.keyfile_path) + .on_input(Message::ChangeKeyfilePath) + .into(), + widget::button("pick keyfile") + .style( + if self.keyfile_path.is_empty() || self.keyfile_path_exists { + widget::button::primary + } else { + widget::button::danger + }, + ) + .on_press(Message::PickKeyfile) + .into(), ]) .into(), widget::row([ @@ -178,13 +316,15 @@ impl State { .into(), widget::checkbox(self.is_secure) .label("security") - .on_toggle(Message::ChangeSecurity) + .on_toggle(|_| Message::ToggleSecurity) .into(), ]) .spacing(10) .into(), widget::container(widget::button("open database").on_press_maybe( - if self.db_path_exists { + if self.db_path_exists + && (self.keyfile_path.is_empty() || self.keyfile_path_exists) + { Some(Message::OpenDb) } else { None @@ -227,23 +367,44 @@ impl State { .filter_map(move |((db_loaded, db_path_exists), event)| match event { iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { match key.as_ref() { + Key::Character("p" | "f" | "i") if !db_loaded => Some(Message::PickDb), + Key::Character("v" | "s") if !db_loaded => Some(Message::ToggleSecurity), + + Key::Named(key::Named::Enter) if !db_loaded && db_path_exists => { + Some(Message::OpenDb) + } + Key::Named(key::Named::Tab) => Some(if modifiers.shift() { Message::FocusPrevious } else { Message::FocusNext }), + Key::Character("/" | "f") => Some(Message::FocusSearch), - Key::Named(key::Named::Enter) if !db_loaded && db_path_exists => { - Some(Message::OpenDb) - } - Key::Character("p" | "f" | "i") if !db_loaded => Some(Message::PickDb), - - Key::Character("k") | Key::Named(key::Named::ArrowUp) => { - Some(Message::SelectionUp) + Key::Character("h") | Key::Named(key::Named::ArrowLeft) => { + Some(Message::SelectionLeft) } Key::Character("j") | Key::Named(key::Named::ArrowDown) => { Some(Message::SelectionDown) } + Key::Character("k") | Key::Named(key::Named::ArrowUp) => { + Some(Message::SelectionUp) + } + Key::Character("l") | Key::Named(key::Named::ArrowRight) => { + Some(Message::SelectionRight) + } + + Key::Character("g") => Some(if modifiers.shift() { + Message::SelectionBottom + } else { + Message::SelectionTop + }), + Key::Named(key::Named::Home) => Some(Message::SelectionTop), + Key::Named(key::Named::End) => Some(Message::SelectionBottom), + + Key::Character("y" | "c") if db_loaded => Some(Message::Copy), + + Key::Character("q") => Some(Message::Quit), // ignore unused keys _ => None, @@ -260,12 +421,32 @@ impl State { self.status = "loading...".into(); - let (db_path, password) = (self.db_path.clone(), self.password.clone()); + let (db_path, keyfile_path, password) = ( + self.db_path.clone(), + self.keyfile_path.clone(), + self.password.clone(), + ); Task::perform( async move { + let key = { + let mut key = keepass::DatabaseKey::new(); + if !password.is_empty() { + key = key.with_password(&password); + } + if !keyfile_path.is_empty() { + key = key + .with_keyfile( + &mut fs::File::open(&keyfile_path).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + } + + key + }; + let db = keepass::Database::open( &mut fs::File::open(&db_path).map_err(|e| e.to_string())?, - keepass::DatabaseKey::new().with_password(&password), + key, ) .map_err(|e| e.to_string())?; @@ -276,6 +457,23 @@ impl State { Message::DbOpened, ) } + + fn populate_view(&mut self) { + self.db_view = self + .db + .as_ref() + .unwrap() + .iter_all_entries() + .filter_map(|entry_ref| { + // this matches everything when the query is an empty string, + // which is the desired behavior so that it displays everything + // (when search isn't being used) + entry_ref + .contains(&self.query) + .then(|| entry_ref.to_owned()) + }) + .collect(); + } } pub fn run() -> Result<(), iced::Error> { diff --git a/src/db.rs b/src/db.rs index 982ab74..104f5cf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,5 +1,43 @@ use std::{fs, io}; +#[derive(Clone, Copy, Default, PartialEq, Eq)] +pub enum BasicField { + Title, + #[default] + Username, + Password, + Url, +} +impl BasicField { + pub fn next(&mut self) { + *self = match self { + Self::Title => Self::Username, + Self::Username => Self::Password, + Self::Password => Self::Url, + Self::Url => Self::Title, + } + } + pub fn previous(&mut self) { + *self = match self { + Self::Title => Self::Url, + Self::Username => Self::Title, + Self::Password => Self::Username, + Self::Url => Self::Password, + } + } +} + +pub trait EntrySearchExt { + fn contains(&self, s: impl AsRef) -> bool; +} +impl EntrySearchExt for keepass::db::EntryRef<'_> { + fn contains(&self, s: impl AsRef) -> bool { + let str = s.as_ref(); + self.get_title().is_some_and(|title| title.contains(str)) + || self.get_url().is_some_and(|url| url.contains(str)) + } +} + pub fn get_last_opened() -> io::Result { Ok(fs::read_to_string( dirs::data_dir()