Repository navigation
feat(tauri): a frontend's invoke and events link across to the Rust that answers - #3129
Merged
Merged
Conversation
…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>
This was referenced Oct 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, socallerson a command came back empty even when the whole frontend used it, and a flow stopped at the wire.A new synthesis pass,
tauriEdgesinsrc/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 withregisteredAtpointing at the handler list. It readsinvokefrom@tauri-apps/api/core,/tauriand the v1 root, aliased, through a namespace, and through the globalwindow.__TAURI__.commands.saveFile()needs nothing of its own whenbindings.tsis indexed, since it already reachesbindings.ts, whose body is an aliasedinvoke('save_file'). With a gitignored bindings file, the call is read by its camelCase name.invoke(safeInvoke(cmd)), or a field set to one (invoke = tauriInvoke), is found by what it does. Its callers hold the literal.tauri-event: anemit→ the listeners of that event, in either language and between windows (calls), and a listener → the Rust struct deriving tauri-specta'sEvent(references).Explore's Flow reads across the seam, on pot-desktop:
How this differs from the two PRs
From #878's known issues:
detect()read every.rsfile on a Rust repo with no Tauri markersuse tauri…import node, or atauri::commanddecorator. A Rust + TypeScript repository that has neither reads nothingmakeLineAt, once per fileinvokeedges attributed to the file noderesolve(); there is no reference for those gates to drop, and no TS reference namedgetSettingscan be pulled to a Rust command by name aloneresolve()matched any reference named like a commandinvoke, and onlycommands.xon an import from a project module that isn't indexedFrom #2524, the rule that a command must be registered stays, with one deliberate change. Registration means a
generate_handler!or tauri-spectacollect_commands!list in code, wherever it sits, not only one passed straight totauri::Builder::default(). The strict form would have found pot-desktop and nothing else:tauri_specta::Builder::new().commands(collect_commands![…]);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;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:
#[cfg]or a platform module; pot-desktop'ssystem_ocrhas 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.greets stay apart, for commands and events alike. A frontend outside anysrc-tauriapp (GitButler'sapps/desktopbesidecrates/gitbutler-tauri) looks across the repository, where a tie is still nothing.invoke('plugin:name|cmd')goes to the crate registering that plugin (Builder::new("name"), tauri-specta'splugin_name).Struct { … }.emit(&app), a local holding one,.emit_to, and a Rustlisten.The hop wording is in
synthesized-hop.ts, so explore,codegraph_nodeand 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:
invoke, v1 APItauri-command, +9tauri-eventtauri-commandtauri-command, +135tauri-eventcalls, +90 listener → struct#[but_api], Sveltetauri-commandrpccommandtauri-commandCmdr 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:
set_proxyandunset_proxyhave no call in its frontend. Its 3system_ocrvariants all link. Each of the 9 event edges was read: 5new_text/new_imagefromwindow.rsintoSourceArea/ImageArea, 2reload_plugin_listbetween windows, 2successfrom the frontend to Rust.bindings.ts. Withbindings.tsdeleted, the camelCase path links the same 93 fromcmds.ts.set_test_throttle. 533 edges come frombindings.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.webInvoke, not Tauri; the other'sinvokeis 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:
Also read across the seam:
getAutoProxy→get_auto_proxyandpatchClashMode→patch_clash_modethere;local_detect→lang_detect,run→run_binaryandselection_translate→SourceAreaon pot-desktop; on CmdrstartAiServer→start_ai_server→onAiStarting→ theAiStartingstruct, andreply→mcp_round_trip_parsedback into Rust; on GitButlerupdateTelemetry→update_telemetrythrough itsinvokeAndRefreshwrapper.Cost:
Median of 3, interleaved,
tauriEdgespass: 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,callersand 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_DAEMONunset 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
invoke(name), a template with holes) links nothing, by design.rpccommand with the real name in its payload, so only its plugin commands link.project://${id}/…) links nothing. Listening through a project's own wrapper aroundlistenisn't followed, onlyinvoke's.docs/design/dynamic-dispatch-coverage-playbook.mdhas the row.docs/design/framework-coverage.mdhas no row this moves.🤖 Generated with Claude Code