Skip to content

Commit fe3ea32

Browse files
committed
feat(ui): guide macOS users to grant Full Disk Access when files are denied
A TCC denial is invisible from the user's side: the backup "succeeds", some files just never reach Drive. Add a root-mounted dismissible banner that latches on the first local.permission_denied activity row, counts DISTINCT files (a denial is permanent, so the same file is re-reported every cycle, unbounded), and offers a one-click deep link to the Full Disk Access pane. The deep link needs an explicit opener capability scope: opener:default only permits mailto/tel/http/https, so a custom scheme would throw at runtime while every mocked test still passed. Document in README and DESIGN s5.3.3 that APFS snapshots do NOT bypass TCC, and that an unsigned binary's TCC grant can silently invalidate on update because macOS binds the grant to the code signature.
1 parent beb9dc4 commit fe3ea32

9 files changed

Lines changed: 566 additions & 9 deletions

File tree

README.md

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,9 @@ These move: check each project's current docs before relying on a cell.
139139
selector.
140140
- Anonymous, opt-out telemetry (coarse counts only; never file names, paths, or
141141
content).
142+
- Guided Full Disk Access onboarding for macOS: when macOS privacy protection
143+
blocks a file, Driven says so and offers a one-click jump to the right
144+
System Settings pane instead of leaving you to find it.
142145

143146
<!--
144147
DRAFT - do not uncomment until the corresponding PR merges. Flip each bullet
@@ -147,9 +150,6 @@ on individually as its PR lands, then delete this comment wrapper.
147150
unmerged - depends on the pluggable-backend seam / `driven-remote` crate,
148151
#200, also unmerged.)
149152
- Local / removable-drive backup destination. (unmerged - same seam as above.)
150-
- Guided Full Disk Access onboarding for macOS, so Driven walks you through
151-
granting access to Mail / Messages / Photos instead of requiring a manual
152-
System Settings visit. (#205, unmerged.)
153153
- Scheduled integrity scrub that periodically re-verifies already-backed-up
154154
files against the destination. (unmerged.)
155155
- Restore drill: a one-click "prove the backup actually restores" check.
@@ -204,9 +204,32 @@ Disk Access. A file in that state fails to open with a permission error, not a
204204
"file is busy" error, and Driven reports it in the activity log rather than
205205
silently skipping it or silently backing it up.
206206

207-
There is currently no in-app guided flow for granting Full Disk Access - that
208-
is planned for a future release. Until then, grant it manually if you want
209-
those folders backed up; everything else backs up normally without it.
207+
Driven notices this and offers the fix. The first time a backup is refused,
208+
a banner appears with a button that opens the Full Disk Access pane directly.
209+
You can dismiss it; everything else backs up normally without the grant.
210+
211+
To grant it:
212+
213+
1. Click **Open Full Disk Access settings** in Driven's banner, or from a
214+
terminal:
215+
216+
```sh
217+
open 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'
218+
```
219+
220+
2. Add Driven with the `+` button (or drag `Driven.app` in) and switch it on.
221+
3. Quit and reopen Driven. A grant only applies to a newly launched process,
222+
so the running app keeps skipping until you restart it.
223+
224+
**The grant can stop working after an update, because Driven is unsigned.**
225+
macOS ties Full Disk Access to the binary's code signature (its cdhash), not
226+
to its name or path. Driven's builds are not signed with a Developer ID, so
227+
every update is a different program as far as the privacy system is concerned.
228+
After installing one, the grant may quietly stop applying and the skips come
229+
back - even though Driven is still listed under Full Disk Access with its
230+
switch on. The fix is to remove the stale Driven entry from that list, add the
231+
new app, and relaunch. This is a consequence of the missing code signature
232+
rather than a bug, and it goes away once macOS code signing lands.
210233

211234
**A locked-file snapshot is not a substitute for Full Disk Access, and Driven
212235
never tries to make it one.** Driven's locked-file handling (Windows VSS,

design/DESIGN.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -798,6 +798,61 @@ patched, and now an EDR-flagged signature we deliberately do not emit. A
798798
`local.permission_denied` file needs Full Disk Access; no snapshot setting
799799
substitutes for it.
800800

801+
#### 5.3.3 Full Disk Access onboarding (macOS)
802+
803+
The remedy s5.3.2 cannot provide. A `local.permission_denied` skip means TCC
804+
refused the read, and the ONLY fix is the user granting Driven Full Disk
805+
Access. So the UI has to ask, and has to ask well: the failure is silent
806+
from the user's point of view (the backup "succeeds", some files just never
807+
appear), and the error text alone does not tell them where to go.
808+
809+
**Trigger.** A root-mounted banner subscribes to the `activity:new` stream
810+
and latches on the first row whose `event_type` is `local.permission_denied`.
811+
It is root-mounted (the `ToastHost` pattern) rather than living on a single
812+
view, so a denial during a background cycle is not missed just because the
813+
user happens to be on another tab. There is deliberately no per-cycle
814+
bookkeeping: a denial is PERMANENT until the user acts, unlike a lock, so
815+
"has this ever happened" is the right question and a sticky latch is the
816+
right shape. Gating on the `backup_done` row would have been wrong twice
817+
over - failed ops suppress it, and it is emitted per source, not per cycle.
818+
819+
**Deduplication.** Because a denial is permanent, the same file produces one
820+
warn row EVERY cycle, forever - an unbounded stream. The banner therefore
821+
counts DISTINCT files, not rows, so a file denied across fifty cycles is
822+
reported once. This aggregation lives purely in the display layer; neither
823+
the executor's emission nor the activity store changes, so the raw rows stay
824+
available for diagnostics.
825+
826+
**The deep link.** The banner's primary action opens the Full Disk Access
827+
pane directly via
828+
`x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`.
829+
Two things make this work and both are load-bearing: the anchor is
830+
version-sensitive (a wrong or stale one silently lands the user on the
831+
generic Privacy & Security list, which is why it is verified against a
832+
control rather than assumed), and the custom scheme must be added to the
833+
opener plugin's capability scope - `opener:default` only permits
834+
`mailto:`/`tel:`/`http`/`https`, so without an explicit
835+
`opener:allow-open-url` scope entry the button would throw at runtime while
836+
every mocked test still passed.
837+
838+
**Dismissal is per-session, on purpose.** The banner is dismissible, but the
839+
dismissal is in-memory and does not persist across restarts (matching the
840+
one existing dismissible-banner precedent, the updater's). That is the
841+
correct semantic here rather than a shortcut: the underlying condition is
842+
unresolved until FDA is granted, so permanently silencing it would hide a
843+
real, ongoing data-coverage gap. Once the grant is in place the denials stop
844+
and the banner stops appearing on its own.
845+
846+
**The unsigned-binary caveat is part of the copy, not a footnote.** macOS
847+
binds a TCC grant to the binary's code signature (its cdhash), not its path.
848+
Driven's V1 builds are unsigned, so every update is a different program as
849+
far as TCC is concerned and a previously working grant can silently stop
850+
applying - the app still appears in the Full Disk Access list, switch on,
851+
while being denied. This is a recurring, confusing support case rather than
852+
a hypothetical, so the banner states it in one line and the README explains
853+
the remove-and-re-add fix in full. It resolves itself when macOS code
854+
signing lands.
855+
801856
### 5.4 Upload pipeline
802857

803858
Per-account:

src-tauri/capabilities/default.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
"dialog:default",
1010
"updater:default",
1111
"process:default",
12-
"opener:default"
12+
"opener:default",
13+
{
14+
"identifier": "opener:allow-open-url",
15+
"allow": [{ "url": "x-apple.systempreferences:*" }]
16+
}
1317
]
1418
}

