Compare commits

...
Author SHA1 Message Date
6695bc4fb9
chore: update metadata 2026-08-28 21:56:20 -07:00
580276c568
build: generalize desktopItem linking 2026-08-28 21:52:23 -07:00
6e7d50a79d
feat: support keyfiles
I don't personally use them, so this is untested,
but I imagine it works.

in addition, I removed an unecessary bool "db_loaded",
changed the style of the picker buttons based on whether the files exist,
and improved ordering of the State struct
2026-08-28 21:45:38 -07:00
f7340ffcb9
feat: improve keyboard shortcuts 2026-08-28 21:11:53 -07:00
2b41e36362
fix: remove edge-case wrapping_add
left this on accident when I decided wrapping around isn't useful
(especially without it scrolling to show the selection)
2026-08-27 10:31:34 -07:00
7aafbe2207
feat: more selection keybinds
also deduplicates code into State::populate_view
2026-08-27 10:28:49 -07:00
b4ac07ffc4
feat: quit and focus_search keybinds 2026-08-26 12:42:29 -07:00
7c86e3d496
feat: entry search 2026-08-26 12:35:59 -07:00
8e6fa9be2f
build: return to stable iced version
I initially thought that only the git version had a clipboard at all,
but that's incorrect; what 0.15 adds is rich clipboard (images, etc)
2026-08-26 12:00:55 -07:00
b7c5a05c6e
feat: copying entry fields 2026-08-26 11:59:23 -07:00
6 changed files with 324 additions and 87 deletions

35
Cargo.lock generated
View file

