Add first-run permission summary dialog before launch

This commit is contained in:
2026-02-28 00:07:49 +02:00
parent a515ee6b4f
commit 45e1c57842
4 changed files with 203 additions and 70 deletions

View File

@@ -1765,6 +1765,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<Vec<(String, u64)>> {

View File

@@ -101,79 +101,41 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> 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::<String>(),
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: &gtk::Button,
record_id: i64,
path: &str,
app_name: &str,
launch_args: Vec<String>,
db: &Rc<Database>,
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::<String>(),
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");
}
}
});
}

View File

@@ -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;

View File

@@ -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<gtk::Widget>,
app_name: &str,
record_id: i64,
db: &Rc<Database>,
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));
}