🔒 Repository is read-only – file editing is disabled.

luna/apps/luna-files/src/places.rs main

280 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
//! Miejsca (XDG) i urządzenia (montowania) do panelu bocznego.
//!
//! Lista montowań czytana jest z `/proc/self/mountinfo`, a rozmiary przez `statvfs`.
//! Montowanie/odmontowanie idzie przez `udisksctl` (UDisks2 po D-Busie, z polkit —
//! hasło pyta `luna-polkit`). Docelowo podmienimy to na bezpośrednie sygnały D-Bus;
//! interfejs funkcji zostaje ten sam.

use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

use libluna_i18n::tr;

#[derive(Debug, Clone)]
pub struct Place {
    pub label: String,
    pub path: PathBuf,
}

impl Place {
    fn new(label: &str, path: PathBuf) -> Self {
        Self {
            label: tr(label),
            path,
        }
    }
}

/// Katalogi użytkownika z `~/.config/user-dirs.dirs` (z wartościami domyślnymi).
pub fn user_dirs() -> BTreeMap<String, PathBuf> {
    let home = home();
    let mut map = BTreeMap::new();
    for key in [
        "DESKTOP",
        "DOCUMENTS",
        "DOWNLOAD",
        "PICTURES",
        "MUSIC",
        "VIDEOS",
        "TEMPLATES",
        "PUBLICSHARE",
    ] {
        map.insert(key.to_owned(), home.clone());
    }
    let config = env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| home.join(".config"))
        .join("user-dirs.dirs");
    if let Ok(text) = fs::read_to_string(config) {
        for line in text.lines() {
            let line = line.trim();
            if line.starts_with('#') {
                continue;
            }
            let Some((key, value)) = line.split_once('=') else {
                continue;
            };
            let Some(key) = key.trim().strip_prefix("XDG_") else {
                continue;
            };
            let Some(key) = key.strip_suffix("_DIR") else {
                continue;
            };
            let value = value.trim().trim_matches('"');
            let path = value.replace("$HOME", &home.display().to_string());
            if !path.is_empty() {
                map.insert(key.to_owned(), PathBuf::from(path));
            }
        }
    }
    map
}

pub fn home() -> PathBuf {
    env::var_os("HOME")
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("/"))
}

/// Miejsca w panelu bocznym (tylko istniejące katalogi).
pub fn places() -> Vec<Place> {
    let dirs = user_dirs();
    let mut out = vec![Place::new("Home", home())];
    for (key, label) in [
        ("DESKTOP", "Desktop"),
        ("DOCUMENTS", "Documents"),
        ("DOWNLOAD", "Downloads"),
        ("PICTURES", "Pictures"),
        ("MUSIC", "Music"),
        ("VIDEOS", "Videos"),
    ] {
        if let Some(path) = dirs.get(key) {
            if path.is_dir() && path != &home() {
                out.push(Place::new(label, path.clone()));
            }
        }
    }
    out.push(Place::new("File System", PathBuf::from("/")));
    out
}

/// Zamontowane urządzenie widoczne w panelu bocznym.
#[derive(Debug, Clone)]
pub struct Mount {
    pub dev: String,
    pub label: String,
    pub mount: PathBuf,
    pub fs: String,
    pub total: u64,
    pub free: u64,
}

/// Lista montowań „użytkowych” (pomija pseudosystemy i montowania systemowe).
pub fn mounts() -> Vec<Mount> {
    let Ok(text) = fs::read_to_string("/proc/self/mountinfo") else {
        return Vec::new();
    };
    let mut out = Vec::new();
    let mut seen = std::collections::HashSet::new();
    for line in text.lines() {
        let Some(m) = parse_mountinfo(line) else {
            continue;
        };
        if !user_visible_mount(&m.mount, &m.fs) {
            continue;
        }
        if !seen.insert(m.mount.clone()) {
            continue;
        }
        let (total, free) = statvfs(&m.mount).unwrap_or((0, 0));
        out.push(Mount {
            label: mount_label(&m.dev).unwrap_or_else(|| {
                m.mount
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_else(|| m.dev.clone())
            }),
            dev: m.dev,
            mount: m.mount,
            fs: m.fs,
            total,
            free,
        });
    }
    out.sort_by(|a, b| a.label.to_lowercase().cmp(&b.label.to_lowercase()));
    out
}

struct MountInfo {
    dev: String,
    mount: PathBuf,
    fs: String,
}