@ -1704,12 +1704,11 @@ dependencies = [
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.103" version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [ dependencies = [
"cfg-if", "once_cell",
"futures-util",
"wasm-bindgen", "wasm-bindgen",
] ]
@ -3487,9 +3486,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.126" version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"once_cell", "once_cell",
@ -3500,19 +3499,23 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-futures" name = "wasm-bindgen-futures"
version = "0.4.76" version = "0.4.58"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
dependencies = [ dependencies = [
"cfg-if",
"futures-util",
"js-sys", "js-sys",
"once_cell",
"wasm-bindgen", "wasm-bindgen",
"web-sys",
] ]
[[package]] [[package]]
name = "wasm-bindgen-macro" name = "wasm-bindgen-macro"
version = "0.2.126" version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [ dependencies = [
"quote", "quote",
"wasm-bindgen-macro-support", "wasm-bindgen-macro-support",
@ -3520,9 +3523,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-macro-support" name = "wasm-bindgen-macro-support"
version = "0.2.126" version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
@ -3533,9 +3536,9 @@ dependencies = [
[[package]] [[package]]
name = "wasm-bindgen-shared" name = "wasm-bindgen-shared"
version = "0.2.126" version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@ -3691,9 +3694,9 @@ dependencies = [
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.103" version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
dependencies = [ dependencies = [
"js-sys", "js-sys",
"wasm-bindgen", "wasm-bindgen",

View file

@ -1,8 +1,10 @@
[package] [package]
name = "keepice" name = "keepice"
description = "simple KeePass database viewer (for now)"
license = "AGPL-3.0-or-later"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
dirs = "6.0.0" dirs = "6.0.0"

View file

@ -1,9 +1,6 @@
version = 1 version = 1
[[annotations]] [[annotations]]
path = [ path = [ "**" ]
"*",
"src/*",
]
SPDX-FileCopyrightText = "2026 Electria" SPDX-FileCopyrightText = "2026 Electria"
SPDX-License-Identifier = "AGPL-3.0-or-later" SPDX-License-Identifier = "AGPL-3.0-or-later"

View file

@ -84,8 +84,7 @@
doCheck = false; doCheck = false;
postFixup = '' postFixup = ''
mkdir -p "$out/share/applications" cp -rs "${desktopItem}"/* "$out"
ln -s "${desktopItem}"/share/applications/* "$out/share/applications/"
''; '';
} }
); );

View file

@ -1,13 +1,13 @@
use std::{borrow::Cow, fs}; use std::{borrow::Cow, fs};
use iced::{ use iced::{
Alignment, Color, Length, Subscription, Task, application, event, Alignment, Color, Length, Subscription, Task, application, clipboard, event,
keyboard::{self, Key, key}, keyboard::{self, Key, key},
theme, widget, theme, widget, window,
}; };
use rfd::{AsyncFileDialog, FileHandle}; use rfd::{AsyncFileDialog, FileHandle};
use crate::db; use crate::db::{self, EntrySearchExt};
type Element<'a> = iced::Element<'a, Message, iced::Theme, iced::Renderer>; type Element<'a> = iced::Element<'a, Message, iced::Theme, iced::Renderer>;
@ -17,17 +17,34 @@ enum Message {
PickDb, PickDb,
PickedDb(Option<FileHandle>), PickedDb(Option<FileHandle>),
ChangeKeyfilePath(String),
PickKeyfile,
PickedKeyfile(Option<FileHandle>),
ChangePassword(String), ChangePassword(String),
ChangeSecurity(bool), ToggleSecurity,
OpenDb, OpenDb,
DbOpened(Result<keepass::Database, String>), DbOpened(Result<keepass::Database, String>),
SelectionChanged(usize), ChangeSelection(usize, db::BasicField),
SelectionUp,
SelectionLeft,
SelectionDown, SelectionDown,
SelectionUp,
SelectionRight,
SelectionTop,
SelectionBottom,
ChangeQuery(String),
Copy,
FocusNext, FocusNext,
FocusPrevious, FocusPrevious,
FocusSearch,
Quit,
} }
#[derive(Default)] #[derive(Default)]
@ -35,14 +52,18 @@ struct State {
db_path: String, db_path: String,
db_path_exists: bool, db_path_exists: bool,
keyfile_path: String,
keyfile_path_exists: bool,
is_secure: bool,
password: String,
db: Option<keepass::Database>, db: Option<keepass::Database>,
db_view: Vec<keepass::db::Entry>, db_view: Vec<keepass::db::Entry>,
selected_entry: usize, selected_entry: usize,
selected_field: db::BasicField,
password: String, query: String,
is_secure: bool,
status: Cow<'static, str>, status: Cow<'static, str>,
error: Cow<'static, str>, error: Cow<'static, str>,
@ -87,79 +108,173 @@ impl State {
} }
Message::PickedDb(None) => eprintln!("no path received from picker"), 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::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::OpenDb => return self.open_db(),
Message::DbOpened(result) => { Message::DbOpened(result) => {
match result { match result {
Ok(db) => { Ok(db) => {
self.error = "".into(); self.error = "".into();
self.db_view = db self.db = Some(db);
.iter_all_entries() self.populate_view();
.map(|entry_ref| entry_ref.clone())
.collect();
self.db = Some(db)
} }
Err(e) => self.error = e.into(), Err(e) => self.error = e.into(),
} }
self.status = "".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 => { Message::SelectionUp => {
self.selected_entry = 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 => { Message::SelectionRight => self.selected_field.next(),
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 Message::SelectionTop => self.selected_entry = 0,
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 { } else {
dbg!((self.selected_entry.wrapping_add(1)).clamp(0, self.db_view.len() - 1)) self.error = "nothing to copy!".into();
}
} else {
self.error = "nothing selected!".into();
} }
} }
Message::FocusNext => return widget::operation::focus_next(), Message::FocusNext => return widget::operation::focus_next(),
Message::FocusPrevious => return widget::operation::focus_previous(), 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() Task::none()
} }
fn view(&self) -> Element<'_> { fn view(&self) -> Element<'_> {
let main = if !self.db_view.is_empty() { 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::scrollable(
widget::grid(self.db_view.iter().enumerate().flat_map(|(i, entry)| { widget::grid(self.db_view.iter().enumerate().flat_map(|(i, entry)| {
[ [
widget::button(widget::text(entry.get_title().unwrap_or_default())) if let Some(title) = entry.get_title() {
.style(if i == self.selected_entry { let field = db::BasicField::Title;
widget::button(widget::text(title))
.style(
if i == self.selected_entry && field == self.selected_field
{
widget::button::primary widget::button::primary
} else { } else {
widget::button::subtle widget::button::subtle
}) },
.on_press(Message::SelectionChanged(i)) )
.into(), .on_press(Message::ChangeSelection(i, field))
widget::button(widget::text(entry.get_username().unwrap_or_default())) .into()
.style(if i == self.selected_entry { } 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 widget::button::primary
} else { } else {
widget::button::subtle widget::button::subtle
}) },
.on_press(Message::SelectionChanged(i)) )
.into(), .on_press(Message::ChangeSelection(i, field))
widget::button(widget::text(entry.get_url().unwrap_or_default())) .into()
.style(if i == self.selected_entry { } 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 widget::button::primary
} else { } else {
widget::button::subtle widget::button::subtle
}) },
.on_press(Message::SelectionChanged(i)) )
.into(), .on_press(Message::ChangeSelection(i, field))
.into()
} 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) .height(Length::Shrink)
.columns(3), .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() .into()
} else { } else {
widget::container( widget::container(
@ -168,7 +283,30 @@ impl State {
widget::text_input("database path", &self.db_path) widget::text_input("database path", &self.db_path)
.on_input(Message::ChangeDbPath) .on_input(Message::ChangeDbPath)
.into(), .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(), .into(),
widget::row([ widget::row([
@ -178,13 +316,15 @@ impl State {
.into(), .into(),
widget::checkbox(self.is_secure) widget::checkbox(self.is_secure)
.label("security") .label("security")
.on_toggle(Message::ChangeSecurity) .on_toggle(|_| Message::ToggleSecurity)
.into(), .into(),
]) ])
.spacing(10) .spacing(10)
.into(), .into(),
widget::container(widget::button("open database").on_press_maybe( 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) Some(Message::OpenDb)
} else { } else {
None None
@ -227,23 +367,44 @@ impl State {
.filter_map(move |((db_loaded, db_path_exists), event)| match event { .filter_map(move |((db_loaded, db_path_exists), event)| match event {
iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => { iced::Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) => {
match key.as_ref() { 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() { Key::Named(key::Named::Tab) => Some(if modifiers.shift() {
Message::FocusPrevious Message::FocusPrevious
} else { } else {
Message::FocusNext Message::FocusNext
}), }),
Key::Character("/" | "f") => Some(Message::FocusSearch),
Key::Named(key::Named::Enter) if !db_loaded && db_path_exists => { Key::Character("h") | Key::Named(key::Named::ArrowLeft) => {
Some(Message::OpenDb) Some(Message::SelectionLeft)
}
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) => { Key::Character("j") | Key::Named(key::Named::ArrowDown) => {
Some(Message::SelectionDown) 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 // ignore unused keys
_ => None, _ => None,
@ -260,12 +421,32 @@ impl State {
self.status = "loading...".into(); 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( Task::perform(
async move { 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( let db = keepass::Database::open(
&mut fs::File::open(&db_path).map_err(|e| e.to_string())?, &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())?; .map_err(|e| e.to_string())?;
@ -276,6 +457,23 @@ impl State {
Message::DbOpened, 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> { pub fn run() -> Result<(), iced::Error> {

View file

@ -1,5 +1,43 @@
use std::{fs, io}; 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<str>) -> bool;
}
impl EntrySearchExt for keepass::db::EntryRef<'_> {
fn contains(&self, s: impl AsRef<str>) -> 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<String> { pub fn get_last_opened() -> io::Result<String> {
Ok(fs::read_to_string( Ok(fs::read_to_string(
dirs::data_dir() dirs::data_dir()