Skip to content

Commit f6d9ca2

Browse files
authored
fix(permissions): clear stale TCC entries on upgrade and grant click (#153)
Signed-off-by: Logan Nguyen <lg.131.dev@gmail.com>
1 parent bdc11ae commit f6d9ca2

6 files changed

Lines changed: 664 additions & 62 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,16 @@ pub fn run() {
11221122
);
11231123
if did_upgrade {
11241124
updater::tcc_reset::tccutil_reset(&app.config().identifier);
1125+
// Persist that the running version's csreq is what
1126+
// owns any TCC entries on disk now (or there are no
1127+
// entries, which is also fine). The click-time grant
1128+
// flow consults this so the user's first grant click
1129+
// after an upgrade does not trigger a second
1130+
// reset+relaunch on top of the one we are about to
1131+
// schedule below. Held in the sidecar (not memory)
1132+
// because the relaunch wipes any in-process state
1133+
// before the user could ever click.
1134+
sidecar.last_reset_for_version = Some(running_version.clone());
11251135
}
11261136

11271137
// Restore persisted snooze flags into the live state.
@@ -1135,6 +1145,9 @@ pub fn run() {
11351145
// snooze.
11361146
updater_state
11371147
.set_last_seen_update_version(sidecar.last_seen_update_version.clone());
1148+
// Mirror the on-disk reset marker so click-time decisions
1149+
// don't have to re-read the sidecar.
1150+
updater_state.set_last_reset_for_version(sidecar.last_reset_for_version.clone());
11381151

11391152
// Record the running version BEFORE any potential restart
11401153
// so the post-restart launch reads a sidecar where the
@@ -1339,7 +1352,11 @@ pub fn run() {
13391352
#[cfg(not(coverage))]
13401353
updater::commands::snooze_update_chat,
13411354
#[cfg(not(coverage))]
1342-
updater::commands::snooze_update_settings
1355+
updater::commands::snooze_update_settings,
1356+
#[cfg(not(coverage))]
1357+
updater::commands::reset_and_relaunch_for_grant,
1358+
#[cfg(not(coverage))]
1359+
updater::commands::consume_pending_grant_resume
13431360
])
13441361
.build(tauri::generate_context!())
13451362
.expect("error while building tauri application")

src-tauri/src/updater/commands.rs

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::config::defaults::{DEFAULT_UPDATER_STATE_FILENAME, MAX_UPDATER_SNOOZE_HOURS};
22
use crate::updater::poller;
3-
use crate::updater::state::{UpdaterSnapshot, UpdaterState};
3+
use crate::updater::state::{SnoozeSidecar, UpdaterSnapshot, UpdaterState};
4+
use crate::updater::tcc_reset;
45
use std::path::PathBuf;
56
use std::time::{SystemTime, UNIX_EPOCH};
67
use tauri::{AppHandle, Manager, State};
@@ -107,6 +108,121 @@ fn sidecar_path(app: &AppHandle) -> Result<PathBuf, String> {
107108
Ok(dir.join(DEFAULT_UPDATER_STATE_FILENAME))
108109
}
109110

111+
/// Stores `service` on `sidecar` so the post-restart launch can resume the
112+
/// grant flow. Returns `Err` when the service string is not one of the
113+
/// values Thuki resets at click time, so callers cannot smuggle arbitrary
114+
/// strings into a later `tccutil reset` invocation.
115+
pub fn prepare_pending_reregister(
116+
sidecar: &mut SnoozeSidecar,
117+
service: &str,
118+
) -> Result<&'static str, String> {
119+
let canonical = tcc_reset::validate_click_time_service(service)
120+
.ok_or_else(|| format!("unsupported tcc service: {service}"))?;
121+
sidecar.pending_reregister = Some(canonical.to_string());
122+
Ok(canonical)
123+
}
124+
125+
/// Removes `pending_reregister` from `sidecar` and returns its previous
126+
/// value. The caller is responsible for persisting the cleared sidecar so
127+
/// the resume flow does not loop on the next restart.
128+
pub fn take_pending_reregister(sidecar: &mut SnoozeSidecar) -> Option<String> {
129+
sidecar.pending_reregister.take()
130+
}
131+
132+
/// Pure decision helper. Returns `true` when the click-time reset can be
133+
/// skipped because the startup path already cleared TCC for the running
134+
/// version. The marker survives A's reset+restart by being persisted to
135+
/// the sidecar, which is why the comparison is meaningful even though
136+
/// `was_reset_at_startup` would always be `false` on a freshly relaunched
137+
/// process.
138+
pub fn click_time_reset_can_skip(
139+
last_reset_for_version: Option<&str>,
140+
running_version: &str,
141+
) -> bool {
142+
last_reset_for_version == Some(running_version)
143+
}
144+
145+
/// Click-time grant flow: persist a "resume after restart" marker, clear
146+
/// the stale TCC entry for the requested service, and relaunch. The
147+
/// frontend hands the service string straight in, so the validator inside
148+
/// `prepare_pending_reregister` is the trust boundary.
149+
///
150+
/// Returns `true` when a relaunch has been scheduled and `false` when the
151+
/// running process already has a clean TCC slate (the startup path's most
152+
/// recent reset matches the running version). In the `false` case the
153+
/// frontend should run the in-line open-Settings + polling flow without
154+
/// expecting a relaunch.
155+
///
156+
/// Sequencing matters when a relaunch is scheduled. Sidecar must be saved
157+
/// BEFORE `tccutil reset` runs so a crash between the two does not leave
158+
/// the user with a cleared grant and no resume marker. The restart is
159+
/// deferred so Tauri can finish dispatching the IPC reply (otherwise the
160+
/// frontend sees a disconnect error rather than a clean relaunch).
161+
#[cfg_attr(coverage_nightly, coverage(off))]
162+
#[tauri::command]
163+
pub fn reset_and_relaunch_for_grant(
164+
app: AppHandle,
165+
state: State<'_, UpdaterState>,
166+
service: String,
167+
) -> Result<bool, String> {
168+
// Validate first so a hostile string never reaches `tccutil` even when
169+
// the startup-clean path skips the reset.
170+
let canonical = tcc_reset::validate_click_time_service(&service)
171+
.ok_or_else(|| format!("unsupported tcc service: {service}"))?;
172+
173+
let running = app.package_info().version.to_string();
174+
let snooze = state.snooze_clone();
175+
if click_time_reset_can_skip(snooze.last_reset_for_version.as_deref(), &running) {
176+
// Startup path already reset TCC for this exact version, so the
177+
// running binary's csreq already owns whatever TCC entries (if
178+
// any) System Settings will display. A second reset+relaunch
179+
// would only add a jarring quit on every grant click.
180+
return Ok(false);
181+
}
182+
183+
let mut snooze = snooze;
184+
prepare_pending_reregister(&mut snooze, canonical)?;
185+
186+
let path = sidecar_path(&app)?;
187+
snooze.save(&path).map_err(|e| e.to_string())?;
188+
state.set_pending_reregister(Some(canonical.to_string()));
189+
190+
let bundle_id = app.config().identifier.clone();
191+
tcc_reset::tccutil_reset_service(&bundle_id, canonical);
192+
193+
let app_handle = app.clone();
194+
tauri::async_runtime::spawn(async move {
195+
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
196+
eprintln!(
197+
"thuki: [updater] relaunching after click-time TCC reset \
198+
to refresh tccd PID tracking"
199+
);
200+
app_handle.restart();
201+
});
202+
203+
Ok(true)
204+
}
205+
206+
/// Frontend-facing companion to `reset_and_relaunch_for_grant`. Reads the
207+
/// `pending_reregister` flag, clears it (in memory and on disk), and
208+
/// returns the value so PermissionsStep can resume the right step on a
209+
/// fresh launch without forcing the user to click a second time.
210+
#[cfg_attr(coverage_nightly, coverage(off))]
211+
#[tauri::command]
212+
pub fn consume_pending_grant_resume(
213+
app: AppHandle,
214+
state: State<'_, UpdaterState>,
215+
) -> Result<Option<String>, String> {
216+
let mut snooze = state.snooze_clone();
217+
let value = take_pending_reregister(&mut snooze);
218+
if value.is_some() {
219+
let path = sidecar_path(&app)?;
220+
snooze.save(&path).map_err(|e| e.to_string())?;
221+
state.set_pending_reregister(None);
222+
}
223+
Ok(value)
224+
}
225+
110226
#[cfg(test)]
111227
mod tests {
112228
use super::*;
@@ -144,4 +260,66 @@ mod tests {
144260
fn snooze_deadline_zero_hours_is_now() {
145261
assert_eq!(snooze_deadline(1_700_000_000, 0), 1_700_000_000);
146262
}
263+
264+
#[test]
265+
fn prepare_pending_reregister_accepts_accessibility() {
266+
let mut sidecar = SnoozeSidecar::default();
267+
let canonical = prepare_pending_reregister(&mut sidecar, "Accessibility").unwrap();
268+
assert_eq!(canonical, "Accessibility");
269+
assert_eq!(sidecar.pending_reregister.as_deref(), Some("Accessibility"));
270+
}
271+
272+
#[test]
273+
fn prepare_pending_reregister_accepts_screen_capture() {
274+
let mut sidecar = SnoozeSidecar::default();
275+
let canonical = prepare_pending_reregister(&mut sidecar, "ScreenCapture").unwrap();
276+
assert_eq!(canonical, "ScreenCapture");
277+
assert_eq!(sidecar.pending_reregister.as_deref(), Some("ScreenCapture"));
278+
}
279+
280+
#[test]
281+
fn prepare_pending_reregister_rejects_unsupported_service() {
282+
let mut sidecar = SnoozeSidecar::default();
283+
let err = prepare_pending_reregister(&mut sidecar, "Camera").unwrap_err();
284+
assert!(err.contains("Camera"), "error must surface offending value");
285+
// Sidecar must remain untouched on rejection so a hostile call
286+
// cannot pollute the persisted resume marker.
287+
assert!(sidecar.pending_reregister.is_none());
288+
}
289+
290+
#[test]
291+
fn take_pending_reregister_returns_and_clears_value() {
292+
let mut sidecar = SnoozeSidecar {
293+
pending_reregister: Some("Accessibility".to_string()),
294+
..SnoozeSidecar::default()
295+
};
296+
assert_eq!(
297+
take_pending_reregister(&mut sidecar),
298+
Some("Accessibility".to_string()),
299+
);
300+
assert!(sidecar.pending_reregister.is_none());
301+
}
302+
303+
#[test]
304+
fn take_pending_reregister_returns_none_when_unset() {
305+
let mut sidecar = SnoozeSidecar::default();
306+
assert!(take_pending_reregister(&mut sidecar).is_none());
307+
}
308+
309+
#[test]
310+
fn click_time_reset_can_skip_when_versions_match() {
311+
assert!(click_time_reset_can_skip(Some("0.8.5"), "0.8.5"));
312+
}
313+
314+
#[test]
315+
fn click_time_reset_does_not_skip_when_versions_differ() {
316+
assert!(!click_time_reset_can_skip(Some("0.8.4"), "0.8.5"));
317+
}
318+
319+
#[test]
320+
fn click_time_reset_does_not_skip_when_marker_is_absent() {
321+
// No prior startup reset for this binary recorded: a stale csreq
322+
// grant could still be on disk, so the click MUST clean it up.
323+
assert!(!click_time_reset_can_skip(None, "0.8.5"));
324+
}
147325
}

src-tauri/src/updater/state.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,22 @@ pub struct SnoozeSidecar {
3232
/// just in memory) so the comparison survives an app restart.
3333
#[serde(default)]
3434
pub last_seen_update_version: Option<String>,
35+
/// TCC service the user just clicked "Grant" for, persisted across the
36+
/// reset+relaunch cycle so the post-restart launch knows to resume the
37+
/// grant flow (open System Settings, trigger a new csreq registration)
38+
/// instead of waiting for another click. Cleared on consumption.
39+
#[serde(default)]
40+
pub pending_reregister: Option<String>,
41+
/// SemVer string of the binary the startup path most recently ran a
42+
/// `tccutil reset` for. The click-time grant flow consults this to
43+
/// decide whether ANOTHER reset+relaunch is necessary: if it matches
44+
/// the running version, the running binary's csreq already owns the
45+
/// only TCC entries on disk (or there are none), so a click does not
46+
/// need a redundant restart. Persisted (rather than held in memory)
47+
/// because A's reset+restart drops any in-process flag before the
48+
/// user can ever click.
49+
#[serde(default)]
50+
pub last_reset_for_version: Option<String>,
3551
}
3652

3753
impl SnoozeSidecar {
@@ -135,13 +151,31 @@ impl UpdaterState {
135151
inner.snooze.last_seen_update_version = version;
136152
}
137153

154+
/// Mirror the on-disk `pending_reregister` field into the live state
155+
/// so a crash before the next sidecar save still leaves the in-memory
156+
/// view consistent with what was persisted.
157+
pub fn set_pending_reregister(&self, value: Option<String>) {
158+
let mut inner = self.inner.lock().expect("updater state mutex");
159+
inner.snooze.pending_reregister = value;
160+
}
161+
138162
pub fn snooze_clone(&self) -> SnoozeSidecar {
139163
self.inner
140164
.lock()
141165
.expect("updater state mutex")
142166
.snooze
143167
.clone()
144168
}
169+
170+
/// Mirrors the persisted `last_reset_for_version` from the sidecar
171+
/// into live state. Called both at startup (when seeding from disk)
172+
/// and immediately after a successful startup-time `tccutil reset`
173+
/// (so the click-time grant flow sees the new value without re-reading
174+
/// the sidecar).
175+
pub fn set_last_reset_for_version(&self, version: Option<String>) {
176+
let mut inner = self.inner.lock().expect("updater state mutex");
177+
inner.snooze.last_reset_for_version = version;
178+
}
145179
}
146180

147181
#[derive(Debug, Clone, Serialize)]
@@ -179,6 +213,8 @@ mod tests {
179213
chat_snoozed_until: Some(1_700_001_000),
180214
last_launched_version: None,
181215
last_seen_update_version: None,
216+
pending_reregister: None,
217+
last_reset_for_version: Some("0.8.5".to_string()),
182218
};
183219
original.save(&path).unwrap();
184220

@@ -205,13 +241,53 @@ mod tests {
205241
chat_snoozed_until: None,
206242
last_launched_version: Some("0.8.1".to_string()),
207243
last_seen_update_version: None,
244+
pending_reregister: None,
245+
last_reset_for_version: None,
246+
};
247+
original.save(&path).unwrap();
248+
249+
let loaded = SnoozeSidecar::load(&path).unwrap();
250+
assert_eq!(loaded, original);
251+
}
252+
253+
#[test]
254+
fn snooze_sidecar_round_trips_pending_reregister() {
255+
let dir = tempfile::tempdir().unwrap();
256+
let path = dir.path().join("updater_state.json");
257+
258+
let original = SnoozeSidecar {
259+
settings_snoozed_until: None,
260+
chat_snoozed_until: None,
261+
last_launched_version: Some("0.8.5".to_string()),
262+
last_seen_update_version: None,
263+
pending_reregister: Some("Accessibility".to_string()),
264+
last_reset_for_version: Some("0.8.5".to_string()),
208265
};
209266
original.save(&path).unwrap();
210267

211268
let loaded = SnoozeSidecar::load(&path).unwrap();
212269
assert_eq!(loaded, original);
213270
}
214271

