From 585320b36399769a0fd5b94b4979c386cf95940a Mon Sep 17 00:00:00 2001 From: lashman Date: Sat, 28 Feb 2026 00:07:49 +0200 Subject: [PATCH] Add first-run permission summary dialog before launch Shows what access the AppImage will have on first launch. Lists file, network, and display server access. Mentions firejail if available. Tracks prompted status in DB so the dialog only appears once per app. --- src/core/database.rs | 8 ++ src/ui/detail_view.rs | 181 ++++++++++++++++++++++-------------- src/ui/mod.rs | 1 + src/ui/permission_dialog.rs | 83 +++++++++++++++++ 4 files changed, 203 insertions(+), 70 deletions(-) create mode 100644 src/ui/permission_dialog.rs diff --git a/src/core/database.rs b/src/core/database.rs index c610c29..a25590a 100644 --- a/src/core/database.rs +++ b/src/core/database.rs @@ -1770,6 +1770,14 @@ impl Database { Ok(()) } + pub fn set_first_run_prompted(&self, id: i64, prompted: bool) -> SqlResult<()> { + self.conn.execute( + "UPDATE appimages SET first_run_prompted = ?2 WHERE id = ?1", + params![id, prompted as i32], + )?; + Ok(()) + } + // --- Launch statistics --- pub fn get_top_launched(&self, limit: i32) -> SqlResult> { diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index 726537a..b7f6ea1 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -101,79 +101,41 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> adw::Nav let launch_args_raw = record.launch_args.clone(); let db_launch = db.clone(); let toast_launch = toast_overlay.clone(); + let first_run_prompted = record.first_run_prompted; + let db_perm = db.clone(); launch_button.connect_clicked(move |btn| { - btn.set_sensitive(false); - let btn_ref = btn.clone(); - let path = path.clone(); - let app_name = app_name_launch.clone(); - let db_launch = db_launch.clone(); - let toast_ref = toast_launch.clone(); + // Check if first-run permission dialog should be shown + if !first_run_prompted { + let btn_ref = btn.clone(); + let path = path.clone(); + let app_name = app_name_launch.clone(); + let app_name_dialog = app_name.clone(); + let db_launch = db_launch.clone(); + let toast_ref = toast_launch.clone(); + let launch_args_raw = launch_args_raw.clone(); + let db_perm = db_perm.clone(); + crate::ui::permission_dialog::show_permission_dialog( + btn, + &app_name_dialog, + record_id, + &db_perm, + move || { + do_launch( + &btn_ref, record_id, &path, &app_name, + launcher::parse_launch_args(launch_args_raw.as_deref()), + &db_launch, &toast_ref, + ); + }, + ); + return; + } + let launch_args = launcher::parse_launch_args(launch_args_raw.as_deref()); - glib::spawn_future_local(async move { - let path_bg = path.clone(); - let result = gio::spawn_blocking(move || { - let appimage_path = std::path::Path::new(&path_bg); - launcher::launch_appimage( - &Database::open().expect("DB open"), - record_id, - appimage_path, - "gui_detail", - &launch_args, - &[], - ) - }).await; - - btn_ref.set_sensitive(true); - match result { - Ok(launcher::LaunchResult::Started { pid, method }) => { - log::info!("Launched: {} (PID: {}, method: {})", path, pid, method.as_str()); - - // App survived startup - do Wayland analysis after a delay - let db_wayland = db_launch.clone(); - let path_clone = path.clone(); - glib::spawn_future_local(async move { - glib::timeout_future(std::time::Duration::from_secs(3)).await; - let analysis_result = gio::spawn_blocking(move || { - wayland::analyze_running_process(pid) - }).await; - if let Ok(Ok(analysis)) = analysis_result { - let status_str = analysis.as_status_str(); - log::info!( - "Runtime Wayland: {} -> {} (pid={}, env: {:?})", - path_clone, analysis.status_label(), analysis.pid, analysis.env_vars, - ); - db_wayland.update_runtime_wayland_status(record_id, status_str).ok(); - } - }); - } - Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => { - log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr); - widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr); - if let Some(app) = gtk::gio::Application::default() { - notification::send_system_notification( - &app, - &format!("crash-{}", record_id), - &format!("{} crashed", app_name), - &stderr.chars().take(200).collect::(), - gtk::gio::NotificationPriority::Urgent, - ); - } - } - Ok(launcher::LaunchResult::Failed(msg)) => { - log::error!("Failed to launch: {}", msg); - let toast = adw::Toast::builder() - .title(&format!("Could not launch: {}", msg)) - .timeout(5) - .build(); - toast_ref.add_toast(toast); - } - Err(_) => { - log::error!("Launch task panicked"); - } - } - }); + do_launch( + btn, record_id, &path, &app_name_launch, + launch_args, &db_launch, &toast_launch, + ); }); - header.pack_end(&launch_button); // Check for Update button let update_button = gtk::Button::builder() @@ -2553,3 +2515,82 @@ fn show_uninstall_dialog( dialog.present(Some(toast_overlay)); } +fn do_launch( + btn: >k::Button, + record_id: i64, + path: &str, + app_name: &str, + launch_args: Vec, + db: &Rc, + toast_overlay: &adw::ToastOverlay, +) { + btn.set_sensitive(false); + let btn_ref = btn.clone(); + let path = path.to_string(); + let app_name = app_name.to_string(); + let db_launch = db.clone(); + let toast_ref = toast_overlay.clone(); + glib::spawn_future_local(async move { + let path_bg = path.clone(); + let result = gio::spawn_blocking(move || { + let appimage_path = std::path::Path::new(&path_bg); + launcher::launch_appimage( + &Database::open().expect("DB open"), + record_id, + appimage_path, + "gui_detail", + &launch_args, + &[], + ) + }).await; + + btn_ref.set_sensitive(true); + match result { + Ok(launcher::LaunchResult::Started { pid, method }) => { + log::info!("Launched: {} (PID: {}, method: {})", path, pid, method.as_str()); + + let db_wayland = db_launch.clone(); + let path_clone = path.clone(); + glib::spawn_future_local(async move { + glib::timeout_future(std::time::Duration::from_secs(3)).await; + let analysis_result = gio::spawn_blocking(move || { + wayland::analyze_running_process(pid) + }).await; + if let Ok(Ok(analysis)) = analysis_result { + let status_str = analysis.as_status_str(); + log::info!( + "Runtime Wayland: {} -> {} (pid={}, env: {:?})", + path_clone, analysis.status_label(), analysis.pid, analysis.env_vars, + ); + db_wayland.update_runtime_wayland_status(record_id, status_str).ok(); + } + }); + } + Ok(launcher::LaunchResult::Crashed { exit_code, stderr, method }) => { + log::error!("App crashed on launch (exit {}, method: {}): {}", exit_code.unwrap_or(-1), method.as_str(), stderr); + widgets::show_crash_dialog(&btn_ref, &app_name, exit_code, &stderr); + if let Some(app) = gtk::gio::Application::default() { + notification::send_system_notification( + &app, + &format!("crash-{}", record_id), + &format!("{} crashed", app_name), + &stderr.chars().take(200).collect::(), + gtk::gio::NotificationPriority::Urgent, + ); + } + } + Ok(launcher::LaunchResult::Failed(msg)) => { + log::error!("Failed to launch: {}", msg); + let toast = adw::Toast::builder() + .title(&format!("Could not launch: {}", msg)) + .timeout(5) + .build(); + toast_ref.add_toast(toast); + } + Err(_) => { + log::error!("Launch task panicked"); + } + } + }); +} + diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 28edc39..22bce26 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -8,6 +8,7 @@ pub mod duplicate_dialog; pub mod fuse_wizard; pub mod integration_dialog; pub mod library_view; +pub mod permission_dialog; pub mod preferences; pub mod security_report; pub mod update_dialog; diff --git a/src/ui/permission_dialog.rs b/src/ui/permission_dialog.rs new file mode 100644 index 0000000..0e00e57 --- /dev/null +++ b/src/ui/permission_dialog.rs @@ -0,0 +1,83 @@ +use adw::prelude::*; +use std::rc::Rc; + +use crate::core::database::Database; +use crate::core::launcher; +use crate::i18n::i18n; + +/// Show a first-run permission summary before launching an AppImage. +/// Returns a dialog that the caller should present. The `on_proceed` callback +/// is called if the user chooses to continue. +pub fn show_permission_dialog( + parent: &impl IsA, + app_name: &str, + record_id: i64, + db: &Rc, + on_proceed: impl Fn() + 'static, +) { + let dialog = adw::AlertDialog::builder() + .heading(&format!("Launch {}?", app_name)) + .body(&i18n( + "This AppImage will run with your full user permissions. \ + It can access your files, network, and desktop.", + )) + .build(); + + let extra = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(12) + .build(); + + let access_label = gtk::Label::builder() + .label(&i18n("This app will have access to:")) + .xalign(0.0) + .build(); + access_label.add_css_class("heading"); + extra.append(&access_label); + + let items = [ + "Your home directory and files", + "Network and internet access", + "Display server (Wayland/X11)", + "System D-Bus and services", + ]; + for item in &items { + let label = gtk::Label::builder() + .label(&format!(" - {}", i18n(item))) + .xalign(0.0) + .build(); + extra.append(&label); + } + + // Show firejail option if available + if launcher::has_firejail() { + let sandbox_note = gtk::Label::builder() + .label(&i18n( + "Firejail is available on your system. You can configure sandboxing in the app's system tab.", + )) + .wrap(true) + .xalign(0.0) + .margin_top(8) + .build(); + sandbox_note.add_css_class("dim-label"); + extra.append(&sandbox_note); + } + + dialog.set_extra_child(Some(&extra)); + + dialog.add_response("cancel", &i18n("Cancel")); + dialog.add_response("proceed", &i18n("Launch")); + dialog.set_response_appearance("proceed", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("proceed")); + dialog.set_close_response("cancel"); + + let db_ref = db.clone(); + dialog.connect_response(Some("proceed"), move |_dlg, _response| { + // Mark as prompted so we don't show again + db_ref.set_first_run_prompted(record_id, true).ok(); + on_proceed(); + }); + + dialog.present(Some(parent)); +}