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:
305
src/ui/drop_dialog.rs
Normal file
305
src/ui/drop_dialog.rs
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user