Skip to content

feat(tauri): a frontend's invoke and events link across to the Rust that answers - #3129

Merged
colbymchenry merged 1 commit into
mainfrom
claude/feat-tauri-ipc
Oct 31, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/feat-tauri-ipc

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Reimplements #878 by @vdavid, folding in #2524 by @nk33-dev. Both are credited: #878 for the whole of the idea, tauri-specta's typed client and events, and the validation apps; #2524 for the registration check, call-site edges with registeredAt, shadow handling and the sync inputs.

Summary

A Tauri app is two programs in one window: the page calls invoke('save_file') and a Rust #[tauri::command] answers. Neither side names the other, so callers on a command came back empty even when the whole frontend used it, and a flow stopped at the wire.

A new synthesis pass, tauriEdges in src/resolution/tauri-synthesizer.ts, pairs the literals and draws the hop the runtime makes:

  • tauri-command: the function a call is written in → the Rust function that answers it, as a call-site edge with registeredAt pointing at the handler list. It reads invoke from @tauri-apps/api/core, /tauri and the v1 root, aliased, through a namespace, and through the global window.__TAURI__.
  • tauri-specta: commands.saveFile() needs nothing of its own when bindings.ts is indexed, since it already reaches bindings.ts, whose body is an aliased invoke('save_file'). With a gitignored bindings file, the call is read by its camelCase name.
  • Project wrappers: a function that hands its first parameter on to invoke (safeInvoke(cmd)), or a field set to one (invoke = tauriInvoke), is found by what it does. Its callers hold the literal.
  • tauri-event: an emit → the listeners of that event, in either language and between windows (calls), and a listener → the Rust struct deriving tauri-specta's Event (references).

Explore's Flow reads across the seam, on pot-desktop:

4. local_detect (src/utils/lang_detect.js:309)
   ↓ dynamic: Tauri command `lang_detect` @src-tauri/src/main.rs:145
5. lang_detect (src-tauri/src/lang_detect.rs:32)

How this differs from the two PRs

From #878's known issues:

Issue Now
detect() read every .rs file on a Rust repo with no Tauri markers No framework resolver. The pass starts from two indexed lookups: a Rust use tauri… import node, or a tauri::command decorator. A Rust + TypeScript repository that has neither reads nothing
O(n²) line count per match makeLineAt, once per file
Raw invoke edges attributed to the file node Attributed to the enclosing function, at the call's line and column
Main's cross-language gates (#2032, #2415) Edges come from a synthesis pass, so they don't go through resolve(); there is no reference for those gates to drop, and no TS reference named getSettings can be pulled to a Rust command by name alone
resolve() matched any reference named like a command Only literals at a Tauri invoke, and only commands.x on an import from a project module that isn't indexed

From #2524, the rule that a command must be registered stays, with one deliberate change. Registration means a generate_handler! or tauri-specta collect_commands! list in code, wherever it sits, not only one passed straight to tauri::Builder::default(). The strict form would have found pot-desktop and nothing else:

  • clash-verge-rev registers through tauri_specta::Builder::new().commands(collect_commands![…]);
  • Cmdr's generate_handler! is built by a macro from a manifest, with $ metavariables, so it can't be read at all. For such a list, the app's declared #[tauri::command] functions answer;
  • a fn handlers() -> … { tauri::generate_handler![…] } passed in by call is ordinary code.

Comments and strings still don't count as registration, and a function nobody registers still links nothing. A registered name must have an attribute that makes it a command, so a plain namesake of an external crate's entry stays unlinked. That reads #[tauri::command], #[command] and a project's own macro around it, like GitButler's #[but_api].

