diff --git a/data/app.driftwood.Driftwood.gschema.xml b/data/app.driftwood.Driftwood.gschema.xml
index 9872109..379a2b4 100644
--- a/data/app.driftwood.Driftwood.gschema.xml
+++ b/data/app.driftwood.Driftwood.gschema.xml
@@ -124,5 +124,10 @@
Security notification threshold
Minimum CVE severity for desktop notifications: critical, high, medium, or low.
+
+ false
+ Watch removable media
+ Scan removable drives for AppImages when mounted.
+
diff --git a/src/core/database.rs b/src/core/database.rs
index 7d17d9c..73d2e17 100644
--- a/src/core/database.rs
+++ b/src/core/database.rs
@@ -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> {
diff --git a/src/core/mod.rs b/src/core/mod.rs
index 10e4554..56e97b3 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -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;
diff --git a/src/core/portable.rs b/src/core/portable.rs
new file mode 100644
index 0000000..bbad943
--- /dev/null
+++ b/src/core/portable.rs
@@ -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 {
+ 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")));
+ }
+}
diff --git a/src/ui/app_card.rs b/src/ui/app_card.rs
index c88707e..71fdced 100644
--- a/src/ui/app_card.rs
+++ b/src/ui/app_card.rs
@@ -149,7 +149,12 @@ pub fn build_priority_badge(record: &AppImageRecord) -> Option {
}
}
- // 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 {
diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs
index 6602aeb..05b7ab2 100644
--- a/src/ui/detail_view.rs
+++ b/src/ui/detail_view.rs
@@ -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),
diff --git a/src/ui/preferences.rs b/src/ui/preferences.rs
index 64babc5..27fa269 100644
--- a/src/ui/preferences.rs
+++ b/src/ui/preferences.rs
@@ -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
diff --git a/src/window.rs b/src/window.rs
index f5ffe4b..f348270 100644
--- a/src/window.rs
+++ b/src/window.rs
@@ -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()));