Add Phase 5 enhancements: security, i18n, analysis, backup, notifications

- Database v8 migration: tags, pinned, avg_startup_ms columns
- Security scanning with CVE matching and batch scan
- Bundled library extraction and vulnerability reports
- Desktop notification system for security alerts
- Backup/restore system for AppImage configurations
- i18n framework with gettext support
- Runtime analysis and Wayland compatibility detection
- AppStream metadata and Flatpak-style build support
- File watcher module for live directory monitoring
- Preferences panel with GSettings integration
- CLI interface for headless operation
- Detail view: tabbed layout with ViewSwitcher in title bar,
  health score, sandbox controls, changelog links
- Library view: sort dropdown, context menu enhancements
- Dashboard: system status, disk usage, launch history
- Security report page with scan and export
- Packaging: meson build, PKGBUILD, metainfo
This commit is contained in:
lashman
2026-02-27 17:16:41 +02:00
parent a7ed3742fb
commit 423323d5a9
51 changed files with 10583 additions and 481 deletions

305
src/ui/drop_dialog.rs Normal file
View File

@@ -0,0 +1,305 @@
use adw::prelude::*;
use gtk::gio;
use std::path::PathBuf;
use std::rc::Rc;
use crate::core::analysis;
use crate::core::database::Database;
use crate::core::discovery;
use crate::i18n::{i18n, ni18n_f};
/// Registered file info returned by the fast registration phase.
struct RegisteredFile {
id: i64,
path: PathBuf,
appimage_type: discovery::AppImageType,
}
/// Show a dialog offering to add dropped AppImage files to the library.
///
/// `files` should already be validated as AppImages (magic bytes checked).
/// `toast_overlay` is used to show result toasts.
/// `on_complete` is called after files are registered to refresh the UI.
pub fn show_drop_dialog(
parent: &impl IsA<gtk::Widget>,
files: Vec<PathBuf>,
toast_overlay: &adw::ToastOverlay,
on_complete: impl Fn() + 'static,
) {
if files.is_empty() {
return;
}
let count = files.len();
// Build heading and body
let heading = if count == 1 {
let name = files[0]
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "AppImage".to_string());
crate::i18n::i18n_f("Add {name}?", &[("{name}", &name)])
} else {
ni18n_f(
"Add {} app?",
"Add {} apps?",
count as u32,
&[("{}", &count.to_string())],
)
};
let body = if count == 1 {
files[0].to_string_lossy().to_string()
} else {
files
.iter()
.filter_map(|f| f.file_name().map(|n| n.to_string_lossy().into_owned()))
.collect::<Vec<_>>()
.join("\n")
};
let dialog = adw::AlertDialog::builder()
.heading(&heading)
.body(&body)
.build();
dialog.add_response("cancel", &i18n("Cancel"));
dialog.add_response("add-only", &i18n("Just add"));
dialog.add_response("add-and-integrate", &i18n("Add to app menu"));
dialog.set_response_appearance("add-and-integrate", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("add-and-integrate"));
dialog.set_close_response("cancel");
let toast_ref = toast_overlay.clone();
let on_complete = Rc::new(on_complete);
dialog.connect_response(None, move |_dialog, response| {
if response == "cancel" {
return;
}
let integrate = response == "add-and-integrate";
let files = files.clone();
let toast_ref = toast_ref.clone();
let on_complete_ref = on_complete.clone();
glib::spawn_future_local(async move {
// Phase 1: Fast registration (copy + DB upsert only)
let result = gio::spawn_blocking(move || {
register_dropped_files(&files)
})
.await;
match result {
Ok(Ok(registered)) => {
let added = registered.len();
// Refresh UI immediately - apps appear with "Analyzing..." badge
on_complete_ref();
// Show toast
if added == 1 {
toast_ref.add_toast(adw::Toast::new(&i18n("Added to your apps")));
} else if added > 0 {
let msg = ni18n_f(
"Added {} app",
"Added {} apps",
added as u32,
&[("{}", &added.to_string())],
);
toast_ref.add_toast(adw::Toast::new(&msg));
}
// Phase 2: Background analysis for each file
let on_complete_bg = on_complete_ref.clone();
for reg in registered {
let on_complete_inner = on_complete_bg.clone();
glib::spawn_future_local(async move {
let _ = gio::spawn_blocking(move || {
analysis::run_background_analysis(
reg.id,
reg.path,
reg.appimage_type,
integrate,
);
})
.await;
// Refresh UI when each analysis completes
on_complete_inner();
});
}
}
Ok(Err(e)) => {
log::error!("Drop processing failed: {}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
}
Err(e) => {
log::error!("Drop task failed: {:?}", e);
toast_ref.add_toast(adw::Toast::new(&i18n("Failed to add app")));
}
}
});
});
dialog.present(Some(parent));
}
/// Fast registration of dropped files - copies to target dir and inserts into DB.
/// Returns a list of registered files for background analysis.
fn register_dropped_files(
files: &[PathBuf],
) -> Result<Vec<RegisteredFile>, String> {
let db = Database::open().map_err(|e| format!("Failed to open database: {}", e))?;
let settings = gio::Settings::new(crate::config::APP_ID);
let scan_dirs: Vec<String> = settings
.strv("scan-directories")
.iter()
.map(|s| s.to_string())
.collect();
// Target directory: first scan directory (default ~/Applications)
let target_dir = scan_dirs
.first()
.map(|d| discovery::expand_tilde(d))
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/tmp"))
.join("Applications")
});
// Ensure target directory exists
std::fs::create_dir_all(&target_dir)
.map_err(|e| format!("Failed to create {}: {}", target_dir.display(), e))?;
// Expand scan dirs for checking if file is already in a scan location
let expanded_scan_dirs: Vec<PathBuf> = scan_dirs
.iter()
.map(|d| discovery::expand_tilde(d))
.collect();
let mut registered = Vec::new();
for file in files {
// Determine if the file is already in a scan directory
let in_scan_dir = expanded_scan_dirs.iter().any(|scan_dir| {
file.parent()
.and_then(|p| p.canonicalize().ok())
.and_then(|parent| {
scan_dir.canonicalize().ok().map(|sd| parent == sd)
})
.unwrap_or(false)
});
let final_path = if in_scan_dir {
file.clone()
} else {
let filename = file
.file_name()
.ok_or_else(|| "No filename".to_string())?;
let dest = target_dir.join(filename);
// Don't overwrite existing files - generate a unique name
let dest = if dest.exists() && dest != *file {
let stem = dest
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("app");
let ext = dest
.extension()
.and_then(|e| e.to_str())
.unwrap_or("AppImage");
let mut counter = 1;
loop {
let candidate = target_dir.join(format!("{}-{}.{}", stem, counter, ext));
if !candidate.exists() {
break candidate;
}
counter += 1;
}
} else {
dest
};
std::fs::copy(file, &dest)
.map_err(|e| format!("Failed to copy {}: {}", file.display(), e))?;
// Make executable
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
if let Err(e) = std::fs::set_permissions(&dest, perms) {
log::warn!("Failed to set executable permissions on {}: {}", dest.display(), e);
}
}
dest
};
// Validate it's actually an AppImage
let appimage_type = match discovery::detect_appimage(&final_path) {
Some(t) => t,
None => {
log::warn!("Not a valid AppImage: {}", final_path.display());
continue;
}
};
let filename = final_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let metadata = std::fs::metadata(&final_path).ok();
let size_bytes = metadata.as_ref().map(|m| m.len() as i64).unwrap_or(0);
let modified = metadata
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.and_then(|dur| {
chrono::DateTime::from_timestamp(dur.as_secs() as i64, 0)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
});
let is_executable = {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata
.as_ref()
.map(|m| m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
{
true
}
};
// Register in database with pending analysis status
let id = db
.upsert_appimage(
&final_path.to_string_lossy(),
&filename,
Some(appimage_type.as_i32()),
size_bytes,
is_executable,
modified.as_deref(),
)
.map_err(|e| format!("Database error: {}", e))?;
if let Err(e) = db.update_analysis_status(id, "pending") {
log::warn!("Failed to set analysis status to 'pending' for id {}: {}", id, e);
}
registered.push(RegisteredFile {
id,
path: final_path,
appimage_type,
});
}
Ok(registered)
}