Skip to content

Commit d378f42

Browse files
committed
M5: Downloads notifications (toast + native, one bridge)
Adds the passive-nudge layer of the downloads watcher: when a file lands in `~/Downloads`, fan out per the settings enum `behavior.fileSystemWatching.downloadsNotifications` (`in-app | macos | both | neither`, default `in-app`) to an in-app toast and/or a macOS native notification. - Add `tauri-plugin-notification` 2.3.3 (Rust + JS, both ≥14 days old on crates.io / npmjs.com); wire the plugin into `lib.rs`; add `notification:default` to `capabilities/default.json`. - New `lib/downloads/event-bridge.svelte.ts`: one `download-detected` listener mounted from `(main)/+page.svelte`. Reads the mode per event, re-checks the FDA gate as defense in depth (the watcher already gates on it), asks the OS for notification permission the first time the macOS path is taken, surfaces a single INFO toast on denial without flipping the user's setting. - New `lib/downloads/DownloadToastContent.svelte`: title with filename + size, optional "in Downloads/<subdir>/" line, snapshotted shortcut hint, "Jump to file" + "Stop showing these" actions, whole-body mouse-clickable but not keyboard-focusable (the buttons own keyboard activation independently). - New `lib/downloads/notifications-mode.ts`: try/catch'd reader and writer for the setting key, plus the Settings deep-link target. The M7 registry entry isn't present yet; the wrapper safely falls back to `'in-app'` until M7 lands. - New `revealPath(explorer, dir, name)` in `reveal.ts`: navigate to a SPECIFIC file's parent and select it, bypassing the latest-in-ring lookup. The toast uses this so a burst of downloads each lands the user on the file the toast was for, not whichever became "latest" while they were reading. - New `lib/downloads/CLAUDE.md` covering architecture, the four dispatch branches, the snapshot-at-creation rule, the reveal-by-path vs reveal-latest distinction, and the FDA defense-in-depth path. - Vitest coverage: 7 behavior tests on the toast component (filename, shortcut hint snapshotting, primary button calls reveal-by-path, body-click also reveals, stop-propagation on buttons, mouse-only click body), a tier-3 a11y test, and 9 bridge tests across all four settings values plus the macOS permission grant/deny paths.
1 parent 39e083b commit d378f42

15 files changed

Lines changed: 1168 additions & 8 deletions

Cargo.lock

Lines changed: 69 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"@tauri-apps/api": "^2.10.1",
4040
"@tauri-apps/plugin-dialog": "^2.7.0",
4141
"@tauri-apps/plugin-fs": "^2.5.0",
42+
"@tauri-apps/plugin-notification": "^2.3.3",
4243
"@tauri-apps/plugin-opener": "^2.5.3",
4344
"@tauri-apps/plugin-process": "^2.3.1",
4445
"@tauri-apps/plugin-store": "^2.4.2",

apps/desktop/src-tauri/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ tauri-plugin-opener = "2"
3838
tauri-plugin-mcp-bridge = "0.11"
3939
tauri-plugin-store = "2"
4040
tauri-plugin-dialog = "2.6"
41+
tauri-plugin-notification = "2.3.3"
4142
serde = { version = "1", features = ["derive"] }
4243
serde_json = "1"
4344
notify = "8"

