Skip to content

Commit bef7da4

Browse files
committed
feat(ui): surface helper state and actions
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.
1 parent 527369e commit bef7da4

16 files changed

Lines changed: 1397 additions & 328 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/orpc/darwin.rs

Lines changed: 50 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,29 @@ 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 takes no decision of its
219+
/// own and opens nothing — only [`helper_grant`] opens Login Items. The one thing
220+
/// it may store is the adoption: a registration that already exists is recorded as
221+
/// the user's earlier opt-in before anything is mutated under it.
222+
///
223+
/// The same single run a status refresh makes, and no more than that: a refresh
224+
/// also starts the convergence loop, and this does not.
225+
async fn helper_status(ctx: OrpcCtx, _input: ()) -> Result<HelperReport, ORPCError> {
226+
Ok(HelperReport::of(&helper_permission::observe(&ctx.app)))
198227
}
199228

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

204-
async fn helper_unregister(_ctx: OrpcCtx, _input: ()) -> Result<HelperServiceStatus, ORPCError> {
205-
service::unregister().map_err(|error| internal_err("darwin.helperUnregister", error))
236+
/// The explicit Disable action: unregister the helper (deferring while an
237+
/// activation runs) and
238+
/// register nothing. No later automatic run overrides it.
239+
async fn helper_disable(ctx: OrpcCtx, _input: ()) -> Result<HelperReport, ORPCError> {
240+
Ok(HelperReport::of(&helper_permission::disable(&ctx.app)))
206241
}
207242

208243
async fn sync_agent_status(_ctx: OrpcCtx, _input: ()) -> Result<SyncAgentStatus, ORPCError> {
@@ -278,14 +313,14 @@ pub fn routes() -> Router<OrpcCtx> {
278313
.output(orpc_specta::specta::<RebuildStatus>())
279314
.handler(rebuild_status),
280315
"helperStatus" => os::<OrpcCtx>()
281-
.output(orpc_specta::specta::<HelperServiceStatus>())
316+
.output(orpc_specta::specta::<HelperReport>())
282317
.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),
318+
"helperGrant" => os::<OrpcCtx>()
319+
.output(orpc_specta::specta::<HelperReport>())
320+
.handler(helper_grant),
321+
"helperDisable" => os::<OrpcCtx>()
322+
.output(orpc_specta::specta::<HelperReport>())
323+
.handler(helper_disable),
289324
"syncAgentStatus" => os::<OrpcCtx>()
290325
.output(orpc_specta::specta::<SyncAgentStatus>())
291326
.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/shared_types/system.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ pub struct Permission {
6969
pub description: String,
7070
/// Whether onboarding requires this permission.
7171
pub required: bool,
72-
/// Whether the app can trigger the system prompt directly.
72+
/// Whether the app can trigger the system prompt directly. False means the
73+
/// row's action can only deep-link into System Settings and wait for the
74+
/// user, which is what the UI renders it as.
75+
///
76+
/// Fixed per row for the TCC permissions, but not a capability in general:
77+
/// the unattended sync helper reports it per observation, and it is false
78+
/// only while macOS holds the registration pending approval in Login Items.
79+
/// Read it as "is System Settings where the user finishes this, right now".
7380
pub can_request_programmatically: bool,
7481
/// Current permission status.
7582
pub status: PermissionStatus,

apps/native/src-tauri/src/state/permissions_state.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
use tauri::{AppHandle, Manager, Runtime};
99

1010
use crate::observable::Observable;
11-
use crate::shared_types::PermissionsState;
11+
use crate::shared_types::{Permission, PermissionsState};
1212
use crate::system::permissions;
1313

1414
pub const PERMISSIONS_CHANGED_EVENT: &str = "permissions_changed";
@@ -28,8 +28,38 @@ pub fn get<R: Runtime>(app: &AppHandle<R>) -> Option<PermissionsState> {
2828
/// Probe all permissions and record the result; the cell write emits
2929
/// `permissions_changed`.
3030
pub fn refresh<R: Runtime>(app: &AppHandle<R>) -> PermissionsState {
31-
let state = permissions::check_all_permissions();
31+
let state = permissions::check_all_permissions(app);
3232
let observable = app.state::<Observable<Option<PermissionsState>>>();
3333
*observable.write_sync() = Some(state.clone());
3434
state
3535
}
36+
37+
/// Replace one permission's row in the last-known state and re-emit.
38+
///
39+
/// The convergence loop cannot use [`refresh`] to publish: that re-probes every
40+
/// permission, and its helper row runs a *second* reconciliation of its own. So
41+
/// the loop reconciles once, turns that report into a row, and drops it in here.
42+
///
43+
/// Does nothing when nothing has been probed yet — startup reconciles before any
44+
/// full probe has run, and inventing the other five rows here would be worse than
45+
/// waiting for the panel's own refresh.
46+
pub fn replace_row<R: Runtime>(app: &AppHandle<R>, row: Permission) {
47+
// A build with the permission skip on reports every row Granted, and the
48+
// onboarding gate depends on that fiction. A real helper row dropped in here
49+
// would flip `all_required_granted` back to false and close the gate the
50+
// flag exists to open.
51+
if permissions::skip_enabled() {
52+
return;
53+
}
54+
let observable = app.state::<Observable<Option<PermissionsState>>>();
55+
let mut cell = observable.write_sync();
56+
let Some(state) = cell.as_mut() else { return };
57+
let Some(slot) = state.permissions.iter_mut().find(|p| p.id == row.id) else {
58+
return;
59+
};
60+
*slot = row;
61+
state.all_required_granted = state
62+
.permissions
63+
.iter()
64+
.all(|p| !p.required || p.status == crate::shared_types::PermissionStatus::Granted);
65+
}

0 commit comments

Comments
 (0)