Skip to content

Commit ca05ccb

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 37ac481 commit ca05ccb

9 files changed

Lines changed: 592 additions & 3 deletions

File tree

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,61 @@ macOS will refuse to open an unsigned app on a double-click. Either:
169169
xattr -dr com.apple.quarantine "/Applications/Driven.app"
170170
```
171171

172+
#### macOS: Full Disk Access (privacy protection)
173+
174+
macOS TCC ("Transparency, Consent, and Control", the privacy layer) blocks every
175+
app from reading certain locations no matter what the file permissions say -
176+
Mail, Messages, Safari data, Photos, and parts of `~/Library`. When Driven hits
177+
one of these it does not fail the backup: it skips that file, reports it as
178+
`local.permission_denied` in the activity log, and retries it next cycle. The
179+
rest of the source backs up normally.
180+
181+
To back those files up, grant Driven Full Disk Access:
182+
183+
1. Open System Settings > Privacy & Security > Full Disk Access. Driven's
184+
in-app banner has a button that opens this pane directly; from a terminal
185+
the equivalent is:
186+
187+
```sh
188+
open 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'
189+
```
190+
191+
2. Add Driven with the `+` button (or drag `Driven.app` in) and switch it on.
192+
3. Quit and reopen Driven. A TCC grant only applies to a newly launched
193+
process, so the running app will keep skipping until you restart it.
194+
195+
**A grant can silently stop working after an update, because Driven is
196+
unsigned.** macOS ties a Full Disk Access grant to the binary's code signature
197+
(its cdhash), not to its path or name. Driven's V1 builds are not signed with a
198+
Developer ID, so as far as TCC is concerned each new build is a *different*
199+
program. After you install an update the grant may quietly stop applying and the
200+
`local.permission_denied` skips come back, even though Driven still appears in
201+
the Full Disk Access list with its switch on. The fix is to remove the stale
202+
Driven entry from that list, re-add the new app, and relaunch. This is a
203+
consequence of the missing code signature rather than a bug, and it goes away
204+
once macOS code signing lands (see the caveat below).
205+
206+
#### What locked-file backup covers on macOS (and what it does not)
207+
208+
Two different problems are easy to confuse here, and only one of them has a
209+
snapshot-based fix:
210+
211+
| Situation | Reported as | Fix |
212+
|-----------|-------------|-----|
213+
| The file is held open / busy - a live database, a VM disk, a mail store | `local.file_locked` | Turn on "Back up locked files using an APFS snapshot" in Settings > Rules |
214+
| macOS privacy protection denies the read | `local.permission_denied` | Grant Full Disk Access (above). Nothing else works. |
215+
216+
The APFS snapshot option mounts a read-only APFS local snapshot through a small
217+
helper that gets administrator access once per session, so Driven can read a
218+
file that is busy while the app itself stays un-elevated.
219+
220+
It does **not** read around a TCC denial. A snapshot mount preserves the
221+
original file's ownership and the mounted tree is itself subject to TCC. The old
222+
`-o noowners` trick that did bypass this was CVE-2020-9771, fixed by Apple years
223+
ago, and is now a pattern endpoint-security tools flag - Driven deliberately
224+
does not use it. So a TCC-denied file needs Full Disk Access, and no snapshot
225+
setting substitutes for it.
226+
172227
#### macOS auto-updater caveat (V1)
173228

174229
Because the macOS build is not signed with a Developer ID in V1, the in-app

design/DESIGN.md

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

739+
#### 5.3.3 Full Disk Access onboarding (macOS)
740+
741+
The remedy s5.3.2 cannot provide. A `local.permission_denied` skip means TCC
742+
refused the read, and the ONLY fix is the user granting Driven Full Disk
743+
Access. So the UI has to ask, and has to ask well: the failure is silent
744+
from the user's point of view (the backup "succeeds", some files just never
745+
appear), and the error text alone does not tell them where to go.
746+
747+
**Trigger.** A root-mounted banner subscribes to the `activity:new` stream
748+
and latches on the first row whose `event_type` is `local.permission_denied`.
749+
It is root-mounted (the `ToastHost` pattern) rather than living on a single
750+
view, so a denial during a background cycle is not missed just because the
751+
user happens to be on another tab. There is deliberately no per-cycle
752+
bookkeeping: a denial is PERMANENT until the user acts, unlike a lock, so
753+
"has this ever happened" is the right question and a sticky latch is the
754+
right shape. Gating on the `backup_done` row would have been wrong twice
755+
over - failed ops suppress it, and it is emitted per source, not per cycle.
756+
757+
**Deduplication.** Because a denial is permanent, the same file produces one
758+
warn row EVERY cycle, forever - an unbounded stream. The banner therefore
759+
counts DISTINCT files, not rows, so a file denied across fifty cycles is
760+
reported once. This aggregation lives purely in the display layer; neither
761+
the executor's emission nor the activity store changes, so the raw rows stay
762+
available for diagnostics.
763+
764+
**The deep link.** The banner's primary action opens the Full Disk Access
765+
pane directly via
766+
`x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`.
767+
Two things make this work and both are load-bearing: the anchor is
768+
version-sensitive (a wrong or stale one silently lands the user on the
769+
generic Privacy & Security list, which is why it is verified against a
770+
control rather than assumed), and the custom scheme must be added to the
771+
opener plugin's capability scope - `opener:default` only permits
772+
`mailto:`/`tel:`/`http`/`https`, so without an explicit
773+
`opener:allow-open-url` scope entry the button would throw at runtime while
774+
every mocked test still passed.
775+
776+
**Dismissal is per-session, on purpose.** The banner is dismissible, but the
777+
dismissal is in-memory and does not persist across restarts (matching the
778+
one existing dismissible-banner precedent, the updater's). That is the
779+
correct semantic here rather than a shortcut: the underlying condition is
780+
unresolved until FDA is granted, so permanently silencing it would hide a
781+
real, ongoing data-coverage gap. Once the grant is in place the denials stop
782+
and the banner stops appearing on its own.
783+
784+
**The unsigned-binary caveat is part of the copy, not a footnote.** macOS
785+
binds a TCC grant to the binary's code signature (its cdhash), not its path.
786+
Driven's V1 builds are unsigned, so every update is a different program as
787+
far as TCC is concerned and a previously working grant can silently stop
788+
applying - the app still appears in the Full Disk Access list, switch on,
789+
while being denied. This is a recurring, confusing support case rather than
790+
a hypothetical, so the banner states it in one line and the README explains
791+
the remove-and-re-add fix in full. It resolves itself when macOS code
792+
signing lands.
793+
739794
### 5.4 Upload pipeline
740795

741796
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)