From 394d4a434b70f6c14587174bc9942b3ba1d0eb59 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 9 Oct 2026 10:44:01 -0700 Subject: [PATCH 1/4] fix(mobile): normalize relay URL before disabling push (#8201) Communities joined with a `ws://` or `wss://` relay URL cannot disable push: the opt-out path passes that URL directly to the HTTP-only NIP-11 fetcher, fails before publishing the inactive lease, and leaves the durable retry pending. This was reproduced in USB logs from TestFlight 0.20.1 (49). Normalize the URL with the existing push relay HTTP helper before fetching metadata, matching the revocation outbox's handling. No settings UI changes. Related: #8199 removes the status text but does not fix this synchronization failure; no duplicate fix found. ### Validation Four regression cases exercise the real community deactivator through its HTTP boundary. The WebSocket cases fail with the device's exact exception before the fix; all four pass afterward. All 37 focused community/revocation tests and targeted Flutter analysis pass. An initial full mobile run had three failures (recount timing and native export timeout/order); all three passed in isolated reruns. The full mobile checks and test suite passed on retry. The local repository-wide gate stopped during Rust compilation without a final result; a full local pass is not claimed. [GitHub CI](https://github.com/block/buzz/actions/runs/37865048873) passed, including mobile tests, Android build, Swift tests, iOS simulator build, and unsigned iOS release build. [Codex review](https://github.com/block/buzz/pull/8201#issuecomment-6071795750) reported no major issues. The installed TestFlight build is unchanged. After this fix lands, validate that upgrading the same installation clears its existing pending opt-out and leaves the relay lease inactive, without toggling notifications back on. Signed-off-by: Tom Brow --- .../shared/community/community_provider.dart | 4 +- .../community/push_opt_out_url_test.dart | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 mobile/test/shared/community/push_opt_out_url_test.dart diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index 9cd24db0e8..f1e0cdef2b 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -130,7 +130,9 @@ Future _deactivateCommunityPushLease( } final decoded = nostr.Nip19.decode(payload: nsec); final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public; - final descriptor = await fetchBuzzPushLeaseDescriptor(community.relayUrl); + final descriptor = await fetchBuzzPushLeaseDescriptor( + canonicalBuzzPushRelayHttpUrl(community.relayUrl), + ); final matchingGrant = (await readBuzzPushEndpointGrants()) .where( (grant) => diff --git a/mobile/test/shared/community/push_opt_out_url_test.dart b/mobile/test/shared/community/push_opt_out_url_test.dart new file mode 100644 index 0000000000..12d9b637a4 --- /dev/null +++ b/mobile/test/shared/community/push_opt_out_url_test.dart @@ -0,0 +1,56 @@ +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + for (final scheme in ['ws', 'wss', 'http', 'https']) { + test( + 'opt-out fetches relay metadata over HTTP for $scheme community', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + final community = Community.create( + name: 'Test', + relayUrl: '$scheme://relay.example:8443', + nsec: nostr.Keys.generate().nsec, + ); + final requests = []; + final client = MockClient((request) async { + requests.add(request); + // Stop at the HTTP boundary, before native grants or publication. + return http.Response('test unavailable', 503); + }); + addTearDown(client.close); + + await http.runWithClient(() async { + await expectLater( + container.read(communityPushLeaseDeactivatorProvider)( + community, + generation: 2, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('NIP-11 request failed with HTTP 503'), + ), + ), + ); + }, () => client); + + final httpScheme = scheme == 'ws' || scheme == 'http' + ? 'http' + : 'https'; + expect( + requests.single.url, + Uri.parse('$httpScheme://relay.example:8443/'), + ); + expect(requests.single.headers['Accept'], 'application/nostr+json'); + }, + ); + } +} From 99924a5a69200d34f5cc67e9b76748d7ece72705 Mon Sep 17 00:00:00 2001 From: VSCteam <144167621+Joxyko@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:19:56 +0200 Subject: [PATCH 2/4] fix(desktop): repair the Windows notification shim's false denial on read (#5600) Addresses #2445. Closely related to #2483 -- see "Relationship to #2483" below; I am happy to close this in favour of that one. ## The problem On Windows the notification plugin's injected shim decides its permission at startup like this ([`guest-js/init.ts:12-18`](https://github.com/tauri-apps/plugins-workspace/blob/v2/plugins/notification/guest-js/init.ts)): ```ts if (window.Notification.permission !== 'default' || __TEMPLATE_windows__) { return await Promise.resolve(window.Notification.permission === 'granted') } return await invoke('plugin:notification|is_permission_granted') ``` `__TEMPLATE_windows__` is replaced with the literal `true` on Windows, so the `||` short-circuits and the shim compares its own freshly-initialised `'default'` against `'granted'` -- `false` -- and stamps **`denied`**. The line that would ask the backend is unreachable, and the backend would have said yes: `permission_state()` returns `PermissionState::Granted` unconditionally on desktop. So on every launch of the Windows app, `window.Notification.permission` is `denied` and nothing in the OS is denying anything. ## Why the toggle is only half of it `getDesktopNotificationPermissionState()` trusts that value, so `refreshPermission()` reports `denied` at mount, and this effect in `hooks.ts` runs before the user touches anything: ```ts React.useEffect(() => { if (settings.desktopEnabled && (permission === "denied" || permission === "unsupported")) { setSettings((current) => ({ ...current, desktopEnabled: false })); } }, [permission, settings.desktopEnabled]); ``` It writes `desktopEnabled: false` and persists it. Measured on a real install: with alerts successfully enabled and `desktopEnabled` persisted as `true`, a clean relaunch comes back with it persisted as `false`. That means fixing only the enable path leaves a Windows user re-enabling alerts after every restart. ## What this changes One place: `getDesktopNotificationPermissionState()` repairs the bogus value instead of reporting it. ```ts if ( window.Notification.permission === "denied" && isTauri() && isWindowsPlatform() ) { try { return await requestDesktopNotificationAccess(); } catch { return window.Notification.permission; } } ``` `requestPermission()` is the only call that reaches the backend, and on Windows it raises no prompt, returns `Granted`, and rewrites the shim's cached value -- so this is self-limiting: the next read short-circuits as `granted` and never requests again. There is a test for exactly that. Repairing at the read rather than at the toggle fixes every consumer at once, including `sendDesktopNotification()`, which gates delivery on `=== "granted"` and would otherwise drop notifications at boot. - `denied` stays terminal on macOS, on Linux, and on ordinary Windows web pages. - A denial that survives the request is still reported as `denied`. - A rejected request falls back to the current value instead of propagating; callers do not handle rejections today. - Adds `isWindowsPlatform()` to `shared/lib/platform.ts`, alongside the existing `isMacPlatform()` / `isLinuxPlatform()`. - No new dependencies. No changes to `hooks.ts` or to notification delivery. ## Verification on a real Windows 11 install Buzz Desktop 0.5.9 release build, Windows 11 Home 10.0.26200, WebView2 151.0.4129.72, attached over CDP. Clean boot, nothing injected: ``` window.Notification.permission -> "denied" localStorage[...].desktopEnabled -> false (written by the effect above) await window.Notification.requestPermission() -> "granted" window.Notification.permission -> "granted" new window.Notification("Buzz", { body: ... }) ToastNotificationManager::History .GetHistory("xyz.block.buzz.app") -> 3 -> 4 (real toast delivered) CreateToastNotifier(...).Setting -> Enabled ``` The OS was permissive throughout: no `ToastEnabled` override, no notification policy keys under HKCU or HKLM, and the app present under `HKCU\...\Notifications\Settings\xyz.block.buzz.app`. Full detail in [this comment](https://github.com/block/buzz/pull/2483#issuecomment-5255972424). To be precise about scope: this confirms the mechanism the patch relies on, measured on a stock release build. The patched build itself is covered by the unit tests below, not by a packaged Windows run. ## Tests `pnpm test` -- full desktop suite. New cases in `lib/desktop.test.mjs`: - Windows Tauri repairs the shim's false denied state on read - the repaired state is cached, so later reads do not request again - a denial that survives the request is reported as denied - denied stays terminal outside the Windows Tauri app (Windows web, Linux Tauri, Linux web) - granted is returned untouched and never triggers a request Plus `shared/lib/platform.test.mjs` for `isWindowsPlatform()` across `Win32` / `Win64` / `Windows` and `MacIntel` / `Darwin` / `Linux x86_64`. ## Relationship to #2483 #2483 fixes the same root cause at the toggle: it retries the request inside `setDesktopEnabled`. That makes the toggle work, which is what #2445 reports, and its `ensureDesktopNotificationPermission` helper is a nicer shape than an inline condition. The difference is where the repair happens. Because #2483 leaves the mount-time read as `denied`, the effect quoted above still turns the setting off on every launch, so the fix does not survive a restart. Repairing at the read covers the toggle path too, since `setDesktopEnabled` calls `refreshPermission()` first and finds `granted`. The two overlap: both add `isWindowsPlatform()` to `platform.ts`, so whichever lands second needs a trivial rebase. If maintainers prefer #2483's shape, the equivalent of this PR is to call its helper from `refreshPermission` as well as from `setDesktopEnabled` -- I am glad to close this and send that instead. I did not want to push changes into someone else's open PR uninvited. ## Once this lands #2445 can close. I am running a per-machine launcher workaround for a small Windows team until then, which this makes unnecessary. Signed-off-by: VSCteam <144167621+Joxyko@users.noreply.github.com> --- .../notifications/lib/desktop.test.mjs | 119 +++++++++++++++++- .../src/features/notifications/lib/desktop.ts | 25 +++- desktop/src/shared/lib/platform.test.mjs | 38 ++++++ desktop/src/shared/lib/platform.ts | 9 ++ 4 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 desktop/src/shared/lib/platform.test.mjs diff --git a/desktop/src/features/notifications/lib/desktop.test.mjs b/desktop/src/features/notifications/lib/desktop.test.mjs index 7fb8830683..43ba6d5014 100644 --- a/desktop/src/features/notifications/lib/desktop.test.mjs +++ b/desktop/src/features/notifications/lib/desktop.test.mjs @@ -23,7 +23,55 @@ class ThrowingNotification { globalThis.window = { Notification: ThrowingNotification }; -const { sendDesktopNotification } = await import("./desktop.ts"); +const { sendDesktopNotification, getDesktopNotificationPermissionState } = + await import("./desktop.ts"); + +// Stands in for the plugin's injected shim: `permission` is a cached value and +// only `requestPermission()` reaches the backend and rewrites it. +function shimNotification(initialPermission, grantedPermission = "granted") { + class ShimNotification { + static permission = initialPermission; + static requestCount = 0; + + static async requestPermission() { + ShimNotification.requestCount += 1; + ShimNotification.permission = grantedPermission; + return grantedPermission; + } + + close() {} + } + + return ShimNotification; +} + +async function withEnvironment({ platform, isTauri, notification }, callback) { + const originalNavigator = Object.getOwnPropertyDescriptor( + globalThis, + "navigator", + ); + const originalNotification = window.Notification; + const originalIsTauri = globalThis.isTauri; + + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { platform, userAgent: "" }, + }); + window.Notification = notification; + globalThis.isTauri = isTauri; + + try { + return await callback(); + } finally { + if (originalNavigator) { + Object.defineProperty(globalThis, "navigator", originalNavigator); + } else { + delete globalThis.navigator; + } + window.Notification = originalNotification; + globalThis.isTauri = originalIsTauri; + } +} test("constructor failure is a delivery miss and does not prevent a later notification", async (t) => { const warnings = []; @@ -50,3 +98,72 @@ test("constructor failure is a delivery miss and does not prevent a later notifi }, ]); }); + +test("Windows Tauri repairs the shim's false denied state on read", async () => { + const Notification = shimNotification("denied"); + + const permission = await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "granted"); + assert.equal(Notification.requestCount, 1); +}); + +test("the repaired state is cached, so later reads do not request again", async () => { + const Notification = shimNotification("denied"); + + await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + async () => { + await getDesktopNotificationPermissionState(); + const second = await getDesktopNotificationPermissionState(); + assert.equal(second, "granted"); + }, + ); + + assert.equal(Notification.requestCount, 1); +}); + +test("a denial that survives the request is reported as denied", async () => { + const Notification = shimNotification("denied", "denied"); + + const permission = await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "denied"); + assert.equal(Notification.requestCount, 1); +}); + +test("denied stays terminal outside the Windows Tauri app", async () => { + for (const environment of [ + { label: "Windows web", platform: "Win32", isTauri: false }, + { label: "Linux Tauri", platform: "Linux x86_64", isTauri: true }, + { label: "Linux web", platform: "Linux x86_64", isTauri: false }, + ]) { + const Notification = shimNotification("denied"); + + const permission = await withEnvironment( + { ...environment, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "denied", environment.label); + assert.equal(Notification.requestCount, 0, environment.label); + } +}); + +test("granted is returned untouched and never triggers a request", async () => { + const Notification = shimNotification("granted"); + + const permission = await withEnvironment( + { platform: "Win32", isTauri: true, notification: Notification }, + () => getDesktopNotificationPermissionState(), + ); + + assert.equal(permission, "granted"); + assert.equal(Notification.requestCount, 0); +}); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 0844c24de5..c0ee61c6b3 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -6,7 +6,11 @@ import { onAction, requestPermission, } from "@tauri-apps/plugin-notification"; -import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; +import { + isLinuxPlatform, + isMacPlatform, + isWindowsPlatform, +} from "@/shared/lib/platform"; // Backend event emitted when a native Linux notification is clicked or a // queued macOS activation becomes available. See src-tauri notification code. @@ -147,6 +151,25 @@ export async function getDesktopNotificationPermissionState(): Promise { + for (const platform of ["Win32", "Win64", "Windows"]) { + withNavigator({ platform, userAgent: "" }, () => { + assert.equal(isWindowsPlatform(), true, platform); + }); + } +}); + +test("Windows detection rejects the other desktop platforms", () => { + for (const platform of ["MacIntel", "Darwin", "Linux x86_64"]) { + withNavigator({ platform, userAgent: "" }, () => { + assert.equal(isWindowsPlatform(), false, platform); + }); + } +}); diff --git a/desktop/src/shared/lib/platform.ts b/desktop/src/shared/lib/platform.ts index 42e7f5b944..b8c2c3ecfc 100644 --- a/desktop/src/shared/lib/platform.ts +++ b/desktop/src/shared/lib/platform.ts @@ -23,6 +23,15 @@ export function isLinuxPlatform(): boolean { ); } +/** Returns true on Windows desktops. */ +export function isWindowsPlatform(): boolean { + if (typeof navigator === "undefined") { + return false; + } + + return /^win/i.test(navigator.platform); +} + /** * The platform's normal application-shortcut modifier: * - macOS: Command (Meta) From 5142cf6b2a0f44686eeda1ed07905ee3560fd78e Mon Sep 17 00:00:00 2001 From: Luke Tornquist Date: Fri, 9 Oct 2026 15:23:27 -0400 Subject: [PATCH 3/4] test(desktop): move CSP guards from Rust to the desktop JS suite (#8172) ## Summary Moves the packaged-app CSP guards from `desktop/src-tauri/tests/csp.rs` to `desktop/src/app/contentSecurityPolicy.test.mjs`. No production code changes beyond one added `export`. `csp.rs` never touched Rust app code. All seven tests parse `tauri.conf.json` and compare CSP strings. The MediaPipe check also `include_str!`'d `desktop/src/features/profile/lib/animatedAvatarCapture.ts` and pulled the `MEDIAPIPE_WASM_BASE` literal out by splitting the text. That made a Tauri test depend on a frontend file. With path-filtered CI (#8156), a change to only that `.ts` file would skip the check. The JS version imports `MEDIAPIPE_WASM_BASE` directly (it's now exported) and reads `src-tauri/tauri.conf.json` from disk. The `desktop/**` CI filter covers both files, so the check runs in Desktop JS whenever either one changes, and the filter needs no cross-boundary special case. The seven tests and their assertions carry over one-for-one, along with their rationale comments. **What got simpler:** we delete a 204-line Rust integration test, the `include_str!` of a frontend file, and the hand-rolled string-literal scraper, and the CI path filter doesn't need to know about this file. ### Related issue Addresses the CSP coupling raised in review of #8156. Related: #4614 (introduced the guards). ### Testing - New suite on Linux: 7/7 pass. - I falsified each test by mutating the real policy or code, one mutation at a time. Each mutation fails exactly the matching test, and the suite passes again once restored: - moved `MEDIAPIPE_WASM_BASE` outside `@mediapipe/` - dropped `'wasm-unsafe-eval'` - dropped the MediaPipe scope from `script-src` - added a bare `https://cdn.jsdelivr.net/` origin - added `'unsafe-inline'` - removed `img-src` - dropped `ws:` from `connect-src` - made the pinned-source predicate always pass - Blox `just ci` at tree `5664c6ce` (same tree as head `f798f94b`): - Passed: fmt, clippy, desktop/Tauri/web checks, security-review/file-size checks, `desktop-test` (6,819 + 139 jsdom), `desktop-build`, `desktop-tauri-check`, `desktop-tauri-test`, `web-build`. - `test-unit`: 94/95 suites pass. The two failing `buzz-acp` `git_bootstrap` tests fail because the workstation's Git 2.43 predates the credential `authtype` protocol. They pass 4/4 with Git 2.51, and this PR touches no `crates/`. - Not run: `mobile-check`/`mobile-test`, because the workstation can't reach pub.dev. No `mobile/` changes. - Hosted CI green: https://github.com/block/buzz/actions/runs/37801901659. This PR selects the Tauri lanes because it deletes `src-tauri/tests/csp.rs`. Later edits that only touch the constant select Desktop JS only. --------- Signed-off-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> --- desktop/src-tauri/tests/csp.rs | 204 ------------------ .../src/app/contentSecurityPolicy.test.mjs | 181 ++++++++++++++++ .../profile/lib/animatedAvatarCapture.ts | 5 +- 3 files changed, 184 insertions(+), 206 deletions(-) delete mode 100644 desktop/src-tauri/tests/csp.rs create mode 100644 desktop/src/app/contentSecurityPolicy.test.mjs diff --git a/desktop/src-tauri/tests/csp.rs b/desktop/src-tauri/tests/csp.rs deleted file mode 100644 index 3497c36863..0000000000 --- a/desktop/src-tauri/tests/csp.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`. -//! -//! The CSP is only enforced on assets Tauri itself serves, so neither -//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under -//! `vite preview`) can catch a policy that breaks the app. These tests pin the -//! non-obvious sources the frontend actually needs, so a future tightening -//! fails here instead of in a signed build. -//! -//! Kept as an integration test so the policy can be checked without the app -//! crate having to declare a test-only module. - -use std::collections::HashMap; - -const TAURI_CONF: &str = include_str!("../tauri.conf.json"); - -fn csp_directives() -> HashMap> { - let conf: serde_json::Value = - serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON"); - let csp = conf["app"]["security"]["csp"] - .as_str() - .expect("app.security.csp is set as a policy string"); - - csp.split(';') - .filter_map(|directive| { - let mut parts = directive.split_whitespace(); - let name = parts.next()?; - Some((name.to_owned(), parts.map(str::to_owned).collect())) - }) - .collect() -} - -fn sources(directive: &str) -> Vec { - csp_directives() - .remove(directive) - .unwrap_or_else(|| panic!("csp is missing the {directive} directive")) -} - -#[test] -fn script_src_allows_wasm_instantiation() { - // Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for - // every code block; MediaPipe selfie segmentation does the same. Without - // this token both silently degrade — highlighting drops to plain text and - // animated avatars keep their background. - assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned())); -} - -/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`. -fn mediapipe_wasm_base() -> String { - const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts"); - - let after = CAPTURE - .split_once("const MEDIAPIPE_WASM_BASE =") - .expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE") - .1; - let url = after - .split_once('"') - .expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal") - .1; - url.split_once('"') - .expect("MEDIAPIPE_WASM_BASE literal is terminated") - .0 - .to_owned() -} - -/// The npm scope the MediaPipe loader must come from. A CSP source ending in -/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any -/// `@mediapipe` package while excluding the rest of what jsDelivr serves. -const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/"; - -#[test] -fn script_src_scopes_the_mediapipe_loader() { - // `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js` - // via a `