Merge branch 'feat/pluginStore' into 'main'

feat!: plugin store

See merge request gabmus/envision!66
This commit is contained in:
GabMus 2025-01-01 18:45:38 +00:00
commit cf03d64f9c
14 changed files with 1471 additions and 46 deletions

View file

@ -7,15 +7,38 @@ use crate::{
lighthouse::lighthouse_profile, openhmd::openhmd_profile, simulated::simulated_profile,
survive::survive_profile, wivrn::wivrn_profile, wmr::wmr_profile,
},
ui::plugins::Plugin,
util::file_utils::get_writer,
};
use serde::{de::Error, Deserialize, Serialize};
use std::{
collections::HashMap,
fs::File,
io::BufReader,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginConfig {
pub plugin: Plugin,
pub enabled: bool,
}
impl From<&Plugin> for PluginConfig {
fn from(p: &Plugin) -> Self {
Self {
plugin: p.clone(),
enabled: true,
}
}
}
impl From<&PluginConfig> for Plugin {
fn from(cp: &PluginConfig) -> Self {
cp.plugin.clone()
}
}
const DEFAULT_WIN_SIZE: [i32; 2] = [360, 400];
const fn default_win_size() -> [i32; 2] {
@ -29,6 +52,8 @@ pub struct Config {
pub user_profiles: Vec<Profile>,
#[serde(default = "default_win_size")]
pub win_size: [i32; 2],
#[serde(default)]
pub plugins: HashMap<String, PluginConfig>,
}
impl Default for Config {
@ -37,8 +62,9 @@ impl Default for Config {
// TODO: using an empty string here is ugly
selected_profile_uuid: "".to_string(),
debug_view_enabled: false,
user_profiles: vec![],
user_profiles: Vec::default(),
win_size: DEFAULT_WIN_SIZE,
plugins: HashMap::default(),
}
}
}

View file

@ -87,3 +87,7 @@ pub fn get_steamvr_bin_dir_path() -> PathBuf {
XDG.get_data_home()
.join("Steam/steamapps/common/SteamVR/bin/linux64")
}
pub fn get_plugins_dir() -> PathBuf {
get_data_dir().join("plugins")
}

View file

@ -363,7 +363,6 @@ pub struct Profile {
pub lighthouse_driver: LighthouseDriver,
#[serde(default = "String::default")]
pub xrservice_launch_options: String,
pub autostart_command: Option<String>,
#[serde(default)]
pub skip_dependency_check: bool,
}
@ -422,7 +421,6 @@ impl Default for Profile {
lighthouse_driver: LighthouseDriver::default(),
xrservice_launch_options: String::default(),
uuid,
autostart_command: None,
skip_dependency_check: false,
}
}
@ -543,7 +541,6 @@ impl Profile {
mercury_enabled: self.features.mercury_enabled,
},
environment: self.environment.clone(),
autostart_command: self.autostart_command.clone(),
pull_on_build: self.pull_on_build,
lighthouse_driver: self.lighthouse_driver,
opencomposite_repo: self.opencomposite_repo.clone(),

View file