272+
#[test]
273+
fn snooze_sidecar_back_compat_old_file_without_pending_reregister() {
274+
// Sidecars written before the click-time grant flow shipped lack
275+
// the field. Loading must default it to None so existing snooze /
276+
// version state is preserved across the upgrade.
277+
let dir = tempfile::tempdir().unwrap();
278+
let path = dir.path().join("updater_state.json");
279+
std::fs::write(
280+
&path,
281+
r#"{"settings_snoozed_until":null,"chat_snoozed_until":null,
282+
"last_launched_version":"0.8.4","last_seen_update_version":null}"#,
283+
)
284+
.unwrap();
285+
286+
let loaded = SnoozeSidecar::load(&path).unwrap();
287+
assert_eq!(loaded.last_launched_version.as_deref(), Some("0.8.4"));
288+
assert!(loaded.pending_reregister.is_none());
289+
}
290+
215291
#[test]
216292
fn snooze_sidecar_back_compat_old_file_without_version_field() {
217293
// Old (pre-0.8.2) sidecar files were written without the
@@ -406,6 +482,30 @@ mod tests {
406482
assert_eq!(snap.settings_snoozed_until, Some(2));
407483
}
408484

485+
#[test]
486+
fn set_last_reset_for_version_round_trips_through_snooze_clone() {
487+
let state = UpdaterState::default();
488+
state.set_last_reset_for_version(Some("0.8.5".to_string()));
489+
assert_eq!(
490+
state.snooze_clone().last_reset_for_version.as_deref(),
491+
Some("0.8.5"),
492+
);
493+
state.set_last_reset_for_version(None);
494+
assert!(state.snooze_clone().last_reset_for_version.is_none());
495+
}
496+
497+
#[test]
498+
fn set_pending_reregister_round_trips_through_snooze_clone() {
499+
let state = UpdaterState::default();
500+
state.set_pending_reregister(Some("Accessibility".to_string()));
501+
assert_eq!(
502+
state.snooze_clone().pending_reregister.as_deref(),
503+
Some("Accessibility"),
504+
);
505+
state.set_pending_reregister(None);
506+
assert!(state.snooze_clone().pending_reregister.is_none());
507+
}
508+
409509
#[test]
410510
fn system_time_to_unix_returns_some_for_now() {
411511
let now = SystemTime::now();

0 commit comments

Comments
 (0)