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]]
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",

View file

@ -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"

View file

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

View file

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

View file

@ -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<FileHandle>),
ChangeKeyfilePath(String),
PickKeyfile,
PickedKeyfile(Option<FileHandle>),
ChangePassword(String),
ChangeSecurity(bool),
ToggleSecurity,
OpenDb,
DbOpened(Result<keepass::Database, String>),
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<keepass::Database>,
db_view: Vec<keepass::db::Entry>,
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 {
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::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() {
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)| {
[
widget::button(widget::text(entry.get_title().unwrap_or_default()))
.style(if i == self.selected_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::SelectionChanged(i))
.into(),
widget::button(widget::text(entry.get_username().unwrap_or_default()))
.style(if i == self.selected_entry {
},
)
.on_press(Message::ChangeSelection(i, field))
.into()
} 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::SelectionChanged(i))
.into(),
widget::button(widget::text(entry.get_url().unwrap_or_default()))
.style(if i == self.selected_entry {
},
)
.on_press(Message::ChangeSelection(i, field))
.into()
} 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::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)
.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()
} 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> {

View file

@ -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<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> {
Ok(fs::read_to_string(
dirs::data_dir()