mirror of
https://gitlab.com/gabmus/envision.git
synced 2025-04-19 19:14:53 +00:00
feat!: plugin store
This commit is contained in:
parent
e5435d0aa3
commit
d38acf0a7e
14 changed files with 1471 additions and 46 deletions
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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(),
|
||||
|
|
106
src/ui/app.rs
106
src/ui/app.rs
|
@ -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");
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
169
src/ui/plugins/add_custom_plugin_win.rs
Normal file
169
src/ui/plugins/add_custom_plugin_win.rs
Normal 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 = >k::Button {
|
||||
set_label: "Cancel",
|
||||
add_css_class: "destructive-action",
|
||||
connect_clicked[sender] => move |_| {
|
||||
sender.input(Self::Input::Close)
|
||||
},
|
||||
},
|
||||
pack_end: add_btn = >k::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
63
src/ui/plugins/mod.rs
Normal 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
499
src/ui/plugins/store.rs
Normal 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 = >k::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 = >k::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 = >k::Box {
|
||||
set_orientation: gtk::Orientation::Vertical,
|
||||
set_hexpand: true,
|
||||
set_vexpand: true,
|
||||
gtk::Stack {
|
||||
set_hexpand: true,
|
||||
set_vexpand: true,
|
||||
add_child = >k::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 = >k::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 }
|
||||
}
|
||||
}
|
327
src/ui/plugins/store_detail.rs
Normal file
327
src/ui/plugins/store_detail.rs
Normal 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 = >k::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 = >k::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 }
|
||||
}
|
||||
}
|
227
src/ui/plugins/store_row_factory.rs
Normal file
227
src/ui/plugins/store_row_factory.rs
Normal 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
|
||||
}
|
||||
}
|
|
@ -156,13 +156,12 @@ pub fn spin_row<F: Fn(>k::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 = >k::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))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue