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)); +}