feat: add custom plugins

This commit is contained in:
Gabriele Musco 2024-12-30 14:28:07 +01:00
commit b6d8b0e6c8
9 changed files with 491 additions and 146 deletions

View file

@ -22,7 +22,6 @@ use std::{
pub struct PluginConfig { pub struct PluginConfig {
pub plugin: Plugin, pub plugin: Plugin,
pub enabled: bool, pub enabled: bool,
pub exec_path: PathBuf,
} }
impl From<&Plugin> for PluginConfig { impl From<&Plugin> for PluginConfig {
@ -30,7 +29,6 @@ impl From<&Plugin> for PluginConfig {
Self { Self {
plugin: p.clone(), plugin: p.clone(),
enabled: true, enabled: true,
exec_path: p.exec_path(),
} }
} }
} }

View file

@ -266,15 +266,20 @@ impl App {
.plugins .plugins
.values() .values()
.filter_map(|cp| { .filter_map(|cp| {
if cp.enabled { if cp.enabled && cp.plugin.validate() {
if let Err(e) = mark_as_executable(&cp.exec_path) { if let Some(exec) = cp.plugin.executable() {
eprintln!( if let Err(e) = mark_as_executable(&exec) {
"Failed to mark plugin {} as executable: {e}", error!(
cp.plugin.appid "failed to mark plugin {} as executable: {e}",
); cp.plugin.appid
None );
None
} else {
Some(format!("'{}'", exec.to_string_lossy()))
}
} else { } else {
Some(format!("'{}'", cp.exec_path.to_string_lossy())) error!("no executable for plugin {}", cp.plugin.appid);
None
} }
} else { } else {
None None

View file

@ -148,7 +148,7 @@ impl AsyncComponent for MainView {
menu! { menu! {
app_menu: { app_menu: {
section! { section! {
"Plugin _store" => PluginStoreAction, "Plugin_s" => PluginStoreAction,
// value inside action is ignored // value inside action is ignored
"_Debug View" => DebugViewToggleAction, "_Debug View" => DebugViewToggleAction,
"_Build Profile" => BuildProfileAction, "_Build Profile" => BuildProfileAction,

View file

@ -0,0 +1,169 @@
use std::path::PathBuf;
use crate::{
constants::APP_ID,
ui::{
preference_rows::{entry_row, file_row},
SENDER_IO_ERR_MSG,
},
};
use super::Plugin;
use adw::prelude::*;
use gtk::glib::clone;
use relm4::prelude::*;
#[tracker::track]
pub struct AddCustomPluginWin {
#[tracker::do_not_track]
parent: gtk::Window,
#[tracker::do_not_track]
win: Option<adw::Dialog>,
/// this is true when enough fields are populated, allowing the creation
/// of the plugin object to add
can_add: bool,
#[tracker::do_not_track]
plugin: Plugin,
}
#[derive(Debug)]
pub enum AddCustomPluginWinMsg {
Present,
Close,
OnNameChange(String),
OnExecPathChange(Option<String>),
Add,
}
#[derive(Debug)]
pub enum AddCustomPluginWinOutMsg {
Add(Plugin),
}
#[derive(Debug)]
pub struct AddCustomPluginWinInit {
pub parent: gtk::Window,
}
#[relm4::component(pub)]
impl SimpleComponent for AddCustomPluginWin {
type Init = AddCustomPluginWinInit;
type Input = AddCustomPluginWinMsg;
type Output = AddCustomPluginWinOutMsg;
view! {
#[name(win)]
adw::Dialog {
set_can_close: true,
#[wrap(Some)]
set_child: inner = &adw::ToolbarView {
set_top_bar_style: adw::ToolbarStyle::Flat,
set_bottom_bar_style: adw::ToolbarStyle::Flat,
set_vexpand: true,
set_hexpand: true,
add_top_bar: top_bar = &adw::HeaderBar {
set_show_end_title_buttons: false,
set_show_start_title_buttons: false,
pack_start: cancel_btn = &gtk::Button {
set_label: "Cancel",
add_css_class: "destructive-action",
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Close)
},
},
pack_end: add_btn = &gtk::Button {
set_label: "Add",
add_css_class: "suggested-action",
#[track = "model.changed(AddCustomPluginWin::can_add())"]
set_sensitive: model.can_add,
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Add)
},
},
#[wrap(Some)]
set_title_widget: title_label = &adw::WindowTitle {
set_title: "Add Custom Plugin",
},
},
#[wrap(Some)]
set_content: content = &adw::PreferencesPage {
set_hexpand: true,
set_vexpand: true,
add: grp = &adw::PreferencesGroup {
add: &entry_row(
"Plugin Name",
"",
clone!(
#[strong] sender,
move |row| sender.input(Self::Input::OnNameChange(row.text().to_string()))
)
),
add: &file_row(
"Plugin Executable",
None,
None,
Some(model.parent.clone()),
clone!(
#[strong] sender,
move |path_s| sender.input(Self::Input::OnExecPathChange(path_s))
)
)
},
},
},
}
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
self.reset();
match message {
Self::Input::Present => self.win.as_ref().unwrap().present(Some(&self.parent)),
Self::Input::Close => {
self.win.as_ref().unwrap().close();
}
Self::Input::Add => {
if self.plugin.validate() {
sender
.output(Self::Output::Add(self.plugin.clone()))
.expect(SENDER_IO_ERR_MSG);
self.win.as_ref().unwrap().close();
}
}
Self::Input::OnNameChange(name) => {
self.plugin.appid = if !name.is_empty() {
format!("{APP_ID}.customPlugin.{name}")
} else {
String::default()
};
self.plugin.name = name;
self.set_can_add(self.plugin.validate());
}
Self::Input::OnExecPathChange(ep) => {
self.plugin.exec_path = ep.map(PathBuf::from);
self.set_can_add(self.plugin.validate());
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let mut model = Self {
tracker: 0,
win: None,
parent: init.parent,
can_add: false,
plugin: Plugin {
short_description: Some("Custom Plugin".into()),
..Default::default()
},
};
let widgets = view_output!();
model.win = Some(widgets.win.clone());
ComponentParts { model, widgets }
}
}

View file

@ -1,34 +1,63 @@
pub mod add_custom_plugin_win;
pub mod store; pub mod store;
mod store_detail; mod store_detail;
mod store_row_factory; mod store_row_factory;
use crate::{paths::get_plugins_dir, util::file_utils::mark_as_executable}; use crate::{paths::get_plugins_dir, util::file_utils::mark_as_executable};
use anyhow::bail;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
pub struct Plugin { pub struct Plugin {
pub appid: String, pub appid: String,
pub name: String, pub name: String,
pub icon_url: Option<String>, pub icon_url: Option<String>,
pub version: String, pub version: Option<String>,
pub short_description: Option<String>, pub short_description: Option<String>,
pub description: Option<String>, pub description: Option<String>,
pub hompage_url: String, pub hompage_url: Option<String>,
pub screenshots: Vec<String>, pub screenshots: Vec<String>,
pub exec_url: String, /// either one of exec_url or exec_path must be provided
pub exec_url: Option<String>,
/// either one of exec_url or exec_path must be provided
pub exec_path: Option<PathBuf>,
} }
impl Plugin { impl Plugin {
pub fn exec_path(&self) -> PathBuf { pub fn executable(&self) -> Option<PathBuf> {
get_plugins_dir().join(format!("{}.AppImage", self.appid)) if self.exec_path.is_some() {
self.exec_path.clone()
} else {
let canonical = self.canonical_exec_path();
if canonical.is_file() {
Some(canonical)
} else {
None
}
}
}
pub fn canonical_exec_path(&self) -> PathBuf {
get_plugins_dir().join(&self.appid)
} }
pub fn is_installed(&self) -> bool { pub fn is_installed(&self) -> bool {
self.exec_path().exists() self.executable().as_ref().is_some_and(|p| p.is_file())
} }
pub fn mark_as_executable(&self) -> anyhow::Result<()> { pub fn mark_as_executable(&self) -> anyhow::Result<()> {
mark_as_executable(&self.exec_path()) if let Some(p) = self.executable().as_ref() {
mark_as_executable(p)
} else {
bail!("no exec_path for plugin")
}
}
/// validate if the plugin can be displayed correctly and run
pub fn validate(&self) -> bool {
!self.appid.is_empty()
&& !self.name.is_empty()
&& self.executable().as_ref().is_some_and(|p| p.is_file())
} }
} }

View file

@ -1,4 +1,7 @@
use super::{ use super::{
add_custom_plugin_win::{
AddCustomPluginWin, AddCustomPluginWinInit, AddCustomPluginWinMsg, AddCustomPluginWinOutMsg,
},
store_detail::{StoreDetail, StoreDetailMsg, StoreDetailOutMsg}, store_detail::{StoreDetail, StoreDetailMsg, StoreDetailOutMsg},
store_row_factory::{StoreRowModel, StoreRowModelInit, StoreRowModelMsg, StoreRowModelOutMsg}, store_row_factory::{StoreRowModel, StoreRowModelInit, StoreRowModelMsg, StoreRowModelOutMsg},
Plugin, Plugin,
@ -11,6 +14,7 @@ use crate::{
use adw::prelude::*; use adw::prelude::*;
use relm4::{factory::AsyncFactoryVecDeque, prelude::*}; use relm4::{factory::AsyncFactoryVecDeque, prelude::*};
use std::{collections::HashMap, fs::remove_file}; use std::{collections::HashMap, fs::remove_file};
use tracing::{debug, error};
#[tracker::track] #[tracker::track]
pub struct PluginStore { pub struct PluginStore {
@ -27,6 +31,14 @@ pub struct PluginStore {
refreshing: bool, refreshing: bool,
locked: bool, locked: bool,
plugins: Vec<Plugin>, plugins: Vec<Plugin>,
#[tracker::do_not_track]
add_custom_plugin_win: Option<Controller<AddCustomPluginWin>>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum PluginStoreSignalSource {
Row,
Detail,
} }
#[derive(Debug)] #[derive(Debug)]
@ -36,11 +48,13 @@ pub enum PluginStoreMsg {
Install(Plugin, relm4::Sender<StoreRowModelMsg>), Install(Plugin, relm4::Sender<StoreRowModelMsg>),
InstallFromDetails(Plugin), InstallFromDetails(Plugin),
InstallDownload(Plugin, relm4::Sender<StoreRowModelMsg>), InstallDownload(Plugin, relm4::Sender<StoreRowModelMsg>),
RemoveFromDetails(Plugin), Remove(Plugin),
Remove(Plugin, relm4::Sender<StoreRowModelMsg>), SetEnabled(PluginStoreSignalSource, Plugin, bool),
SetEnabled(Plugin, bool),
ShowDetails(usize), ShowDetails(usize),
ShowPluginList, ShowPluginList,
PresentAddCustomPluginWin,
AddPluginToConfig(Plugin),
AddCustomPlugin(Plugin),
} }
#[derive(Debug)] #[derive(Debug)]
@ -53,6 +67,26 @@ pub enum PluginStoreOutMsg {
UpdateConfigPlugins(HashMap<String, PluginConfig>), UpdateConfigPlugins(HashMap<String, PluginConfig>),
} }
impl PluginStore {
fn refresh_plugin_rows(&mut self) {
let mut guard = self.plugin_rows.as_mut().unwrap().guard();
guard.clear();
self.plugins.iter().for_each(|plugin| {
guard.push_back(StoreRowModelInit {
plugin: plugin.clone(),
enabled: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.enabled),
needs_update: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.plugin.version != plugin.version),
});
});
}
}
#[relm4::component(pub async)] #[relm4::component(pub async)]
impl AsyncComponent for PluginStore { impl AsyncComponent for PluginStore {
type Init = PluginStoreInit; type Init = PluginStoreInit;
@ -63,12 +97,21 @@ impl AsyncComponent for PluginStore {
view! { view! {
#[name(win)] #[name(win)]
adw::Window { adw::Window {
set_title: Some("Plugin Store"), set_title: Some("Plugins"),
#[name(main_stack)] #[name(main_stack)]
gtk::Stack { gtk::Stack {
add_child = &adw::ToolbarView { add_child = &adw::ToolbarView {
set_top_bar_style: adw::ToolbarStyle::Flat, set_top_bar_style: adw::ToolbarStyle::Flat,
add_top_bar: headerbar = &adw::HeaderBar { add_top_bar: headerbar = &adw::HeaderBar {
pack_start: add_custom_plugin_btn = &gtk::Button {
set_icon_name: "list-add-symbolic",
set_tooltip_text: Some("Add custom plugin"),
#[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"]
set_sensitive: !(model.refreshing || model.locked),
connect_clicked[sender] => move |_| {
sender.input(Self::Input::PresentAddCustomPluginWin)
},
},
pack_end: refreshbtn = &gtk::Button { pack_end: refreshbtn = &gtk::Button {
set_icon_name: "view-refresh-symbolic", set_icon_name: "view-refresh-symbolic",
set_tooltip_text: Some("Refresh"), set_tooltip_text: Some("Refresh"),
@ -76,8 +119,8 @@ impl AsyncComponent for PluginStore {
set_sensitive: !(model.refreshing || model.locked), set_sensitive: !(model.refreshing || model.locked),
connect_clicked[sender] => move |_| { connect_clicked[sender] => move |_| {
sender.input(Self::Input::Refresh); sender.input(Self::Input::Refresh);
} },
} },
}, },
#[wrap(Some)] #[wrap(Some)]
set_content: inner = &gtk::Box { set_content: inner = &gtk::Box {
@ -169,6 +212,40 @@ impl AsyncComponent for PluginStore {
self.win.as_ref().unwrap().present(); self.win.as_ref().unwrap().present();
sender.input(Self::Input::Refresh); sender.input(Self::Input::Refresh);
} }
Self::Input::AddCustomPlugin(plugin) => {
if self.config_plugins.contains_key(&plugin.appid) {
alert(
"Failed to add custom plugin",
Some("A plugin with the same name already exists"),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
return;
}
sender.input(Self::Input::AddPluginToConfig(plugin));
sender.input(Self::Input::Refresh);
}
Self::Input::AddPluginToConfig(plugin) => {
self.config_plugins
.insert(plugin.appid.clone(), PluginConfig::from(&plugin));
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
}
Self::Input::PresentAddCustomPluginWin => {
let add_win = AddCustomPluginWin::builder()
.launch(AddCustomPluginWinInit {
parent: self.win.as_ref().unwrap().clone().upcast(),
})
.forward(sender.input_sender(), |msg| match msg {
AddCustomPluginWinOutMsg::Add(plugin) => {
Self::Input::AddCustomPlugin(plugin)
}
});
add_win.sender().emit(AddCustomPluginWinMsg::Present);
self.add_custom_plugin_win = Some(add_win);
}
Self::Input::Refresh => { Self::Input::Refresh => {
self.set_refreshing(true); self.set_refreshing(true);
// TODO: populate from web // TODO: populate from web
@ -176,15 +253,16 @@ impl AsyncComponent for PluginStore {
Plugin { Plugin {
appid: "com.github.galiser.wlx-overlay-s".into(), appid: "com.github.galiser.wlx-overlay-s".into(),
name: "WLX Overlay S".into(), name: "WLX Overlay S".into(),
version: "0.4.4".into(), version: Some("0.6.0".into()),
hompage_url: "https://github.com/galister/wlx-overlay-s".into(), hompage_url: Some("https://github.com/galister/wlx-overlay-s".into()),
icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()), icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()),
screenshots: vec![ screenshots: vec![
"https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(), "https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(),
], ],
description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()), description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()),
short_description: Some("Access your Wayland/X11 desktop".into()), short_description: Some("Access your Wayland/X11 desktop".into()),
exec_url: "https://github.com/galister/wlx-overlay-s/releases/download/v0.4.4/WlxOverlay-S-v0.4.4-x86_64.AppImage".into() exec_url: Some("https://github.com/galister/wlx-overlay-s/releases/download/v0.6/WlxOverlay-S-v0.6-x86_64.AppImage".into()),
exec_path: None,
}, },
]; ];
{ {
@ -202,23 +280,7 @@ impl AsyncComponent for PluginStore {
})); }));
} }
self.set_plugins(plugins); self.set_plugins(plugins);
{ self.refresh_plugin_rows();
let mut guard = self.plugin_rows.as_mut().unwrap().guard();
guard.clear();
self.plugins.iter().for_each(|plugin| {
guard.push_back(StoreRowModelInit {
plugin: plugin.clone(),
enabled: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.enabled),
needs_update: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.plugin.version != plugin.version),
});
});
}
self.set_refreshing(false); self.set_refreshing(false);
} }
Self::Input::InstallFromDetails(plugin) => { Self::Input::InstallFromDetails(plugin) => {
@ -233,23 +295,7 @@ impl AsyncComponent for PluginStore {
{ {
sender.input(Self::Input::Install(plugin, row.input_sender.clone())) sender.input(Self::Input::Install(plugin, row.input_sender.clone()))
} else { } else {
eprintln!("could not find corresponding listbox row!") error!("could not find corresponding listbox row")
}
}
// TODO: merge implementation with install
Self::Input::RemoveFromDetails(plugin) => {
if let Some(row) = self
.plugin_rows
.as_mut()
.unwrap()
.guard()
.iter()
.find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid))
.flatten()
{
sender.input(Self::Input::Remove(plugin, row.input_sender.clone()))
} else {
eprintln!("could not find corresponding listbox row!")
} }
} }
Self::Input::Install(plugin, row_sender) => { Self::Input::Install(plugin, row_sender) => {
@ -257,79 +303,110 @@ impl AsyncComponent for PluginStore {
sender.input(Self::Input::InstallDownload(plugin, row_sender)) sender.input(Self::Input::InstallDownload(plugin, row_sender))
} }
Self::Input::InstallDownload(plugin, row_sender) => { Self::Input::InstallDownload(plugin, row_sender) => {
if let Err(e) = download_file_async(&plugin.exec_url, &plugin.exec_path()).await { let mut plugin = plugin.clone();
alert( match plugin.exec_url.as_ref() {
"Download failed", Some(url) => {
Some(&format!( let exec_path = plugin.canonical_exec_path();
"Downloading {} {} failed:\n\n{e}", if let Err(e) = download_file_async(url, &exec_path).await {
plugin.name, plugin.version alert(
)), "Download failed",
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()), Some(&format!(
); "Downloading {} {} failed:\n\n{e}",
} else { plugin.name,
self.config_plugins plugin
.insert(plugin.appid.clone(), PluginConfig::from(&plugin)); .version
sender .as_ref()
.output(Self::Output::UpdateConfigPlugins( .unwrap_or(&"(no version)".to_string())
self.config_plugins.clone(), )),
)) Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
.expect(SENDER_IO_ERR_MSG); );
} } else {
plugin.exec_path = Some(exec_path);
sender.input(Self::Input::AddPluginToConfig(plugin.clone()));
}
}
None => {
alert(
"Download failed",
Some(&format!(
"Downloading {} {} failed:\n\nNo executable url provided for this plugin, this is likely a bug!",
plugin.name,
plugin.version.as_ref().unwrap_or(&"(no version)".to_string()))
),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>())
);
}
};
row_sender.emit(StoreRowModelMsg::Refresh(true, false)); row_sender.emit(StoreRowModelMsg::Refresh(true, false));
self.details.emit(StoreDetailMsg::Refresh(true, false)); self.details
.emit(StoreDetailMsg::Refresh(plugin.appid, true, false));
self.set_locked(false); self.set_locked(false);
} }
Self::Input::Remove(plugin, row_sender) => { Self::Input::Remove(plugin) => {
self.set_locked(true); self.set_locked(true);
let exec = plugin.exec_path(); if let Some(exec) = plugin.executable() {
if exec.is_file() { // delete executable only if it's not a custom plugin
if let Err(e) = remove_file(&exec) { if exec.is_file() && plugin.exec_url.is_some() {
alert( if let Err(e) = remove_file(&exec) {
"Failed removing plugin", alert(
Some(&format!( "Failed removing plugin",
"Could not remove plugin executable {}:\n\n{e}", Some(&format!(
exec.to_string_lossy() "Could not remove plugin executable {}:\n\n{e}",
)), exec.to_string_lossy()
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()), )),
); Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
} else { );
self.config_plugins.remove(&plugin.appid); }
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
} }
} }
row_sender.emit(StoreRowModelMsg::Refresh(false, false)); self.config_plugins.remove(&plugin.appid);
self.details.emit(StoreDetailMsg::Refresh(false, false)); self.set_plugins(
self.plugins
.clone()
.into_iter()
.filter(|p| !(p.appid == plugin.appid && p.exec_url.is_none()))
.collect(),
);
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
self.refresh_plugin_rows();
self.details
.emit(StoreDetailMsg::Refresh(plugin.appid, false, false));
self.set_locked(false); self.set_locked(false);
} }
Self::Input::SetEnabled(plugin, enabled) => { Self::Input::SetEnabled(signal_sender, plugin, enabled) => {
if let Some(cp) = self.config_plugins.get_mut(&plugin.appid) { if let Some(cp) = self.config_plugins.get_mut(&plugin.appid) {
cp.enabled = enabled; cp.enabled = enabled;
if let Some(row) = self if signal_sender == PluginStoreSignalSource::Detail {
.plugin_rows if let Some(row) = self
.as_mut() .plugin_rows
.unwrap() .as_mut()
.guard() .unwrap()
.iter() .guard()
.find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid)) .iter()
.flatten() .find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid))
{ .flatten()
row.input_sender.emit(StoreRowModelMsg::Refresh( {
row.input_sender.emit(StoreRowModelMsg::Refresh(
enabled,
cp.plugin.version != plugin.version,
));
} else {
error!("could not find corresponding listbox row")
}
}
if signal_sender == PluginStoreSignalSource::Row {
self.details.emit(StoreDetailMsg::Refresh(
plugin.appid,
enabled, enabled,
cp.plugin.version != plugin.version, cp.plugin.version != plugin.version,
)); ));
} else {
eprintln!("could not find corresponding listbox row!")
} }
self.details.emit(StoreDetailMsg::Refresh(
enabled,
cp.plugin.version != plugin.version,
));
} else { } else {
eprintln!( debug!(
"failed to set plugin {} enabled: could not find in hashmap", "failed to set plugin {} enabled: could not find in hashmap",
plugin.appid plugin.appid
) )
@ -358,7 +435,7 @@ impl AsyncComponent for PluginStore {
.unwrap() .unwrap()
.set_visible_child_name("detailsview"); .set_visible_child_name("detailsview");
} else { } else {
eprintln!("plugins list index out of range!") error!("plugins list index out of range!")
} }
} }
Self::Input::ShowPluginList => { Self::Input::ShowPluginList => {
@ -387,13 +464,14 @@ impl AsyncComponent for PluginStore {
.forward(sender.input_sender(), move |msg| match msg { .forward(sender.input_sender(), move |msg| match msg {
StoreDetailOutMsg::GoBack => Self::Input::ShowPluginList, StoreDetailOutMsg::GoBack => Self::Input::ShowPluginList,
StoreDetailOutMsg::Install(plugin) => Self::Input::InstallFromDetails(plugin), StoreDetailOutMsg::Install(plugin) => Self::Input::InstallFromDetails(plugin),
StoreDetailOutMsg::Remove(plugin) => Self::Input::RemoveFromDetails(plugin), StoreDetailOutMsg::Remove(plugin) => Self::Input::Remove(plugin),
StoreDetailOutMsg::SetEnabled(plugin, enabled) => { StoreDetailOutMsg::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(plugin, enabled) Self::Input::SetEnabled(PluginStoreSignalSource::Detail, plugin, enabled)
} }
}), }),
config_plugins: init.config_plugins, config_plugins: init.config_plugins,
main_stack: None, main_stack: None,
add_custom_plugin_win: None,
}; };
let details_view = model.details.widget(); let details_view = model.details.widget();
@ -408,11 +486,9 @@ impl AsyncComponent for PluginStore {
StoreRowModelOutMsg::Install(appid, row_sender) => { StoreRowModelOutMsg::Install(appid, row_sender) => {
Self::Input::Install(appid, row_sender) Self::Input::Install(appid, row_sender)
} }
StoreRowModelOutMsg::Remove(appid, row_sender) => { StoreRowModelOutMsg::Remove(appid) => Self::Input::Remove(appid),
Self::Input::Remove(appid, row_sender)
}
StoreRowModelOutMsg::SetEnabled(plugin, enabled) => { StoreRowModelOutMsg::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(plugin, enabled) Self::Input::SetEnabled(PluginStoreSignalSource::Row, plugin, enabled)
} }
}), }),
); );

View file

@ -2,6 +2,7 @@ use super::Plugin;
use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG}; use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG};
use adw::prelude::*; use adw::prelude::*;
use relm4::prelude::*; use relm4::prelude::*;
use tracing::warn;
#[tracker::track] #[tracker::track]
pub struct StoreDetail { pub struct StoreDetail {
@ -20,7 +21,7 @@ pub enum StoreDetailMsg {
SetPlugin(Plugin, bool, bool), SetPlugin(Plugin, bool, bool),
SetIcon, SetIcon,
SetScreenshots, SetScreenshots,
Refresh(bool, bool), Refresh(String, bool, bool),
Install, Install,
Remove, Remove,
SetEnabled(bool), SetEnabled(bool),
@ -86,7 +87,8 @@ impl AsyncComponent for StoreDetail {
set_hexpand: true, set_hexpand: true,
#[name(icon)] #[name(icon)]
gtk::Image { gtk::Image {
set_icon_name: Some("image-missing-symbolic"), set_icon_name: Some("application-x-addon-symbolic"),
set_margin_end: 12,
set_pixel_size: 96, set_pixel_size: 96,
}, },
gtk::Label { gtk::Label {
@ -224,9 +226,14 @@ impl AsyncComponent for StoreDetail {
self.icon.as_ref().unwrap().set_from_file(Some(dest)); self.icon.as_ref().unwrap().set_from_file(Some(dest));
} }
Err(e) => { Err(e) => {
eprintln!("Failed downloading icon '{url}': {e}"); warn!("Failed downloading icon '{url}': {e}");
} }
}; };
} else {
self.icon
.as_ref()
.unwrap()
.set_icon_name(Some("application-x-addon-symbolic"));
} }
} }
} }
@ -254,16 +261,18 @@ impl AsyncComponent for StoreDetail {
carousel.append(&clamp); carousel.append(&clamp);
} }
Err(e) => { Err(e) => {
eprintln!("Failed downloading screenshot '{url}': {e}"); warn!("failed downloading screenshot '{url}': {e}");
} }
}; };
} }
} }
} }
Self::Input::Refresh(enabled, needs_update) => { Self::Input::Refresh(appid, enabled, needs_update) => {
self.mark_all_changed(); if self.plugin.as_ref().is_some_and(|p| p.appid == appid) {
self.set_enabled(enabled); self.mark_all_changed();
self.set_needs_update(needs_update); self.set_enabled(enabled);
self.set_needs_update(needs_update);
}
} }
Self::Input::Install => { Self::Input::Install => {
if let Some(plugin) = self.plugin.as_ref() { if let Some(plugin) = self.plugin.as_ref() {
@ -277,6 +286,11 @@ impl AsyncComponent for StoreDetail {
sender sender
.output(Self::Output::Remove(plugin.clone())) .output(Self::Output::Remove(plugin.clone()))
.expect(SENDER_IO_ERR_MSG); .expect(SENDER_IO_ERR_MSG);
if plugin.exec_url.is_none() {
sender
.output(Self::Output::GoBack)
.expect(SENDER_IO_ERR_MSG);
}
} }
} }
Self::Input::SetEnabled(enabled) => { Self::Input::SetEnabled(enabled) => {

View file

@ -4,6 +4,7 @@ use gtk::prelude::*;
use relm4::{ use relm4::{
factory::AsyncFactoryComponent, prelude::DynamicIndex, AsyncFactorySender, RelmWidgetExt, factory::AsyncFactoryComponent, prelude::DynamicIndex, AsyncFactorySender, RelmWidgetExt,
}; };
use tracing::error;
#[derive(Debug)] #[derive(Debug)]
#[tracker::track] #[tracker::track]
@ -28,6 +29,7 @@ pub struct StoreRowModelInit {
#[derive(Debug)] #[derive(Debug)]
pub enum StoreRowModelMsg { pub enum StoreRowModelMsg {
LoadIcon, LoadIcon,
/// params: enabled, needs_update
Refresh(bool, bool), Refresh(bool, bool),
SetEnabled(bool), SetEnabled(bool),
} }
@ -35,7 +37,7 @@ pub enum StoreRowModelMsg {
#[derive(Debug)] #[derive(Debug)]
pub enum StoreRowModelOutMsg { pub enum StoreRowModelOutMsg {
Install(Plugin, relm4::Sender<StoreRowModelMsg>), Install(Plugin, relm4::Sender<StoreRowModelMsg>),
Remove(Plugin, relm4::Sender<StoreRowModelMsg>), Remove(Plugin),
SetEnabled(Plugin, bool), SetEnabled(Plugin, bool),
} }
@ -57,7 +59,7 @@ impl AsyncFactoryComponent for StoreRowModel {
set_margin_all: 12, set_margin_all: 12,
#[name(icon)] #[name(icon)]
gtk::Image { gtk::Image {
set_icon_name: Some("image-missing-symbolic"), set_icon_name: Some("application-x-addon-symbolic"),
set_icon_size: gtk::IconSize::Large, set_icon_size: gtk::IconSize::Large,
}, },
gtk::Box { gtk::Box {
@ -124,7 +126,6 @@ impl AsyncFactoryComponent for StoreRowModel {
sender sender
.output(Self::Output::Remove( .output(Self::Output::Remove(
plugin.clone(), plugin.clone(),
sender.input_sender().clone()
)) ))
.expect(SENDER_IO_ERR_MSG); .expect(SENDER_IO_ERR_MSG);
} }
@ -176,7 +177,7 @@ impl AsyncFactoryComponent for StoreRowModel {
self.icon.as_ref().unwrap().set_from_file(Some(dest)); self.icon.as_ref().unwrap().set_from_file(Some(dest));
} }
Err(e) => { Err(e) => {
eprintln!("Failed downloading icon '{url}': {e}"); error!("failed downloading icon '{url}': {e}");
} }
}; };
} }

View file

@ -156,13 +156,12 @@ pub fn spin_row<F: Fn(&gtk::Adjustment) + 'static>(
row row
} }
pub fn path_row<F: Fn(Option<String>) + 'static + Clone>( fn filedialog_row_base<F: Fn(Option<String>) + 'static + Clone>(
title: &str, title: &str,
description: Option<&str>, description: Option<&str>,
value: Option<String>, value: Option<String>,
root_win: Option<gtk::Window>,
cb: F, cb: F,
) -> adw::ActionRow { ) -> (adw::ActionRow, gtk::Label) {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title(title) .title(title)
.subtitle_lines(0) .subtitle_lines(0)
@ -174,14 +173,14 @@ pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
row.set_subtitle(d); row.set_subtitle(d);
} }
let path_label = &gtk::Label::builder() let path_label = gtk::Label::builder()
.label(match value.as_ref() { .label(match value.as_ref() {
None => "(None)", None => "(None)",
Some(p) => p.as_str(), Some(p) => p.as_str(),
}) })
.wrap(true) .wrap(true)
.build(); .build();
row.add_suffix(path_label); row.add_suffix(&path_label);
let clear_btn = gtk::Button::builder() let clear_btn = gtk::Button::builder()
.icon_name("edit-clear-symbolic") .icon_name("edit-clear-symbolic")
@ -200,6 +199,60 @@ pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
cb(None) cb(None)
} }
)); ));
(row, path_label)
}
pub fn file_row<F: Fn(Option<String>) + 'static + Clone>(
title: &str,
description: Option<&str>,
value: Option<String>,
root_win: Option<gtk::Window>,
cb: F,
) -> adw::ActionRow {
let (row, path_label) = filedialog_row_base(title, description, value, cb.clone());
let filedialog = gtk::FileDialog::builder()
.modal(true)
.title(format!("Select {}", title))
.build();
row.connect_activated(clone!(
#[weak]
path_label,
move |_| {
filedialog.open(
root_win.as_ref(),
gio::Cancellable::NONE,
clone!(
#[weak]
path_label,
#[strong]
cb,
move |res| {
if let Ok(file) = res {
if let Some(path) = file.path() {
let path_s = path.to_string_lossy().to_string();
path_label.set_text(&path_s);
cb(Some(path_s))
}
}
}
),
)
}
));
row
}
pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
title: &str,
description: Option<&str>,
value: Option<String>,
root_win: Option<gtk::Window>,
cb: F,
) -> adw::ActionRow {
let (row, path_label) = filedialog_row_base(title, description, value, cb.clone());
let filedialog = gtk::FileDialog::builder() let filedialog = gtk::FileDialog::builder()
.modal(true) .modal(true)
.title(format!("Select Path for {}", title)) .title(format!("Select Path for {}", title))
@ -220,8 +273,8 @@ pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
move |res| { move |res| {
if let Ok(file) = res { if let Ok(file) = res {
if let Some(path) = file.path() { if let Some(path) = file.path() {
let path_s = path.to_str().unwrap().to_string(); let path_s = path.to_string_lossy().to_string();
path_label.set_text(path_s.as_str()); path_label.set_text(&path_s);
cb(Some(path_s)) cb(Some(path_s))
} }
} }