From cc4aa49e42cf7a7b0948f6b2346fab2cb954c426 Mon Sep 17 00:00:00 2001 From: Alex Shabalin <110031243+alex-sparus@users.noreply.github.com> Date: Thu, 20 Aug 2026 16:59:49 +0200 Subject: [PATCH] feat(ui): surface helper state and actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: none of the helper machinery is visible or drivable — no way to grant or disable, no sign that macOS is waiting in Login Items or that an upgrade is waiting out a running activation — and the repair banner evaluates once per launch, so it keeps warning about a helper the convergence loop fixed seconds later. Solution: orpc endpoints for status, grant, and disable; one permission row produced whole from each reconciliation report, with replace_row letting the convergence loop publish updates without running a second reconciliation; the repair banner follows live helper state instead of the launch snapshot. The UI never re-words or re-classifies a report — rows render the backend sentence verbatim, and the TS test fixtures mirror the backend vocabulary exactly. --- .../src-tauri/src/commands/permissions.rs | 3 +- apps/native/src-tauri/src/orpc/darwin.rs | 65 +++- apps/native/src-tauri/src/orpc/permissions.rs | 4 +- .../src-tauri/src/shared_types/system.rs | 9 +- .../src-tauri/src/state/permissions_state.rs | 34 +- .../src-tauri/src/system/permissions.rs | 347 ++++++----------- .../permissions/permissions-panel.test.tsx | 348 ++++++++++++++++++ .../widget/permissions/permissions-panel.tsx | 229 +++++++++--- .../src/components/widget/repair/lib.test.ts | 109 +++++- .../src/components/widget/repair/lib.ts | 60 ++- .../components/widget/repair/repair.test.tsx | 170 +++++++++ .../src/components/widget/repair/repair.tsx | 139 +++++-- apps/native/src/components/widget/widget.tsx | 10 +- apps/native/src/ipc/orpc-bindings.ts | 44 ++- apps/native/src/ipc/types.ts | 30 +- apps/native/src/lib/permissions.ts | 7 + apps/native/src/utils/test-fixtures.ts | 28 ++ 17 files changed, 1291 insertions(+), 345 deletions(-) create mode 100644 apps/native/src/components/widget/permissions/permissions-panel.test.tsx create mode 100644 apps/native/src/components/widget/repair/repair.test.tsx create mode 100644 apps/native/src/lib/permissions.ts diff --git a/apps/native/src-tauri/src/commands/permissions.rs b/apps/native/src-tauri/src/commands/permissions.rs index dde4bbece..309e4919a 100644 --- a/apps/native/src-tauri/src/commands/permissions.rs +++ b/apps/native/src-tauri/src/commands/permissions.rs @@ -26,8 +26,9 @@ pub async fn refresh_permissions(app: AppHandle) -> Result<(), String> { /// For manual permissions (full-disk), this opens System Settings. #[tauri::command] pub async fn permissions_request( + app: AppHandle, permission_id: String, ) -> Result { - permissions::request_permission(&permission_id) + permissions::request_permission(&app, &permission_id) .map_err(|e| capture_err("permissions_request", e)) } diff --git a/apps/native/src-tauri/src/orpc/darwin.rs b/apps/native/src-tauri/src/orpc/darwin.rs index a7adaa369..6e02e110c 100644 --- a/apps/native/src-tauri/src/orpc/darwin.rs +++ b/apps/native/src-tauri/src/orpc/darwin.rs @@ -3,14 +3,14 @@ use super::{OrpcCtx, helpers::internal_err}; use crate::commands::{apply, evolve, rollback}; use crate::privileged_helper::{ - protocol::{HelperServiceStatus, SyncAgentLaunchConfig}, - service, + protocol::SyncAgentLaunchConfig, sync_agent::{self, SyncAgentStatus}, }; use crate::shared_types::{ AppManagementCheckResult, BuildCheckResult, EtcClobberCheckResult, EvolveCancelResult, OkResult, RebuildStatus, RollbackResult, }; +use crate::system::helper_permission; use orpc::*; use serde::{Deserialize, Serialize}; use specta::Type; @@ -68,6 +68,28 @@ struct InstallSyncAgentInput { config: Option, } +/// What one reconciliation run found, for a client that only has to display it: +/// whether the helper is installed and answering at this build, and the sentence +/// that says what else is true. +#[derive(Debug, Deserialize, Serialize, Type)] +#[serde(rename_all = "camelCase")] +struct HelperReport { + at_this_build: bool, + detail: String, +} + +impl HelperReport { + fn of(report: &crate::privileged_helper::reconcile::Reconciled) -> Self { + Self { + at_this_build: matches!( + report, + crate::privileged_helper::reconcile::Reconciled::AtThisBuild + ), + detail: helper_permission::describe(report), + } + } +} + #[derive(Debug, Deserialize, Serialize, Type)] #[serde(rename_all = "camelCase")] struct AdoptManualChangesResult { @@ -193,16 +215,29 @@ async fn rebuild_status(ctx: OrpcCtx, _input: ()) -> Result Result { - Ok(service::status()) +/// One run of the reconciliation function, reported. It takes no decision of its +/// own and opens nothing — only [`helper_grant`] opens Login Items. The one thing +/// it may store is the adoption: a registration that already exists is recorded as +/// the user's earlier opt-in before anything is mutated under it. +/// +/// The same single run a status refresh makes, and no more than that: a refresh +/// also starts the convergence loop, and this does not. +async fn helper_status(ctx: OrpcCtx, _input: ()) -> Result { + Ok(HelperReport::of(&helper_permission::observe(&ctx.app))) } -async fn helper_register(_ctx: OrpcCtx, _input: ()) -> Result { - service::register().map_err(|error| internal_err("darwin.helperRegister", error)) +/// The explicit Grant action. Grant is the only action that may open Login +/// Items; `permissions.request("privileged-helper")` is the same action reached +/// from the permission row. +async fn helper_grant(ctx: OrpcCtx, _input: ()) -> Result { + Ok(HelperReport::of(&helper_permission::grant(&ctx.app))) } -async fn helper_unregister(_ctx: OrpcCtx, _input: ()) -> Result { - service::unregister().map_err(|error| internal_err("darwin.helperUnregister", error)) +/// The explicit Disable action: unregister the helper (deferring while an +/// activation runs) and +/// register nothing. No later automatic run overrides it. +async fn helper_disable(ctx: OrpcCtx, _input: ()) -> Result { + Ok(HelperReport::of(&helper_permission::disable(&ctx.app))) } async fn sync_agent_status(_ctx: OrpcCtx, _input: ()) -> Result { @@ -278,14 +313,14 @@ pub fn routes() -> Router { .output(orpc_specta::specta::()) .handler(rebuild_status), "helperStatus" => os::() - .output(orpc_specta::specta::()) + .output(orpc_specta::specta::()) .handler(helper_status), - "helperRegister" => os::() - .output(orpc_specta::specta::()) - .handler(helper_register), - "helperUnregister" => os::() - .output(orpc_specta::specta::()) - .handler(helper_unregister), + "helperGrant" => os::() + .output(orpc_specta::specta::()) + .handler(helper_grant), + "helperDisable" => os::() + .output(orpc_specta::specta::()) + .handler(helper_disable), "syncAgentStatus" => os::() .output(orpc_specta::specta::()) .handler(sync_agent_status), diff --git a/apps/native/src-tauri/src/orpc/permissions.rs b/apps/native/src-tauri/src/orpc/permissions.rs index c486cc17a..bd81720ff 100644 --- a/apps/native/src-tauri/src/orpc/permissions.rs +++ b/apps/native/src-tauri/src/orpc/permissions.rs @@ -25,8 +25,8 @@ async fn refresh(ctx: OrpcCtx, _input: ()) -> Result<(), ORPCError> { .map_err(|e| internal_err("permissions.refresh", e)) } -async fn request(_ctx: OrpcCtx, input: RequestInput) -> Result { - cmd::permissions_request(input.permission_id) +async fn request(ctx: OrpcCtx, input: RequestInput) -> Result { + cmd::permissions_request(ctx.app, input.permission_id) .await .map_err(|e| internal_err("permissions.request", e)) } diff --git a/apps/native/src-tauri/src/shared_types/system.rs b/apps/native/src-tauri/src/shared_types/system.rs index f3c61ecbb..051a2747e 100644 --- a/apps/native/src-tauri/src/shared_types/system.rs +++ b/apps/native/src-tauri/src/shared_types/system.rs @@ -69,7 +69,14 @@ pub struct Permission { pub description: String, /// Whether onboarding requires this permission. pub required: bool, - /// Whether the app can trigger the system prompt directly. + /// Whether the app can trigger the system prompt directly. False means the + /// row's action can only deep-link into System Settings and wait for the + /// user, which is what the UI renders it as. + /// + /// Fixed per row for the TCC permissions, but not a capability in general: + /// the unattended sync helper reports it per observation, and it is false + /// only while macOS holds the registration pending approval in Login Items. + /// Read it as "is System Settings where the user finishes this, right now". pub can_request_programmatically: bool, /// Current permission status. pub status: PermissionStatus, diff --git a/apps/native/src-tauri/src/state/permissions_state.rs b/apps/native/src-tauri/src/state/permissions_state.rs index b554323dc..40a94aa35 100644 --- a/apps/native/src-tauri/src/state/permissions_state.rs +++ b/apps/native/src-tauri/src/state/permissions_state.rs @@ -8,7 +8,7 @@ use tauri::{AppHandle, Manager, Runtime}; use crate::observable::Observable; -use crate::shared_types::PermissionsState; +use crate::shared_types::{Permission, PermissionsState}; use crate::system::permissions; pub const PERMISSIONS_CHANGED_EVENT: &str = "permissions_changed"; @@ -28,8 +28,38 @@ pub fn get(app: &AppHandle) -> Option { /// Probe all permissions and record the result; the cell write emits /// `permissions_changed`. pub fn refresh(app: &AppHandle) -> PermissionsState { - let state = permissions::check_all_permissions(); + let state = permissions::check_all_permissions(app); let observable = app.state::>>(); *observable.write_sync() = Some(state.clone()); state } + +/// Replace one permission's row in the last-known state and re-emit. +/// +/// The convergence loop cannot use [`refresh`] to publish: that re-probes every +/// permission, and its helper row runs a *second* reconciliation of its own. So +/// the loop reconciles once, turns that report into a row, and drops it in here. +/// +/// Does nothing when nothing has been probed yet — startup reconciles before any +/// full probe has run, and inventing the other five rows here would be worse than +/// waiting for the panel's own refresh. +pub fn replace_row(app: &AppHandle, row: Permission) { + // A build with the permission skip on reports every row Granted, and the + // onboarding gate depends on that fiction. A real helper row dropped in here + // would flip `all_required_granted` back to false and close the gate the + // flag exists to open. + if permissions::skip_enabled() { + return; + } + let observable = app.state::>>(); + let mut cell = observable.write_sync(); + let Some(state) = cell.as_mut() else { return }; + let Some(slot) = state.permissions.iter_mut().find(|p| p.id == row.id) else { + return; + }; + *slot = row; + state.all_required_granted = state + .permissions + .iter() + .all(|p| !p.required || p.status == crate::shared_types::PermissionStatus::Granted); +} diff --git a/apps/native/src-tauri/src/system/permissions.rs b/apps/native/src-tauri/src/system/permissions.rs index 8c0cef7ab..896ba0ded 100644 --- a/apps/native/src-tauri/src/system/permissions.rs +++ b/apps/native/src-tauri/src/system/permissions.rs @@ -10,12 +10,15 @@ //! - App Management - recommended so activation can update managed apps //! - Administrator privileges (sudo access) +use crate::privileged_helper::reconcile::Reconciled; pub(crate) use crate::shared_types::{Permission, PermissionStatus, PermissionsState}; +use crate::system::helper_permission; use anyhow::Result; use log::{debug, info, warn}; use std::fs; use std::path::PathBuf; use std::process::Command; +use tauri::{AppHandle, Runtime}; impl Default for PermissionsState { fn default() -> Self { @@ -82,6 +85,9 @@ fn get_default_permissions() -> Vec { privileged_helper_permission( default_status, "Enable this once per device to allow nixmac to activate already-built system generations unattended.", + // No report yet, so nothing says macOS is waiting on the user. Every + // path that has one replaces this row whole (`helper_row`). + true, ), ] } @@ -106,6 +112,11 @@ fn vite_skip_permissions_enabled() -> bool { false } +/// Whether this build reports every permission granted without probing. +pub(crate) fn skip_enabled() -> bool { + skip_permissions_enabled() +} + fn skip_permissions_enabled() -> bool { vite_skip_permissions_enabled() || e2e_skip_permissions_enabled() } @@ -140,7 +151,11 @@ fn granted_folder_permission(id: &str, name: &str, description: &str) -> Permiss } } -fn privileged_helper_permission(status: PermissionStatus, instructions: &str) -> Permission { +fn privileged_helper_permission( + status: PermissionStatus, + instructions: &str, + can_request_programmatically: bool, +) -> Permission { Permission { id: "privileged-helper".to_string(), name: "Unattended Sync Helper".to_string(), @@ -148,7 +163,7 @@ fn privileged_helper_permission(status: PermissionStatus, instructions: &str) -> "Required for unattended device sync to activate builds without a password prompt" .to_string(), required: true, - can_request_programmatically: true, + can_request_programmatically, status, instructions: Some(instructions.to_string()), } @@ -374,8 +389,12 @@ fn check_admin_privileges() -> PermissionStatus { } } -/// Check all permissions and return the current state -pub fn check_all_permissions() -> PermissionsState { +/// Check all permissions and return the current state. +/// +/// The helper row is one run of the reconciliation function, which is also how +/// a routine refresh converges the installed helper on this build. It must not +/// run on the main thread — see [`helper_permission::observe`]. +pub fn check_all_permissions(app: &AppHandle) -> PermissionsState { // Debug/test environments may not have TCC permissions granted and cannot // obtain them programmatically, so the skip flags satisfy the whole gate. if skip_permissions_enabled() { @@ -388,43 +407,43 @@ pub fn check_all_permissions() -> PermissionsState { // Fill in each permission status and update all_required_granted on the fly for perm in &mut permissions { - perm.status = match perm.id.as_str() { - "desktop" => check_desktop_access(), - "documents" => check_documents_access(), - "admin" => check_admin_privileges(), - "full-disk" => { - let (status, detail) = check_full_disk_access(); - // Keep the default "how to grant it" text unless the probe has - // something more accurate to say. - if let Some(detail) = detail { - perm.instructions = Some(detail); - } - // A probe that could not decide must not keep the gate shut — - // the same reasoning that makes app-management Recommended - // rather than Required (see `app_management_permission`), except - // discovered at runtime. Requiring access we cannot verify shows - // a permissions banner to users who may well have granted it. - // Only this row relaxes: every other `Unknown` still blocks. - if status == PermissionStatus::Unknown { - perm.required = false; + // The helper row is the one row a report produces whole — its status, the + // sentence that tells the user what is true, and whether nixmac can still + // get anywhere on its own — so it is replaced rather than patched field by + // field, and `helper_row` stays the only place a report becomes a row. + if perm.id == "privileged-helper" { + *perm = helper_row(&check_privileged_helper(app)); + } else { + perm.status = match perm.id.as_str() { + "desktop" => check_desktop_access(), + "documents" => check_documents_access(), + "admin" => check_admin_privileges(), + "full-disk" => { + let (status, detail) = check_full_disk_access(); + // Keep the default "how to grant it" text unless the probe + // has something more accurate to say. + if let Some(detail) = detail { + perm.instructions = Some(detail); + } + // A probe that could not decide must not keep the gate shut + // — the same reasoning that makes app-management Recommended + // rather than Required (see `app_management_permission`), + // except discovered at runtime. Requiring access we cannot + // verify shows a permissions banner to users who may well + // have granted it. Only this row relaxes: every other + // `Unknown` still blocks. + if status == PermissionStatus::Unknown { + perm.required = false; + } + status } - status - } - "app-management" => check_app_management(), - "privileged-helper" => { - let (status, detail) = check_privileged_helper(); - // Keep the default instructions when healthy; surface what is - // actually wrong otherwise. - if let Some(detail) = detail { - perm.instructions = Some(detail); + "app-management" => check_app_management(), + other => { + debug_assert!(false, "no probe for permission id {other:?}"); + PermissionStatus::Unknown } - status - } - other => { - debug_assert!(false, "no probe for permission id {other:?}"); - PermissionStatus::Unknown - } - }; + }; + } // If this permission is required and not granted, mark all_required_granted as false if perm.required && perm.status != PermissionStatus::Granted { @@ -456,7 +475,10 @@ pub fn check_all_permissions() -> PermissionsState { /// Request a specific permission /// For programmatic permissions (desktop, documents), this triggers the OS prompt /// For manual permissions (FDA, admin), this returns instructions -pub fn request_permission(permission_id: &str) -> Result { +pub fn request_permission( + app: &AppHandle, + permission_id: &str, +) -> Result { info!("Requesting permission: {}", permission_id); match permission_id { @@ -595,196 +617,44 @@ pub fn request_permission(permission_id: &str) -> Result { Ok(app_management_permission(check_app_management())) } - "privileged-helper" => { - // SMAppService registration requires the helper binary and its - // LaunchDaemon plist to be embedded inside the .app bundle - // (Contents/MacOS/nixmac-helper and - // Contents/Library/LaunchDaemons/com.darkmatter.nixmac.helper.plist). - // Those assets are only staged by `bun run desktop:build[:local]` - // (externalBin in package.json). Under `tauri dev` the app runs as - // a bare binary from target/debug with no bundle, so - // registerAndReturnError: fails with an opaque "Operation not - // permitted". Detect that up front and surface a clear Pending - // state instead of propagating the OS error to the UI. - let install = crate::system::install_location::check_install_location(); - - const APPROVE_INSTRUCTIONS: &str = "Approve nixmac in System Settings → General → Login Items & Extensions if macOS asks for background item approval."; - - let (status, instructions) = if install.bundle_path.is_none() { - warn!( - "privileged helper registration skipped: nixmac is not running from a .app bundle" - ); - ( - PermissionStatus::Pending, - "Build a signed .app with `bun run desktop:build:local`, drag it into /Applications, and launch it from there to install the unattended sync helper. It cannot be installed from a dev build." - .to_string(), - ) - } else if cfg!(debug_assertions) && !install.in_applications_dir { - // Debug builds run out of the build tree, never /Applications. - // Registering from there would pin the LaunchDaemon to that - // bundle path, so only connect to a helper that an - // /Applications install already registered. Release builds - // keep the normal register flow regardless of location. - warn!( - "privileged helper registration skipped: nixmac is running outside /Applications" - ); - let (status, detail) = probe_installed_helper(); - ( - status, - detail.unwrap_or_else(|| APPROVE_INSTRUCTIONS.to_string()), - ) - } else { - match crate::privileged_helper::service::register() { - // Registration alone is not a working helper: wait briefly - // for the freshly launched daemon to answer a status - // round-trip before reporting Granted. - Ok(status) if status.authorized => match await_helper_ready() { - Ok(()) => (PermissionStatus::Granted, APPROVE_INSTRUCTIONS.to_string()), - Err(error) => ( - PermissionStatus::Pending, - format!( - "The helper is registered but did not answer a status probe: {error:#}." - ), - ), - }, - Ok(_) => { - crate::privileged_helper::service::open_login_items_settings(); - (PermissionStatus::Pending, APPROVE_INSTRUCTIONS.to_string()) - } - Err(error) => { - warn!("privileged helper registration failed: {error:#}"); - crate::privileged_helper::service::open_login_items_settings(); - (PermissionStatus::Pending, APPROVE_INSTRUCTIONS.to_string()) - } - } - }; - Ok(privileged_helper_permission(status, &instructions)) - } + // Grant: record the decision, then reconcile under it. First-time + // registration and repairing a half-finished one are the same step, and + // the write is idempotent. Grant is the only action that may open Login + // Items; `darwin.helperGrant` is this same action. + "privileged-helper" => Ok(helper_row(&helper_permission::grant(app))), _ => Err(anyhow::anyhow!("Unknown permission: {}", permission_id)), } } -/// Wait for a freshly registered helper daemon to come up and answer a -/// status round-trip. launchd starts it asynchronously after approval, so the -/// socket appears a moment after `register()` returns. -fn await_helper_ready() -> Result<()> { - const ATTEMPTS: u32 = 10; - const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(300); - - let mut last_error = anyhow::anyhow!("helper socket never appeared"); - for attempt in 0..ATTEMPTS { - if attempt > 0 { - std::thread::sleep(RETRY_DELAY); - } - if !crate::privileged_helper::client::socket_available() { - continue; - } - match crate::privileged_helper::client::status() { - Ok(response) if response.ok => return Ok(()), - Ok(response) => { - last_error = anyhow::anyhow!( - response - .error - .unwrap_or_else(|| "helper returned an error".to_string()) - ); - } - Err(error) => last_error = error, - } - } - Err(last_error) -} - -/// One authenticated status round-trip to an already-installed helper -/// daemon, bypassing SMAppService entirely. Used when the app is not running -/// from /Applications: it must never install the helper from there (the -/// LaunchDaemon would point at the wrong bundle path), but if a signed -/// /Applications copy already installed one, mutual code-signature -/// validation is what decides whether this copy may use it. -fn probe_installed_helper() -> (PermissionStatus, Option) { - const INSTALL_FROM_APPLICATIONS: &str = "nixmac is not running from /Applications, so it cannot install the unattended sync helper itself. Install it by launching a signed nixmac from /Applications; a correctly signed copy running elsewhere can then connect to it."; - - if !crate::privileged_helper::client::socket_available() { - return ( - PermissionStatus::Pending, - Some(INSTALL_FROM_APPLICATIONS.to_string()), - ); - } - match crate::privileged_helper::client::status() { - Ok(response) if response.ok => (PermissionStatus::Granted, None), - Ok(response) => ( - PermissionStatus::Pending, - Some(format!( - "The helper is running but refused this app: {}. {INSTALL_FROM_APPLICATIONS}", - response - .error - .unwrap_or_else(|| "unknown error".to_string()) - )), - ), - Err(error) => ( - PermissionStatus::Pending, - Some(format!( - "The helper did not answer a status probe: {error:#}. {INSTALL_FROM_APPLICATIONS}" - )), - ), - } +/// Status of the unattended sync helper: one run of the reconciliation +/// function, as a permission row. +/// +/// Only a helper of this build answering an authenticated `Status` is granted. +/// An `SMAppService` registration alone proves nothing — the approval outlives +/// the binary it was granted for — and neither does a socket. Everything else is +/// a report, and the report is what tells the user what to do: approve in Login +/// Items, move the app to /Applications, restart the app, or wait out a running +/// activation. +fn check_privileged_helper(app: &AppHandle) -> Reconciled { + let report = helper_permission::observe(app); + // A refresh is how a user comes back to this — opening the panel, or + // pressing Check again — and one run is usually not enough: the platform + // refuses a register for about a second after any unregister, so the run + // that repairs a helper typically reports a failure and needs a successor. + // Starting the loop here means the visit converges instead of handing back a + // scary report. A loop already running ignores this. + helper_permission::start_converging(app); + report } -/// Status of the unattended sync helper, with a detail message when it is -/// not fully working. -/// -/// `SMAppService` registration alone is not proof of a working helper: the -/// approval persists in the BackgroundTaskManagement database even after the -/// helper binary is gone or replaced. Granted therefore additionally requires -/// a live status round-trip through the socket, which also exercises both -/// code-signature checks (client validates the daemon, daemon validates this -/// client). -fn check_privileged_helper() -> (PermissionStatus, Option) { - // Debug builds run outside /Applications (bare dev binaries or a locally - // built .app), where SMAppService describes this bundle copy rather than - // the daemon that an /Applications install registered. Skip it there and - // rely on the authenticated socket round-trip alone: a correctly signed - // copy can talk to an installed helper from anywhere. Release builds - // always go through the full SMAppService check below. - if cfg!(debug_assertions) - && !crate::system::install_location::check_install_location().in_applications_dir - { - return probe_installed_helper(); - } - - let status = crate::privileged_helper::service::status(); - if !status.available { - return (PermissionStatus::Unknown, status.detail); - } - if !status.authorized { - return (PermissionStatus::Pending, None); - } - if !status.socket_available { - return ( - PermissionStatus::Pending, - Some( - "The helper is approved in Login Items, but its daemon is not running (no socket). Use Grant to re-register it, or toggle nixmac off and on in System Settings → General → Login Items & Extensions." - .to_string(), - ), - ); - } - match crate::privileged_helper::client::status() { - Ok(response) if response.ok => (PermissionStatus::Granted, None), - Ok(response) => ( - PermissionStatus::Pending, - Some(format!( - "The helper is running but refused this app: {}. Unattended sync will fall back to password prompts until a matching signed build talks to it.", - response - .error - .unwrap_or_else(|| "unknown error".to_string()) - )), - ), - Err(error) => ( - PermissionStatus::Pending, - Some(format!( - "The helper is registered but did not answer a status probe: {error:#}." - )), - ), - } +/// One report as the permission row it produces. +pub(crate) fn helper_row(report: &Reconciled) -> Permission { + let (status, detail) = helper_permission::row(report); + privileged_helper_permission( + status, + &detail, + helper_permission::can_request_programmatically(report), + ) } /// Best-effort App Management status. @@ -801,6 +671,29 @@ fn check_app_management() -> PermissionStatus { #[cfg(test)] mod tests { use super::*; + use crate::privileged_helper::reconcile::Reconciled; + + /// The row a report produces has to carry the report's own answer to "is + /// System Settings where the user finishes this", or the panel offers the + /// wrong action: an approval waiting in Login Items would get a button that + /// hands the helper back instead of one that opens the pane. Asserted through + /// `helper_row` rather than on the predicate alone, because the wiring between + /// them is what the UI actually reads and nothing else pins it. + #[test] + fn the_row_takes_its_deep_link_from_the_report() { + assert!(!helper_row(&Reconciled::PendingApproval).can_request_programmatically); + assert!(helper_row(&Reconciled::AtThisBuild).can_request_programmatically); + assert!(helper_row(&Reconciled::NoHelper).can_request_programmatically); + } + + /// A bare app handle. The skip flags below short-circuit before anything + /// reads app state, which is what keeps these tests from probing the real + /// helper. + fn mock_app() -> tauri::App { + tauri::test::mock_builder() + .build(tauri::test::mock_context(tauri::test::noop_assets())) + .expect("mock app builds") + } #[cfg(debug_assertions)] #[test] @@ -943,7 +836,8 @@ mod tests { unsafe { std::env::remove_var("NIXMAC_SKIP_PERMISSIONS") }; unsafe { std::env::set_var("VITE_NIXMAC_SKIP_PERMISSIONS", "true") }; - let state = check_all_permissions(); + let app = mock_app(); + let state = check_all_permissions(app.handle()); assert!(state.all_required_granted); assert!( @@ -966,7 +860,8 @@ mod tests { unsafe { std::env::set_var("NIXMAC_SKIP_PERMISSIONS", "true") }; unsafe { std::env::remove_var("VITE_NIXMAC_SKIP_PERMISSIONS") }; - let state = check_all_permissions(); + let app = mock_app(); + let state = check_all_permissions(app.handle()); assert!(state.all_required_granted); assert!( diff --git a/apps/native/src/components/widget/permissions/permissions-panel.test.tsx b/apps/native/src/components/widget/permissions/permissions-panel.test.tsx new file mode 100644 index 000000000..2541d3d4e --- /dev/null +++ b/apps/native/src/components/widget/permissions/permissions-panel.test.tsx @@ -0,0 +1,348 @@ +import type { Permission, PermissionStatus } from "@/ipc/types"; +import { HELPER_PERMISSION_ID } from "@/lib/permissions"; +import { APPROVE_IN_LOGIN_ITEMS } from "@/utils/test-fixtures"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The helper row is the one permission nixmac installs rather than asks macOS + * for, so it is the one row that can hand it back — as one button, a toggle of + * the standing decision. Everything else the row shows comes from the backend's + * reconciliation report: the status decides whether "Granted" is shown, and + * `instructions` is the sentence. + */ + +const mockRefresh = vi.fn(); +const mockRequest = vi.fn(); +const mockDisableHelper = vi.fn(); + +vi.mock("@/ipc/api", () => ({ + tauriAPI: { + permissions: { + refresh: (...args: unknown[]) => mockRefresh(...args), + request: (...args: unknown[]) => mockRequest(...args), + requestFullDiskAccess: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/orpc", () => ({ + client: { + darwin: { + helperDisable: (...args: unknown[]) => mockDisableHelper(...args), + }, + permissions: { + refresh: (...args: unknown[]) => mockRefresh(...args), + }, + }, + orpc: { + system: { + installLocation: { + queryOptions: () => ({ + queryKey: ["installLocation"], + queryFn: async () => ({ bundlePath: null, inApplicationsDir: false }), + }), + }, + }, + }, +})); + +const permissionsState = vi.fn(); +const helperPreference = vi.fn(); +vi.mock("@nixmac/state", () => ({ + useViewModel: (select: (state: { permissions: unknown; preferences: unknown }) => unknown) => + select({ + permissions: permissionsState(), + preferences: { helperPreference: helperPreference() }, + }), +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: { bundlePath: null, inApplicationsDir: false } }), +})); + +function helperPermission(overrides: Partial) { + return { + id: HELPER_PERMISSION_ID, + name: "Unattended Sync Helper", + description: "Required for unattended device sync", + required: true, + canRequestProgrammatically: true, + status: "pending", + instructions: "a report", + ...overrides, + }; +} + +function helperRow(status: PermissionStatus, instructions: string, ...others: unknown[]) { + return { + permissions: [helperPermission({ status, instructions }), ...others], + allRequiredGranted: status === "granted", + checkedAt: null, + }; +} + +/** + * The row the backend sends while macOS holds the registration pending approval + * in Login Items: the sentence names the pane, and `canRequestProgrammatically` + * is false because no run nixmac makes can finish it — only the user can. + */ +function awaitingApprovalRow() { + return { + permissions: [ + helperPermission({ + instructions: APPROVE_IN_LOGIN_ITEMS, + canRequestProgrammatically: false, + }), + ], + allRequiredGranted: false, + checkedAt: null, + }; +} + +/** + * A second row, to check that one row's action leaves the other's alone. + * + * `admin` specifically: its grant takes `handleGrant`'s plain + * `permissions.request` branch, so the mocked request is what holds the action in + * flight. The `full-disk` and `app-management` branches wait out a `setTimeout` + * of their own, which would settle the row after the test body and take its + * running label with it. + */ +const adminRow = { + id: "admin", + name: "Administrator Privileges", + description: "Required to install system packages and modify system configurations", + required: true, + canRequestProgrammatically: false, + status: "pending", + instructions: "You will be prompted for your password when needed", +}; + +async function panel() { + const { PermissionsPanel } = await import("./permissions-panel"); + const rendered = render(); + // The store is mocked as a bare selector call, so nothing re-renders on its + // own when a mocked value changes: a test that flips one mid-flight has to ask + // for the render itself, or it asserts against the pre-flip markup. + return { ...rendered, repaint: () => rendered.rerender() }; +} + +describe("PermissionsPanel — the unattended sync helper row", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRefresh.mockResolvedValue(undefined); + helperPreference.mockReturnValue("unset"); + }); + + it("offers Enable, and only Enable, while no helper is wanted", async () => { + for (const preference of ["unset", "disabled"]) { + helperPreference.mockReturnValue(preference); + permissionsState.mockReturnValue( + helperRow("pending", "The unattended sync helper is not installed."), + ); + + const { unmount } = await panel(); + + expect(screen.getByRole("button", { name: "Enable" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Disable" })).toBeNull(); + unmount(); + } + }); + + it("offers Disable in every state the helper is wanted in, granted or not", async () => { + // A row that is not granted is still one nixmac keeps reconciling towards on + // its own, so the only thing left for the user to decide is whether they + // still want it — and offering Disable only on a granted row would leave a + // helper this build cannot use with no way out but Enable, which records the + // opposite. The reports that want the user to act say so in `instructions`. + // The one exception is approval pending, which has its own test below. + helperPreference.mockReturnValue("granted"); + for (const status of ["granted", "pending", "denied", "unknown"] as const) { + permissionsState.mockReturnValue(helperRow(status, "a report")); + + const { unmount } = await panel(); + + expect(screen.getByRole("button", { name: "Disable" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Enable" })).toBeNull(); + unmount(); + } + }); + + it("offers the Login Items deep link, not Disable, while approval is pending", async () => { + // macOS is holding the registration and nothing nixmac does next makes that + // go, so the row offers the same "Open Settings" action as the other rows + // that send the user into System Settings — the pane the sentence names, + // which is where macOS asks for the approval. Disable would be answering a + // question the user has not been asked yet, and Enable would run a + // reconciliation that can only report the same thing again. + for (const preference of ["unset", "granted"]) { + helperPreference.mockReturnValue(preference); + permissionsState.mockReturnValue(awaitingApprovalRow()); + + const { unmount } = await panel(); + + expect(screen.getByRole("button", { name: /Open Settings/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Disable" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Enable" })).toBeNull(); + expect(screen.getByText(APPROVE_IN_LOGIN_ITEMS)).toBeTruthy(); + unmount(); + } + }); + + it("opens Login Items through the same grant action the row always used", async () => { + // The deep link is not a new backend call: it is `permissions.request` for + // this row, which records the decision and reconciles, and is the only action + // allowed to open Login Items. In this state it opens them before it + // reconciles at all — the registration is already waiting for approval, so + // the pane does not depend on what the run goes on to report. + helperPreference.mockReturnValue("granted"); + permissionsState.mockReturnValue(awaitingApprovalRow()); + mockRequest.mockResolvedValue({ id: HELPER_PERMISSION_ID, status: "pending" }); + + const { getByRole } = await panel(); + fireEvent.click(getByRole("button", { name: /Open Settings/ })); + + await waitFor(() => { + expect(mockRequest).toHaveBeenCalledWith(HELPER_PERMISSION_ID); + expect(mockRefresh).toHaveBeenCalled(); + }); + expect(mockDisableHelper).not.toHaveBeenCalled(); + }); + + it("offers Disable on a granted row whose decision was never recorded", async () => { + // An adopted registration: the reconciliation run records `granted` before + // it reports, so this is the window before the mirrored preference catches + // up. A granted row must never offer to enable what is already enabled. + helperPreference.mockReturnValue("unset"); + permissionsState.mockReturnValue(helperRow("granted", "installed and answering")); + + await panel(); + + expect(screen.getByRole("button", { name: "Disable" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Enable" })).toBeNull(); + }); + + it("renders every report the backend can send for a row that is not granted", async () => { + // The vocabulary: move the app, restart it, wait out a running activation + // (with and without the activation's details), and plain failures. Each + // arrives as the row's instructions and is shown verbatim — the UI never + // re-words a report. All of them are reachable with the helper wanted, + // which is what this row is. Approval pending comes with a row of its own + // and is asserted in the Login Items test above. The sentences mirror the + // backend's `helper_permission::describe` vocabulary. + helperPreference.mockReturnValue("granted"); + for (const report of [ + "nixmac runs from /Volumes/nixmac/nixmac.app — move it to /Applications.", + "this app was replaced while running (build a is running, b is installed) — restart nixmac.", + "nixmac is waiting for a running activation to finish before updating the unattended sync helper (/nix/store/abc/activate submitted by the sync agent).", + "nixmac is waiting for a running activation to finish before updating the unattended sync helper.", + "the helper could not be unregistered: SMAppService 1 refused.", + ]) { + permissionsState.mockReturnValue(helperRow("pending", report)); + + const { unmount } = await panel(); + + expect(screen.getByText(report)).toBeTruthy(); + unmount(); + } + }); + + it("keeps the clicked action's button until it finishes", async () => { + // Both actions record the decision before the run they start, so the + // preference this row picks its button from flips while the run is still + // going. The button must not: an Enable click that turned into "Disable" + // mid-run would offer to undo an action still in flight, and the row would + // be labelling the opposite of what was asked. + helperPreference.mockReturnValue("unset"); + permissionsState.mockReturnValue(helperRow("pending", "a report")); + let finishGrant: () => void = () => {}; + mockRequest.mockReturnValue( + new Promise((resolve) => { + finishGrant = () => resolve({ id: HELPER_PERMISSION_ID, status: "pending" }); + }), + ); + + const { getByRole, repaint } = await panel(); + fireEvent.click(getByRole("button", { name: "Enable" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Enabling/ })).toBeTruthy(); + }); + // The decision the click recorded, now mirrored back mid-run. + helperPreference.mockReturnValue("granted"); + repaint(); + + expect(screen.getByRole("button", { name: /Enabling/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Disable" })).toBeNull(); + + finishGrant(); + await waitFor(() => { + expect(screen.queryByRole("button", { name: /Enabling/ })).toBeNull(); + }); + }); + + it("leaves another row's in-flight button alone", async () => { + // The in-flight action is keyed by row for this: only the row that owns an + // action is disabled, so a click on any other row is expected at any moment. + // With one slot for the whole panel it would evict this row's, and the row + // would fall back to the decision its own running Enable already recorded — + // offering to disable a helper it is still enabling. + helperPreference.mockReturnValue("unset"); + permissionsState.mockReturnValue(helperRow("pending", "a report", adminRow)); + mockRequest.mockReturnValue(new Promise(() => {})); + + const { getByRole } = await panel(); + fireEvent.click(getByRole("button", { name: "Enable" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: /Enabling/ })).toBeTruthy(); + }); + helperPreference.mockReturnValue("granted"); + // No `repaint` needed: this click starts the second row's action, and that + // state change is the render which reads the flipped preference back. + fireEvent.click(getByRole("button", { name: /Open Settings/ })); + + expect(screen.getByRole("button", { name: /Enabling/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Disable" })).toBeNull(); + // And the row that was clicked second reports its own action, not the first. + expect(screen.getByRole("button", { name: /Waiting/ })).toBeTruthy(); + }); + + it("keeps the label rendered while the action runs, and says the running word", async () => { + // What fixes the button's width is that the idle label stays in the layout, + // hidden, with the spinner over it. jsdom has no layout, so what is asserted + // here is the testable half — the label is still rendered, and the running + // word is the accessible name meanwhile, so the button is never nameless + // while its label is hidden. + helperPreference.mockReturnValue("granted"); + permissionsState.mockReturnValue(helperRow("granted", "installed and answering")); + mockDisableHelper.mockReturnValue(new Promise(() => {})); + + const { getByRole } = await panel(); + fireEvent.click(getByRole("button", { name: "Disable" })); + + const running = await waitFor(() => screen.getByRole("button", { name: "Disabling…" })); + expect(screen.getByText("Disable")).toBeTruthy(); + // The running label and `disabled` come from the same input, so a button + // that says it is working cannot still be taking clicks. + expect(running).toBeDisabled(); + }); + + it("disabling reports what the run did and re-probes", async () => { + permissionsState.mockReturnValue(helperRow("granted", "installed and answering")); + mockDisableHelper.mockResolvedValue({ + atThisBuild: false, + detail: "The unattended sync helper is disabled and has been removed.", + }); + + const { getByRole } = await panel(); + fireEvent.click(getByRole("button", { name: "Disable" })); + + await waitFor(() => { + expect(mockDisableHelper).toHaveBeenCalledTimes(1); + expect(mockRefresh).toHaveBeenCalled(); + }); + expect(screen.getByText("The unattended sync helper is disabled and has been removed.")).toBeTruthy(); + }); +}); diff --git a/apps/native/src/components/widget/permissions/permissions-panel.tsx b/apps/native/src/components/widget/permissions/permissions-panel.tsx index 31ba27980..8bd97b1cb 100644 --- a/apps/native/src/components/widget/permissions/permissions-panel.tsx +++ b/apps/native/src/components/widget/permissions/permissions-panel.tsx @@ -3,12 +3,76 @@ import { Button } from "@/components/ui/button"; import { tauriAPI } from "@/ipc/api"; import type { Permission } from "@/ipc/types"; -import { orpc } from "@/lib/orpc"; +import { client, orpc } from "@/lib/orpc"; +import { HELPER_PERMISSION_ID } from "@/lib/permissions"; import { cn } from "@/lib/utils"; import { useViewModel } from "@nixmac/state"; import { useQuery } from "@tanstack/react-query"; import { AppWindow, Check, ExternalLink, Folder, HardDrive, KeyRound, Loader2, ShieldCheck, Terminal } from "lucide-react"; -import { useEffect, useState } from "react"; +import { type ComponentProps, type ReactNode, useEffect, useState } from "react"; + +type ActionButtonProps = { + idle: ReactNode; + busy: string; + isBusy: boolean; + variant?: ComponentProps["variant"]; + onClick: () => void; +}; + +/** + * One row's action button. `isBusy` drives both the running label and + * `disabled`, so the two cannot disagree. + * + * The idle label keeps its box and the spinner is laid over it: swapping + * content would resize the button, and the design-system Button animates every + * resize. The spinner stays solid because here `disabled` means "running", and + * the base's pointer-events rule is what refuses the click. `aria-label` + * carries the running word while the label is hidden. + */ +function ActionButton({ idle, busy, isBusy, variant, onClick }: ActionButtonProps) { + return ( + + ); +} + +/** + * What one row's grant button says. A row nixmac cannot advance on its own + * can only deep-link into System Settings; the helper is installed by nixmac + * rather than requested from macOS, hence "Enable". + */ +function grantLabel(perm: Permission): Pick { + if (!perm.canRequestProgrammatically) { + return { + idle: ( + <> + Open Settings +