ui/src/App.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { onMounted } from "vue";
33
import { useRoute } from "vue-router";
44
import { useI18n } from "vue-i18n";
55
6+
import FdaBanner from "./components/FdaBanner.vue";
67
import GlobalProgressBar from "./components/GlobalProgressBar.vue";
78
import PausedBanner from "./components/PausedBanner.vue";
89
import ToastHost from "./components/ToastHost.vue";
@@ -134,6 +135,11 @@ const NAV_LINK_ACTIVE = "text-teal-700 dark:text-teal-300 font-semibold";
134135
<header class="sticky top-0 z-30 bg-zinc-50 dark:bg-zinc-950" data-testid="app-header">
135136
<GlobalProgressBar />
136137
<PausedBanner />
138+
<!-- macOS TCC (DESIGN s5.3.2): shown only once a read has actually been
139+
refused, so it costs nothing on Windows/Linux. Lives in the sticky
140+
header - and therefore mounts for the app's whole lifetime - so it
141+
cannot miss the `activity:new` denial it subscribes to. -->
142+
<FdaBanner />
137143
<nav
138144
class="flex flex-wrap items-center gap-x-6 gap-y-2 border-b border-zinc-200 bg-white px-6 py-3 text-sm dark:border-zinc-800 dark:bg-zinc-900"
139145
:aria-label="t('nav.primary')"

ui/src/__tests__/app-shell.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,9 @@ describe("App shell", () => {
171171
// progress store's TWO sync events (status_changed for the phase,
172172
// source_progress for the moving counters) + the pause event registered +
173173
// the two ToastHost subscriptions (status_changed for "Backup started",
174-
// activity:new for the backup_done row behind "Backup complete").
175-
expect(listenMock).toHaveBeenCalledTimes(8);
174+
// activity:new for the backup_done row behind "Backup complete") + the
175+
// FdaBanner's own activity:new subscription (the macOS TCC denial row).
176+
expect(listenMock).toHaveBeenCalledTimes(9);
176177
expect(invokeMock).toHaveBeenCalledWith("get_pending_update_info", undefined);
177178
expect(invokeMock).toHaveBeenCalledWith("get_sync_status", undefined);
178179
expect(invokeMock).toHaveBeenCalledWith("get_pause_state", undefined);

0 commit comments

Comments
 (0)