Other additions real apps needed:

  • Platform variants. One entry can be several functions, each behind a #[cfg] or a platform module; pot-desktop's system_ocr has three and Cmdr has many. Every one is that command on its platform, so all link. Two entries claiming one key stay a tie, which links nothing.
  • Apps are scoped by folder, so a monorepo's two greets stay apart, for commands and events alike. A frontend outside any src-tauri app (GitButler's apps/desktop beside crates/gitbutler-tauri) looks across the repository, where a tie is still nothing.
  • Plugins: invoke('plugin:name|cmd') goes to the crate registering that plugin (Builder::new("name"), tauri-specta's plugin_name).
  • Svelte and Vue files are read, in scripts and markup. feat(resolution): link imported Tauri invokes to registered Rust commands #2524 left them out, and Cmdr and GitButler are Svelte.
  • Events are closed end to end. feat(tauri): cross-language IPC bridge resolver (#772) #878 linked a listener to the struct; the emitter and the listener then both point at the struct, which is no path. Both directions of the wire are read here, including Struct { … }.emit(&app), a local holding one, .emit_to, and a Rust listen.

The hop wording is in synthesized-hop.ts, so explore, codegraph_node and the call paths read the same.

Validation

Tests: 34 in __tests__/tauri-ipc.test.ts, each indexing a real fixture. With the pass unregistered, 27 fail; the other 7 are guards. I then broke each guard on its own and its test failed: the shadow check, the command attribute, registration, app scoping for commands and for events, the tie, test files, and the fan-out cap.

A/B against main, full natural-key diff of nodes, edges and unresolved refs:

Repo Commit Shape Nodes Edges added Removed Refs
pot-desktop 594d32ed raw invoke, v1 API same +28 tauri-command, +9 tauri-event 0 same
clash-verge-rev ef784e45 tauri-specta, committed bindings same +93 tauri-command 0 same
Cmdr, IPC subtree (1,588 files) 9c48105d tauri-specta, raw, macro-built list same +1,093 tauri-command, +135 tauri-event calls, +90 listener → struct 0 same
GitButler, IPC subtree (1,248 files) 40ec990b wrappers, #[but_api], Svelte same +33 tauri-command 0 same
Yaak f7aea62c plugins, one rpc command same +6 tauri-command 0 same
tauri-specta examples ebe38d6e three apps in one repository same +91, +5, +3 0 same
lightningcss (Rust + JS, no Tauri) cbeaf83b control same 0 0 same
express 1c6f1b0f control same 0 0 same
ripgrep 91f5d22e control same 0 0 same

Cmdr and GitButler are subtrees, all their Rust plus every frontend file that touches the IPC, so both arms stay light. A file left out couldn't have made an edge.

Accounting against grep:

  • pot-desktop: all 18 commands it invokes link, and 20 of 22 commands have a caller. set_proxy and unset_proxy have no call in its frontend. Its 3 system_ocr variants all link. Each of the 9 event edges was read: 5 new_text / new_image from window.rs into SourceArea / ImageArea, 2 reload_plugin_list between windows, 2 success from the frontend to Rust.
  • clash-verge-rev: 93 of 93 commands, 92 through bindings.ts. With bindings.ts deleted, the camelCase path links the same 93 from cmds.ts.
  • Cmdr: 611 of 630 commands have a frontend caller; the 19 left are test-only hooks like set_test_throttle. 533 edges come from bindings.ts, and 560 from callers directly, because its #lib/… subpath imports don't resolve statically. A random sample of 16 event edges were all real, emitter to listener, in all three directions.
  • GitButler: 33 of 35 literal call sites. One of the 2 left is webInvoke, not Tauri; the other's invoke is destructured from a store object. Most of its commands go through an RTK table (extraOptions: { command }), which this doesn't read.

Flows, deterministic probes through codegraph_explore:

On clash-verge-rev:

1. SystemInfoCard (src/components/home/system-info-card.tsx:29)
   ↓ calls
2. getSystemInfo (src/services/cmds.ts:349)
   ↓ calls
3. getSystemInfo (src/services/bindings.ts:7)
   ↓ dynamic: Tauri command `get_system_info` @src-tauri/src/ipc.rs:7
4. get_system_info (crates/tauri-plugin-clash-verge-sysinfo/src/commands.rs:25)

Also read across the seam: getAutoProxy → get_auto_proxy and patchClashMode → patch_clash_mode there; local_detect → lang_detect, run → run_binary and selection_translate → SourceArea on pot-desktop; on Cmdr startAiServer → start_ai_server → onAiStarting → the AiStarting struct, and reply → mcp_round_trip_parsed back into Rust; on GitButler updateTelemetry → update_telemetry through its invokeAndRefresh wrapper.

Cost:

Median of 3, interleaved, tauriEdges pass: lightningcss 2 ms (the non-Tauri path), pot-desktop 35 ms, clash-verge-rev 153 ms. Whole-index wall time didn't move outside noise on any of them (6.2 → 5.9 s, 3.3 → 3.3 s, 6.1 → 6.1 s).

Kernel parity: not applicable; no extraction changed. #[tauri::command] was already on the node.

Agent A/B: not run. #878's author found no free-choice read reduction on Sonnet, since grep bridges a wire name that's the same string on both sides. The win is in impact, callers and explore's Flow, which the probes above show directly.

Suite: one full Vitest 4 suite on the stack of this PR and its two siblings in landing order (MediatR, Razor, Tauri): 844 files, 10272 passed, 0 failed, 87 skipped, on top commit 47e446e5 over main d494e43, with CODEGRAPH_KERNEL_EXPECT=1, CODEGRAPH_NO_DAEMON unset and 2 workers. Then rebased clean onto f92bdca, with every hunk away from main's; the PRs' own test files and the shared-area ones (extraction, explore-blast-radius, explore-exact-target, synthesis-progress, synthesized-hop) pass there, 865 of 865, on stack top 5d9b0b05.

Left out

  • A command name built at run time (invoke(name), a template with holes) links nothing, by design.
  • Yaak dispatches everything through one rpc command with the real name in its payload, so only its plugin commands link.
  • An event name built at run time (GitButler's project://${id}/…) links nothing. Listening through a project's own wrapper around listen isn't followed, only invoke's.

docs/design/dynamic-dispatch-coverage-playbook.md has the row. docs/design/framework-coverage.md has no row this moves.

🤖 Generated with Claude Code

…hat answers

A Tauri app is two programs in one window: the page calls invoke('save_file')
and a Rust #[tauri::command] answers, and neither names the other. callers
on a command came back empty even when the whole frontend used it, and a
flow stopped at the wire.

A new synthesis pass, tauri-synthesizer.ts, pairs the literals. tauri-command
links the function a call is written in to the Rust function a handler list
registers, with registeredAt at the list. It reads invoke imported, aliased,
through a namespace, dynamically and off the global API; a project's own
functions that hand their first parameter to invoke; and tauri-specta's
commands.fooBar(), which reaches an indexed bindings.ts on its own and is
read by its camelCase name when bindings.ts is not in the index. tauri-event
links an emit to its listeners in either language, and a listener to the
struct deriving tauri-specta's Event.

Registration means a generate_handler! or collect_commands! list in code,
not only one handed straight to tauri::Builder::default(): clash-verge-rev
registers through tauri_specta's builder, and Cmdr's list is built by a macro,
so there the app's declared commands answer. A name a list holds once can be
one function per platform, and all of them link. Apps are scoped by folder,
plugins by name, and a tie links nothing.

Reimplements PR #878 by @vdavid, folding in PR #2524 by @nk33-dev.

Validation: A/B against main on pot-desktop (+37 edges), clash-verge-rev (+93),
Cmdr (+1,318 on a 1,588-file subtree), GitButler (+33), Yaak (+6) and
tauri-specta's examples (+99), none removed, every class triaged. 611 of
Cmdr's 630 commands now have a frontend caller. lightningcss, express and
ripgrep are byte-identical. The pass costs 2 ms on a Rust + JS project with
no Tauri, 153 ms on clash-verge-rev.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant