diff --git a/Cargo.lock b/Cargo.lock index 9814506..0e96f9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1704,11 +1704,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -3486,9 +3487,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -3499,23 +3500,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3523,9 +3520,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -3536,9 +3533,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] @@ -3694,9 +3691,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index a67fc13..3c0551f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,8 @@ [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 1c09bde..c0ac1bb 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1,6 +1,9 @@ version = 1 [[annotations]] -path = [ "**" ] +path = [ + "*", + "src/*", +] SPDX-FileCopyrightText = "2026 Electria" SPDX-License-Identifier = "AGPL-3.0-or-later" diff --git a/flake.nix b/flake.nix index 2551926..2aa1a02 100644 --- a/flake.nix +++ b/flake.nix @@ -84,7 +84,8 @@ doCheck = false; postFixup = '' - cp -rs "${desktopItem}"/* "$out" + mkdir -p "$out/share/applications" + ln -s "${desktopItem}"/share/applications/* "$out/share/applications/" ''; } ); diff --git a/src/app/mod.rs b/src/app/mod.rs index eae00ff..7de9448 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, clipboard, event, + Alignment, Color, Length, Subscription, Task, application, event, keyboard::{self, Key, key}, - theme, widget, window, + theme, widget, }; use rfd::{AsyncFileDialog, FileHandle}; -use crate::db::{self, EntrySearchExt}; +use crate::db; type Element<'a> = iced::Element<'a, Message, iced::Theme, iced::Renderer>; @@ -17,34 +17,17 @@ enum Message { PickDb, PickedDb(Option), - ChangeKeyfilePath(String), - PickKeyfile, - PickedKeyfile(Option), - ChangePassword(String), - ToggleSecurity, + ChangeSecurity(bool), OpenDb, DbOpened(Result), - ChangeSelection(usize, db::BasicField), - - SelectionLeft, - SelectionDown, + SelectionChanged(usize), SelectionUp, - SelectionRight, - - SelectionTop, - SelectionBottom, - - ChangeQuery(String), - - Copy, + SelectionDown, FocusNext, FocusPrevious, - FocusSearch, - - Quit, } #[derive(Default)] @@ -52,18 +35,14 @@ 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, - selected_field: db::BasicField, - query: String, + + password: String, + + is_secure: bool, status: Cow<'static, str>, error: Cow<'static, str>, @@ -108,173 +87,79 @@ 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::ToggleSecurity => self.is_secure = !self.is_secure, + Message::ChangeSecurity(bool) => self.is_secure = bool, Message::OpenDb => return self.open_db(), Message::DbOpened(result) => { match result { Ok(db) => { self.error = "".into(); - self.db = Some(db); - self.populate_view(); + self.db_view = db + .iter_all_entries() + .map(|entry_ref| entry_ref.clone()) + .collect(); + self.db = Some(db) } Err(e) => self.error = e.into(), } self.status = "".into(); } - 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::SelectionChanged(val) => self.selected_entry = val, Message::SelectionUp => { self.selected_entry = - (self.selected_entry.saturating_sub(1)).clamp(0, self.db_view.len() - 1) + dbg!((self.selected_entry.wrapping_sub(1)).clamp(0, self.db_view.len() - 1)) } - 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(); - } + 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 } else { - self.error = "nothing selected!".into(); + dbg!((self.selected_entry.wrapping_add(1)).clamp(0, self.db_view.len() - 1)) } } 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.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() + 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 } else { - 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() + 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 } else { - 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() + 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 } else { - 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(), - ]) + widget::button::subtle + }) + .on_press(Message::SelectionChanged(i)) + .into(), + ] + })) + .height(Length::Shrink) + .columns(3), + ) .into() } else { widget::container( @@ -283,30 +168,7 @@ impl State { widget::text_input("database path", &self.db_path) .on_input(Message::ChangeDbPath) .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(), + widget::button("pick file").on_press(Message::PickDb).into(), ]) .into(), widget::row([ @@ -316,15 +178,13 @@ impl State { .into(), widget::checkbox(self.is_secure) .label("security") - .on_toggle(|_| Message::ToggleSecurity) + .on_toggle(Message::ChangeSecurity) .into(), ]) .spacing(10) .into(), widget::container(widget::button("open database").on_press_maybe( - if self.db_path_exists - && (self.keyfile_path.is_empty() || self.keyfile_path_exists) - { + if self.db_path_exists { Some(Message::OpenDb) } else { None @@ -367,44 +227,23 @@ 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::Character("h") | Key::Named(key::Named::ArrowLeft) => { - Some(Message::SelectionLeft) + 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("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, @@ -421,32 +260,12 @@ impl State { self.status = "loading...".into(); - let (db_path, keyfile_path, password) = ( - self.db_path.clone(), - self.keyfile_path.clone(), - self.password.clone(), - ); + let (db_path, password) = (self.db_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())?, - key, + keepass::DatabaseKey::new().with_password(&password), ) .map_err(|e| e.to_string())?; @@ -457,23 +276,6 @@ 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 104f5cf..982ab74 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,43 +1,5 @@ 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()