5 Commits

Author SHA1 Message Date
Himadri Bhattacharjee
33748c85b8 deps: adb_client fix has been merged upstream 2025-12-27 15:10:25 +05:30
Himadri Bhattacharjee
554657d6ef feat: remove debug outputs to reduce binary size 2025-12-25 10:04:47 +05:30
Himadri Bhattacharjee
d14699846d deps: remove web a11y features since we are not targeting web 2025-12-25 10:04:21 +05:30
Himadri Bhattacharjee
b8cc6e7500 feat: use bitfields to store state booleans more efficiently 2025-12-25 09:15:54 +05:30
Himadri Bhattacharjee
9308276f6f refactor: move ui and business logic into manageable modules 2025-12-24 20:05:50 +05:30
9 changed files with 462 additions and 423 deletions

8
Cargo.lock generated
View File

@@ -110,8 +110,9 @@ dependencies = [
[[package]]
name = "adb_client"
version = "2.1.18"
source = "git+https://github.com/lavafroth/adb_client?branch=with-read-timeout#f69a3d8a1aa58713e899a8d07ba13588027390e9"
version = "2.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "517ba09db77302f5326492b7f0b9cb99fdf1b4c58f1b749a00f081195cf52949"
dependencies = [
"base64",
"bincode",
@@ -5324,7 +5325,7 @@ dependencies = [
[[package]]
name = "zilch"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"adb_client",
"eframe",
@@ -5332,6 +5333,7 @@ dependencies = [
"egui_alignments",
"egui_extras",
"env_logger",
"log",
"phf 0.13.1",
"rfd",
"rusb",

View File

@@ -3,13 +3,18 @@ name = "zilch"
version = "0.2.0"
edition = "2024"
[profile.release]
strip = true
[dependencies]
adb_client = {version = "2.1.18", git = "https://github.com/lavafroth/adb_client", branch = "with-read-timeout"}
eframe = "0.33.2"
adb_client = { version = "2.1.18" }
# TODO: drop x11 support when wayland adoption increases. Will decrease binary size
eframe = {version = "0.33.2", features = ["accesskit", "default_fonts", "glow", "wayland", "x11"] }
egui = "0.33.2"
egui_alignments = "0.3.6"
egui_extras = "0.33.2"
env_logger = "0.11.8"
log = "0.4.29"
phf = { version = "0.13.1", features = ["macros"] }
rfd = { version = "0.16.0", features = ["ashpd", "pollster", "urlencoding", "wayland", "xdg-portal"] }
rusb = "0.9.4"

62
src/action.rs Normal file
View File

@@ -0,0 +1,62 @@
use crate::adb_shell_text::ShellCommandText;
use crate::{Package, PackageIdentifier, ShellRunError};
use adb_client::ADBUSBDevice;
pub enum Action {
Uninstall(Package),
Revert(PackageIdentifier, crate::listview::State),
Disable(PackageIdentifier),
}
impl Action {
pub fn apply_on_device(self, device: &mut ADBUSBDevice) -> Result<(), ShellRunError> {
match self {
Action::Uninstall(pkg) => {
if pkg.path.is_empty() {
return Err(ShellRunError::BackupNotPossible(pkg.id));
}
let _copy_command_no_output = device.shell_command_text(&format!(
"cp {} /data/local/tmp/{}.apk",
pkg.path, pkg.id
))?;
let output =
device.shell_command_text(&format!("pm uninstall --user 0 -k {}", pkg.id))?;
if !output.contains("Success") {
return Err(ShellRunError::UninstallFailed(pkg.id));
}
}
Action::Revert(id, crate::listview::State::Disabled) => {
let revert_command = format!("pm enable {id}");
let output = device.shell_command_text(&revert_command)?;
if !output.contains("new state: enabled") {
return Err(ShellRunError::RevertFailed(id));
}
}
Action::Revert(id, _uninstalled) => {
let revert_command = format!("pm install-existing {id}");
let output = device.shell_command_text(&revert_command)?;
if !output.contains("inaccessible or not found") {
return Ok(());
}
let revert_command = format!("pm install -r --user 0 /data/local/tmp/{id}.apk");
let output = device.shell_command_text(&revert_command)?;
if !output.contains("Success") {
return Err(ShellRunError::RevertFailed(id));
}
}
Action::Disable(id) => {
let disable_command = format!("pm disable-user {id}");
let output = device.shell_command_text(&disable_command)?;
if !output.contains("new state: disabled-user") {
return Err(ShellRunError::DisableFailed(id));
}
}
}
Ok(())
}
}

98
src/action_bar.rs Normal file
View File

@@ -0,0 +1,98 @@
use crate::Action;
use crate::categories;
use crate::listview;
use crate::listview::State;
use egui::Vec2;
use egui::{Button, RichText, Spinner};
impl crate::App {
pub fn action_bar(&mut self, ui: &mut egui::Ui) {
ui.add_space(6.0);
ui.style_mut().spacing.button_padding = [6.0, 6.0].into();
ui.horizontal_wrapped(|ui| {
for (category, bits) in categories::NAMES.into_iter().zip(categories::VALUES) {
let selected = self.categories & bits == bits;
if ui
.add(
Button::selectable(selected, RichText::new(category).size(12.0))
.corner_radius(10.0),
)
.clicked()
{
self.categories ^= bits;
};
}
});
let button = if self.disable_mode {
Button::new("disable")
} else {
Button::new("uninstall")
};
let mut selected: Vec<&listview::Entry> = vec![];
let mut selected_app_state = 0b111;
for entry in self.entries.values().filter(|entry| entry.selected) {
selected.push(entry);
selected_app_state &= entry.state as u8;
}
// eprintln!("{0:08b}", self.selected_app_state);
ui.separator();
ui.horizontal(|ui| {
let button_size = [80.0, 30.0].into();
if self.busy {
ui.add_sized(button_size, Spinner::new());
} else if selected_app_state == State::Enabled as u8 {
if add_enabled_button(true, ui, button_size, button) {
if self.disable_mode {
for entry in selected.iter() {
self.action_tx
.send(Action::Disable(entry.package.id.clone()))
.expect("failed to send message to backend");
}
} else {
for entry in selected.iter() {
self.action_tx
.send(Action::Uninstall(entry.package.clone()))
.expect("failed to send message to backend");
}
}
self.busy = true;
}
} else if selected_app_state == State::Uninstalled as u8 {
if add_enabled_button(true, ui, button_size, Button::new("revert")) {
for entry in selected.iter() {
self.action_tx
.send(Action::Revert(entry.package.id.clone(), entry.state))
.expect("failed to send message to backend");
}
self.busy = true;
}
} else {
// the selection is a mix of enabled and disabled apps:
// gray out the button
add_enabled_button(false, ui, button_size, button);
}
ui.checkbox(&mut self.disable_mode, "disable mode")
.on_hover_text("prefer disabling apps to uninstalling");
ui.separator();
ui.label(format!("{} selected", selected.len()));
ui.separator();
});
ui.add_space(2.0);
}
}
fn add_enabled_button(enabled: bool, ui: &mut egui::Ui, size: Vec2, button: Button) -> bool {
ui.add_enabled_ui(enabled, |ui| ui.add_sized(size, button))
.inner
.clicked()
}

18
src/adb_shell_text.rs Normal file
View File

@@ -0,0 +1,18 @@
use crate::ShellRunError;
use adb_client::{ADBDeviceExt, ADBUSBDevice};
pub trait ShellCommandText {
fn shell_command_text(&mut self, command: &str) -> Result<String, ShellRunError>;
}
impl ShellCommandText for ADBUSBDevice {
fn shell_command_text(&mut self, command: &str) -> Result<String, ShellRunError> {
let mut buf = Vec::with_capacity(4096);
self.shell_command(&[command], &mut buf)
.map_err(|e| match e {
adb_client::RustADBError::UsbError(rusb::Error::Timeout) => ShellRunError::Timeout,
_ => ShellRunError::Unrecoverable,
})?;
String::from_utf8(buf).map_err(|_| ShellRunError::ParseError)
}
}

24
src/categories.rs Normal file
View File

@@ -0,0 +1,24 @@
pub const RECOMMENDED: u8 = 0b10000;
pub const ADVANCED: u8 = 0b01000;
pub const EXPERT: u8 = 0b00100;
pub const UNSAFE: u8 = 0b00010;
pub const UNIDENTIFIED: u8 = 0b00001;
pub const VALUES: [u8; 5] = [RECOMMENDED, ADVANCED, EXPERT, UNSAFE, UNIDENTIFIED];
pub const NAMES: [&str; 5] = [
"Recommended",
"Advanced",
"Expert",
"Unsafe",
"Unidentified",
];
pub fn value_to_name(value: u8) -> &'static str {
match value {
RECOMMENDED => "Recommended",
ADVANCED => "Advanced",
EXPERT => "Expert",
UNSAFE => "Unsafe",
_ => "Unidentified",
}
}

114
src/listview.rs Normal file
View File

@@ -0,0 +1,114 @@
use crate::Metadata;
use crate::Package;
use crate::categories;
use egui::{Align, Button, Color32, Layout, RichText, Sense, Stroke, Style, text::LayoutJob};
#[repr(u8)]
#[derive(PartialEq, Eq, Clone, Copy)]
pub enum State {
Enabled = 0b001,
Uninstalled = 0b010,
Disabled = 0b110,
}
pub struct Entry {
pub package: Package,
pub expand_triggered: bool,
pub state: State,
pub selected: bool,
pub metadata: Option<&'static Metadata>,
}
impl Entry {
pub fn render(&mut self, ui: &mut egui::Ui) {
let id = ui.make_persistent_id(format!("{}_state", self.package.id));
let mut state =
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false);
if self.expand_triggered {
state.toggle(ui);
self.expand_triggered = false;
}
let header = ui.horizontal(|ui| {
ui.style_mut().spacing.button_padding = egui::vec2(20.0, 10.0);
ui.with_layout(Layout::top_down_justified(egui::Align::LEFT), |ui| {
// supply faint versions of the colors for disabled apps
let faint_bg = ui.style().visuals.faint_bg_color;
let selection_bg = ui.style().visuals.selection.bg_fill;
let fg = ui.style().visuals.selection.stroke.color;
let faint_selection_bg = selection_bg.lerp_to_gamma(faint_bg, 0.6);
let faint_fg = fg.lerp_to_gamma(faint_bg, 0.6);
let response = ui.add(self.button(
faint_selection_bg,
selection_bg,
faint_fg,
categories::value_to_name(self.metadata.map(|m| m.removal).unwrap_or_default()),
));
let id = ui.make_persistent_id(format!("{}_interact", self.package.id));
if ui
.interact(response.rect, id, Sense::click())
.double_clicked()
{
self.expand_triggered = true;
self.selected ^= true;
} else if ui.interact(response.rect, id, Sense::click()).clicked() {
self.selected ^= true;
}
});
});
state.show_body_indented(&header.response, ui, |ui| {
ui.add_space(4.0);
ui.label(
RichText::new(
self.metadata
.map(|m| m.description)
.unwrap_or("Description unavailable."),
)
.size(12.0),
);
ui.add_space(4.0);
});
}
fn button<'a>(
&self,
faint_bg: Color32,
selection_bg: Color32,
faint_fg: Color32,
right_text: &'a str,
) -> Button<'a> {
let mut job = LayoutJob::default();
let mut label = RichText::new(format!("{}\n", self.package.label)).size(12.0);
let mut package_id = RichText::new(&self.package.id).monospace().size(10.0);
let mut right_text = RichText::new(right_text);
if self.state != State::Enabled {
label = label.strikethrough().color(faint_fg);
package_id = package_id.strikethrough().color(faint_fg);
right_text = right_text.color(faint_fg);
}
label.append_to(
&mut job,
&Style::default(),
egui::FontSelection::Default,
Align::Min,
);
package_id.append_to(
&mut job,
&Style::default(),
egui::FontSelection::Default,
Align::Min,
);
let button = Button::selectable(self.selected, job);
if self.state != State::Enabled {
button.stroke(Stroke::new(1.0, selection_bg)).fill(faint_bg)
} else {
button
}
.right_text(right_text)
}
}

