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.
This commit is contained in:
@@ -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<Vec<(String, u64)>> {
|
||||
|
||||
@@ -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: >k::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");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
83
src/ui/permission_dialog.rs
Normal file
83
src/ui/permission_dialog.rs
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user