Skip to content

Commit 4017a23

Browse files
committed
feat(helper)!: wire reconciliation into startup and apply
Apply chooses between the helper and the administrator-password prompt before any bytes reach the helper, from reconciled service state, and reports every refusal instead of substituting a prompt. The permission row, Grant and Disable all derive from one reconciliation run. - delete the App Management bypass: skipping the helper for applies that touch managed app bundles was a silent password substitution while an enabled registration could still admit sync-agent work - delete the status-probe admission gate and the pre-dispatch error fallback, both of which could select the password path after an exchange had been attempted - offer Disable in every state, so a registration waiting for approval can be removed - drop the folded service status readout; its responding flag was the last thing an admission check could be re-derived from BREAKING CHANGE: helperRegister and helperUnregister are replaced by helperGrant and helperDisable; helperStatus returns the reconciliation report.
1 parent 654877b commit 4017a23

19 files changed

Lines changed: 1801 additions & 681 deletions

File tree

‎apps/native/src-tauri/src/commands/permissions.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ pub async fn refresh_permissions(app: AppHandle) -> Result<(), String> {
2626
/// For manual permissions (full-disk), this opens System Settings.
2727
#[tauri::command]
2828
pub async fn permissions_request(
29+
app: AppHandle,
2930
permission_id: String,
3031
) -> Result<shared_types::Permission, String> {
31-
permissions::request_permission(&permission_id)
32+
permissions::request_permission(&app, &permission_id)
3233
.map_err(|e| capture_err("permissions_request", e))
3334
}

‎apps/native/src-tauri/src/main.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,21 @@ fn run_gui_mode(
726726
}
727727
});
728728