@ -11,6 +11,7 @@ use super::{
},
libsurvive_setup_window::{LibsurviveSetupMsg, LibsurviveSetupWindow},
main_view::{MainView, MainViewInit, MainViewMsg, MainViewOutMsg},
plugins::store::{PluginStore, PluginStoreInit, PluginStoreMsg, PluginStoreOutMsg},
util::{copiable_code_snippet, copy_text, open_with_default_handler},
wivrn_conf_editor::{WivrnConfEditor, WivrnConfEditorInit, WivrnConfEditorMsg},
};
@ -21,7 +22,7 @@ use crate::{
build_opencomposite::get_build_opencomposite_jobs, build_openhmd::get_build_openhmd_jobs,
build_wivrn::get_build_wivrn_jobs,
},
config::Config,
config::{Config, PluginConfig},
constants::APP_NAME,
depcheck::common::dep_pkexec,
file_builders::{
@ -56,7 +57,11 @@ use relm4::{
new_action_group, new_stateful_action, new_stateless_action,
prelude::*,
};
use std::{collections::VecDeque, fs::remove_file, time::Duration};
use std::{
collections::{HashMap, VecDeque},
fs::remove_file,
time::Duration,
};
use tracing::error;
pub struct App {
@ -74,7 +79,7 @@ pub struct App {
config: Config,
xrservice_worker: Option<JobWorker>,
autostart_worker: Option<JobWorker>,
plugins_worker: Option<JobWorker>,
restart_xrservice: bool,
build_worker: Option<JobWorker>,
profiles: Vec<Profile>,
@ -89,13 +94,14 @@ pub struct App {
vkinfo: Option<VulkanInfo>,
inhibit_fail_notif: Option<NotificationHandle>,
pluginstore: Option<AsyncController<PluginStore>>,
}
#[derive(Debug)]
pub enum Msg {
OnServiceLog(Vec<String>),
OnServiceExit(i32),
OnAutostartExit(i32),
OnPluginsExit(i32),
OnBuildLog(Vec<String>),
OnBuildExit(i32),
ClockTicking,
@ -120,6 +126,8 @@ pub enum Msg {
StartProber,
OnProberExit(bool),
WivrnCheckPairMode,
OpenPluginStore,
UpdateConfigPlugins(HashMap<String, PluginConfig>),
NoOp,
}
@ -235,19 +243,43 @@ impl App {
pub fn run_autostart(&mut self, sender: AsyncComponentSender<Self>) {
let prof = self.get_selected_profile();
if let Some(autostart_cmd) = &prof.autostart_command {
let plugins_cmd = self
.config
.plugins
.values()
.filter_map(|cp| {
if cp.enabled && cp.plugin.validate() {
if let Err(e) = cp.plugin.mark_as_executable() {
error!(
"failed to mark plugin {} as executable: {e}",
cp.plugin.appid
);
None
} else {
Some(format!(
"'{}'",
cp.plugin.executable().unwrap().to_string_lossy()
))
}
} else {
None
}
})
.collect::<Vec<String>>()
.join(" & ");
if !plugins_cmd.is_empty() {
let mut jobs = VecDeque::new();
jobs.push_back(WorkerJob::new_cmd(
Some(prof.environment.clone()),
"sh".into(),
Some(vec!["-c".into(), autostart_cmd.clone()]),
Some(vec!["-c".into(), plugins_cmd]),
));
let autostart_worker = JobWorker::new(jobs, sender.input_sender(), |msg| match msg {
let plugins_worker = JobWorker::new(jobs, sender.input_sender(), |msg| match msg {
JobWorkerOut::Log(rows) => Msg::OnServiceLog(rows),
JobWorkerOut::Exit(code) => Msg::OnAutostartExit(code),
JobWorkerOut::Exit(code) => Msg::OnPluginsExit(code),
});
autostart_worker.start();
self.autostart_worker = Some(autostart_worker);
plugins_worker.start();
self.plugins_worker = Some(plugins_worker);
}
}
@ -277,27 +309,17 @@ impl App {
}
pub fn shutdown_xrservice(&mut self) {
if let Some(worker) = self.autostart_worker.as_ref() {
worker.stop();
if let Some(w) = self.plugins_worker.as_ref() {
w.stop();
}
self.xrservice_ready = false;
if let Some(w) = self.openxr_prober_worker.as_ref() {
w.stop();
// this can cause threads to remain hanging...
self.openxr_prober_worker = None;
}
self.set_inhibit_session(false);
if let Some(worker) = self.xrservice_worker.as_ref() {
worker.stop();
if let Some(w) = self.xrservice_worker.as_ref() {
w.stop();
}
self.libmonado = None;
self.main_view
.sender()
.emit(MainViewMsg::XRServiceActiveChanged(false, None, false));
self.debug_view
.sender()
.emit(DebugViewMsg::XRServiceActiveChanged(false));
self.xr_devices = vec![];
}
}
@ -363,6 +385,8 @@ impl AsyncComponent for App {
}
}
Msg::OnServiceExit(code) => {
self.set_inhibit_session(false);
self.xrservice_ready = false;
self.restore_openxr_openvr_files();
self.main_view
.sender()
@ -370,6 +394,8 @@ impl AsyncComponent for App {
self.debug_view
.sender()
.emit(DebugViewMsg::XRServiceActiveChanged(false));
self.libmonado = None;
self.xr_devices = vec![];
if code != 0 && code != 15 {
// 15 is SIGTERM
sender.input(Msg::OnServiceLog(vec![format!(
@ -384,7 +410,7 @@ impl AsyncComponent for App {
self.start_xrservice(sender, false);
}
}
Msg::OnAutostartExit(_) => self.autostart_worker = None,
Msg::OnPluginsExit(_) => self.plugins_worker = None,
Msg::ClockTicking => {
self.main_view.sender().emit(MainViewMsg::ClockTicking);
let xrservice_worker_is_alive = self
@ -791,6 +817,21 @@ impl AsyncComponent for App {
}
}
}
Msg::OpenPluginStore => {
let pluginstore = PluginStore::builder()
.launch(PluginStoreInit {
config_plugins: self.config.plugins.clone(),
})
.forward(sender.input_sender(), move |msg| match msg {
PluginStoreOutMsg::UpdateConfigPlugins(cp) => Msg::UpdateConfigPlugins(cp),
});
pluginstore.sender().emit(PluginStoreMsg::Present);
self.pluginstore = Some(pluginstore);
}
Msg::UpdateConfigPlugins(cp) => {
self.config.plugins = cp;
self.config.save();
}
}
}
@ -892,6 +933,17 @@ impl AsyncComponent for App {
}
)
);
stateless_action!(
actions,
PluginStoreAction,
clone!(
#[strong]
sender,
move |_| {
sender.input(Msg::OpenPluginStore);
}
)
);
// this bypasses the macro because I need the underlying gio action
// to enable/disable it in update()
let configure_wivrn_action = {
@ -963,7 +1015,7 @@ impl AsyncComponent for App {
config,
profiles,
xrservice_worker: None,
autostart_worker: None,
plugins_worker: None,
build_worker: None,
xr_devices: vec![],
restart_xrservice: false,
@ -974,6 +1026,7 @@ impl AsyncComponent for App {
openxr_prober_worker: None,
xrservice_ready: false,
inhibit_fail_notif: None,
pluginstore: None,
};
let widgets = view_output!();
@ -1046,6 +1099,7 @@ new_stateless_action!(pub BuildProfileCleanAction, AppActionGroup, "buildprofile
new_stateless_action!(pub QuitAction, AppActionGroup, "quit");
new_stateful_action!(pub DebugViewToggleAction, AppActionGroup, "debugviewtoggle", (), bool);
new_stateless_action!(pub ConfigureWivrnAction, AppActionGroup, "configurewivrn");
new_stateless_action!(pub PluginStoreAction, AppActionGroup, "store");
new_stateless_action!(pub DebugOpenDataAction, AppActionGroup, "debugopendata");
new_stateless_action!(pub DebugOpenPrefixAction, AppActionGroup, "debugopenprefix");

View file

@ -2,7 +2,7 @@ use super::{
alert::alert,
app::{
AboutAction, BuildProfileAction, BuildProfileCleanAction, ConfigureWivrnAction,
DebugViewToggleAction,
DebugViewToggleAction, PluginStoreAction,
},
devices_box::{DevicesBox, DevicesBoxMsg},
install_wivrn_box::{InstallWivrnBox, InstallWivrnBoxInit, InstallWivrnBoxMsg},
@ -148,6 +148,7 @@ impl AsyncComponent for MainView {
menu! {
app_menu: {
section! {
"Plugin_s" => PluginStoreAction,
// value inside action is ignored
"_Debug View" => DebugViewToggleAction,
"_Build Profile" => BuildProfileAction,

View file

@ -13,6 +13,7 @@ mod libsurvive_setup_window;
mod macros;
mod main_view;
mod openhmd_calibration_box;
pub mod plugins;
mod preference_rows;
mod profile_editor;
mod steam_launch_options_box;

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 }
}
}

63
src/ui/plugins/mod.rs Normal file
View file

@ -0,0 +1,63 @@
pub mod add_custom_plugin_win;
pub mod store;
mod store_detail;
mod store_row_factory;
use crate::{paths::get_plugins_dir, util::file_utils::mark_as_executable};
use anyhow::bail;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
pub struct Plugin {
pub appid: String,
pub name: String,
pub icon_url: Option<String>,
pub version: Option<String>,
pub short_description: Option<String>,
pub description: Option<String>,
pub hompage_url: Option<String>,
pub screenshots: Vec<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 {
pub fn executable(&self) -> Option<PathBuf> {
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 {
self.executable().as_ref().is_some_and(|p| p.is_file())
}
pub fn mark_as_executable(&self) -> anyhow::Result<()> {
if let Some(p) = self.executable().as_ref() {
mark_as_executable(p)
} else {
bail!("no executable found 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())
}
}

499
src/ui/plugins/store.rs Normal file
View file

@ -0,0 +1,499 @@
use super::{
add_custom_plugin_win::{
AddCustomPluginWin, AddCustomPluginWinInit, AddCustomPluginWinMsg, AddCustomPluginWinOutMsg,
},
store_detail::{StoreDetail, StoreDetailMsg, StoreDetailOutMsg},
store_row_factory::{StoreRowModel, StoreRowModelInit, StoreRowModelMsg, StoreRowModelOutMsg},
Plugin,
};
use crate::{
config::PluginConfig,
downloader::download_file_async,
ui::{alert::alert, SENDER_IO_ERR_MSG},
};
use adw::prelude::*;
use relm4::{factory::AsyncFactoryVecDeque, prelude::*};
use std::{collections::HashMap, fs::remove_file};
use tracing::{debug, error};
#[tracker::track]
pub struct PluginStore {
#[tracker::do_not_track]
win: Option<adw::Window>,
#[tracker::do_not_track]
plugin_rows: Option<AsyncFactoryVecDeque<StoreRowModel>>,
#[tracker::do_not_track]
details: AsyncController<StoreDetail>,
#[tracker::do_not_track]
main_stack: Option<gtk::Stack>,
#[tracker::do_not_track]
config_plugins: HashMap<String, PluginConfig>,
refreshing: bool,
locked: bool,
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)]
pub enum PluginStoreMsg {
Present,
Refresh,
Install(Plugin, relm4::Sender<StoreRowModelMsg>),
InstallFromDetails(Plugin),
InstallDownload(Plugin, relm4::Sender<StoreRowModelMsg>),
Remove(Plugin),
SetEnabled(PluginStoreSignalSource, Plugin, bool),
ShowDetails(usize),
ShowPluginList,
PresentAddCustomPluginWin,
AddPluginToConfig(Plugin),
AddCustomPlugin(Plugin),
}
#[derive(Debug)]
pub struct PluginStoreInit {
pub config_plugins: HashMap<String, PluginConfig>,
}
#[derive(Debug)]
pub enum PluginStoreOutMsg {
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)]
impl AsyncComponent for PluginStore {
type Init = PluginStoreInit;
type Input = PluginStoreMsg;
type Output = PluginStoreOutMsg;
type CommandOutput = ();
view! {
#[name(win)]
adw::Window {
set_title: Some("Plugins"),
#[name(main_stack)]
gtk::Stack {
add_child = &adw::ToolbarView {
set_top_bar_style: adw::ToolbarStyle::Flat,
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 {
set_icon_name: "view-refresh-symbolic",
set_tooltip_text: Some("Refresh"),
#[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"]
set_sensitive: !(model.refreshing || model.locked),
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Refresh);
},
},
},
#[wrap(Some)]
set_content: inner = &gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_hexpand: true,
set_vexpand: true,
gtk::Stack {
set_hexpand: true,
set_vexpand: true,
add_child = &gtk::ScrolledWindow {
set_hscrollbar_policy: gtk::PolicyType::Never,
set_hexpand: true,
set_vexpand: true,
adw::Clamp {
#[name(listbox)]
gtk::ListBox {
#[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"]
set_sensitive: !(model.refreshing || model.locked),
add_css_class: "boxed-list",
set_valign: gtk::Align::Start,
set_margin_all: 12,
set_selection_mode: gtk::SelectionMode::None,
connect_row_activated[sender] => move |_, row| {
sender.input(
Self::Input::ShowDetails(
row.index() as usize
)
);
},
}
}
} -> {
set_name: "pluginlist"
},
add_child = &gtk::Spinner {
set_hexpand: true,
set_vexpand: true,
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
#[track = "model.changed(PluginStore::refreshing())"]
set_spinning: model.refreshing,
} -> {
set_name: "spinner"
},
add_child = &adw::StatusPage {
set_hexpand: true,
set_vexpand: true,
set_title: "No Plugins Found",
set_description: Some("Make sure you're connected to the internet and refresh"),
set_icon_name: Some("application-x-addon-symbolic"),
} -> {
set_name: "emptystate"
},
#[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::plugins())"]
set_visible_child_name: if model.refreshing {
"spinner"
} else if model.plugins.is_empty() {
"emptystate"
} else {
"pluginlist"
},
},
}
} -> {
set_name: "listview"
},
add_child = &adw::Bin {
#[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"]
set_sensitive: !(model.refreshing || model.locked),
set_child: Some(details_view),
} -> {
set_name: "detailsview",
},
set_visible_child_name: "listview",
}
}
}
async fn update(
&mut self,
message: Self::Input,
sender: AsyncComponentSender<Self>,
_root: &Self::Root,
) {
self.reset();
match message {
Self::Input::Present => {
self.win.as_ref().unwrap().present();
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.set_refreshing(true);
// TODO: populate from web
let mut plugins = vec![
Plugin {
appid: "com.github.galiser.wlx-overlay-s".into(),
name: "WLX Overlay S".into(),
version: Some("0.6.0".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()),
screenshots: vec![
"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()),
short_description: Some("Access your Wayland/X11 desktop".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,
},
];
{
let appids_from_web = plugins
.iter()
.map(|p| p.appid.clone())
.collect::<Vec<String>>();
// add all plugins that are in config but not retrieved
plugins.extend(self.config_plugins.values().filter_map(|cp| {
if appids_from_web.contains(&cp.plugin.appid) {
None
} else {
Some(Plugin::from(cp))
}
}));
}
self.set_plugins(plugins);
self.refresh_plugin_rows();
self.set_refreshing(false);
}
Self::Input::InstallFromDetails(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::Install(plugin, row.input_sender.clone()))
} else {
error!("could not find corresponding listbox row")
}
}
Self::Input::Install(plugin, row_sender) => {
self.set_locked(true);
sender.input(Self::Input::InstallDownload(plugin, row_sender))
}
Self::Input::InstallDownload(plugin, row_sender) => {
let mut plugin = plugin.clone();
match plugin.exec_url.as_ref() {
Some(url) => {
let exec_path = plugin.canonical_exec_path();
if let Err(e) = download_file_async(url, &exec_path).await {
alert(
"Download failed",
Some(&format!(
"Downloading {} {} failed:\n\n{e}",
plugin.name,
plugin
.version
.as_ref()
.unwrap_or(&"(no version)".to_string())
)),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
} 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));
self.details
.emit(StoreDetailMsg::Refresh(plugin.appid, true, false));
self.set_locked(false);
}
Self::Input::Remove(plugin) => {
self.set_locked(true);
if let Some(exec) = plugin.executable() {
// delete executable only if it's not a custom plugin
if exec.is_file() && plugin.exec_url.is_some() {
if let Err(e) = remove_file(&exec) {
alert(
"Failed removing plugin",
Some(&format!(
"Could not remove plugin executable {}:\n\n{e}",
exec.to_string_lossy()
)),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
}
}
}
self.config_plugins.remove(&plugin.appid);
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::Input::SetEnabled(signal_sender, plugin, enabled) => {
if let Some(cp) = self.config_plugins.get_mut(&plugin.appid) {
cp.enabled = enabled;
if signal_sender == PluginStoreSignalSource::Detail {
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()
{
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,
cp.plugin.version != plugin.version,
));
}
} else {
debug!(
"failed to set plugin {} enabled: could not find in hashmap",
plugin.appid
)
}
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
}
// we use index here because it's the listbox not the row that can
// send this signal, so I don't directly have the plugin object
Self::Input::ShowDetails(index) => {
if let Some(plugin) = self.plugins.get(index) {
self.details.sender().emit(StoreDetailMsg::SetPlugin(
plugin.clone(),
self.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.enabled),
self.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.plugin.version != plugin.version),
));
self.main_stack
.as_ref()
.unwrap()
.set_visible_child_name("detailsview");
} else {
error!("plugins list index out of range!")
}
}
Self::Input::ShowPluginList => {
self.main_stack
.as_ref()
.unwrap()
.set_visible_child_name("listview");
}
}
}
async fn init(
init: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let mut model = Self {
tracker: 0,
refreshing: false,
locked: false,
win: None,
plugins: Vec::default(),
plugin_rows: None,
details: StoreDetail::builder()
.launch(())
.forward(sender.input_sender(), move |msg| match msg {
StoreDetailOutMsg::GoBack => Self::Input::ShowPluginList,
StoreDetailOutMsg::Install(plugin) => Self::Input::InstallFromDetails(plugin),
StoreDetailOutMsg::Remove(plugin) => Self::Input::Remove(plugin),
StoreDetailOutMsg::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(PluginStoreSignalSource::Detail, plugin, enabled)
}
}),
config_plugins: init.config_plugins,
main_stack: None,
add_custom_plugin_win: None,
};
let details_view = model.details.widget();
let widgets = view_output!();
model.win = Some(widgets.win.clone());
model.plugin_rows = Some(
AsyncFactoryVecDeque::builder()
.launch(widgets.listbox.clone())
.forward(sender.input_sender(), move |msg| match msg {
StoreRowModelOutMsg::Install(appid, row_sender) => {
Self::Input::Install(appid, row_sender)
}
StoreRowModelOutMsg::Remove(appid) => Self::Input::Remove(appid),
StoreRowModelOutMsg::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(PluginStoreSignalSource::Row, plugin, enabled)
}
}),
);
model.main_stack = Some(widgets.main_stack.clone());
AsyncComponentParts { model, widgets }
}
}

View file

@ -0,0 +1,327 @@
use super::Plugin;
use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG};
use adw::prelude::*;
use relm4::prelude::*;
use tracing::warn;
#[tracker::track]
pub struct StoreDetail {
plugin: Option<Plugin>,
enabled: bool,
#[tracker::do_not_track]
carousel: Option<adw::Carousel>,
#[tracker::do_not_track]
icon: Option<gtk::Image>,
needs_update: bool,
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum StoreDetailMsg {
SetPlugin(Plugin, bool, bool),
SetIcon,
SetScreenshots,
Refresh(String, bool, bool),
Install,
Remove,
SetEnabled(bool),
}
#[derive(Debug)]
pub enum StoreDetailOutMsg {
Install(Plugin),
Remove(Plugin),
GoBack,
SetEnabled(Plugin, bool),
}
#[relm4::component(pub async)]
impl AsyncComponent for StoreDetail {
type Init = ();
type Input = StoreDetailMsg;
type Output = StoreDetailOutMsg;
type CommandOutput = ();
view! {
adw::ToolbarView {
set_top_bar_style: adw::ToolbarStyle::Flat,
add_top_bar: headerbar = &adw::HeaderBar {
#[wrap(Some)]
set_title_widget: title = &adw::WindowTitle {
#[track = "model.changed(Self::plugin())"]
set_title: model
.plugin
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or_default(),
},
pack_start: backbtn = &gtk::Button {
set_icon_name: "go-previous-symbolic",
set_tooltip_text: Some("Back"),
connect_clicked[sender] => move |_| {
sender.output(Self::Output::GoBack).expect(SENDER_IO_ERR_MSG);
}
},
},
#[wrap(Some)]
set_content: inner = &gtk::ScrolledWindow {
set_hscrollbar_policy: gtk::PolicyType::Never,
set_hexpand: true,
set_vexpand: true,
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_hexpand: true,
set_vexpand: true,
set_margin_top: 12,
set_margin_bottom: 48,
set_margin_start: 12,
set_margin_end: 12,
set_spacing: 24,
adw::Clamp { // icon, name, buttons
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_hexpand: true,
set_vexpand: false,
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_hexpand: true,
#[name(icon)]
gtk::Image {
set_icon_name: Some("application-x-addon-symbolic"),
set_margin_end: 12,
set_pixel_size: 96,
},
gtk::Label {
add_css_class: "title-2",
set_hexpand: true,
set_xalign: 0.0,
#[track = "model.changed(Self::plugin())"]
set_text: model
.plugin
.as_ref()
.map(|p| p.name.as_str())
.unwrap_or_default(),
set_ellipsize: gtk::pango::EllipsizeMode::None,
set_wrap: true,
},
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_halign: gtk::Align::Center,
set_valign: gtk::Align::Center,
set_spacing: 6,
gtk::Button {
#[track = "model.changed(Self::plugin())"]
set_visible: !model
.plugin
.as_ref()
.is_some_and(|p| p.is_installed()),
set_label: "Install",
add_css_class: "suggested-action",
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Install);
}
},
gtk::Button {
#[track = "model.changed(Self::plugin())"]
set_visible: model
.plugin
.as_ref()
.is_some_and(|p| p.is_installed()),
set_label: "Remove",
add_css_class: "destructive-action",
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Remove);
}
},
gtk::Button {
#[track = "model.changed(Self::plugin()) || model.changed(Self::needs_update())"]
set_visible: model
.plugin
.as_ref()
.is_some_and(|p| p.is_installed()) && model.needs_update,
add_css_class: "suggested-action",
set_label: "Update",
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Install);
}
},
gtk::Switch {
#[track = "model.changed(Self::plugin())"]
set_visible: model.plugin.as_ref()
.is_some_and(|p| p.is_installed()),
#[track = "model.changed(Self::enabled())"]
set_active: model.enabled,
set_tooltip_text: Some("Plugin enabled"),
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
connect_state_set[sender] => move |_, state| {
sender.input(Self::Input::SetEnabled(state));
gtk::glib::Propagation::Proceed
}
},
}
}
},
},
gtk::Box { // screenshots
set_orientation: gtk::Orientation::Vertical,
set_spacing: 12,
#[name(carousel)]
adw::Carousel {
set_allow_mouse_drag: true,
set_allow_scroll_wheel: false,
set_spacing: 24,
},
adw::CarouselIndicatorDots {
set_carousel: Some(&carousel),
},
},
adw::Clamp { // description
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
gtk::Label {
set_xalign: 0.0,
#[track = "model.changed(Self::plugin())"]
set_text: model
.plugin
.as_ref()
.and_then(|p| p
.description
.as_deref()
).unwrap_or(""),
set_ellipsize: gtk::pango::EllipsizeMode::None,
set_wrap: true,
set_justify: gtk::Justification::Fill,
},
},
},
}
}
}
}
async fn update(
&mut self,
message: Self::Input,
sender: AsyncComponentSender<Self>,
_root: &Self::Root,
) {
self.reset();
match message {
Self::Input::SetPlugin(p, enabled, needs_update) => {
self.set_plugin(Some(p));
self.set_enabled(enabled);
self.set_needs_update(needs_update);
sender.input(Self::Input::SetIcon);
sender.input(Self::Input::SetScreenshots);
}
Self::Input::SetIcon => {
if let Some(plugin) = self.plugin.as_ref() {
if let Some(url) = plugin.icon_url.as_ref() {
match cache_file(url, None).await {
Ok(dest) => {
self.icon.as_ref().unwrap().set_from_file(Some(dest));
}
Err(e) => {
warn!("Failed downloading icon '{url}': {e}");
}
};
} else {
self.icon
.as_ref()
.unwrap()
.set_icon_name(Some("application-x-addon-symbolic"));
}
}
}
Self::Input::SetScreenshots => {
let carousel = self.carousel.as_ref().unwrap().clone();
while let Some(child) = carousel.first_child() {
carousel.remove(&child);
}
if let Some(plugin) = self.plugin.as_ref() {
carousel
.parent()
.unwrap()
.set_visible(!plugin.screenshots.is_empty());
for url in plugin.screenshots.iter() {
match cache_file(url, None).await {
Ok(dest) => {
let pic = gtk::Picture::builder()
.height_request(300)
.css_classes(["card"])
.overflow(gtk::Overflow::Hidden)
.valign(gtk::Align::Center)
.build();
pic.set_filename(Some(dest));
let clamp = adw::Clamp::builder().child(&pic).build();
carousel.append(&clamp);
}
Err(e) => {
warn!("failed downloading screenshot '{url}': {e}");
}
};
}
}
}
Self::Input::Refresh(appid, enabled, needs_update) => {
if self.plugin.as_ref().is_some_and(|p| p.appid == appid) {
self.mark_all_changed();
self.set_enabled(enabled);
self.set_needs_update(needs_update);
}
}
Self::Input::Install => {
if let Some(plugin) = self.plugin.as_ref() {
sender
.output(Self::Output::Install(plugin.clone()))
.expect(SENDER_IO_ERR_MSG);
}
}
Self::Input::Remove => {
if let Some(plugin) = self.plugin.as_ref() {
sender
.output(Self::Output::Remove(plugin.clone()))
.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.set_enabled(enabled);
if let Some(plugin) = self.plugin.as_ref() {
sender
.output(Self::Output::SetEnabled(plugin.clone(), enabled))
.expect(SENDER_IO_ERR_MSG);
}
}
}
}
async fn init(
_init: Self::Init,
root: Self::Root,
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
let mut model = Self {
tracker: 0,
plugin: None,
enabled: false,
carousel: None,
icon: None,
needs_update: false,
};
let widgets = view_output!();
model.carousel = Some(widgets.carousel.clone());
model.icon = Some(widgets.icon.clone());
AsyncComponentParts { model, widgets }
}
}

