Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/native/src-tauri/src/commands/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<shared_types::Permission, String> {
permissions::request_permission(&permission_id)
permissions::request_permission(&app, &permission_id)
.map_err(|e| capture_err("permissions_request", e))
}
12 changes: 12 additions & 0 deletions apps/native/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,18 @@ fn run_gui_mode(
}
});

// Reconcile the installed privileged helper with this build. This is
// the only actor in an upgrade: a Finder replacement and the
// updater's relaunch both converge here, on the new GUI's ordinary
// startup, and the old one prepares nothing.
//
// Off the main thread, and off the startup path: the run makes
// ServiceManagement calls *on* the main queue and awaits them, and it
// waits — legitimately, for as long as it takes — on an activation a
// previous build's helper is still running. It reports through the
// permission row; nothing here waits for it.
system::helper_permission::start_converging(handle);

// Background initialize the nix-darwin docs index once at startup for fast option-shape lookup.
let docs_handle = handle.clone();
tauri::async_runtime::spawn_blocking(move || {
Expand Down
64 changes: 49 additions & 15 deletions apps/native/src-tauri/src/orpc/darwin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,6 +68,28 @@ struct InstallSyncAgentInput {
config: Option<SyncAgentLaunchConfig>,
}

/// 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 {
Expand Down Expand Up @@ -193,16 +215,28 @@ async fn rebuild_status(ctx: OrpcCtx, _input: ()) -> Result<RebuildStatus, ORPCE
.map_err(|error| internal_err("darwin.rebuildStatus", error))
}

async fn helper_status(_ctx: OrpcCtx, _input: ()) -> Result<HelperServiceStatus, ORPCError> {
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<HelperReport, ORPCError> {
Ok(HelperReport::of(&helper_permission::observe(&ctx.app)))
}

async fn helper_register(_ctx: OrpcCtx, _input: ()) -> Result<HelperServiceStatus, ORPCError> {
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<HelperReport, ORPCError> {
Ok(HelperReport::of(&helper_permission::grant(&ctx.app)))
}

async fn helper_unregister(_ctx: OrpcCtx, _input: ()) -> Result<HelperServiceStatus, ORPCError> {
service::unregister().map_err(|error| internal_err("darwin.helperUnregister", error))
/// The explicit Disable action: retire a running helper, unregister it, and
/// register nothing. No later automatic run overrides it.
async fn helper_disable(ctx: OrpcCtx, _input: ()) -> Result<HelperReport, ORPCError> {
Ok(HelperReport::of(&helper_permission::disable(&ctx.app)))
}

async fn sync_agent_status(_ctx: OrpcCtx, _input: ()) -> Result<SyncAgentStatus, ORPCError> {
Expand Down Expand Up @@ -278,14 +312,14 @@ pub fn routes() -> Router<OrpcCtx> {
.output(orpc_specta::specta::<RebuildStatus>())
.handler(rebuild_status),
"helperStatus" => os::<OrpcCtx>()
.output(orpc_specta::specta::<HelperServiceStatus>())
.output(orpc_specta::specta::<HelperReport>())
.handler(helper_status),
"helperRegister" => os::<OrpcCtx>()
.output(orpc_specta::specta::<HelperServiceStatus>())
.handler(helper_register),
"helperUnregister" => os::<OrpcCtx>()
.output(orpc_specta::specta::<HelperServiceStatus>())
.handler(helper_unregister),
"helperGrant" => os::<OrpcCtx>()
.output(orpc_specta::specta::<HelperReport>())
.handler(helper_grant),
"helperDisable" => os::<OrpcCtx>()
.output(orpc_specta::specta::<HelperReport>())
.handler(helper_disable),
"syncAgentStatus" => os::<OrpcCtx>()
.output(orpc_specta::specta::<SyncAgentStatus>())
.handler(sync_agent_status),
Expand Down
4 changes: 2 additions & 2 deletions apps/native/src-tauri/src/orpc/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Permission, ORPCError> {
cmd::permissions_request(input.permission_id)
async fn request(ctx: OrpcCtx, input: RequestInput) -> Result<Permission, ORPCError> {
cmd::permissions_request(ctx.app, input.permission_id)
.await
.map_err(|e| internal_err("permissions.request", e))
}
Expand Down
22 changes: 14 additions & 8 deletions apps/native/src-tauri/src/privileged_helper/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@ use std::os::unix::net::UnixStream;
use std::time::Duration;

const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(30 * 60);
/// The one generous bound in this client, and deliberately not one of the short

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This good comment makes me think the line below should be changed to a from_mins for emphasis and future-proofing.

/// leashes above: an activation legitimately runs for many minutes, so nothing
/// shorter can be put here without turning ordinary long applies into lost
/// results. Half an hour is the accepted ceiling. An activation still running
/// when it expires is reported as an unknown outcome by an apply and as a
/// deferral by the sync agent — neither compensates for it, and the activation
/// itself keeps running. Do not reuse this on `Status` or `Retire`, and do not
/// shorten it to match them.
const ACTIVATION_TIMEOUT: Duration = Duration::from_mins(30);
/// Status probes back the permissions UI; a wedged helper must not stall a
/// permissions refresh, so they get a short leash instead of CLIENT_TIMEOUT.
const STATUS_PROBE_TIMEOUT: Duration = Duration::from_secs(2);
Expand Down Expand Up @@ -82,6 +90,11 @@ pub enum AssessedExchange {
Unidentified(String),
}

/// Whether the socket path exists. Diagnostic only — it proves nothing about a
/// helper, which is why nothing in the app reads it: the sync agent's no-config
/// mode prints it, and every decision comes from an authenticated exchange or
/// from the absence window in `socket_probe`.
#[allow(dead_code)] // Used by the sync agent binary this module is shared into.
pub fn socket_available() -> bool {
std::path::Path::new(HELPER_SOCKET_PATH).exists()
}
Expand Down Expand Up @@ -205,13 +218,6 @@ fn exchange_on(
})
}

/// Sends `Status`. GUI-only by protocol policy: the sync agent has no
/// lifecycle role and the helper refuses every request from it but
/// `TryActivate`.
pub fn status() -> Result<HelperExchange, HelperClientError> {
exchange(&HelperRequest::Status, STATUS_PROBE_TIMEOUT)
}

/// Sends `Status`, keeping the peer assessment. Reconciliation's discovery
/// exchange: it works against a helper of any build, and what it may do about
/// one it cannot talk to depends on which way the assessment went.
Expand Down
4 changes: 0 additions & 4 deletions apps/native/src-tauri/src/privileged_helper/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,6 @@ pub mod client;
pub mod helper_runtime;
pub mod peer_auth;
pub mod protocol;
// Complete and unreachable from production: nothing calls the reconciliation
// function yet. The change that wires it into startup, the grant and disable
// actions, apply, and the updater removes this.
#[allow(dead_code)]
pub mod reconcile;
pub mod root_activation;
pub mod service;
Expand Down
30 changes: 0 additions & 30 deletions apps/native/src-tauri/src/privileged_helper/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,6 @@ pub const HELPER_SOCKET_DIR: &str = "/var/run/nixmac";
pub const BUILD_ID: &str = env!("NIXMAC_BUILD_ID");
const DEFAULT_SYNC_AGENT_INTERVAL_SECONDS: u32 = 900;

#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HelperServiceStatus {
pub label: String,
pub available: bool,
pub registered: bool,
pub authorized: bool,
pub socket_available: bool,
/// The daemon answered an authenticated `Status` round-trip naming a
/// state: the client validated the daemon's signature and the daemon
/// accepted this client. A typed refusal or an unparseable reply never
/// sets this.
pub responding: bool,
pub detail: Option<String>,
}

impl HelperServiceStatus {
pub fn unavailable(detail: impl Into<String>) -> Self {
Self {
label: HELPER_LABEL.to_string(),
available: false,
registered: false,
authorized: false,
socket_available: false,
responding: false,
detail: Some(detail.into()),
}
}
}

// ---------------------------------------------------------------------------
// Requests.
//
Expand Down
27 changes: 19 additions & 8 deletions apps/native/src-tauri/src/privileged_helper/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,8 @@ pub enum Stopped {
// The sentences the permissions UI shows. Written here, next to the variants
// they explain, because that is where this app already puts helper detail text
// (`system::permissions` composes the same kind of string and the panel renders
// it verbatim). No caller until the change that wires the report into the UI.
// it verbatim). `system::helper_permission` is what turns one of these into the
// row's detail.

impl std::fmt::Display for Displacement {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
Expand Down Expand Up @@ -542,7 +543,11 @@ mod evidence {
}
}

use evidence::{Committed, RetiredHelper};
// `Committed` is nameable outside this module because the environment methods
// that demand one are; minting one is not — `Committed::checked` stays visible
// only in here, so the register step remains the only place one comes from.
pub use evidence::Committed;
use evidence::RetiredHelper;

/// Which of the three rules authorizes one unregister.
///
Expand Down Expand Up @@ -589,8 +594,12 @@ pub enum PeerReply<C> {
/// Everything a run observes or does, injected so the decisions above can be
/// driven through every case without a helper, a bundle, or a settings store.
///
/// Only observations and effects live here. No method decides anything, and
/// none of them can open System Settings.
/// Only observations and effects live here, and no method decides anything about
/// the goal or the helper. Nothing this function does asks for System Settings
/// either: the capability is absent from this trait, so a run cannot open a pane
/// whatever it observes. The GUI's Grant action opens Login Items around its own
/// call to this function — never from inside one — which is what keeps startup
/// and status refreshes from opening anything.
pub trait Environment {
/// An answered connection, held open. Opaque: the only thing done with one
/// is asking whether its peer is still there.
Expand Down Expand Up @@ -725,9 +734,7 @@ impl<R: Runtime> Environment for LiveEnvironment<'_, R> {
}

fn unregister(&self) -> Result<(), String> {
service::unregister()
.map(|_status| ())
.map_err(|error| format!("{error:#}"))
service::unregister().map_err(|error| format!("{error:#}"))
}

fn replace_helper(
Expand Down Expand Up @@ -1319,7 +1326,11 @@ impl<E: Environment> Run<'_, E> {
/// Re-evaluated before each one rather than once per pass: both facts can
/// change while a pass runs — an app can be moved, and a bundle can be
/// replaced by an update — and what they guard is destructive.
fn gates<E: Environment>(env: &E) -> Result<(), Reconciled> {
///
/// Public for one reason: Apply asks the same question before deciding whether
/// it may touch a helper, and a second implementation of it could disagree with
/// this one.
pub fn gates<E: Environment>(env: &E) -> Result<(), Reconciled> {
// Canonical install. A copy running from anywhere else observes and
// reports only: it may not even ask a helper to retire.
if let InstallLocation::Elsewhere(observed) = env.install_location() {
Expand Down
Loading
Loading