729+
// Reconcile the installed privileged helper with this build. This is
730+
// the only actor in an upgrade: a Finder replacement and the
731+
// updater's relaunch both converge here, on the new GUI's ordinary
732+
// startup, and the old one prepares nothing.
733+
//
734+
// Off the main thread, and off the startup path: the run makes
735+
// ServiceManagement calls *on* the main queue and awaits them, and it
736+
// waits — legitimately, for as long as it takes — on an activation a
737+
// previous build's helper is still running. It reports through the
738+
// permission row; nothing here waits for it.
739+
let helper_handle = handle.clone();
740+
tauri::async_runtime::spawn_blocking(move || {
741+
system::helper_permission::observe(&helper_handle);
742+
});
743+
729744
// Background initialize the nix-darwin docs index once at startup for fast option-shape lookup.
730745
let docs_handle = handle.clone();
731746
tauri::async_runtime::spawn_blocking(move || {

‎apps/native/src-tauri/src/orpc/darwin.rs‎

Lines changed: 46 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
use super::{OrpcCtx, helpers::internal_err};
44
use crate::commands::{apply, evolve, rollback};
55
use crate::privileged_helper::{
6-
protocol::{HelperServiceStatus, SyncAgentLaunchConfig},
7-
service,
6+
protocol::SyncAgentLaunchConfig,
87
sync_agent::{self, SyncAgentStatus},
98
};
109
use crate::shared_types::{
1110
AppManagementCheckResult, BuildCheckResult, EtcClobberCheckResult, EvolveCancelResult,
1211
OkResult, RebuildStatus, RollbackResult,
1312
};
13+
use crate::system::helper_permission;
1414
use orpc::*;
1515
use serde::{Deserialize, Serialize};
1616
use specta::Type;
@@ -68,6 +68,28 @@ struct InstallSyncAgentInput {
6868
config: Option<SyncAgentLaunchConfig>,
6969
}
7070

71+
/// What one reconciliation run found, for a client that only has to display it:
72+
/// whether the helper is installed and answering at this build, and the sentence
73+
/// that says what else is true.
74+
#[derive(Debug, Deserialize, Serialize, Type)]
75+
#[serde(rename_all = "camelCase")]
76+
struct HelperReport {
77+
at_this_build: bool,
78+
detail: String,
79+
}
80+
81+
impl HelperReport {
82+
fn of(report: &crate::privileged_helper::reconcile::Reconciled) -> Self {
83+
Self {
84+
at_this_build: matches!(
85+
report,
86+
crate::privileged_helper::reconcile::Reconciled::AtThisBuild
87+
),
88+
detail: helper_permission::describe(report),
89+
}
90+
}
91+
}
92+
7193
#[derive(Debug, Deserialize, Serialize, Type)]
7294
#[serde(rename_all = "camelCase")]
7395
struct AdoptManualChangesResult {
@@ -193,16 +215,25 @@ async fn rebuild_status(ctx: OrpcCtx, _input: ()) -> Result<RebuildStatus, ORPCE
193215
.map_err(|error| internal_err("darwin.rebuildStatus", error))
194216
}
195217

196-
async fn helper_status(_ctx: OrpcCtx, _input: ()) -> Result<HelperServiceStatus, ORPCError> {
197-
Ok(service::status())
218+
/// One run of the reconciliation function, reported. It decides nothing about
219+
/// the goal and never resolves the stored decision; it is the same run a status
220+
/// refresh makes. It can open Login Items in exactly one case — when it is the
221+
/// run that answers a Grant click that was told `Busy`.
222+
async fn helper_status(ctx: OrpcCtx, _input: ()) -> Result<HelperReport, ORPCError> {
223+
Ok(HelperReport::of(&helper_permission::observe(&ctx.app)))
198224
}
199225

200-
async fn helper_register(_ctx: OrpcCtx, _input: ()) -> Result<HelperServiceStatus, ORPCError> {
201-
service::register().map_err(|error| internal_err("darwin.helperRegister", error))
226+
/// The explicit Grant action. Grant is the only action that may open Login
227+
/// Items; `permissions.request("privileged-helper")` is the same action reached
228+
/// from the permission row.
229+
async fn helper_grant(ctx: OrpcCtx, _input: ()) -> Result<HelperReport, ORPCError> {
230+
Ok(HelperReport::of(&helper_permission::grant(&ctx.app)))
202231
}
203232

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

208239
async fn sync_agent_status(_ctx: OrpcCtx, _input: ()) -> Result<SyncAgentStatus, ORPCError> {
@@ -278,14 +309,14 @@ pub fn routes() -> Router<OrpcCtx> {
278309
.output(orpc_specta::specta::<RebuildStatus>())
279310
.handler(rebuild_status),
280311
"helperStatus" => os::<OrpcCtx>()
281-
.output(orpc_specta::specta::<HelperServiceStatus>())
312+
.output(orpc_specta::specta::<HelperReport>())
282313
.handler(helper_status),
283-
"helperRegister" => os::<OrpcCtx>()
284-
.output(orpc_specta::specta::<HelperServiceStatus>())
285-
.handler(helper_register),
286-
"helperUnregister" => os::<OrpcCtx>()
287-
.output(orpc_specta::specta::<HelperServiceStatus>())
288-
.handler(helper_unregister),
314+
"helperGrant" => os::<OrpcCtx>()
315+
.output(orpc_specta::specta::<HelperReport>())
316+
.handler(helper_grant),
317+
"helperDisable" => os::<OrpcCtx>()
318+
.output(orpc_specta::specta::<HelperReport>())
319+
.handler(helper_disable),
289320
"syncAgentStatus" => os::<OrpcCtx>()
290321
.output(orpc_specta::specta::<SyncAgentStatus>())
291322
.handler(sync_agent_status),

‎apps/native/src-tauri/src/orpc/permissions.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ async fn refresh(ctx: OrpcCtx, _input: ()) -> Result<(), ORPCError> {
2525
.map_err(|e| internal_err("permissions.refresh", e))
2626
}
2727

28-
async fn request(_ctx: OrpcCtx, input: RequestInput) -> Result<Permission, ORPCError> {
29-
cmd::permissions_request(input.permission_id)
28+
async fn request(ctx: OrpcCtx, input: RequestInput) -> Result<Permission, ORPCError> {
29+
cmd::permissions_request(ctx.app, input.permission_id)
3030
.await
3131
.map_err(|e| internal_err("permissions.request", e))
3232
}

‎apps/native/src-tauri/src/privileged_helper/client.rs‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ use std::os::unix::net::UnixStream;
77
use std::time::Duration;
88

99
const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);
10+
/// The one generous bound in this client, and deliberately not one of the short
11+
/// leashes above: an activation legitimately runs for many minutes, so nothing
12+
/// shorter can be put here without turning ordinary long applies into lost
13+
/// results. Half an hour is the accepted ceiling. An activation still running
14+
/// when it expires is reported as an unknown outcome by an apply and as a
15+
/// deferral by the sync agent — neither compensates for it, and the activation
16+
/// itself keeps running. Do not reuse this on `Status` or `Retire`, and do not
17+
/// shorten it to match them.
1018
const ACTIVATION_TIMEOUT: Duration = Duration::from_secs(30 * 60);
1119
/// Status probes back the permissions UI; a wedged helper must not stall a
1220
/// permissions refresh, so they get a short leash instead of CLIENT_TIMEOUT.
@@ -82,6 +90,11 @@ pub enum AssessedExchange {
8290
Unidentified(String),
8391
}
8492

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

208-
/// Sends `Status`. GUI-only by protocol policy: the sync agent has no
209-
/// lifecycle role and the helper refuses every request from it but
210-
/// `TryActivate`.
211-
pub fn status() -> Result<HelperExchange, HelperClientError> {
212-
exchange(&HelperRequest::Status, STATUS_PROBE_TIMEOUT)
213-
}
214-
215221
/// Sends `Status`, keeping the peer assessment. Reconciliation's discovery
216222
/// exchange: it works against a helper of any build, and what it may do about
217223
/// one it cannot talk to depends on which way the assessment went.

‎apps/native/src-tauri/src/privileged_helper/mod.rs‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,6 @@ pub mod client;
99
pub mod helper_runtime;
1010
pub mod peer_auth;
1111
pub mod protocol;
12-
// Complete and unreachable from production: nothing calls the reconciliation
13-
// function yet. The change that wires it into startup, the grant and disable
14-
// actions, apply, and the updater removes this.
15-
#[allow(dead_code)]
1612
pub mod reconcile;
1713
pub mod root_activation;
1814
pub mod service;

‎apps/native/src-tauri/src/privileged_helper/protocol.rs‎

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,36 +20,6 @@ pub const HELPER_SOCKET_DIR: &str = "/var/run/nixmac";
2020
pub const BUILD_ID: &str = env!("NIXMAC_BUILD_ID");
2121
const DEFAULT_SYNC_AGENT_INTERVAL_SECONDS: u32 = 900;
2222

23-
#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)]
24-
#[serde(rename_all = "camelCase")]
25-
pub struct HelperServiceStatus {
26-
pub label: String,
27-
pub available: bool,
28-
pub registered: bool,
29-
pub authorized: bool,
30-
pub socket_available: bool,
31-
/// The daemon answered an authenticated `Status` round-trip naming a
32-
/// state: the client validated the daemon's signature and the daemon
33-
/// accepted this client. A typed refusal or an unparseable reply never
34-
/// sets this.
35-
pub responding: bool,
36-
pub detail: Option<String>,
37-
}
38-
39-
impl HelperServiceStatus {
40-
pub fn unavailable(detail: impl Into<String>) -> Self {
41-
Self {
42-
label: HELPER_LABEL.to_string(),
43-
available: false,
44-
registered: false,
45-
authorized: false,
46-
socket_available: false,
47-
responding: false,
48-
detail: Some(detail.into()),
49-
}
50-
}
51-
}
52-
5323
// ---------------------------------------------------------------------------
5424
// Requests.
5525
//

‎apps/native/src-tauri/src/privileged_helper/reconcile.rs‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,8 @@ pub enum Stopped {
192192
// The sentences the permissions UI shows. Written here, next to the variants
193193
// they explain, because that is where this app already puts helper detail text
194194
// (`system::permissions` composes the same kind of string and the panel renders
195-
// it verbatim). No caller until the change that wires the report into the UI.
195+
// it verbatim). `system::helper_permission` is what turns one of these into the
196+
// row's detail.
196197

197198
impl std::fmt::Display for Displacement {
198199
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -537,7 +538,11 @@ mod evidence {
537538
}
538539
}
539540

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

542547
/// Which of the three rules authorizes one unregister.
543548
///
@@ -584,8 +589,12 @@ pub enum PeerReply<C> {
584589
/// Everything a run observes or does, injected so the decisions above can be
585590
/// driven through every case without a helper, a bundle, or a settings store.
586591
///
587-
/// Only observations and effects live here. No method decides anything, and
588-
/// none of them can open System Settings.
592+
/// Only observations and effects live here, and no method decides anything about
593+
/// the goal or the helper. Nothing this function does asks for System Settings
594+
/// either: the GUI's `reported` opens Login Items only when a Grant click is
595+
/// waiting for an answer, so what opens it is that click and never a report this
596+
/// function produced. A run that no click is waiting on — startup, a status
597+
/// refresh — therefore opens nothing, whatever it observes.
589598
pub trait Environment {
590599
/// An answered connection, held open. Opaque: the only thing done with one
591600
/// is asking whether its peer is still there.
@@ -720,9 +729,7 @@ impl<R: Runtime> Environment for LiveEnvironment<'_, R> {
720729
}
721730

722731
fn unregister(&self) -> Result<(), String> {
723-
service::unregister()
724-
.map(|_status| ())
725-
.map_err(|error| format!("{error:#}"))
732+
service::unregister().map_err(|error| format!("{error:#}"))
726733
}
727734

728735
fn replace_helper(
@@ -1293,7 +1300,11 @@ impl<E: Environment> Run<'_, E> {
12931300
/// Re-evaluated before each one rather than once per pass: both facts can
12941301
/// change while a pass runs — an app can be moved, and a bundle can be
12951302
/// replaced by an update — and what they guard is destructive.
1296-
fn gates<E: Environment>(env: &E) -> Result<(), Reconciled> {
1303+
///
1304+
/// Public for one reason: Apply asks the same question before deciding whether
1305+
/// it may touch a helper, and a second implementation of it could disagree with
1306+
/// this one.
1307+
pub fn gates<E: Environment>(env: &E) -> Result<(), Reconciled> {
12971308
// Canonical install. A copy running from anywhere else observes and
12981309
// reports only: it may not even ask a helper to retire.
12991310
if let InstallLocation::Elsewhere(observed) = env.install_location() {
@@ -2439,7 +2450,10 @@ mod tests {
24392450
..World::default()
24402451
});
24412452

2442-
assert_eq!(reconciled(&fake), stopped(Stopped::NoLongerGrantedDuringRun));
2453+
assert_eq!(
2454+
reconciled(&fake),
2455+
stopped(Stopped::NoLongerGrantedDuringRun)
2456+
);
24432457
assert_eq!(
24442458
fake.mutations(),
24452459
vec![Event::ReplacementUnregistered { holding: 1 }]
@@ -2574,7 +2588,10 @@ mod tests {
25742588
// verification that reused those numbers would judge this helper missing
25752589
// and report a failure on a fresh install that in fact worked.
25762590
let waits = socket_probe::ABSENCE_ATTEMPTS as usize + 1;
2577-
assert!(waits < VERIFY_LISTEN_ATTEMPTS as usize, "the delay is reachable");
2591+
assert!(
2592+
waits < VERIFY_LISTEN_ATTEMPTS as usize,
2593+
"the delay is reachable"
2594+
);
25782595
let fake = Fake::new(World {
25792596
preference: HelperPreference::Granted,
25802597
starts_answering_after: waits,

0 commit comments

Comments
 (0)