apps/desktop/src-tauri/capabilities/default.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"fs:allow-remove",
3636
"updater:default",
3737
"process:allow-restart",
38-
"dialog:allow-ask"
38+
"dialog:allow-ask",
39+
"notification:default"
3940
]
4041
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ pub fn run() {
300300
.plugin(tauri_plugin_fs::init())
301301
.plugin(tauri_plugin_process::init())
302302
.plugin(tauri_plugin_dialog::init())
303+
.plugin(tauri_plugin_notification::init())
303304
.setup(|app| {
304305
// === Logging setup ===
305306
//
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
# Downloads (frontend)
2+
3+
Frontend half of the downloads-watcher feature. Wires the backend `download-detected` Tauri event to the right user
4+
surface (in-app toast, macOS native notification, both, or neither) and owns the "Reveal latest download" / "Reveal this
5+
specific file" navigation helpers.
6+
7+
Backend counterpart: [`src-tauri/src/downloads/CLAUDE.md`](../../../src-tauri/src/downloads/CLAUDE.md).
8+
9+
## Architecture
10+
11+
| File | Purpose |
12+
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
13+
| `reveal.ts` | `revealLatestDownload(explorer)` (M4): consult ring + scan fallback. `revealPath(explorer, dir, name)` (M5): jump to a specific file. |
14+
| `RevealEmptyToastContent.svelte` | M4 INFO toast: "Your Downloads folder is empty…" with a "Go to Downloads" action. |
15+
| `RevealFdaToastContent.svelte` | M4 INFO toast: "Cmdr needs Full Disk Access…" with an "Open System Settings" action. |
16+
| `reveal-ids.ts` | Dedup ids for M4's INFO toasts. |
17+
| `event-bridge.svelte.ts` | M5 listener bridge: one `download-detected` subscription, dispatches per the settings enum. |
18+
| `DownloadToastContent.svelte` | M5 in-app toast: title with filename + size, optional subdir line, snapshotted shortcut hint, Jump + Stop-showing actions. |
19+
| `notifications-mode.ts` | Reader, writer, and deep-link helper for `behavior.fileSystemWatching.downloadsNotifications`. |
20+
21+
## Settings-gated dispatch
22+
23+
`startDownloadsEventBridge` reads `getDownloadsNotificationsMode()` per event and fans out to:
24+
25+
- `'in-app'``addToast(DownloadToastContent, ...)` only.
26+
- `'macos'``sendNotification(...)` from `@tauri-apps/plugin-notification` only.
27+
- `'both'` → both.
28+
- `'neither'` → no-op.
29+
30+
The macOS native path also asks the OS for permission the first time a session needs it. On denial we surface a single
31+
INFO toast with a stable dedup id; we DON'T flip the user's setting and we DON'T retry. The user can re-enable in System
32+
Settings whenever; their preference stays put.
33+
34+
## Snapshot-at-creation rule
35+
36+
The shortcut hint shown on each in-app toast is the value of `getEffectiveShortcuts('downloads.revealLatest')[0]` at
37+
toast-creation time, passed as a prop. A remap that happens between this toast appearing and the user clicking does NOT
38+
change what's displayed — that would be confusing, because the hint would no longer match what the user actually pressed
39+
when the toast first showed up. The next toast picks up the new binding naturally.
40+
41+
Pure-prop-driven: the toast component reads `event`, `shortcutHint`, `explorer`, and `toastId` once on mount. No live
42+
subscriptions, no module state. The `ToastItem` host extends the toast store with a `props` field (see `lib/ui/toast/`)
43+
which is forwarded only to component-content toasts that opt in; existing toasts that don't pass `props` keep their
44+
zero-prop shape.
45+
46+
## Reveal-by-path vs reveal-latest
47+
48+
| Helper | When to call |
49+
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50+
| `revealLatestDownload(...)` | The user pressed ⌘J (or another "jump to latest" affordance). Consults the watcher's ring; falls back to a Downloads scan when the ring is empty. |
51+
| `revealPath(explorer, ...)` | The user clicked or pressed Jump on a SPECIFIC toast. Takes them to the file the toast was for, even if a newer download has landed and become "the latest." |
52+
53+
The split matters when a burst of downloads arrives: each toast must take the user to the file IT advertised, not to
54+
whichever file is most recent at click time.
55+
56+
## FDA defense-in-depth
57+
58+
The watcher won't emit `download-detected` when the FDA gate is closed — that's the contract enforced in
59+
`runtime::refresh_runtime`. The bridge re-checks the gate per event anyway (one `commands.downloadsWatcherStatus()`
60+
call) before surfacing any toast or OS notification. This guards against a stale event slipping through during a gate
61+
flip and mirrors the same defensive shape `revealLatestDownload` uses.
62+
63+
## Clickability shape
64+
65+
The downloads toast is whole-body clickable for mouse, but the clickable surface is NOT keyboard-focusable. The two
66+
explicit buttons inside ("Jump to file" and "Stop showing these") own keyboard activation independently; the body click
67+
is a mouse-only convenience.
68+
69+
Both buttons call `event.stopPropagation()` in their click handlers so the body-click reveal doesn't also fire
70+
underneath (otherwise "Stop showing these" would navigate to the file before the Settings window came up).
71+
72+
## Settings registry note
73+
74+
The `behavior.fileSystemWatching.downloadsNotifications` registry entry is M7's territory. M5 reads the setting via
75+
try-catch'd `getSetting` so the key path works whether or not the registry knows about it yet; the documented default is
76+
`'in-app'`. Once M7 lands the entry, the try-catch becomes belt-and-braces with no behavior change.
77+
78+
## Deep-link target
79+
80+
`openSettingsToDownloadsNotifications` currently opens `Behavior > Drive indexing` — that's where the "Notify on
81+
~/Downloads changes" sub-group will live once M7 renames the section to "File system watching." M7 swaps the section
82+
path and (if the deep-link helper grows sub-group anchor support) focuses the specific row.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, vi } from 'vitest'
2+
import { mount, tick } from 'svelte'
3+
import { expectNoA11yViolations } from '$lib/test-a11y'
4+
5+
vi.mock('./reveal', () => ({
6+
revealPath: vi.fn(() => Promise.resolve()),
7+
}))
8+
9+
vi.mock('./notifications-mode', () => ({
10+
setDownloadsNotificationsMode: vi.fn(),
11+
openSettingsToDownloadsNotifications: vi.fn(() => Promise.resolve()),
12+
}))
13+
14+
vi.mock('$lib/ui/toast', () => ({
15+
dismissToast: vi.fn(),
16+
}))
17+
18+
import DownloadToastContent from './DownloadToastContent.svelte'
19+
20+
describe('DownloadToastContent a11y', () => {
21+
it('renders with no a11y violations', async () => {
22+
const target = document.createElement('div')
23+
document.body.appendChild(target)
24+
mount(DownloadToastContent, {
25+
target,
26+
props: {
27+
toastId: 'downloads:a11y',
28+
explorer: undefined,
29+
event: {
30+
path: '/Users/me/Downloads/report.pdf',
31+
parentDir: '/Users/me/Downloads',
32+
fileName: 'report.pdf',
33+
observedAtMs: 1_700_000_000_000,
34+
inSubdir: false,
35+
sizeBytes: 1024,
36+
},
37+
shortcutHint: '⌘J',
38+
},
39+
})
40+
await tick()
41+
await expectNoA11yViolations(target)
42+
})
43+
})

0 commit comments

Comments
 (0)