[Bug] Linux: notification sounds never play, WebKitGTK can't load media from Tauri's custom URI scheme (MEDIA_ERR_SRC_NOT_SUPPORTED)
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
**Describe the bug**
On Linux, notification sounds never play. `playNotificationSound()` builds `new Audio("/sounds/.mp3")` (`desktop/src/features/notifications/lib/sound.ts`), which resolves against the page origin. On Linux, Tauri serves `frontendDist` through a custom URI scheme registered with `webkit_web_context_register_uri_scheme` (`tauri://localhost`), and **WebKitGTK's media backend cannot load media from a custom URI scheme**. The element fails with `MEDIA_ERR_SRC_NOT_SUPPORTED` (code 4) at `readyState 0`, and both error paths in `playNotificationSound` swallow it:
```ts
audio.play().catch(() => {
// Best-effort — user may not have interacted with the page yet.
});
```
so there is no console output, no toast, and no telemetry. The desktop notification itself still appears, which is why this reads as "sound settings do nothing" rather than "notifications are broken". On GNOME the notification daemon plays its own default alert, so users can hear *a* sound while never hearing the sound they picked in Settings.
This is Linux-only. WKWebView and WebView2 both serve media from their custom scheme handlers.
**To Reproduce:**
1. Run Buzz Desktop on Linux (AppImage or a local `pnpm tauri dev` build).
2. Settings > Notifications: pick any sound for @Mentions, confirm alerts are on.
3. Have someone @mention you in a channel.
4. The desktop notification appears. The selected `.mp3` never plays.
Minimal standalone repro, no Buzz needed. Requires `gjs` and `gir1.2-webkit2-4.1` (Debian/Ubuntu: `gjs libwebkit2gtk-4.1-0 gir1.2-webkit2-4.1`). Put any mp3 at `/tmp/flutter.mp3`:
```js
// repro.js (run: gjs repro.js)
imports.gi.versions.Gtk = '3.0';
const { Gtk, WebKit2, GLib, Gio } = imports.gi;
Gtk.init(null);
const HTML = `
const a = new Audio('/sounds/flutter.mp3');
a.addEventListener('ended', () => document.title = 'RESULT|ENDED');
a.addEventListener('error', () => document.title =
'RESULT|MEDIA_ERROR|readyState=' + a.readyState + '|code=' + a.error.code);
a.play().catch(e => document.title = 'RESULT|PLAY_REJECTED|' + e.name);
<\/script></body></html>`;
WebKit2.WebContext.get_default().register_uri_scheme('tauri', (req) => {
const path = req.get_path();
const [mime, data] = path === '/sounds/flutter.mp3'
? ['audio/mpeg', GLib.file_get_contents('/tmp/flutter.mp3')[1]]
: ['text/html', new TextEncoder().encode(HTML)];
const bytes = new GLib.Bytes(data);
req.finish(Gio.MemoryInputStream.new_from_bytes(bytes), bytes.get_size(), mime);
});
const w = new Gtk.Window({ default_width: 200, default_height: 100 });
// wry sets AutoplayPolicy.Allow by default, so match that here.
const v = new WebKit2.WebView({
website_policies: new WebKit2.WebsitePolicies({ autoplay: WebKit2.AutoplayPolicy.ALLOW }),
});
w.add(v);
v.connect('notify::title', () => {
if ((v.title || '').startsWith('RESULT|')) { print(v.title); Gtk.main_quit(); }
});
v.load_uri('tauri://localhost/index.html');
w.show_all();
Gtk.main();
```
**Expected behavior:**
The selected notification sound plays. Failing that, the failure is visible somewhere instead of being swallowed by an empty `catch`.
**Supporting Material**
Four runs, same machine, same `desktop/public/sounds/flutter.mp3`, isolated GStreamer registry each time. Only the delivery URL changes:
| source URL | WebKitGTK 2.52.3 (system) | WebKitGTK bundled in `Buzz_0.4.23_amd64.AppImage` |
|---|---|---|
| `file:///tmp/.../flutter.mp3` | `ENDED`, `readyState=4` | `ENDED`, `readyState=3` |
| `tauri://localhost/sounds/flutter.mp3` | `MEDIA_ERROR`, `readyState=0`, `err=4` | `MEDIA_ERROR`, `readyState=0`, `err=4` |
| `fetch()` then `blob:` URL | `ENDED`, `readyState=4` | `ENDED`, `readyState=3` |
`fetch('/sounds/flutter.mp3')` over the custom scheme returns `200` with the full 15597 bytes, so the scheme handler and the asset itself are fine. Only the media element's loader rejects the URL.
The bundled AppImage WebKit behaves identically to the system one, so this is not an AppImage packaging problem. It reproduces on a plain distro WebKitGTK.
Two related notes while in this code:
1. `sound.ts` swallows both failure modes (`try/catch` around `getAudio` and `.catch(() => {})` on `play()`). Even a `console.warn` in the reject path would have made this self-diagnosing.
2. In `useAppShellDesktopNotifications.ts` and `use-feed-desktop-notifications.ts` the sound is gated on `sendDesktopNotification()` resolving `true`. If OS notification delivery fails, the sound is suppressed too. These are separate user-facing features and probably should not be coupled.
**Suggested fix**
Load the sound bytes once and play from a `blob:` URL. Verified working on both WebKit builds above:
```ts
const cache = new Map<SoundName, HTMLAudioElement>();
async function getAudio(name: SoundName): Promise<HTMLAudioElement> {
let audio = cache.get(name);
if (!audio) {
// WebKitGTK's media backend cannot load from Tauri's custom URI scheme,
// so fetch the asset and hand the element a blob: URL instead.
const response = await fetch(`/sounds/${name}.mp3`);
audio = new Audio(URL.createObjectURL(await response.blob()));
cache.set(name, audio);
}
return audio;
}
```
Inlining the mp3s as `data:` URIs at build time would also work, at the cost of bundle size. Either way, the `.catch()` should log rather than discard.
**Environment (please complete the following information):**
- OS: Ubuntu 26.04, GNOME on Wayland, x86_64
- Version: Buzz 0.4.23 (`Buzz_0.4.23_amd64.AppImage`)
- System WebKitGTK: `libwebkit2gtk-4.1-0` 2.52.3-0ubuntu0.26.04.2
- GStreamer: 1.28.2
**Additional context**
Unrelated to #2560, which covers AppImage library bundling and GStreamer registry pollution on the same machine. Those are real, but the table above shows they are not what silences the notification sounds: the bundled WebKit plays the same mp3 fine over `file://` and `blob:`.
Contributor guide
Assessment
This issue has not been assessed yet.