Check protected-file access before macOS startup guidance
Run a conservative, read-only preflight off the UI thread before offering Full Disk Access setup. Successful or inconclusive checks stay silent; only a protected-access denial offers guidance. Honor the native reminder opt-out locally and expose manual settings access in the macOS app menu.
This commit is contained in:
@@ -158,6 +158,12 @@ fn make_new_app_menu(ctx: &AppContext) -> Menu {
|
|||||||
|
|
||||||
let preferences_menu_items = vec![
|
let preferences_menu_items = vec![
|
||||||
updateable_custom_item_without_checkmark(CustomAction::ShowSettings, ctx),
|
updateable_custom_item_without_checkmark(CustomAction::ShowSettings, ctx),
|
||||||
|
MenuItem::Custom(CustomMenuItem::new(
|
||||||
|
"Full Disk Access Settings...",
|
||||||
|
crate::full_disk_access::open_settings,
|
||||||
|
no_updates,
|
||||||
|
None,
|
||||||
|
)),
|
||||||
MenuItem::Separator,
|
MenuItem::Separator,
|
||||||
updateable_custom_item_without_checkmark(CustomAction::ToggleKeybindingsPage, ctx),
|
updateable_custom_item_without_checkmark(CustomAction::ToggleKeybindingsPage, ctx),
|
||||||
updateable_custom_item_without_checkmark(CustomAction::ConfigureKeybindings, ctx),
|
updateable_custom_item_without_checkmark(CustomAction::ConfigureKeybindings, ctx),
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
//! Optional, device-local guidance for macOS Full Disk Access.
|
||||||
|
//!
|
||||||
|
//! macOS requires the user to grant this permission in System Settings. The
|
||||||
|
//! stored marker records dismissal of the guidance, never authorization.
|
||||||
|
//! A conservative filesystem preflight checks effective protected-file access;
|
||||||
|
//! macOS provides no supported API for querying the Full Disk Access toggle.
|
||||||
|
|
||||||
|
use galaxy_core::user_preferences::GetUserPreferences;
|
||||||
|
use galaxyui::modals::{AlertDialogWithCallbacks, ModalButton};
|
||||||
|
use galaxyui::AppContext;
|
||||||
|
|
||||||
|
const PROMPT_DISMISSED_KEY: &str = "FullDiskAccessPromptDismissed";
|
||||||
|
const SETTINGS_URL: &str =
|
||||||
|
"x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum ProtectedFileAccess {
|
||||||
|
Accessible,
|
||||||
|
Denied,
|
||||||
|
Unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks access after the macOS GUI has opened, unless reminders were disabled.
|
||||||
|
pub(crate) fn prompt_on_startup(ctx: &mut AppContext) {
|
||||||
|
if !cfg!(target_os = "macos") || prompt_dismissed(ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (sender, receiver) = futures::channel::oneshot::channel();
|
||||||
|
ctx.background_executor()
|
||||||
|
.spawn(async move {
|
||||||
|
let _ = sender.send(protected_file_access());
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
|
||||||
|
let weak_app = ctx.weak_app();
|
||||||
|
ctx.foreground_executor()
|
||||||
|
.spawn(async move {
|
||||||
|
if receiver.await.ok() != Some(ProtectedFileAccess::Denied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(mut app) = weak_app.upgrade() {
|
||||||
|
app.update(|ctx| {
|
||||||
|
if ctx.window_ids().next().is_some() && !prompt_dismissed(ctx) {
|
||||||
|
show_guidance(ctx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prompt_dismissed(ctx: &AppContext) -> bool {
|
||||||
|
match ctx
|
||||||
|
.private_user_preferences()
|
||||||
|
.read_value(PROMPT_DISMISSED_KEY)
|
||||||
|
{
|
||||||
|
Ok(Some(_)) => true,
|
||||||
|
Ok(None) => false,
|
||||||
|
Err(error) => {
|
||||||
|
log::warn!("Could not read Full Disk Access guidance preference: {error}");
|
||||||
|
true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is an effective-access heuristic, not a supported permission query.
|
||||||
|
/// Open only the current user's database; never inspect or parse its contents.
|
||||||
|
/// Missing files and ambiguous failures must not produce a permission prompt.
|
||||||
|
fn protected_file_access() -> ProtectedFileAccess {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::fs::OpenOptions;
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
|
||||||
|
let Some(home) = dirs::home_dir() else {
|
||||||
|
return ProtectedFileAccess::Unknown;
|
||||||
|
};
|
||||||
|
let path = home.join("Library/Application Support/com.apple.TCC/TCC.db");
|
||||||
|
match OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||||
|
.open(path)
|
||||||
|
{
|
||||||
|
Ok(file) => {
|
||||||
|
let is_regular_file = file.metadata().is_ok_and(|metadata| metadata.is_file());
|
||||||
|
drop(file);
|
||||||
|
if is_regular_file {
|
||||||
|
ProtectedFileAccess::Accessible
|
||||||
|
} else {
|
||||||
|
ProtectedFileAccess::Unknown
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// EACCES may reflect ordinary POSIX permissions or ACLs, so only
|
||||||
|
// EPERM counts as protected-access denial. Neither is proof of the
|
||||||
|
// setting's value; all other errors remain unknown.
|
||||||
|
Err(error) if error.raw_os_error() == Some(libc::EPERM) => ProtectedFileAccess::Denied,
|
||||||
|
Err(_) => ProtectedFileAccess::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
{
|
||||||
|
ProtectedFileAccess::Unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_guidance(ctx: &mut AppContext) {
|
||||||
|
ctx.show_native_platform_modal(AlertDialogWithCallbacks::for_app(
|
||||||
|
"Protected-file access is unavailable",
|
||||||
|
"Full Disk Access is optional. Enabling it gives Galaxy, including its agents and \
|
||||||
|
commands you run, broad access to protected files on this Mac.\n\n\
|
||||||
|
To enable it, open System Settings > Privacy & Security > Full Disk Access and \
|
||||||
|
turn on Galaxy. If Galaxy is missing, click + and select the installed Galaxy app. \
|
||||||
|
macOS may ask you to quit and reopen Galaxy.\n\n\
|
||||||
|
You can continue without this permission and reopen these settings from Galaxy's menu later.",
|
||||||
|
vec![
|
||||||
|
ModalButton::for_app("Open System Settings", open_settings),
|
||||||
|
ModalButton::for_app("Not Now", |_| {}),
|
||||||
|
],
|
||||||
|
remember_dismissal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the macOS settings page without claiming or changing authorization.
|
||||||
|
pub(crate) fn open_settings(ctx: &mut AppContext) {
|
||||||
|
if cfg!(target_os = "macos") {
|
||||||
|
ctx.open_url(SETTINGS_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remember_dismissal(ctx: &mut AppContext) {
|
||||||
|
if let Err(error) = ctx
|
||||||
|
.private_user_preferences()
|
||||||
|
.write_value(PROMPT_DISMISSED_KEY, true.to_string())
|
||||||
|
{
|
||||||
|
log::warn!("Could not save Full Disk Access guidance preference: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ mod experiments;
|
|||||||
mod external_secrets;
|
mod external_secrets;
|
||||||
#[cfg(target_family = "wasm")]
|
#[cfg(target_family = "wasm")]
|
||||||
mod font_fallback;
|
mod font_fallback;
|
||||||
|
mod full_disk_access;
|
||||||
pub mod galaxy_managed_paths_watcher;
|
pub mod galaxy_managed_paths_watcher;
|
||||||
pub use galaxy_managed_paths_watcher as warp_managed_paths_watcher;
|
pub use galaxy_managed_paths_watcher as warp_managed_paths_watcher;
|
||||||
mod global_resource_handles;
|
mod global_resource_handles;
|
||||||
@@ -2826,6 +2827,10 @@ fn launch(ctx: &mut galaxyui::AppContext, app_state: Option<AppState>, launch_mo
|
|||||||
timer.mark_interval_end("WINDOWS_CREATED");
|
timer.mark_interval_end("WINDOWS_CREATED");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if cfg!(target_os = "macos") && matches!(launch_mode, LaunchMode::App { .. }) {
|
||||||
|
full_disk_access::prompt_on_startup(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "local_fs")]
|
#[cfg(feature = "local_fs")]
|
||||||
if matches!(launch_mode, LaunchMode::App { .. })
|
if matches!(launch_mode, LaunchMode::App { .. })
|
||||||
&& PersistenceWriter::as_ref(ctx).sender().is_none()
|
&& PersistenceWriter::as_ref(ctx).sender().is_none()
|
||||||
|
|||||||
Reference in New Issue
Block a user