Skip to content
Merged
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
35 changes: 29 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ These move: check each project's current docs before relying on a cell.
selector.
- Anonymous, opt-out telemetry (coarse counts only; never file names, paths, or
content).
- Guided Full Disk Access onboarding for macOS: when macOS privacy protection
blocks a file, Driven says so and offers a one-click jump to the right
System Settings pane instead of leaving you to find it.

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

There is currently no in-app guided flow for granting Full Disk Access - that
is planned for a future release. Until then, grant it manually if you want
those folders backed up; everything else backs up normally without it.
Driven notices this and offers the fix. The first time a backup is refused,
a banner appears with a button that opens the Full Disk Access pane directly.
You can dismiss it; everything else backs up normally without the grant.

To grant it:

1. Click **Open Full Disk Access settings** in Driven's banner, or from a
terminal:

```sh
open 'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'
```

2. Add Driven with the `+` button (or drag `Driven.app` in) and switch it on.
3. Quit and reopen Driven. A grant only applies to a newly launched process,
so the running app keeps skipping until you restart it.

**The grant can stop working after an update, because Driven is unsigned.**
macOS ties Full Disk Access to the binary's code signature (its cdhash), not
to its name or path. Driven's builds are not signed with a Developer ID, so
every update is a different program as far as the privacy system is concerned.
After installing one, the grant may quietly stop applying and the skips come
back - even though Driven is still listed under Full Disk Access with its
switch on. The fix is to remove the stale Driven entry from that list, add the
new app, and relaunch. This is a consequence of the missing code signature
rather than a bug, and it goes away once macOS code signing lands.

**A locked-file snapshot is not a substitute for Full Disk Access, and Driven
never tries to make it one.** Driven's locked-file handling (Windows VSS,
Expand Down
55 changes: 55 additions & 0 deletions design/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,6 +798,61 @@ patched, and now an EDR-flagged signature we deliberately do not emit. A
`local.permission_denied` file needs Full Disk Access; no snapshot setting
substitutes for it.

#### 5.3.3 Full Disk Access onboarding (macOS)

The remedy s5.3.2 cannot provide. A `local.permission_denied` skip means TCC
refused the read, and the ONLY fix is the user granting Driven Full Disk
Access. So the UI has to ask, and has to ask well: the failure is silent
from the user's point of view (the backup "succeeds", some files just never
appear), and the error text alone does not tell them where to go.

**Trigger.** A root-mounted banner subscribes to the `activity:new` stream
and latches on the first row whose `event_type` is `local.permission_denied`.
It is root-mounted (the `ToastHost` pattern) rather than living on a single
view, so a denial during a background cycle is not missed just because the
user happens to be on another tab. There is deliberately no per-cycle
bookkeeping: a denial is PERMANENT until the user acts, unlike a lock, so
"has this ever happened" is the right question and a sticky latch is the
right shape. Gating on the `backup_done` row would have been wrong twice
over - failed ops suppress it, and it is emitted per source, not per cycle.

**Deduplication.** Because a denial is permanent, the same file produces one
warn row EVERY cycle, forever - an unbounded stream. The banner therefore
counts DISTINCT files, not rows, so a file denied across fifty cycles is
reported once. This aggregation lives purely in the display layer; neither
the executor's emission nor the activity store changes, so the raw rows stay
available for diagnostics.

**The deep link.** The banner's primary action opens the Full Disk Access
pane directly via
`x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`.
Two things make this work and both are load-bearing: the anchor is
version-sensitive (a wrong or stale one silently lands the user on the
generic Privacy & Security list, which is why it is verified against a
control rather than assumed), and the custom scheme must be added to the
opener plugin's capability scope - `opener:default` only permits
`mailto:`/`tel:`/`http`/`https`, so without an explicit
`opener:allow-open-url` scope entry the button would throw at runtime while
every mocked test still passed.

**Dismissal is per-session, on purpose.** The banner is dismissible, but the
dismissal is in-memory and does not persist across restarts (matching the
one existing dismissible-banner precedent, the updater's). That is the
correct semantic here rather than a shortcut: the underlying condition is
unresolved until FDA is granted, so permanently silencing it would hide a
real, ongoing data-coverage gap. Once the grant is in place the denials stop
and the banner stops appearing on its own.

**The unsigned-binary caveat is part of the copy, not a footnote.** macOS
binds a TCC grant to the binary's code signature (its cdhash), not its path.
Driven's V1 builds are unsigned, so every update is a different program as
far as TCC is concerned and a previously working grant can silently stop
applying - the app still appears in the Full Disk Access list, switch on,
while being denied. This is a recurring, confusing support case rather than
a hypothetical, so the banner states it in one line and the README explains
the remove-and-re-add fix in full. It resolves itself when macOS code
signing lands.

### 5.4 Upload pipeline

Per-account:
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"dialog:default",
"updater:default",
"process:default",
"opener:default"
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "x-apple.systempreferences:*" }]
}
]
}
6 changes: 6 additions & 0 deletions ui/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { onMounted } from "vue";
import { useRoute } from "vue-router";
import { useI18n } from "vue-i18n";

import FdaBanner from "./components/FdaBanner.vue";
import GlobalProgressBar from "./components/GlobalProgressBar.vue";
import PausedBanner from "./components/PausedBanner.vue";
import ToastHost from "./components/ToastHost.vue";
Expand Down Expand Up @@ -134,6 +135,11 @@ const NAV_LINK_ACTIVE = "text-teal-700 dark:text-teal-300 font-semibold";
<header class="sticky top-0 z-30 bg-zinc-50 dark:bg-zinc-950" data-testid="app-header">
<GlobalProgressBar />
<PausedBanner />
<!-- macOS TCC (DESIGN s5.3.2): shown only once a read has actually been
refused, so it costs nothing on Windows/Linux. Lives in the sticky
header - and therefore mounts for the app's whole lifetime - so it
cannot miss the `activity:new` denial it subscribes to. -->
<FdaBanner />
<nav
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"
:aria-label="t('nav.primary')"
Expand Down
5 changes: 3 additions & 2 deletions ui/src/__tests__/app-shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,9 @@ describe("App shell", () => {
// progress store's TWO sync events (status_changed for the phase,
// source_progress for the moving counters) + the pause event registered +
// the two ToastHost subscriptions (status_changed for "Backup started",
// activity:new for the backup_done row behind "Backup complete").
expect(listenMock).toHaveBeenCalledTimes(8);
// activity:new for the backup_done row behind "Backup complete") + the
// FdaBanner's own activity:new subscription (the macOS TCC denial row).
expect(listenMock).toHaveBeenCalledTimes(9);
expect(invokeMock).toHaveBeenCalledWith("get_pending_update_info", undefined);
expect(invokeMock).toHaveBeenCalledWith("get_sync_status", undefined);
expect(invokeMock).toHaveBeenCalledWith("get_pause_state", undefined);
Expand Down
Loading