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))
}
65 changes: 50 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,29 @@ 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: 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<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 +313,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
9 changes: 8 additions & 1 deletion apps/native/src-tauri/src/shared_types/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 32 additions & 2 deletions apps/native/src-tauri/src/state/permissions_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -28,8 +28,38 @@ pub fn get<R: Runtime>(app: &AppHandle<R>) -> Option<PermissionsState> {
/// Probe all permissions and record the result; the cell write emits
/// `permissions_changed`.
pub fn refresh<R: Runtime>(app: &AppHandle<R>) -> PermissionsState {
let state = permissions::check_all_permissions();
let state = permissions::check_all_permissions(app);
let observable = app.state::<Observable<Option<PermissionsState>>>();
*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<R: Runtime>(app: &AppHandle<R>, 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::<Observable<Option<PermissionsState>>>();
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);
}
Loading
Loading