Files
buzz/desktop/src-tauri/Cargo.toml
T
f86cfc7369 fix(desktop): back/forward via keyboard chords, mouse X1/X2 buttons, and swipe gestures (#3778)
## Problem

Two related gaps in global back/forward navigation. Fixes #3775.

1. The keyboard shortcuts almost never fire in real use — users fall
back to clicking the toolbar chevrons and assume the shortcuts don't
exist.
2. On macOS, mouse back/forward buttons (X1/X2) and horizontal swipe
gestures do nothing, although they navigate in every browser and in
Slack.

**Duplicate check:** searched open PRs and issues — none found beyond
#3775 (filed alongside this fix). #3078 / #3377 are
next/previous-*channel* navigation, a different feature.

## Root causes

**Keyboard:** `useBackForwardControls`'s keydown handler bailed whenever
the event target was editable — but `useComposerAutofocus` deliberately
focuses the message composer (a ProseMirror contenteditable) on mount
and on every channel switch. In steady state focus almost always lives
in the composer, so the chords were silently swallowed. Invisible to CI
because `navigation.spec.ts` only ever clicked the `global-back` /
`global-forward` buttons, never pressed the keys.

**Mouse/swipe:** on macOS, WKWebView never delivers X1/X2 button events
or swipe gestures to the page (Safari handles them natively in the app
layer, not in page JS), and Buzz had no native handler.

## Fix

### Keyboard chords (web layer)

Match the existing platform chord regardless of the event target and
drop the editable-target guard:

- `⌘[` / `⌘]` have no text-editing semantics in macOS text fields, and
the TipTap/StarterKit editor config binds no `Mod-[` / `Mod-]` shortcuts
(checked `useRichTextEditor.ts` — list indentation is Tab/Shift-Tab).
- `preventDefault()` keeps the chord out of the editor — asserted in the
e2e test.

This matches browsers and Slack, where back/forward chords work while a
text field is focused. Chord matching is extracted into a pure helper,
`app/navigation/backForwardChords.ts`, so it can be unit tested;
behavior (bindings, modifier exclusivity, `code`-based matching for
non-US layouts) is unchanged.

### macOS mouse buttons and swipe gestures (native layer)

An NSEvent local monitor in `mouse_nav.rs` catches what the webview
can't see and emits a `mouse-nav` Tauri event to the main window
(`emit_to`, so navigation stays scoped if multi-window ever lands) that
the frontend acts on. Two AppKit event shapes map to navigation:

- `otherMouseUp` with button 3/4 — mice whose X1/X2 buttons arrive as
plain button events. These are swallowed after emitting so nothing
downstream double-handles them.
- `swipe` with a horizontal delta — AppKit's page-swipe gesture
(`swipeWithEvent:`): `deltaX > 0` back, `deltaX < 0` forward. Sent by
mouse drivers that synthesize a page-swipe gesture for the back/forward
buttons instead of button-3/4 events (the hardware this was verified
on). Stock Apple trackpad and Magic Mouse swipes arrive as phased
scroll-wheel events instead, which this PR does not handle — that path
(`ScrollWheel` + `trackSwipeEventWithOptions:`, which also needs
scroll-edge detection) is deferred to a follow-up. Swipes are passed
through (swallowing mid-gesture events could confuse AppKit gesture
tracking).

The swipe path was verified end to end on hardware whose back/forward
buttons emit only swipe gestures, never button-3/4 events — an
instrumented event monitor confirmed the events arrive as
`NSEventType::Swipe` with `deltaX ±1`, and navigation worked after
mapping them.

## Tests

- **13 unit tests** for the web-side chord matcher
(`backForwardChords.test.mjs`): supported chords, modifier exclusivity,
`code` fallback, and preservation of line-editing shortcuts.
- **6 Rust unit tests** for the native mapping helpers (`mouse_nav.rs`):
button 3/4 directions, other buttons ignored, swipe delta sign →
direction, zero-delta (gesture-begin) ignored.
- **e2e regression case** in `navigation.spec.ts`: presses the platform
chord *while the composer is focused* — the missing coverage. Verified
it fails against the pre-fix implementation and passes with the fix.
- Full desktop unit suite: 3832/3832 pass. Full Rust suite (`cargo
test`, buzz-desktop): 1888 passed / 0 failed. `pnpm typecheck`, `biome
check`, `pnpm check`, `cargo fmt --check`, `cargo clippy`: clean (no new
warnings).
- Full Playwright e2e: 958 passed; 6 failures are relay-infrastructure
tests (live relay seeding / relay state seam) that fail identically
without this change — `navigation.spec.ts` is fully green.

## Manual test

1. Open a channel, then another (composer autofocuses on each switch).
2. `⌘[` — returns to the previous channel; `⌘]` — forward again. Typing
`[` / `]` in the composer inserts normally.
3. Mouse back/forward buttons navigate the same way, from anywhere in
the window (verified on macOS on hardware using both event shapes).

## Update — 2026-07-31

Removed the redundant DOM mouse-button handler after verifying it was
unnecessary. The native macOS path remains unchanged and was revalidated
manually.

---------

Signed-off-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz>
Signed-off-by: Matheus Iser <matheusiser@squareup.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1yvnq5equak5errqpku8stskushny9wsvt0fc2ywcpwt79yslwaqswe7tse <23260a641ceda9918c01b70f05c2dc85e642ba0c5bd38511d80b97e2921f7741@buzz.block.builderlab.xyz>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-02 14:53:01 -04:00

149 lines
7.1 KiB
TOML

[workspace]
[package]
name = "buzz-desktop"
version = "0.5.3"
description = "Buzz desktop app"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "buzz_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[features]
default = ["system-keyring"]
mesh-llm = ["dep:iroh", "dep:mesh-llm-sdk", "dep:mesh-llm-host-runtime", "dep:mesh-llm-client", "dep:mesh-llm-node", "dep:mesh-llm-system", "dep:mesh-llm-events"]
# OS keyring backing for desktop secret storage (nsec private keys). When
# disabled, secrets fall back to 0o600 files. On by default for real builds.
system-keyring = ["dep:keyring"]
[build-dependencies]
base64 = "0.22"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tauri-build = { version = "2", features = [] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
ctrlc = { version = "3", features = ["termination"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "vendored"], optional = true }
# Used directly (alongside tauri-plugin-notification) so we can hold the posting
# D-Bus connection open. GNOME 46+ dismisses a notification the moment that
# connection is dropped, which the plugin does immediately. Default features
# keep the pure-Rust zbus backend, matching the plugin (no libdbus needed).
notify-rust = "4"
# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned
# to the exact version wry links so both resolve to one webkit2gtk-sys and we
# don't get duplicate symbols; bump in lockstep with wry.
webkit2gtk = { version = "=2.0.2", features = ["v2_22"] }
[target.'cfg(target_os = "macos")'.dependencies]
block2 = { version = "0.6", default-features = false, features = ["std"] }
objc2 = { version = "0.6.4", default-features = false }
objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] }
objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] }
keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true }
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
window-vibrancy = "0.6"
user-idle = { version = "0.6", default-features = false }
plist = "1"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Security", "Win32_Storage_FileSystem", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_Foundation"] }
keyring = { version = "3.6.3", default-features = false, features = ["windows-native", "vendored"], optional = true }
user-idle = { version = "0.6", default-features = false }
[dependencies]
atomic-write-file = "0.3"
anyhow = "1"
dirs = "6"
tauri = { version = "2", features = ["macos-private-api", "tray-icon"] }
tauri-plugin-deep-link = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
tauri-plugin-window-state = "2"
tauri-plugin-dialog = "2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
infer = "0.19"
hex = "0.4"
ed25519-dalek = "=3.0.0-rc.0"
tokio = { version = "1", features = ["fs", "sync", "rt", "macros", "time", "net", "io-util"] }
tokio-tungstenite = { version = "0.29", features = ["rustls-tls-webpki-roots"] }
tokio-util = { version = "0.7", features = ["rt"] }
bytes = "1"
futures-util = "0.3"
opus = "0.3"
neteq = { version = "0.8", default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
toml = "0.8"
nostr = { version = "0.44", features = ["nip44", "nip49"] }
# OS-entropy source for backup passphrase generation (already in the tree as a
# transitive dependency; pinned here for direct use).
getrandom = "0.2"
zeroize = "1"
reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] }
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] }
url = "2"
buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" }
buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" }
buzz_sdk_pkg = { package = "buzz-sdk", path = "../../crates/buzz-sdk" }
buzz_agent_pkg = { package = "buzz-agent", path = "../../crates/buzz-agent" }
buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" }
iroh = { version = "1.0.2", optional = true }
mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true }
mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true }
# Model catalog + hardware survey for the Share-compute model picker (same
# diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client.
mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true }
mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true }
mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true }
mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true }
base64 = "0.22"
sha2 = "0.11"
tar = "0.4"
bzip2 = "0.6"
chrono = { version = "0.4", features = ["serde"] }
tauri-plugin-global-shortcut = "2"
tauri-plugin-notification = "2.3.3"
uuid = { version = "1", features = ["v4", "v5"] }
png = "0.18"
# wayland-data-control: without it arboard is X11-only on Linux, so copies made
# in a Wayland session land in XWayland's clipboard where Wayland-native apps
# never see them (set_text still returns Ok). The backing wl-clipboard-rs dep is
# target-gated inside arboard; at runtime X11 sessions still fall back to X11.
arboard = { version = "3", features = ["wayland-data-control"] }
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif"] }
zip = "8"
flate2 = "1"
sherpa-onnx = "1.12"
regex = "1"
rusqlite = { version = "0.37", features = ["bundled"] }
axum = "0.8"
rodio = "0.22"
earshot = "1.0"
rubato = "3.0"
audioadapter-buffers = "3.0"
tempfile = "3"
strip-ansi-escapes = "0.2"
tracing = "0.1"
[dev-dependencies]
tauri-utils = "2"
# `test-util` enables tokio's paused-clock (`start_paused`) so the relay
# admission gate tests can assert exact wait durations without real sleeps.
tokio = { version = "1", features = ["test-util"] }
# The relay's media validation, so the snapshot-sharing tests can prove the
# full export → sanitize → relay-accept → import contract end to end.
buzz_media_pkg = { package = "buzz-media", path = "../../crates/buzz-media" }