View File

@@ -2,6 +2,7 @@
use std::{
collections::{BTreeMap, BTreeSet},
fmt::Display,
io::BufReader,
sync::mpsc::{Receiver, Sender, channel},
thread::{sleep, spawn},
@@ -10,12 +11,17 @@ use std::{
use adb_client::{ADBDeviceExt, ADBUSBDevice};
use eframe::egui;
use egui::{
Align, Button, Color32, Label, Layout, RichText, Sense, Spinner, Stroke, Style, TextEdit,
TopBottomPanel, Visuals, text::LayoutJob,
};
use egui::{Align, CentralPanel, Label, Spinner, TextEdit, TopBottomPanel};
use egui_alignments::{center_horizontal, column};
use crate::{action::Action, adb_shell_text::ShellCommandText};
mod action;
mod action_bar;
mod adb_shell_text;
mod categories;
mod listview;
mod metadata;
mod shortcuts;
const WORKER_THREAD_POLL: Duration = Duration::from_secs(5);
const LABEL_EXTRACTOR: &[u8; 2124] = include_bytes!("./extractor.dex");
@@ -24,20 +30,18 @@ type FrontendPayload = PackageDiff;
struct App {
search_query: String,
uninstallable: bool,
reinstallable: bool,
entries: BTreeMap<String, Entry>,
entries: BTreeMap<String, listview::Entry>,
categories: u8,
package_diff_rx: Receiver<FrontendPayload>,
device_lost_rx: Receiver<()>,
action_tx: Sender<Action>,
// error_rx: Receiver<ShellRunError>,
action_error_rx: Receiver<ShellRunError>,
action_done_rx: Receiver<()>,
disable_mode: bool,
disable_mode: bool,
have_device: bool,
busy: bool,
action_result_rx: Receiver<Result<ActionResult, ShellRunError>>,
}
type PackageIdentifier = String;
@@ -50,15 +54,6 @@ pub struct Package {
label: String,
}
struct Entry {
package: Package,
expand_triggered: bool,
enabled: bool,
selected: bool,
metadata: Option<&'static Metadata>,
strictly_disabled: bool,
}
struct PackageDiff {
added: Vec<Package>,
removed: Vec<PackageIdentifier>,
@@ -75,15 +70,32 @@ impl PackageDiff {
}
}
#[derive(Debug)]
pub enum ShellRunError {
Timeout,
ParseError,
Unrecoverable,
UnsuccessfulOperation(PackageIdentifier),
UninstallFailed(PackageIdentifier),
BackupNotPossible(PackageIdentifier),
RevertFailed(PackageIdentifier),
DisableFailed(String),
DisableFailed(PackageIdentifier),
}
impl Display for ShellRunError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ShellRunError::Timeout => f.write_str("timed out in running shell command on device"),
ShellRunError::ParseError => {
f.write_str("failed to parse the output of shell command from the device")
}
ShellRunError::Unrecoverable => f.write_str("unrecoverable error"),
ShellRunError::UninstallFailed(id) => write!(f, "failed to uninstall package {id}"),
ShellRunError::BackupNotPossible(id) => {
write!(f, "failed to backup package {id} before uninstall")
}
ShellRunError::RevertFailed(id) => write!(f, "failed to revert package {id}"),
ShellRunError::DisableFailed(id) => write!(f, "failed to disable package {id}"),
}
}
}
#[derive(Debug)]
@@ -92,41 +104,10 @@ pub struct Metadata {
removal: u8,
}
mod categories {
pub const RECOMMENDED: u8 = 0b10000;
pub const ADVANCED: u8 = 0b01000;
pub const EXPERT: u8 = 0b00100;
pub const UNSAFE: u8 = 0b00010;
pub const UNIDENTIFIED: u8 = 0b00001;
pub const VALUES: [u8; 5] = [RECOMMENDED, ADVANCED, EXPERT, UNSAFE, UNIDENTIFIED];
pub const NAMES: [&str; 5] = [
"Recommended",
"Advanced",
"Expert",
"Unsafe",
"Unidentified",
];
pub fn value_to_name(value: u8) -> &'static str {
match value {
RECOMMENDED => "Recommended",
ADVANCED => "Advanced",
EXPERT => "Expert",
UNSAFE => "Unsafe",
_ => "Unidentified",
}
}
}
pub enum Action {
Uninstall(Package),
Revert(PackageIdentifier, bool),
Disable(PackageIdentifier),
}
fn main() -> eframe::Result {
env_logger::init(); // Log to stderr (if you run with `RUST_LOG=debug`).
env_logger::builder()
.filter_level(log::LevelFilter::Warn)
.init();
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default().with_inner_size([800.0, 300.0]),
..Default::default()
@@ -158,8 +139,6 @@ fn main() -> eframe::Result {
busy: false,
disable_mode: false,
have_device: false,
uninstallable: false,
reinstallable: false,
search_query: "".to_owned(),
device_lost_rx,
package_diff_rx,
@@ -167,80 +146,18 @@ fn main() -> eframe::Result {
action_tx,
categories: categories::RECOMMENDED,
action_done_rx,
action_result_rx,
action_error_rx: action_result_rx,
}))
}),
)
}
impl Action {
fn apply_on_device(self, device: &mut ADBUSBDevice) -> Result<ActionResult, ShellRunError> {
match self {
Action::Uninstall(pkg) => {
if pkg.path.is_empty() {
return Err(ShellRunError::BackupNotPossible(pkg.id));
}
let _copy_command_no_output = device.shell_command_text(&format!(
"cp {} /data/local/tmp/{}.apk",
pkg.path, pkg.id
))?;
let output =
device.shell_command_text(&format!("pm uninstall --user 0 -k {}", pkg.id))?;
if !output.contains("Success") {
return Err(ShellRunError::UnsuccessfulOperation(pkg.id));
}
Ok(ActionResult::Uninstalled(pkg.id))
}
Action::Revert(id, was_disabled) => {
if was_disabled {
let revert_command = format!("pm enable {id}");
let output = device.shell_command_text(&revert_command)?;
if !output.contains("new state: enabled") {
return Err(ShellRunError::UnsuccessfulOperation(id));
}
} else {
let revert_command = format!("pm install-existing {id}");
let output = device.shell_command_text(&revert_command)?;
if !output.contains("inaccessible or not found") {
return Ok(ActionResult::Reverted(id));
}
let revert_command = format!("pm install -r --user 0 /data/local/tmp/{id}.apk");
let output = device.shell_command_text(&revert_command)?;
if !output.contains("Success") {
return Err(ShellRunError::RevertFailed(id));
}
}
Ok(ActionResult::Reverted(id))
}
Action::Disable(id) => {
let disable_command = format!("pm disable-user {id}");
let output = device.shell_command_text(&disable_command)?;
if !output.contains("new state: disabled-user") {
return Err(ShellRunError::DisableFailed(id));
}
Ok(ActionResult::Disabled(id))
}
}
}
}
pub enum ActionResult {
Uninstalled(PackageIdentifier),
Disabled(PackageIdentifier),
Reverted(PackageIdentifier),
}
fn worker_thread(
package_diff_tx: Sender<PackageDiff>,
device_lost_tx: Sender<()>,
action_rx: Receiver<Action>,
action_done_tx: Sender<()>,
action_result_tx: Sender<Result<ActionResult, ShellRunError>>,
action_error_tx: Sender<ShellRunError>,
ctx: egui::Context,
) {
let mut maybe_device: Option<ADBUSBDevice> = None;
@@ -266,9 +183,11 @@ fn worker_thread(
while let Some(device) = maybe_device.as_mut() {
// do all the actions in bulk before the next render
while let Ok(action) = action_rx.try_recv() {
action_result_tx
.send(action.apply_on_device(device))
.expect("failed to send to ui");
if let Err(action_error) = action.apply_on_device(device) {
action_error_tx
.send(action_error)
.expect("failed to send to ui");
}
}
action_done_tx.send(()).expect("failed to send to ui");
@@ -296,6 +215,42 @@ fn worker_thread(
}
}
impl App {
fn reconcile(&mut self, package_diff: PackageDiff) {
for package in package_diff.added {
let maybe_meta = metadata::STORE.get(&package.id);
self.entries.insert(
package.id.clone(),
listview::Entry {
package,
metadata: maybe_meta,
expand_triggered: false,
state: listview::State::Enabled,
selected: false,
},
);
}
for package_id in package_diff.removed {
if let Some(entry) = self.entries.get_mut(&package_id) {
entry.state = listview::State::Uninstalled;
};
}
for package_id in package_diff.disabled {
if let Some(entry) = self.entries.get_mut(&package_id) {
entry.state = listview::State::Disabled;
};
}
for package_id in package_diff.re_enabled {
if let Some(entry) = self.entries.get_mut(&package_id) {
entry.state = listview::State::Enabled;
};
}
}
}
impl eframe::App for App {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.set_pixels_per_point(1.5);
@@ -304,62 +259,20 @@ impl eframe::App for App {
self.busy = false;
}
if let Ok(action_result) = self.action_result_rx.try_recv() {
match action_result {
Ok(completed) => match completed {
// TODO: add feedback that the action was successful
ActionResult::Disabled(_) => {}
ActionResult::Reverted(_) => {}
ActionResult::Uninstalled(_) => {}
},
Err(e) => eprintln!("{e:?}"),
}
if let Ok(action_error) = self.action_error_rx.try_recv() {
log::error!("{}", action_error.to_string());
}
if let Ok(package_diff) = self.package_diff_rx.try_recv() {
self.have_device = true;
for package in package_diff.added {
let maybe_meta = metadata::STORE.get(&package.id);
self.entries.insert(
package.id.clone(),
Entry {
strictly_disabled: false,
package,
metadata: maybe_meta,
expand_triggered: false,
enabled: true,
selected: false,
},
);
}
for package_id in package_diff.removed {
if let Some(entry) = self.entries.get_mut(&package_id) {
entry.enabled = false;
};
}
for package_id in package_diff.disabled {
if let Some(entry) = self.entries.get_mut(&package_id) {
entry.enabled = false;
entry.strictly_disabled = true;
};
}
for package_id in package_diff.re_enabled {
if let Some(entry) = self.entries.get_mut(&package_id) {
entry.enabled = true;
entry.strictly_disabled = false;
};
}
self.reconcile(package_diff);
}
if let Ok(()) = self.device_lost_rx.try_recv() {
self.have_device = false;
self.entries.clear();
println!("device lost");
log::warn!("device lost");
}
if !self.have_device {
@@ -374,115 +287,20 @@ impl eframe::App for App {
return;
};
TopBottomPanel::bottom("action_bar").show(ctx, |ui| {
ui.add_space(6.0);
TopBottomPanel::bottom("action_bar").show(ctx, |ui| self.action_bar(ui));
ui.style_mut().spacing.button_padding = [6.0, 6.0].into();
ui.horizontal_wrapped(|ui| {
for (category, bits) in categories::NAMES.into_iter().zip(categories::VALUES) {
let selected = self.categories & bits == bits;
if ui
.add(
Button::selectable(selected, RichText::new(category).size(12.0))
.corner_radius(10.0),
)
.clicked()
{
self.categories ^= bits;
};
}
});
let button = if self.disable_mode {
Button::new("disable")
} else {
Button::new("uninstall")
};
let mut selected: Vec<&Entry> = vec![];
for entry in self.entries.values().filter(|entry| entry.selected) {
selected.push(entry);
if entry.enabled {
self.uninstallable = true;
} else {
self.reinstallable = true;
}
}
ui.separator();
ui.horizontal(|ui| {
let button_size = [80.0, 30.0];
if self.busy {
ui.add_sized(button_size, Spinner::new());
} else if self.uninstallable == self.reinstallable {
ui.add_enabled_ui(false, |ui| {
ui.add_sized(button_size, button);
});
} else if self.uninstallable {
ui.add_enabled_ui(true, |ui| {
if !ui.add_sized(button_size, button).clicked() {
return;
}
if self.disable_mode {
for entry in selected.iter() {
self.action_tx
.send(Action::Disable(entry.package.id.clone()))
.expect("failed to send message to backend");
}
} else {
for entry in selected.iter() {
self.action_tx
.send(Action::Uninstall(entry.package.clone()))
.expect("failed to send message to backend");
}
}
self.busy = true;
});
} else if self.reinstallable {
ui.add_enabled_ui(true, |ui| {
if ui.add_sized(button_size, Button::new("revert")).clicked() {
for entry in selected.iter() {
self.action_tx
.send(Action::Revert(
entry.package.id.clone(),
entry.strictly_disabled,
))
.expect("failed to send message to backend");
}
self.busy = true;
}
});
}
ui.checkbox(&mut self.disable_mode, "disable mode")
.on_hover_text("prefer disabling apps to uninstalling");
ui.separator();
ui.label(format!("{} selected", selected.len()));
ui.separator();
});
ui.add_space(2.0);
});
let mut search = None;
egui::CentralPanel::default().show(ctx, |ui| {
CentralPanel::default().show(ctx, |ui| {
ui.take_available_width();
ui.horizontal(|ui| {
let search = ui.horizontal(|ui| {
ui.take_available_width();
search.replace(ui.add_sized(
ui.add_sized(
[ui.available_width(), 20.0],
TextEdit::singleline(&mut self.search_query).hint_text("Search"),
))
)
});
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
self.uninstallable = false;
self.reinstallable = false;
for (id, entry) in self.entries.iter_mut() {
let query_lower = self.search_query.to_lowercase();
let entry_removal = entry
@@ -493,78 +311,15 @@ impl eframe::App for App {
|| entry.package.label.to_lowercase().contains(&query_lower))
&& (entry_removal & self.categories == entry_removal)
{
render_entry(ui, entry);
entry.render(ui);
}
}
});
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
for (_id, entry) in self.entries.iter_mut() {
entry.selected = false;
}
}
if let Some(focusable_search) = search
&& ui.input(|i| {
i.key_pressed(egui::Key::S)
|| i.key_pressed(egui::Key::Slash)
|| (i.modifiers.ctrl && i.key_pressed(egui::Key::F))
})
{
focusable_search.request_focus();
}
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl)
&& let Some(path) = rfd::FileDialog::new()
.set_file_name("zilch.ini")
.save_file()
{
let mut enabled = vec![];
let mut uninstalled = vec![];
let mut disabled = vec![];
for (id, entry) in self.entries.iter() {
if entry.enabled {
enabled.push(id.clone());
continue;
}
if entry.strictly_disabled {
disabled.push(id.clone());
continue;
}
uninstalled.push(id.clone());
}
let contents = format!(
"disabled={}\nenabled={}\nuninstalled={}",
disabled.join(","),
enabled.join(","),
uninstalled.join(",")
);
if let Err(e) = std::fs::write(&path, contents) {
eprintln!("failed to write device state to {}: {e}", path.display());
};
}
self.handle_shortcuts(ui, search.response);
});
}
}
pub trait ShellCommandExt {
fn shell_command_text(&mut self, command: &str) -> Result<String, ShellRunError>;
}
impl ShellCommandExt for ADBUSBDevice {
fn shell_command_text(&mut self, command: &str) -> Result<String, ShellRunError> {
let mut buf = Vec::with_capacity(4096);
self.shell_command(&[command], &mut buf)
.map_err(|e| match e {
adb_client::RustADBError::UsbError(rusb::Error::Timeout) => ShellRunError::Timeout,
_ => ShellRunError::Unrecoverable,
})?;
String::from_utf8(buf).map_err(|_| ShellRunError::ParseError)
}
}
fn fetch_packages(
device: &mut ADBUSBDevice,
pkg_set: &BTreeSet<String>,
@@ -640,85 +395,3 @@ fn fetch_packages(
current_disabled_set,
))
}
fn create_button(entry: &'_ Entry, faint_bg: Color32, selection_bg: Color32) -> Button<'_> {
let mut job = LayoutJob::default();
let mut label = RichText::new(format!("{}\n", entry.package.label)).size(12.0);
let mut package_id = RichText::new(&entry.package.id).monospace().size(10.0);
if !entry.enabled {
label = label.strikethrough();
package_id = package_id.strikethrough();
}
label.append_to(
&mut job,
&Style::default(),
egui::FontSelection::Default,
Align::Min,
);
package_id.append_to(
&mut job,
&Style::default(),
egui::FontSelection::Default,
Align::Min,
);
let button = Button::selectable(entry.selected, job);
if !entry.enabled {
button.stroke(Stroke::new(1.0, selection_bg)).fill(faint_bg)
} else {
button
}
}
fn render_entry(ui: &mut egui::Ui, entry: &mut Entry) {
let id = ui.make_persistent_id(format!("{}_state", entry.package.id));
let mut state =
egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false);
if entry.expand_triggered {
state.toggle(ui);
entry.expand_triggered = false;
}
let header_res = ui.horizontal(|ui| {
ui.style_mut().spacing.button_padding = egui::vec2(20.0, 10.0);
ui.with_layout(Layout::top_down_justified(egui::Align::LEFT), |ui| {
let faint_bg = ui.style().visuals.faint_bg_color;
let selection_bg = ui.style().visuals.selection.bg_fill;
let faint_selection_bg = selection_bg.lerp_to_gamma(faint_bg, 0.6);
let response = ui.add(
create_button(entry, faint_selection_bg, selection_bg).right_text(
categories::value_to_name(
entry.metadata.map(|m| m.removal).unwrap_or_default(),
),
),
);
let id = ui.make_persistent_id(format!("{}_interact", entry.package.id));
if ui
.interact(response.rect, id, Sense::click())
.double_clicked()
{
entry.expand_triggered = true;
entry.selected ^= true;
} else if ui.interact(response.rect, id, Sense::click()).clicked() {
entry.selected ^= true;
}
});
});
state.show_body_indented(&header_res.response, ui, |ui| {
ui.add_space(4.0);
ui.label(
RichText::new(
entry
.metadata
.map(|m| m.description)
.unwrap_or("Description unavailable."),
)
.size(12.0),
);
ui.add_space(4.0);
});
}

43
src/shortcuts.rs Normal file
View File

@@ -0,0 +1,43 @@
impl crate::App {
pub fn handle_shortcuts(&mut self, ui: &mut egui::Ui, search_modal: egui::Response) {
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
for (_id, entry) in self.entries.iter_mut() {
entry.selected = false;
}
}
if ui.input(|i| {
i.key_pressed(egui::Key::S)
|| i.key_pressed(egui::Key::Slash)
|| (i.modifiers.ctrl && i.key_pressed(egui::Key::F))
}) {
search_modal.request_focus();
}
if ui.input(|i| i.key_pressed(egui::Key::S) && i.modifiers.ctrl)
&& let Some(path) = rfd::FileDialog::new()
.set_file_name("zilch.ini")
.save_file()
{
let mut enabled = vec![];
let mut uninstalled = vec![];
let mut disabled = vec![];
for (id, entry) in self.entries.iter() {
match entry.state {
crate::listview::State::Enabled => enabled.push(id.clone()),
crate::listview::State::Disabled => disabled.push(id.clone()),
crate::listview::State::Uninstalled => uninstalled.push(id.clone()),
}
}
let contents = format!(
"disabled={}\nenabled={}\nuninstalled={}",
disabled.join(","),
enabled.join(","),
uninstalled.join(",")
);
if let Err(e) = std::fs::write(&path, contents) {
eprintln!("failed to write device state to {}: {e}", path.display());
};
}
}
}