Skip to content

Commit f2d3feb

Browse files
committed
Typed events: foundation + volume-space-changed reference
- Turn on tauri-specta's event support (`tauri_specta::Event` + `collect_events!` + `Builder::events()`), mounted once via `mount_events` in `lib.rs` setup. Events now generate typed names + payload types into `bindings.ts` and fold into the existing `bindings-fresh` check, the same machinery commands already use. - Migrate `volume-space-changed` end-to-end as the proven reference: `VolumeSpaceChanged` payload derives `Event`, emits via `payload.emit(app)`, and the FE listens through a typed `onVolumeSpaceChanged` wrapper instead of a hand-typed `listen('volume-space-changed')`. - Plan + categorized inventory in `docs/specs/typed-events-plan.md`; pattern documented in `lib/ipc/CLAUDE.md`.
1 parent d720c5b commit f2d3feb

9 files changed

Lines changed: 402 additions & 25 deletions

File tree

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,10 @@
4646
4747
#[cfg(test)]
4848
use specta_typescript::Typescript;
49-
use tauri_specta::Builder;
49+
use tauri_specta::{Builder, collect_events};
5050

5151
use crate::ipc_collectors::collect_all_types;
52+
use crate::space_poller::VolumeSpaceChanged;
5253

5354
/// Public greeting used by the example webview surface; kept here as the
5455
/// foundational smoke test for the specta wiring.
@@ -561,7 +562,13 @@ pub fn builder() -> Builder<tauri::Wry> {
561562
// Build the final Commands combining the runtime handler with all type info.
562563
// `internal::command` takes the handler fn and the type-collector fn pointer.
563564
let combined_commands = tauri_specta::internal::command(runtime_handler, collect_all_types);
564-
Builder::<tauri::Wry>::new().commands(combined_commands)
565+
Builder::<tauri::Wry>::new()
566+
.commands(combined_commands)
567+
// Typed events. Each registered struct derives `tauri_specta::Event`;
568+
// its kebab-cased name is the wire event name and its TS type + a typed
569+
// `events.<name>.listen(...)` helper are generated into `bindings.ts`.
570+
// Mounted onto the app via `mount_events` in `crate::run`.
571+
.events(collect_events![VolumeSpaceChanged])
565572
}
566573

