Add portable mode with removable media detection and scanning

This commit is contained in:
lashman
2026-02-28 00:16:52 +02:00
parent f2abfba753
commit 2108b0f3d8
8 changed files with 138 additions and 1 deletions

View File

@@ -124,5 +124,10 @@
<summary>Security notification threshold</summary>
<description>Minimum CVE severity for desktop notifications: critical, high, medium, or low.</description>
</key>
<key name="watch-removable-media" type="b">
<default>false</default>
<summary>Watch removable media</summary>
<description>Scan removable drives for AppImages when mounted.</description>
</key>
</schema>
</schemalist>

View File

@@ -1786,6 +1786,14 @@ impl Database {
Ok(())
}
pub fn set_portable(&self, id: i64, portable: bool, mount_point: Option<&str>) -> SqlResult<()> {
self.conn.execute(
"UPDATE appimages SET is_portable = ?2, mount_point = ?3 WHERE id = ?1",
params![id, portable as i32, mount_point],
)?;
Ok(())
}
// --- Launch statistics ---
pub fn get_top_launched(&self, limit: i32) -> SqlResult<Vec<(String, u64)>> {

View File

@@ -11,6 +11,7 @@ pub mod integrator;
pub mod launcher;
pub mod notification;
pub mod orphan;
pub mod portable;
pub mod report;
pub mod security;
pub mod updater;

71
src/core/portable.rs Normal file
View File

@@ -0,0 +1,71 @@
use std::path::{Path, PathBuf};
/// Information about a mounted removable device.
#[derive(Debug, Clone)]
pub struct MountInfo {
pub device: String,
pub mount_point: PathBuf,
pub fs_type: String,
}
/// Detect removable media mount points by parsing /proc/mounts.
/// Looks for user-accessible removable paths with common removable fs types.
pub fn detect_removable_mounts() -> Vec<MountInfo> {
let content = match std::fs::read_to_string("/proc/mounts") {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let removable_prefixes = ["/media/", "/run/media/", "/mnt/"];
let removable_fs = ["vfat", "exfat", "ntfs", "ntfs3", "fuseblk", "ext4", "ext3", "btrfs"];
content
.lines()
.filter_map(|line| {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 3 {
return None;
}
let device = parts[0];
let mount_point = parts[1];
let fs_type = parts[2];
// Check if mount point is in a removable location
let is_removable = removable_prefixes
.iter()
.any(|prefix| mount_point.starts_with(prefix));
if !is_removable {
return None;
}
// Check filesystem type
if !removable_fs.contains(&fs_type) {
return None;
}
Some(MountInfo {
device: device.to_string(),
mount_point: PathBuf::from(mount_point),
fs_type: fs_type.to_string(),
})
})
.collect()
}
/// Check if a given path is located on a removable device.
pub fn is_path_on_removable(path: &Path) -> bool {
let mounts = detect_removable_mounts();
mounts.iter().any(|m| path.starts_with(&m.mount_point))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_path_on_removable_with_no_mounts() {
// On a test system, /home paths shouldn't be on removable media
assert!(!is_path_on_removable(Path::new("/home/user/test.AppImage")));
}
}

View File

@@ -149,7 +149,12 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
}
}
// 3. Wayland issue (not Native or Unknown)
// 3. Portable / removable media
if record.is_portable {
return Some(widgets::status_badge("Portable", "info"));
}
// 4. Wayland issue (not Native or Unknown)
if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws);
if status != WaylandStatus::Unknown && status != WaylandStatus::Native {

View File

@@ -1800,6 +1800,22 @@ fn build_storage_tab(
.build();
size_group.add(&appimage_row);
if record.is_portable {
let location_label = if let Some(ref mp) = record.mount_point {
format!("Portable ({})", mp)
} else {
"Portable (removable media)".to_string()
};
let location_row = adw::ActionRow::builder()
.title("Location")
.subtitle(&location_label)
.build();
let badge = widgets::status_badge("Portable", "info");
badge.set_valign(gtk::Align::Center);
location_row.add_suffix(&badge);
size_group.add(&location_row);
}
if !fp.paths.is_empty() {
let categories = [
("Configuration", fp.config_size),

View File

@@ -265,6 +265,17 @@ fn build_behavior_page(settings: &gio::Settings) -> adw::PreferencesPage {
});
automation_group.add(&auto_integrate_row);
let removable_row = adw::SwitchRow::builder()
.title(&i18n("Watch removable media"))
.subtitle(&i18n("Scan USB drives and other removable media for AppImages"))
.active(settings.boolean("watch-removable-media"))
.build();
let settings_rem = settings.clone();
removable_row.connect_active_notify(move |row| {
settings_rem.set_boolean("watch-removable-media", row.is_active()).ok();
});
automation_group.add(&removable_row);
page.add(&automation_group);
// Backup group

View File

@@ -1058,6 +1058,17 @@ impl DriftwoodWindow {
dirs.push(system_dir.to_string());
}
// Include removable media mount points if enabled
if settings.boolean("watch-removable-media") {
for mount in crate::core::portable::detect_removable_mounts() {
let mp = mount.mount_point.to_string_lossy().to_string();
if !dirs.iter().any(|d| d == &mp) {
log::info!("Including removable mount: {} ({}, {})", mp, mount.device, mount.fs_type);
dirs.push(mp);
}
}
}
let toast_overlay = self.imp().toast_overlay.get().unwrap().clone();
let window_weak = self.downgrade();
@@ -1121,6 +1132,15 @@ impl DriftwoodWindow {
new_count += 1;
}
// Detect portable/removable media
if crate::core::portable::is_path_on_removable(&d.path) {
let mounts = crate::core::portable::detect_removable_mounts();
let mount_point = mounts.iter()
.find(|m| d.path.starts_with(&m.mount_point))
.map(|m| m.mount_point.to_string_lossy().to_string());
bg_db.set_portable(id, true, mount_point.as_deref()).ok();
}
// Mark for background analysis
bg_db.update_analysis_status(id, "pending").ok();
needs_analysis.push((id, d.path.clone(), d.appimage_type.clone()));