View file

@ -0,0 +1,227 @@
use super::Plugin;
use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG};
use gtk::prelude::*;
use relm4::{
factory::AsyncFactoryComponent, prelude::DynamicIndex, AsyncFactorySender, RelmWidgetExt,
};
use tracing::error;
#[derive(Debug)]
#[tracker::track]
pub struct StoreRowModel {
#[no_eq]
pub plugin: Plugin,
#[tracker::do_not_track]
icon: Option<gtk::Image>,
#[tracker::do_not_track]
pub input_sender: relm4::Sender<StoreRowModelMsg>,
pub enabled: bool,
pub needs_update: bool,
}
#[derive(Debug)]
pub struct StoreRowModelInit {
pub plugin: Plugin,
pub enabled: bool,
pub needs_update: bool,
}
#[derive(Debug)]
pub enum StoreRowModelMsg {
LoadIcon,
/// params: enabled, needs_update
Refresh(bool, bool),
SetEnabled(bool),
}
#[derive(Debug)]
pub enum StoreRowModelOutMsg {
Install(Plugin, relm4::Sender<StoreRowModelMsg>),
Remove(Plugin),
SetEnabled(Plugin, bool),
}
#[relm4::factory(async pub)]
impl AsyncFactoryComponent for StoreRowModel {
type Init = StoreRowModelInit;
type Input = StoreRowModelMsg;
type Output = StoreRowModelOutMsg;
type CommandOutput = ();
type ParentWidget = gtk::ListBox;
view! {
root = gtk::ListBoxRow {
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_hexpand: true,
set_vexpand: false,
set_spacing: 12,
set_margin_all: 12,
#[name(icon)]
gtk::Image {
set_icon_name: Some("application-x-addon-symbolic"),
set_icon_size: gtk::IconSize::Large,
},
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 6,
set_hexpand: true,
set_vexpand: true,
gtk::Label {
add_css_class: "title-3",
set_hexpand: true,
set_xalign: 0.0,
set_text: &self.plugin.name,
set_ellipsize: gtk::pango::EllipsizeMode::None,
set_wrap: true,
},
gtk::Label {
add_css_class: "dim-label",
set_hexpand: true,
set_xalign: 0.0,
set_text: self.plugin.short_description
.as_deref()
.unwrap_or(""),
set_ellipsize: gtk::pango::EllipsizeMode::None,
set_wrap: true,
},
},
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 6,
set_vexpand: true,
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
gtk::Button {
#[track = "self.changed(StoreRowModel::plugin())"]
set_visible: !self.plugin.is_installed(),
set_icon_name: "folder-download-symbolic",
add_css_class: "suggested-action",
set_tooltip_text: Some("Install"),
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
connect_clicked[sender, plugin] => move |_| {
sender
.output(Self::Output::Install(
plugin.clone(),
sender.input_sender().clone()
))
.expect(SENDER_IO_ERR_MSG);
}
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 6,
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
gtk::Button {
#[track = "self.changed(StoreRowModel::plugin())"]
set_visible: self.plugin.is_installed(),
set_icon_name: "user-trash-symbolic",
add_css_class: "destructive-action",
set_tooltip_text: Some("Remove"),
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
connect_clicked[sender, plugin] => move |_| {
sender
.output(Self::Output::Remove(
plugin.clone(),
))
.expect(SENDER_IO_ERR_MSG);
}
},
gtk::Button {
#[track = "self.changed(StoreRowModel::plugin()) || self.changed(StoreRowModel::needs_update())"]
set_visible: self.plugin.is_installed() && self.needs_update,
set_icon_name: "view-refresh-symbolic",
add_css_class: "suggested-action",
set_tooltip_text: Some("Update"),
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
connect_clicked[sender, plugin] => move |_| {
sender
.output(Self::Output::Install(
plugin.clone(),
sender.input_sender().clone()
))
.expect(SENDER_IO_ERR_MSG);
}
},
},
gtk::Switch {
#[track = "self.changed(StoreRowModel::plugin())"]
set_visible: self.plugin.is_installed(),
#[track = "self.changed(StoreRowModel::enabled())"]
set_active: self.enabled,
set_valign: gtk::Align::Center,
set_halign: gtk::Align::Center,
set_tooltip_text: Some("Plugin enabled"),
connect_state_set[sender] => move |_, state| {
sender.input(Self::Input::SetEnabled(state));
gtk::glib::Propagation::Proceed
}
},
},
}
}
}
async fn update(&mut self, message: Self::Input, sender: AsyncFactorySender<Self>) {
self.reset();
match message {
Self::Input::LoadIcon => {
if let Some(url) = self.plugin.icon_url.as_ref() {
match cache_file(url, None).await {
Ok(dest) => {
self.icon.as_ref().unwrap().set_from_file(Some(dest));
}
Err(e) => {
error!("failed downloading icon '{url}': {e}");
}
};
}
}
Self::Input::SetEnabled(state) => {
self.set_enabled(state);
sender
.output(Self::Output::SetEnabled(self.plugin.clone(), state))
.expect(SENDER_IO_ERR_MSG);
}
Self::Input::Refresh(enabled, needs_update) => {
self.mark_all_changed();
self.set_enabled(enabled);
self.set_needs_update(needs_update);
}
}
}
async fn init_model(
init: Self::Init,
_index: &DynamicIndex,
sender: AsyncFactorySender<Self>,
) -> Self {
Self {
tracker: 0,
plugin: init.plugin,
enabled: init.enabled,
icon: None,
input_sender: sender.input_sender().clone(),
needs_update: init.needs_update,
}
}
fn init_widgets(
&mut self,
_index: &DynamicIndex,
root: Self::Root,
_returned_widget: &<Self::ParentWidget as relm4::factory::FactoryView>::ReturnedWidget,
sender: AsyncFactorySender<Self>,
) -> Self::Widgets {
let plugin = self.plugin.clone(); // for use in a signal handler
let widgets = view_output!();
self.icon = Some(widgets.icon.clone());
sender.input(Self::Input::LoadIcon);
widgets
}
}