567574
#[cfg(test)]

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ pub fn run() {
162162
// path silently overwrites the committed file with raw specta output on
163163
// every dev launch.
164164
let specta_builder = ipc::builder();
165+
// `invoke_handler()` returns an owned closure (it clones the command map
166+
// internally), so we grab it here before moving `specta_builder` into the
167+
// `setup` closure where `mount_events` registers the typed events.
168+
let invoke_handler = specta_builder.invoke_handler();
165169
let builder = tauri::Builder::default();
166170

167171
// Window state plugin is only available on desktop platforms. The filter
@@ -264,7 +268,12 @@ pub fn run() {
264268
.plugin(tauri_plugin_dialog::init())
265269
.plugin(tauri_plugin_notification::init())
266270
.plugin(downloads::global_shortcut::plugin_builder())
267-
.setup(|app| {
271+
.setup(move |app| {
272+
// Mount the typed `tauri-specta` events onto the app. Required before
273+
// any `Event::emit` / `Event::listen` call resolves the event name
274+
// from the registry. See `ipc.rs` for the event collection.
275+
specta_builder.mount_events(app);
276+
268277
// === Logging setup ===
269278
//
270279
// Hand-rolled fern dispatch tree (`logging::dispatch::init`) replaces
@@ -712,7 +721,7 @@ pub fn run() {
712721
Ok(())
713722
})
714723
.on_menu_event(menu::handle_menu_event)
715-
.invoke_handler(specta_builder.invoke_handler())
724+
.invoke_handler(invoke_handler)
716725
.on_window_event(|window, event| {
717726
// Main-window focus re-checks the FDA gate so the Downloads
718727
// watcher starts/stops on transitions. Covers the "user

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
//! same single `statfs` per tick with the permanent watcher.
1616
1717
use log::{debug, info, warn};
18-
use serde::Serialize;
18+
use serde::{Deserialize, Serialize};
1919
use std::collections::HashMap;
2020
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
2121
use std::sync::{Mutex, OnceLock};
2222
use std::time::Duration;
2323
use tauri::{AppHandle, Emitter};
24+
use tauri_specta::Event;
2425

2526
use crate::file_system::get_volume_manager;
2627
use crate::file_system::volume::DEFAULT_VOLUME_ID;
@@ -84,13 +85,16 @@ struct CachedSpace {
8485
available_bytes: u64,
8586
}
8687

87-
/// Payload for the `volume-space-changed` Tauri event.
88-
#[derive(Clone, Serialize)]
88+
/// Typed `volume-space-changed` Tauri event. The struct name kebab-cases to the
89+
/// wire event name (`volume-space-changed`) via `tauri_specta::Event`. Both the
90+
/// TS payload type and a typed `events.volumeSpaceChanged.listen(...)` helper are
91+
/// generated into `apps/desktop/src/lib/ipc/bindings.ts`.
92+
#[derive(Clone, Serialize, Deserialize, specta::Type, Event)]
8993
#[serde(rename_all = "camelCase")]
90-
struct VolumeSpaceChangedPayload {
91-
volume_id: String,
92-
total_bytes: u64,
93-
available_bytes: u64,
94+
pub struct VolumeSpaceChanged {
95+
pub volume_id: String,
96+
pub total_bytes: u64,
97+
pub available_bytes: u64,
9498
}
9599

96100
/// Payload for the `low-disk-space` Tauri event.
@@ -385,13 +389,13 @@ fn update_cache(volume_id: &str, space: &CachedSpace) {
385389

386390
fn emit(volume_id: &str, space: &CachedSpace) {
387391
let Some(app) = APP_HANDLE.get() else { return };
388-
let payload = VolumeSpaceChangedPayload {
392+
let payload = VolumeSpaceChanged {
389393
volume_id: volume_id.to_string(),
390394
total_bytes: space.total_bytes,
391395
available_bytes: space.available_bytes,
392396
};
393397
debug!("volume-space-changed: {} ({} avail)", volume_id, space.available_bytes);
394-
if let Err(e) = app.emit("volume-space-changed", &payload) {
398+
if let Err(e) = payload.emit(app) {
395399
warn!("Failed to emit volume-space-changed: {}", e);
396400
}
397401
}

apps/desktop/src/lib/file-explorer/pane/FilePane.svelte

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
listDirectoryStart,
3131
listen,
3232
onMtpDeviceDisconnected,
33+
onVolumeSpaceChanged,
3334
openFile,
3435
refreshListingIndexSizes,
3536
resolvePathVolume,
@@ -2511,18 +2512,15 @@
25112512
userHomePath = h.endsWith('/') ? h.slice(0, -1) : h
25122513
})
25132514
2514-
// Listen for live disk-space updates from the backend poller
2515-
void listen<{ volumeId: string; totalBytes: number; availableBytes: number }>(
2516-
'volume-space-changed',
2517-
(event) => {
2518-
if (event.payload.volumeId === volumeId) {
2519-
volumeSpace = {
2520-
totalBytes: event.payload.totalBytes,
2521-
availableBytes: event.payload.availableBytes,
2522-
}
2515+
// Listen for live disk-space updates from the backend poller (typed event)
2516+
void onVolumeSpaceChanged((payload) => {
2517+
if (payload.volumeId === volumeId) {
2518+
volumeSpace = {
2519+
totalBytes: payload.totalBytes,
2520+
availableBytes: payload.availableBytes,
25232521
}
2524-
},
2525-
).then((fn) => {
2522+
}
2523+
}).then((fn) => {
25262524
unlistenSpaceChanged = fn
25272525
})
25282526

apps/desktop/src/lib/ipc/CLAUDE.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,31 @@ For commands that return `Result<T, E>` on the Rust side, the TS wrapper returns
3434
`{ status: 'ok', data: T } | { status: 'error', error: E }`. Most call sites unwrap via `throwIpcError` from
3535
`$lib/tauri-commands/ipc-types`.
3636

37+
## Typed events
38+
39+
Events are wired through the same `tauri-specta` machinery as commands, but not all events are migrated yet — many are
40+
still raw `app.emit("name", payload)` on the Rust side with a hand-mirrored TS `listen<{…}>(...)`. See
41+
[`docs/specs/typed-events-plan.md`](../../../../../docs/specs/typed-events-plan.md) for the migration plan, the proven
42+
pattern, and the full event inventory.
43+
44+
A typed event is a Rust struct deriving `tauri_specta::Event` (kebab-cased struct name = wire event name), registered
45+
via `collect_events![Struct]` in `ipc.rs::builder()`, and mounted with `specta_builder.mount_events(app)` in `lib.rs`'s
46+
`setup` (required, else `Event::emit` panics). Regen generates an `events.<name>` helper into `bindings.ts`:
47+
48+
```ts
49+
events.volumeSpaceChanged.listen((event) => {
50+
/* event.payload is typed */
51+
})
52+
```
53+
54+
As with commands, don't call `events.*` raw in components — add a thin `on<Event>(cb)` wrapper in `tauri-commands/`
55+
(returns `UnlistenFn`) and import it from the barrel. The reference event is `volume-space-changed`
56+
(`onVolumeSpaceChanged` in `tauri-commands/storage.ts`).
57+
58+
The same type-shape constraints apply (no `skip_serializing_if`, no `serde_json::Value`). Events with a runtime-built
59+
name or a `serde_json::Value` payload (the `mcp-*` MCP-dispatch relay, `viewer:file-changed:<session-id>`) stay
60+
string-based; the plan doc explains why.
61+
3762
## Call-site convention: name your arguments
3863

3964
Specta-generated wrappers take **positional** arguments (in declaration order), not an object. That's elegant when the

apps/desktop/src/lib/ipc/bindings.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// This file has been generated by Tauri Specta. Do not edit this file manually.
44

55
import { invoke as __TAURI_INVOKE } from '@tauri-apps/api/core'
6+
import * as __TAURI_EVENT from '@tauri-apps/api/event'
67

78
/** Commands */
89
export const commands = {
@@ -2236,6 +2237,11 @@ export const commands = {
22362237
typedError<FriendlyError, string>(__TAURI_INVOKE('preview_friendly_error', { errorCode, variant, providerPath })),
22372238
}
22382239

2240+
/** Events */
2241+
export const events = {
2242+
volumeSpaceChanged: makeEvent<VolumeSpaceChanged>('volume-space-changed'),
2243+
}
2244+
22392245
/* Types */
22402246
// Active settings snapshot cached at startup for inclusion in crash reports.
22412247
export type ActiveSettings = {
@@ -4164,6 +4170,18 @@ export type VolumeCopyScanResult = {
41644170
conflicts: ScanConflict[]
41654171
}
41664172

4173+
/**
4174+
* Typed `volume-space-changed` Tauri event. The struct name kebab-cases to the
4175+
* wire event name (`volume-space-changed`) via `tauri_specta::Event`. Both the
4176+
* TS payload type and a typed `events.volumeSpaceChanged.listen(...)` helper are
4177+
* generated into `apps/desktop/src/lib/ipc/bindings.ts`.
4178+
*/
4179+
export type VolumeSpaceChanged = {
4180+
volumeId: string
4181+
totalBytes: number
4182+
availableBytes: number
4183+
}
4184+
41674185
// Information about volume space.
41684186
export type VolumeSpaceInfo = {
41694187
// In bytes.
@@ -4290,3 +4308,23 @@ async function typedError<T, E>(
42904308
return { status: 'error', error: e as any }
42914309
}
42924310
}
4311+
4312+
function makeEvent<T>(name: string) {
4313+
const base = {
4314+
listen: (cb: __TAURI_EVENT.EventCallback<T>) => __TAURI_EVENT.listen(name, cb),
4315+
once: (cb: __TAURI_EVENT.EventCallback<T>) => __TAURI_EVENT.once(name, cb),
4316+
emit: ((payload: T) => __TAURI_EVENT.emit(name, payload) as unknown) as T extends null
4317+
? () => Promise<void>
4318+
: (payload: T) => Promise<void>,
4319+
}
4320+
4321+
const fn = (target: import('@tauri-apps/api/webview').Webview | import('@tauri-apps/api/window').Window) => ({
4322+
listen: (cb: __TAURI_EVENT.EventCallback<T>) => target.listen(name, cb),
4323+
once: (cb: __TAURI_EVENT.EventCallback<T>) => target.once(name, cb),
4324+
emit: ((payload: T) => target.emit(name, payload) as unknown) as T extends null
4325+
? () => Promise<void>
4326+
: (payload: T) => Promise<void>,
4327+
})
4328+
4329+
return Object.assign(fn, base)
4330+
}

apps/desktop/src/lib/tauri-commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export {
125125
onVolumeContextAction,
126126
watchVolumeSpace,
127127
unwatchVolumeSpace,
128+
onVolumeSpaceChanged,
128129
setDiskSpaceThreshold,
129130
setLowDiskSpaceConfig,
130131
checkFullDiskAccess,

apps/desktop/src/lib/tauri-commands/storage.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'
44
import type { VolumeInfo } from '../file-explorer/types'
55
import type { TimedOut } from './ipc-types'
66
import { getAppLogger } from '$lib/logging/logger'
7-
import { commands } from '$lib/ipc/bindings'
7+
import { commands, events, type VolumeSpaceChanged } from '$lib/ipc/bindings'
88
import { throwIpcError } from './ipc-types'
99

1010
const log = getAppLogger('storage')
@@ -156,6 +156,18 @@ export async function unwatchVolumeSpace(watcherId: string): Promise<void> {
156156
await commands.unwatchVolumeSpace(watcherId)
157157
}
158158

159+
/**
160+
* Subscribes to live disk-space updates from the backend poller. The payload is
161+
* the typed `tauri-specta` event, so `volumeId` / `totalBytes` / `availableBytes`
162+
* are checked at compile time against the Rust `VolumeSpaceChanged` struct.
163+
* Call the returned `UnlistenFn` in `onDestroy` to avoid leaks.
164+
*/
165+
export async function onVolumeSpaceChanged(callback: (payload: VolumeSpaceChanged) => void): Promise<UnlistenFn> {
166+
return events.volumeSpaceChanged.listen((event) => {
167+
callback(event.payload)
168+
})
169+
}
170+
159171
/**
160172
* Updates the disk space change threshold at runtime (in MB).
161173
*/

0 commit comments

Comments
 (0)