Skip to content

Commit 2647024

Browse files
feat: allow uninstalling apps
Signed-off-by: Oskar Manhart <52569953+oskardotglobal@users.noreply.github.com>
1 parent 8045d95 commit 2647024

8 files changed

Lines changed: 119 additions & 50 deletions

File tree

winapps-cli/src/main.rs

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use std::collections::HashSet;
2+
13
use clap::{Command, arg};
4+
use inquire::MultiSelect;
25
use miette::{IntoDiagnostic, Result};
3-
use tracing::{Level, info};
6+
use tracing::{Level, debug, info};
47
use tracing_subscriber::EnvFilter;
5-
use winapps::{Config, Freerdp, RemoteClient};
8+
use winapps::{Config, Freerdp, RemoteClient, config::App};
69

710
fn cli() -> Command {
811
Command::new("winapps-cli")
@@ -26,8 +29,6 @@ fn cli() -> Command {
2629

2730
fn main() -> Result<()> {
2831
tracing_subscriber::fmt()
29-
.without_time()
30-
.with_target(false)
3132
.with_level(true)
3233
.with_max_level(Level::INFO)
3334
.with_env_filter(EnvFilter::from_default_env())
@@ -45,16 +46,41 @@ fn main() -> Result<()> {
4546
Some(("setup", _)) => {
4647
info!("Running setup");
4748

48-
// TODO: Allow deleting apps, maybe pass installed apps
49-
// so they can be deselected?
50-
match inquire::MultiSelect::new("Select apps to link", config.get_available_apps()?)
49+
let available = config.get_available_apps()?;
50+
let installed: Vec<usize> = available
51+
.iter()
52+
.enumerate()
53+
.filter_map(|(i, app)| config.linked_apps.contains_key(&app.id).then_some(i))
54+
.collect();
55+
56+
debug!(
57+
"{} apps available, {} apps installed",
58+
available.len(),
59+
config.linked_apps.len()
60+
);
61+
62+
match MultiSelect::new("Select apps to link", available)
63+
.with_default(installed.as_slice())
64+
.with_page_size(20)
5165
.prompt_skippable()
5266
.map_err(|e| winapps::Error::Command {
5367
message: "Failed to display selection dialog".into(),
5468
source: e.into(),
5569
})? {
56-
Some(apps) => apps.into_iter().try_for_each(|app| app.link(&mut config))?,
57-
None => info!("No apps selected, skipping setup..."),
70+
Some(apps) => {
71+
let selected: HashSet<App> = apps.into_iter().collect();
72+
let installed: HashSet<App> = config.linked_apps.values().cloned().collect();
73+
74+
for app in selected.symmetric_difference(&installed).cloned() {
75+
match (selected.contains(&app), installed.contains(&app)) {
76+
(true, false) => app.link(&mut config)?,
77+
(false, true) => app.unlink(&mut config)?,
78+
(false, false) => (),
79+
(true, true) => unreachable!(),
80+
}
81+
}
82+
}
83+
None => info!("No apps (de-)selected, skipping setup..."),
5884
};
5985

6086
Ok(())

winapps/src/backend/container.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ impl Backend for Container {
3434
.with_err("Could not get container status")
3535
.wait_with_output()?;
3636

37-
debug!("{command} returned state: {state}");
37+
debug!("{command} returned state: {}", state.trim());
3838
ensure!(state.trim() == Self::STATE_RUNNING, Error::VmNotRunning);
3939

4040
Ok(())

winapps/src/backend/mod.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,15 @@ impl Config {
6161
self.backend.get_host(self)
6262
}
6363

64+
fn normalize_app_id(input: String) -> String {
65+
input
66+
.strip_suffix(".exe")
67+
.map(|s| s.to_string())
68+
.unwrap_or(input)
69+
}
70+
6471
pub fn get_available_apps(&self) -> Result<Vec<App>> {
72+
// todo: stronger parsing, better errors
6573
let apps = Command::new("C:\\ExtractPrograms.ps1")
6674
.into_remote(self)
6775
.wait_with_output()?
@@ -71,11 +79,14 @@ impl Config {
7179

7280
match (split.next(), split.next(), split.next(), split.next()) {
7381
(Some(id), Some(name), Some(path), Some(icon)) => Some(App {
74-
id: id.to_string(),
82+
id: Self::normalize_app_id(id.to_string()),
7583
name: name.to_string(),
7684
win_exec: path.to_string(),
7785
kind: AppKind::FromBase64(icon.to_string()),
7886
}),
87+
88+
// Skip ids ending in .dll for now
89+
(Some(id), _, _, _) if id.ends_with(".dll") => None,
7990
_ => None,
8091
}
8192
})

winapps/src/command.rs

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{Config, Error, IntoResult, Result, bail, ensure};
2-
use shlex::{split, try_join};
2+
use shlex::split;
33
use std::{
44
fmt::{Display, Formatter},
55
iter,
@@ -35,29 +35,25 @@ impl FromStr for Command {
3535
bail!("Commands should have an executable")
3636
};
3737

38-
Ok(Self {
39-
exec,
40-
args: items.collect(),
41-
error_message: String::from("Error running child command"),
42-
loud: false,
43-
})
38+
Ok(Command::new(exec).args(items))
4439
}
4540
}
4641

4742
impl Command {
48-
pub fn new<T: Into<String>>(exec: T) -> Self {
43+
pub fn new<T: Into<String> + Display>(exec: T) -> Self {
4944
Self {
45+
error_message: format!("Error running child command {}", &exec),
5046
exec: exec.into(),
5147
args: Vec::new(),
52-
error_message: String::from("Error running child command"),
5348
loud: false,
5449
}
5550
}
5651

5752
pub fn into_remote(mut self, config: &Config) -> Self {
58-
let prev =
59-
try_join(iter::once(self.exec.as_str()).chain(self.args.iter().map(|s| s.as_str())))
60-
.expect("Since this has been created by shlex::join, it should always be valid");
53+
let prev = iter::once(self.exec)
54+
.chain(self.args.iter().cloned())
55+
.collect::<Vec<String>>()
56+
.join(" ");
6157

6258
self.exec = "sshpass".to_string();
6359
self.clear_args()
@@ -66,6 +62,7 @@ impl Command {
6662
"ssh",
6763
format!("{}@{}", config.auth.username, config.get_host()).as_str(),
6864
"-oStrictHostKeyChecking=accept-new",
65+
"-oWarnWeakCrypto=no-pq-kex",
6966
"-p",
7067
config.auth.ssh_port.to_string().as_str(),
7168
])
@@ -137,17 +134,15 @@ impl Command {
137134
message: self.error_message.clone(),
138135
})?;
139136

137+
let stdout = String::from_utf8_lossy_owned(output.stdout);
138+
139+
debug!("Got stdout: {stdout}");
140140
debug!(
141-
"Child exit code is zero, returning output {} {}",
142-
output.stdout.len(),
143-
output.stderr.len()
141+
"Got stderr: {}",
142+
String::from_utf8_lossy_owned(output.stderr)
144143
);
145144

146-
Ok(format!(
147-
"{}\n{}",
148-
String::from_utf8(output.stdout).expect("Commands should always return valid utf-8"),
149-
String::from_utf8(output.stderr).expect("Commands should always return valid utf-8")
150-
))
145+
Ok(stdout)
151146
}
152147
}
153148

winapps/src/config/apps.rs

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,23 @@ use crate::{
55
ensure,
66
};
77
use base64::{Engine, prelude::BASE64_STANDARD};
8-
use std::{fmt::Display, fs::write};
9-
use tracing::debug;
8+
use std::{fmt::Display, fs, os::unix::fs::PermissionsExt};
9+
use tracing::{debug, warn};
1010

1111
impl PartialEq for App {
1212
fn eq(&self, other: &Self) -> bool {
1313
self.id == other.id
1414
}
1515
}
1616

17+
impl Eq for App {}
18+
19+
impl std::hash::Hash for App {
20+
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
21+
self.id.hash(state);
22+
}
23+
}
24+
1725
impl Display for App {
1826
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
1927
f.write_fmt(format_args!("{} ({})", self.name, self.win_exec))
@@ -32,7 +40,7 @@ impl App {
3240
match &self.kind {
3341
AppKind::FromBase64(base64) => {
3442
let path = icons_dir()?.join(format!("{}.png", self.id));
35-
write(path.clone(), BASE64_STANDARD.decode(base64)?)?;
43+
fs::write(path.clone(), BASE64_STANDARD.decode(base64)?)?;
3644

3745
self.kind = AppKind::Existing;
3846

@@ -72,21 +80,33 @@ Comment={} (WinApps)",
7280

7381
let path = desktop_dir()?.join(format!("{}.desktop", self.id));
7482

75-
write(&path, self.try_as_desktop_file()?)?;
83+
fs::write(&path, self.try_as_desktop_file()?)?;
84+
fs::set_permissions(&path, PermissionsExt::from_mode(0o750))?;
7685

77-
if !config.linked_apps.contains(&self) {
86+
if !config.linked_apps.contains_key(&self.id) {
7887
debug!("Writing app {} to config", self.id);
7988

80-
config.linked_apps.push(self);
89+
config.linked_apps.insert(self.id.clone(), self);
8190
config.save()?;
8291
}
8392

8493
Ok(())
8594
}
86-
}
8795

88-
impl Config {
89-
pub fn find_linked_app(&self, id: String) -> Option<&App> {
90-
self.linked_apps.iter().find(|app| app.id == id)
96+
pub fn unlink(self, config: &mut Config) -> Result<()> {
97+
let path = desktop_dir()?.join(format!("{}.desktop", self.id));
98+
99+
fs::remove_file(&path).unwrap_or_else(|_| {
100+
warn!(
101+
"Could not delete desktop file for {} ({})",
102+
self.id,
103+
path.to_string_lossy()
104+
)
105+
});
106+
107+
debug!("Removing app {} to config", self.id);
108+
109+
config.linked_apps.remove_entry(&self.id);
110+
config.save()
91111
}
92112
}

winapps/src/config/mod.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use derive_new::new;
2-
use serde::{Deserialize, Serialize};
3-
use std::net::IpAddr;
2+
use serde::{Deserialize, Deserializer, Serialize};
3+
use std::{collections::HashMap, net::IpAddr};
44

55
use crate::Backends;
66

@@ -19,8 +19,9 @@ pub struct Config {
1919
pub manual: ManualConfig,
2020
#[new(value = "FreerdpConfig::new()")]
2121
pub freerdp: FreerdpConfig,
22-
#[new(value = "Vec::new()")]
23-
pub linked_apps: Vec<App>,
22+
#[new(value = "HashMap::new()")]
23+
#[serde(deserialize_with = "deserialize_apps")]
24+
pub linked_apps: HashMap<String, App>,
2425
#[new(value = "false")]
2526
pub debug: bool,
2627

@@ -92,9 +93,25 @@ pub enum AppKind {
9293

9394
#[derive(Debug, Deserialize, Serialize, Clone)]
9495
pub struct App {
95-
pub id: String,
9696
pub name: String,
9797
pub win_exec: String,
98+
99+
#[serde(skip)]
100+
pub id: String,
101+
98102
#[serde(skip)]
99103
pub kind: AppKind,
100104
}
105+
106+
fn deserialize_apps<'de, D>(deserializer: D) -> Result<HashMap<String, App>, D::Error>
107+
where
108+
D: Deserializer<'de>,
109+
{
110+
let mut apps = HashMap::<String, App>::deserialize(deserializer)?;
111+
112+
for (id, app) in apps.iter_mut() {
113+
app.id = id.clone();
114+
}
115+
116+
Ok(apps)
117+
}

winapps/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#![allow(clippy::new_without_default)]
2+
#![warn(clippy::unwrap_used)]
23
#![feature(decl_macro)]
34
#![feature(exit_status_error)]
4-
#![feature(once_cell_try)]
5+
#![feature(string_from_utf8_lossy_owned)]
56

67
pub use crate::{
78
backend::{Backend, Backends},

winapps/src/remote_client/freerdp.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,8 @@ impl RemoteClient for Freerdp {
4848
fn run_app(self, config: &Config, app_name: String, args: Vec<String>) -> Result<()> {
4949
let path = config
5050
.linked_apps
51-
.iter()
52-
.filter_map(|app| app.id.eq(&app_name).then_some(app.win_exec.clone()))
53-
.next()
51+
.get(&app_name)
52+
.map(|app| app.win_exec.clone())
5453
.unwrap_or(app_name);
5554

5655
let Some(home_regex) = dirs::home_dir().map(|home| {

0 commit comments

Comments
 (0)