diff --git a/Cargo.lock b/Cargo.lock index 48f934c2..1a97edcb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -785,6 +785,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -962,6 +971,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.37" @@ -1544,8 +1562,11 @@ dependencies = [ "paste", "ron", "rust-embed", + "secret-service", + "secstr", "serde", "shlex", + "thiserror 2.0.16", "tokio", "url", ] @@ -2866,6 +2887,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -3758,6 +3788,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -4749,6 +4780,20 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e0826a989adedc2a244799e823aece04662b66609d96af8dff7ac6df9a8925d" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -4759,6 +4804,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -4785,6 +4839,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -6204,6 +6269,34 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.16", + "hkdf", + "num", + "once_cell", + "serde", + "sha2", + "zbus 5.11.0", +] + +[[package]] +name = "secstr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04f657244f605c4cf38f6de5993e8bd050c8a303f86aeabff142d5c7c113e12" +dependencies = [ + "libc", +] + [[package]] name = "self_cell" version = "1.2.0" diff --git a/Cargo.toml b/Cargo.toml index fe1a0097..1f8a8c20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,9 @@ i18n-embed-fl = "0.10" icu = { version = "2.0.0", features = ["compiled_data"] } rust-embed = "8" url = "2.5" +secret-service = { version = "5.0.0", features = ["rt-tokio-crypto-rust"], optional = true } +thiserror = { version = "2.0", optional = true } +secstr = { version = "0.5", optional = true } [dependencies.cosmic-files] git = "https://github.com/pop-os/cosmic-files.git" @@ -48,10 +51,11 @@ features = ["about", "multi-window", "tokio", "winit", "surface-message"] fork = "0.2" [features] -default = ["dbus-config", "wgpu", "wayland"] +default = ["dbus-config", "wgpu", "wayland", "password_manager"] dbus-config = ["libcosmic/dbus-config"] wgpu = ["libcosmic/wgpu", "cosmic-files/wgpu"] wayland = ["libcosmic/wayland", "cosmic-files/wayland"] +password_manager = [ "secret-service", "thiserror", "secstr" ] [profile.release-with-debug] inherits = "release" diff --git a/i18n/en/cosmic_term.ftl b/i18n/en/cosmic_term.ftl index 7a7ceb24..b1058cf9 100644 --- a/i18n/en/cosmic_term.ftl +++ b/i18n/en/cosmic_term.ftl @@ -99,3 +99,10 @@ pane-toggle-maximize = Toggle maximized menu-color-schemes = Color schemes... menu-settings = Settings... menu-about = About COSMIC Terminal... + +# Password Manager +menu-password-manager = Passwords... +passwords-title = Passwords +add-password = Add Password +password-input = Password +password-input-description = Description diff --git a/i18n/sv-SE/cosmic_term.ftl b/i18n/sv-SE/cosmic_term.ftl index 3f7af528..b162c827 100644 --- a/i18n/sv-SE/cosmic_term.ftl +++ b/i18n/sv-SE/cosmic_term.ftl @@ -111,3 +111,10 @@ menu-settings = Inställningar… menu-about = Om COSMIC Terminal… repository = Källkod support = Support + +# Lösenordshanterare +menu-password-manager = Lösenord… +passwords-title = Lösenord +add-password = Lägg till lösenord +password-input = Lösenord +password-input-description = Beskrivning diff --git a/src/key_bind.rs b/src/key_bind.rs index f2bfd871..900f7c9f 100644 --- a/src/key_bind.rs +++ b/src/key_bind.rs @@ -45,6 +45,8 @@ pub fn key_binds() -> HashMap { Key::Character("X".into()), PaneToggleMaximized ); + #[cfg(feature = "password_manager")] + bind!([Ctrl, Alt], Key::Character("p".into()), PasswordManager); // Ctrl+Tab and Ctrl+Shift+Tab cycle through tabs // Ctrl+Tab is not a special key for terminals and is free to use diff --git a/src/main.rs b/src/main.rs index c330ab2c..7268a00c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,8 @@ use terminal_box::terminal_box; use crate::dnd::DndDrop; mod terminal_box; +#[cfg(feature = "password_manager")] +mod password_manager; mod terminal_theme; mod dnd; @@ -236,6 +238,8 @@ pub enum Action { Profiles, SelectAll, Settings, + #[cfg(feature = "password_manager")] + PasswordManager, ShowHeaderBar(bool), TabActivate0, TabActivate1, @@ -278,6 +282,8 @@ impl Action { Self::PaneSplitHorizontal => Message::PaneSplit(pane_grid::Axis::Horizontal), Self::PaneSplitVertical => Message::PaneSplit(pane_grid::Axis::Vertical), Self::PaneToggleMaximized => Message::PaneToggleMaximized, + #[cfg(feature = "password_manager")] + Self::PasswordManager => Message::ToggleContextPage(ContextPage::PasswordManager), Self::Paste => Message::Paste(entity_opt), Self::PastePrimary => Message::PastePrimary(entity_opt), Self::ProfileOpen(profile_id) => Message::ProfileOpen(*profile_id), @@ -362,6 +368,10 @@ pub enum Message { PaneResized(pane_grid::ResizeEvent), PaneSplit(pane_grid::Axis), PaneToggleMaximized, + #[cfg(feature = "password_manager")] + PasswordManager(password_manager::PasswordManagerMessage), + #[cfg(feature = "password_manager")] + PasswordPaste(secstr::SecUtf8, pane_grid::Pane), Paste(Option), PastePrimary(Option), PasteValue(Option, String), @@ -412,6 +422,8 @@ pub enum ContextPage { ColorSchemes(ColorSchemeKind), Profiles, Settings, + #[cfg(feature = "password_manager")] + PasswordManager, } /// The [`App`] stores application-specific state. @@ -456,6 +468,8 @@ pub struct App { profile_expanded: Option, show_advanced_font_settings: bool, modifiers: Modifiers, + #[cfg(feature = "password_manager")] + password_mgr: password_manager::PasswordManager, } impl App { @@ -1569,6 +1583,8 @@ impl Application for App { profile_expanded: None, show_advanced_font_settings: false, modifiers: Modifiers::empty(), + #[cfg(feature = "password_manager")] + password_mgr: Default::default(), }; app.set_curr_font_weights_and_stretches(); @@ -1582,6 +1598,10 @@ impl Application for App { if self.core.window.show_context { // Close context drawer if open self.core.window.show_context = false; + #[cfg(feature = "password_manager")] + if self.context_page == ContextPage::PasswordManager { + self.password_mgr.clear(); + } } else if self.find { // Close find if open self.find = false; @@ -1596,6 +1616,10 @@ impl Application for App { if self.core.window.show_context { Task::none() } else { + #[cfg(feature = "password_manager")] + if self.context_page == ContextPage::PasswordManager { + self.password_mgr.clear(); + } self.update_focus() } } @@ -2147,6 +2171,23 @@ impl Application for App { self.pane_model.panes.drop(pane, target); } Message::PaneDragged(_) => {} + #[cfg(feature = "password_manager")] + Message::PasswordManager(msg) => { + return self.password_mgr.update(msg); + } + #[cfg(feature = "password_manager")] + Message::PasswordPaste(password, pane) => { + if let Some(tab_model) = self.pane_model.panes.get(pane) { + let entity = tab_model.active(); + if let Some(terminal) = tab_model.data::>(entity) { + let terminal = terminal.lock().unwrap(); + terminal.paste(password.into_unsecure()); + terminal.input_scroll(b"\n".as_slice()); + self.core.window.show_context = false; + self.password_mgr.clear(); + } + } + } Message::Paste(entity_opt) => { return clipboard::read().map(move |value_opt| match value_opt { Some(value) => action::app(Message::PasteValue(entity_opt, value)), @@ -2612,6 +2653,16 @@ impl Application for App { ColorSchemeKind::Light => light_entity, }); } + + #[cfg(feature = "password_manager")] + if ContextPage::PasswordManager == context_page { + if self.core.window.show_context { + self.password_mgr.pane = Some(self.pane_model.focused()); + return self.password_mgr.refresh_password_list(); + } else { + self.password_mgr.clear(); + } + } } Message::UpdateDefaultProfile((default, profile_id)) => { config_set!(default_profile, default.then_some(profile_id)); @@ -2685,6 +2736,12 @@ impl Application for App { Message::ToggleContextPage(ContextPage::Settings), ) .title(fl!("settings")), + #[cfg(feature = "password_manager")] + ContextPage::PasswordManager => context_drawer::context_drawer( + self.password_mgr.context_page(self.core.system_theme()), + Message::ToggleContextPage(ContextPage::PasswordManager), + ) + .title(fl!("passwords-title")), }) } diff --git a/src/menu.rs b/src/menu.rs index 2829de97..cf8459a9 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -70,7 +70,7 @@ pub fn context_menu<'a>( .on_press(Message::TabContextAction(entity, action)) }; - widget::container(column!( + let mut content = column!( menu_item(fl!("copy"), Action::Copy), menu_item(fl!("paste"), Action::Paste), menu_item(fl!("select-all"), Action::SelectAll), @@ -83,31 +83,39 @@ pub fn context_menu<'a>( divider::horizontal::light(), menu_item(fl!("new-tab"), Action::TabNew), menu_item(fl!("menu-settings"), Action::Settings), - menu_checkbox( - fl!("show-headerbar"), - config.show_headerbar, - Action::ShowHeaderBar(!config.show_headerbar) - ), - )) - .padding(1) - //TODO: move style to libcosmic - .style(|theme| { - let cosmic = theme.cosmic(); - let component = &cosmic.background.component; - widget::container::Style { - icon_color: Some(component.on.into()), - text_color: Some(component.on.into()), - background: Some(Background::Color(component.base.into())), - border: Border { - radius: cosmic.radius_s().map(|x| x + 1.0).into(), - width: 1.0, - color: component.divider.into(), - }, - ..Default::default() - } - }) - .width(Length::Fixed(240.0)) - .into() + ); + #[cfg(feature = "password_manager")] + { + content = content.push(menu_item( + fl!("menu-password-manager"), + Action::PasswordManager, + )); + } + content = content.push(menu_checkbox( + fl!("show-headerbar"), + config.show_headerbar, + Action::ShowHeaderBar(!config.show_headerbar), + )); + widget::container(content) + .padding(1) + //TODO: move style to libcosmic + .style(|theme| { + let cosmic = theme.cosmic(); + let component = &cosmic.background.component; + widget::container::Style { + icon_color: Some(component.on.into()), + text_color: Some(component.on.into()), + background: Some(Background::Color(component.base.into())), + border: Border { + radius: cosmic.radius_s().map(|x| x + 1.0).into(), + width: 1.0, + color: component.divider.into(), + }, + ..Default::default() + } + }) + .width(Length::Fixed(240.0)) + .into() } pub fn color_scheme_menu<'a>( @@ -234,6 +242,12 @@ pub fn menu_bar<'a>( Action::ColorSchemes(config.color_scheme_kind()), ), MenuItem::Button(fl!("menu-settings"), None, Action::Settings), + #[cfg(feature = "password_manager")] + MenuItem::Button( + fl!("menu-password-manager"), + None, + Action::PasswordManager, + ), MenuItem::Divider, MenuItem::Button(fl!("menu-about"), None, Action::About), ], diff --git a/src/password_manager.rs b/src/password_manager.rs new file mode 100644 index 00000000..85e7d6f6 --- /dev/null +++ b/src/password_manager.rs @@ -0,0 +1,509 @@ +use cosmic::{ + Element, Task, Theme, cosmic_theme, + iced::{Alignment, Length, Padding}, + style, + widget::{self, settings::Section}, +}; + +use crate::{Message, fl, icon_cache_get}; + +#[derive(Clone, Debug)] +pub enum PasswordManagerMessage { + Error(String), + FetchAndPastePassword(String), + FetchAndExpand(String), + Collapse, + Delete(String), + Expand(String, secstr::SecUtf8), + New, + RefreshList, + ToggleShowPassword, + ListRefreshed(Vec), + DescriptionInput(String), + DescriptionInputAndUpdate(String), + PasswordInput(String), + PasswordInputAndUpdate(String), + Update, + None, +} + +struct PasswordInputState { + pub original: Option, + pub input: InputState, + pub show_password: bool, +} + +#[derive(Clone, PartialEq, Eq)] +struct InputState { + pub identifier: String, + pub password: String, +} + +pub struct PasswordManager { + input_state: Option, + pub password_list: Vec, + //Which pane we should paste to, ie. which pane had focus when the + //password manager was opened. Just to be sure it doesn't change under + //our feet. + pub pane: Option, + pub expanded_entry: Option, +} + +impl PasswordManager { + pub fn new() -> Self { + Self { + input_state: None, + password_list: Default::default(), + pane: None, + expanded_entry: None, + } + } + + pub fn update(&mut self, msg: PasswordManagerMessage) -> Task> { + match msg { + PasswordManagerMessage::Error(err) => { + log::error!("{err}"); + } + PasswordManagerMessage::FetchAndPastePassword(identifier) => { + return self.fetch_and_paste(identifier); + } + PasswordManagerMessage::FetchAndExpand(identifier) => { + return self.fetch_and_expand(identifier); + } + PasswordManagerMessage::Delete(identifier) => { + return self.delete_password(identifier); + } + PasswordManagerMessage::RefreshList => { + return self.refresh_password_list(); + } + PasswordManagerMessage::ListRefreshed(list) => { + self.password_list = list; + } + PasswordManagerMessage::Collapse => { + self.expanded_entry = None; + self.input_state = None; + self.expanded_entry = None; + } + PasswordManagerMessage::Expand(identifier, password) => { + self.input_state = Some(PasswordInputState { + original: Some(InputState { + identifier: identifier.clone(), + password: password.clone().into_unsecure(), + }), + input: InputState { + identifier: identifier.clone(), + password: password.into_unsecure(), + }, + show_password: false, + }); + self.expanded_entry = Some(identifier); + } + PasswordManagerMessage::ToggleShowPassword => { + if let Some(input_state) = self.input_state.as_mut() { + input_state.show_password = !input_state.show_password; + } + } + PasswordManagerMessage::DescriptionInput(description) => { + if let Some(input_state) = self.input_state.as_mut() { + input_state.input.identifier = description; + } + } + PasswordManagerMessage::DescriptionInputAndUpdate(description) => { + if let Some(input_state) = self.input_state.as_mut() { + input_state.input.identifier = description.clone(); + return self.add_or_update_password_entry(); + } + } + PasswordManagerMessage::PasswordInput(password) => { + if let Some(input_state) = self.input_state.as_mut() { + input_state.input.password = password; + } + } + PasswordManagerMessage::PasswordInputAndUpdate(password) => { + if let Some(input_state) = self.input_state.as_mut() { + input_state.input.password = password; + return self.add_or_update_password_entry(); + } + } + PasswordManagerMessage::Update => { + return self.add_or_update_password_entry(); + } + PasswordManagerMessage::New => { + self.new_password(); + } + PasswordManagerMessage::None => {} + } + Task::none() + } + + pub fn clear(&mut self) { + self.input_state = None; + self.password_list.clear(); + self.pane = None; + self.expanded_entry = None; + } + + pub fn fetch_and_paste(&self, identifier: String) -> Task> { + if let Some(pane) = self.pane { + cosmic::task::future(async move { + match store::get_password(identifier.clone()).await { + Ok(password) => Message::PasswordPaste(password, pane), + Err(err) => Message::PasswordManager(PasswordManagerMessage::Error(format!( + "Failed to fetch password {identifier}: {err}" + ))), + } + }) + } else { + log::error!("No active pane set for password manager to use"); + Task::none() + } + } + + pub fn fetch_and_expand(&mut self, identifier: String) -> Task> { + cosmic::task::future(async move { + match store::get_password(identifier.clone()).await { + Ok(password) => { + Message::PasswordManager(PasswordManagerMessage::Expand(identifier, password)) + } + Err(err) => Message::PasswordManager(PasswordManagerMessage::Error(format!( + "Failed to fetch password {identifier}: {err}" + ))), + } + }) + } + + pub fn refresh_password_list(&self) -> Task> { + cosmic::task::future(async { + match store::fetch_password_list().await { + Ok(list) => Message::PasswordManager(PasswordManagerMessage::ListRefreshed(list)), + Err(err) => Message::PasswordManager(PasswordManagerMessage::Error(format!( + "Failed to fetch password list: {err}" + ))), + } + }) + } + + pub fn delete_password(&mut self, identifier: String) -> Task> { + if self.expanded_entry.as_ref() == Some(&identifier) { + self.expanded_entry = None; + } + cosmic::task::future(async move { + if let Err(err) = store::delete_password(identifier.clone()).await { + return Message::PasswordManager(PasswordManagerMessage::Error(format!( + "Failed to delete password {identifier}: {err}" + ))); + } + match store::fetch_password_list().await { + Ok(list) => Message::PasswordManager(PasswordManagerMessage::ListRefreshed(list)), + Err(err) => Message::PasswordManager(PasswordManagerMessage::Error(format!( + "Failed to fetch password list: {err}" + ))), + } + }) + } + + pub fn add_or_update_password_entry(&mut self) -> Task> { + if let Some(input_state) = &self.input_state + && !input_state.input.identifier.is_empty() + { + let original = input_state.original.clone(); + let identifier = input_state.input.identifier.clone(); + let password = input_state.input.password.clone(); + let expanded_identifier = input_state + .original + .as_ref() + .map(|i| i.identifier.clone()) + .unwrap_or(String::new()); + + // Ensure we have a non-empty identifier + if identifier.is_empty() { + return Task::none(); + } + + // If the identifier have changed, we need to update + // the password list and expand the new id + if expanded_identifier != identifier { + self.expanded_entry = Some(identifier.clone()); + if let Some(i) = self + .password_list + .iter() + .position(|s| s == &expanded_identifier) + { + self.password_list[i] = identifier.clone(); + } + } + + // Don't do anything if nothing have changed + if let Some(original) = &original { + if original == &input_state.input { + return Task::none(); + } + } + + cosmic::task::future(async move { + if let Err(err) = store::add_password(identifier.clone(), password.clone()).await { + Message::PasswordManager(PasswordManagerMessage::Error(format!( + "Failed to add password {identifier}: {err}" + ))) + } else { + if let Some(original) = original { + if original.identifier != identifier { + if let Err(err) = + store::delete_password(original.identifier.clone()).await + { + return Message::PasswordManager(PasswordManagerMessage::Error( + format!( + "Failed to delete password {}: {err}", + original.identifier + ), + )); + } + } + } + Message::PasswordManager(PasswordManagerMessage::None) + } + }) + } else { + Task::none() + } + } + + pub fn context_page(&self, theme: &Theme) -> Element<'_, Message> { + let cosmic_theme::Spacing { + space_s, + space_xs, + space_xxs, + space_xxxs, + .. + } = theme.cosmic().spacing; + + let mut sections = Vec::with_capacity(2); + + let mut passwords_section = widget::settings::section(); + + for password_id in &self.password_list { + let expanded = self.expanded_entry.as_ref() == Some(password_id); + + passwords_section = passwords_section.add( + widget::settings::item::item_row(vec![ + widget::button::text(password_id.clone()) + .width(Length::Fixed(290.0)) + .on_press(Message::PasswordManager( + PasswordManagerMessage::FetchAndPastePassword(password_id.clone()), + )) + .into(), + widget::button::custom(icon_cache_get("edit-delete-symbolic", 16)) + .on_press(Message::PasswordManager(PasswordManagerMessage::Delete( + password_id.clone(), + ))) + .class(style::Button::Icon) + .into(), + if expanded { + widget::button::custom(icon_cache_get("go-up-symbolic", 16)) + .on_press(Message::PasswordManager(PasswordManagerMessage::Collapse)) + } else { + widget::button::custom(icon_cache_get("go-down-symbolic", 16)).on_press( + Message::PasswordManager(PasswordManagerMessage::FetchAndExpand( + password_id.clone(), + )), + ) + } + .class(style::Button::Icon) + .into(), + ]) + .align_y(Alignment::Center) + .spacing(space_xxs), + ); + + if expanded { + if let Some(input_state) = &self.input_state { + let expanded_section: Section<'_, Message> = widget::settings::section().add( + widget::column::with_children(vec![ + widget::column::with_children(vec![ + widget::text(fl!("password-input-description")).into(), + widget::text_input("", input_state.input.identifier.clone()) + .on_input(move |text| { + Message::PasswordManager( + PasswordManagerMessage::DescriptionInput(text), + ) + }) + .on_submit(move |text| { + Message::PasswordManager( + PasswordManagerMessage::DescriptionInputAndUpdate(text), + ) + }) + .on_unfocus(Message::PasswordManager( + PasswordManagerMessage::Update, + )) + .into(), + ]) + .spacing(space_xxxs) + .into(), + widget::column::with_children(vec![ + widget::text(fl!("password-input")).into(), + widget::secure_input( + "", + input_state.input.password.clone(), + Some(Message::PasswordManager( + PasswordManagerMessage::ToggleShowPassword, + )), + !input_state.show_password, + ) + .on_input(move |text| { + Message::PasswordManager(PasswordManagerMessage::PasswordInput( + text, + )) + }) + .on_submit(move |text| { + Message::PasswordManager( + PasswordManagerMessage::PasswordInputAndUpdate(text), + ) + }) + .on_unfocus(Message::PasswordManager( + PasswordManagerMessage::Update, + )) + .into(), + ]) + .spacing(space_xxxs) + .into(), + ]) + .padding([0, space_s]) + .spacing(space_xs), + ); + + let padding = Padding { + top: 0.0, + bottom: 0.0, + left: space_s.into(), + right: space_s.into(), + }; + + passwords_section = + passwords_section.add(widget::container(expanded_section).padding(padding)) + } + } + } + sections.push(passwords_section.into()); + + let add_password = widget::row::with_children(vec![ + widget::horizontal_space().into(), + widget::button::standard(fl!("add-password")) + .on_press(Message::PasswordManager(PasswordManagerMessage::New)) + .into(), + ]); + sections.push(add_password.into()); + + widget::settings::view_column(sections).into() + } + + pub fn new_password(&mut self) { + if !self.password_list.contains(&"".to_string()) { + self.password_list.push("".to_string()); + } + self.input_state = Some(PasswordInputState { + original: None, + input: InputState { + identifier: Default::default(), + password: Default::default(), + }, + show_password: true, + }); + self.expanded_entry = Some("".to_string()); + } +} + +impl Default for PasswordManager { + fn default() -> Self { + Self::new() + } +} + +mod store { + use std::string::FromUtf8Error; + + use thiserror::Error; + + #[derive(Error, Debug)] + pub enum Error { + #[error(transparent)] + SecretService(#[from] secret_service::Error), + #[error(transparent)] + FromUtf8(#[from] FromUtf8Error), + #[error("No password found for identifier `{0}`")] + NoPasswordForIdentifier(String), + } + + pub async fn fetch_password_list() -> Result, Error> { + let mut list = Vec::new(); + let ss = secret_service::SecretService::connect(secret_service::EncryptionType::Dh).await?; + let collection = ss.get_default_collection().await?; + + let mut attributes = std::collections::HashMap::new(); + attributes.insert("application", "com.system76.CosmicTerm"); + + let search_items = collection.search_items(attributes).await?; + + for item in search_items { + if let Some(identity) = item + .get_attributes() + .await + .ok() + .and_then(|attribs| attribs.get("identifier").cloned()) + { + list.push(identity); + } + } + list.sort(); + Ok(list) + } + + pub async fn add_password(identifier: String, password: String) -> Result<(), Error> { + let ss = secret_service::SecretService::connect(secret_service::EncryptionType::Dh).await?; + let collection = ss.get_default_collection().await?; + + let mut attributes = std::collections::HashMap::new(); + attributes.insert("application", "com.system76.CosmicTerm"); + attributes.insert("identifier", &identifier); + + let label = format!("CosmicTerm - {}", identifier); + + collection + .create_item(&label, attributes, password.as_bytes(), true, "text/plain") + .await?; + Ok(()) + } + + pub async fn get_password(identifier: String) -> Result { + let ss = secret_service::SecretService::connect(secret_service::EncryptionType::Dh).await?; + let collection = ss.get_default_collection().await?; + + let mut attributes = std::collections::HashMap::new(); + attributes.insert("application", "com.system76.CosmicTerm"); + attributes.insert("identifier", &identifier); + + let search_items = collection.search_items(attributes).await?; + if let Some(item) = search_items.first() { + let secret = item.get_secret().await?; + Ok(String::from_utf8(secret)?.into()) + } else { + Err(Error::NoPasswordForIdentifier(identifier)) + } + } + + pub async fn delete_password(identifier: String) -> Result<(), Error> { + let ss = secret_service::SecretService::connect(secret_service::EncryptionType::Dh).await?; + let collection = ss.get_default_collection().await?; + + let mut attributes = std::collections::HashMap::new(); + attributes.insert("application", "com.system76.CosmicTerm"); + attributes.insert("identifier", &identifier); + + let search_items = collection.search_items(attributes).await?; + + if let Some(item) = search_items.first() { + Ok(item.delete().await?) + } else { + Err(Error::NoPasswordForIdentifier(identifier)) + } + } +}