View file

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

View file

@ -130,14 +130,6 @@ impl SimpleComponent for ProfileEditor {
prof.borrow_mut().prefix = n_path.unwrap_or_default().into();
}),
),
add: &entry_row("Autostart Command",
model.profile.borrow().autostart_command.as_ref().unwrap_or(&String::default()),
clone!(#[strong] prof, move |row| {
let txt = row.text().trim().to_string();
prof.borrow_mut().autostart_command =
if txt.is_empty() {None} else {Some(txt)};
})
),
add: &switch_row("Dependency Check",
Some("Warning: disabling dependency checks may result in build failures"),
!model.profile.borrow().skip_dependency_check,

View file

@ -7,6 +7,7 @@ use nix::{
use std::{
fs::{self, copy, create_dir_all, remove_dir_all, File, OpenOptions},
io::{BufReader, BufWriter},
os::unix::fs::PermissionsExt,
path::Path,
};
use tracing::{debug, error};
@ -151,6 +152,17 @@ pub fn mount_has_nosuid(path: &Path) -> Result<bool, Errno> {
}
}
pub fn mark_as_executable(path: &Path) -> anyhow::Result<()> {
if !path.is_file() {
bail!("Path '{}' is not a file", path.to_string_lossy())
} else {
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(path, perms)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::mount_has_nosuid;