/// Parsuje jedną linię `/proc/self/mountinfo`:
/// `36 35 98:0 /root /mnt rw - ext4 /dev/sda1 rw`.
fn parse_mountinfo(line: &str) -> Option<MountInfo> {
    let parts: Vec<&str> = line.split_whitespace().collect();
    if parts.len() < 7 {
        return None;
    }
    let mount = PathBuf::from(unescape(parts[4]));
    let sep = parts.iter().position(|p| *p == "-")?;
    let fs = parts.get(sep + 1)?.to_string();
    let dev = parts.get(sep + 2).unwrap_or(&"").to_string();
    Some(MountInfo { dev, mount, fs })
}

fn user_visible_mount(mount: &Path, fs: &str) -> bool {
    const FS: [&str; 18] = [
        "ext2", "ext3", "ext4", "btrfs", "xfs", "f2fs", "vfat", "exfat", "ntfs", "ntfs3",
        "fuseblk", "iso9660", "udf", "hfsplus", "hfs", "msdos", "jfs", "reiserfs",
    ];
    if !FS.contains(&fs) {
        return false;
    }
    const SYSTEM: [&str; 9] = [
        "/",
        "/boot",
        "/boot/efi",
        "/home",
        "/tmp",
        "/var",
        "/usr",
        "/opt",
        "/srv",
    ];
    if SYSTEM.iter().any(|s| mount == Path::new(s)) {
        return false;
    }
    true
}

/// Odkodowuje oktalne sekwencje z mountinfo (`\040` = spacja).
fn unescape(text: &str) -> String {
    let bytes = text.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'\\' && i + 3 < bytes.len() {
            if let Ok(v) = u8::from_str_radix(&text[i + 1..i + 4], 8) {
                out.push(v);
                i += 4;
                continue;
            }
        }
        out.push(bytes[i]);
        i += 1;
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Etykieta wolumenu z `/dev/disk/by-label` (np. „PENDRIVE”).
pub fn mount_label(dev: &str) -> Option<String> {
    let dir = Path::new("/dev/disk/by-label");
    let target = fs::canonicalize(dev).ok()?;
    for entry in fs::read_dir(dir).ok()?.flatten() {
        if fs::canonicalize(entry.path()).ok().as_ref() == Some(&target) {
            return Some(entry.file_name().to_string_lossy().replace("\\x20", " "));
        }
    }
    None
}

/// `(rozmiar, wolne)` w bajtach.
pub fn statvfs(path: &Path) -> Option<(u64, u64)> {
    let st = rustix::fs::statvfs(path).ok()?;
    let unit = if st.f_frsize != 0 {
        st.f_frsize
    } else {
        st.f_bsize
    };
    Some((st.f_blocks * unit, st.f_bavail * unit))
}

/// Montuje urządzenie przez UDisks2 (polkit pyta o hasło, gdy trzeba).
pub fn mount_device(dev: &str) {
    spawn_udisks(&["mount", "-b", dev]);
}

/// Odmontowuje urządzenie przez UDisks2.
pub fn unmount_device(dev: &str) {
    spawn_udisks(&["unmount", "-b", dev]);
}

fn spawn_udisks(args: &[&str]) {
    if let Err(e) = Command::new("udisksctl").args(args).spawn() {
        eprintln!("luna-files: udisksctl {:?}: {e}", args);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mountinfo_line_is_parsed() {
        let line =
            "36 35 98:0 /mnt1 /run/media/patryk/PEN\\040DRIVE rw,noatime - vfat /dev/sdb1 rw";
        let m = parse_mountinfo(line).unwrap();
        assert_eq!(m.mount, PathBuf::from("/run/media/patryk/PEN DRIVE"));
        assert_eq!(m.fs, "vfat");
        assert_eq!(m.dev, "/dev/sdb1");
    }

    #[test]
    fn system_mounts_are_hidden() {
        assert!(user_visible_mount(Path::new("/run/media/x/USB"), "vfat"));
        assert!(!user_visible_mount(Path::new("/"), "ext4"));
        assert!(!user_visible_mount(Path::new("/home"), "ext4"));
        assert!(!user_visible_mount(Path::new("/mnt/x"), "proc"));
    }

    #[test]
    fn octal_sequences_are_unescaped() {
        assert_eq!(unescape("a\\040b\\134c"), "a b\\c");
    }
}