From f883781c1f8c350fe1489456c806db2d75b0f5f2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 2 Jul 2026 00:21:55 -0400 Subject: [PATCH] fix(security+audit): strip latent RCE grants, opener allowlist, GDI leaks, CI hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the deep-audit wave (reviewer-verified: capability identifiers valid, no removed-crate references, GDI free ordering correct): - Removed 8 never-registered plugins (clipboard-manager, fs, shell, http, process, os, dialog, global-shortcut) from Cargo.toml AND their capability grants (shell:allow-execute, unscoped fs writes, http:default, …) — verified the web never invokes any of them. A latent RCE-class surface is gone. - on_new_window: only http/https/mailto reach the OS opener (file:///custom schemes previously bypassed the opener capability scope entirely). - set_badge_count: freed hdc + hdc_screen on all three GDI error paths (leaked per badge update in a long-running tray app). - 8s reveal failsafe gated by an AtomicBool: no longer re-shows a window the user closed to tray; page-load reveal now fires once only (logout reloads don't re-surface a tray-hidden window); recovery for a missed page-load event preserved. - toast.rs: store pruned on Activated too + capped at 20 (was unbounded). - Startup no longer panics when the bundled icon is missing (tray skipped gracefully); msSmartScreenProtection no longer disabled (throttling disables kept); rust-version corrected to 1.77.2. - release.yml update-manifest: fails on empty signatures (was: could publish a manifest that traps Windows users in a failed-update loop); partial- failure window documented. Deleted the stale upstream tauri.yml workflow. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/release.yml | 15 +++ .github/workflows/tauri.yml | 168 --------------------------- src-tauri/Cargo.toml | 12 +- src-tauri/capabilities/desktop.json | 1 - src-tauri/capabilities/migrated.json | 35 ------ src-tauri/src/lib.rs | 130 ++++++++++++++------- src-tauri/src/native/toast.rs | 19 ++- 7 files changed, 120 insertions(+), 260 deletions(-) delete mode 100644 .github/workflows/tauri.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 989f758..d57a106 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -24,6 +24,14 @@ jobs: id: release env: TOKEN: ${{ secrets.RELEASE_TOKEN }} + # NOTE (partial-failure window): this renames the `latest` release's + # name/body to the new version up front, before the platform builds run. + # If a build later fails, the release keeps the OLD binary assets but the + # NEW name. That's cosmetic: the auto-updater reads release.json, which is + # only regenerated by the update-manifest job — and that job `needs:` BOTH + # build-windows and build-linux, so a failed/skipped build prevents any + # manifest (and therefore updater) change. Clients keep the last good + # release.json until a fully successful run replaces it. run: | VERSION="4.12.${{ github.run_number }}" EXISTING=$(curl -sf "$GITEA_URL/api/v1/repos/$REPO/releases/tags/latest" \ @@ -277,6 +285,13 @@ jobs: WIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64-setup.nsis.zip.sig") LIN_SIG=$(curl -sf "$BASE/LotusChat-x86_64.AppImage.tar.gz.sig") + + # Never publish a manifest with a missing/empty signature: the updater + # would reject (or worse, accept an unsigned) artifact. Fail the job so + # the previous good release.json stays in place. + [ -n "$WIN_SIG" ] || { echo "ERROR: empty Windows signature" >&2; exit 1; } + [ -n "$LIN_SIG" ] || { echo "ERROR: empty Linux signature" >&2; exit 1; } + DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) python3 -c "import json,sys; v,d,wu,ws,lu,ls=sys.argv[1:]; print(json.dumps({'version':v,'notes':'Latest Lotus Chat release','pub_date':d,'platforms':{'windows-x86_64':{'url':wu,'signature':ws},'linux-x86_64':{'url':lu,'signature':ls}}},indent=2))" \ diff --git a/.github/workflows/tauri.yml b/.github/workflows/tauri.yml deleted file mode 100644 index bc73653..0000000 --- a/.github/workflows/tauri.yml +++ /dev/null @@ -1,168 +0,0 @@ -name: "Publish Tauri App" -on: - release: - types: [published] - -jobs: - # Windows-x86_64 - windows-x86_64: - runs-on: windows-latest - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: true - - name: Setup node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: ".node-version" - package-manager-cache: false - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable # They use branch based releases - - name: Install cinny dependencies - run: cd cinny && npm ci - - name: Install tauri dependencies - run: npm ci - - name: Build desktop app with Tauri - uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa # v0.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - NODE_OPTIONS: "--max_old_space_size=4096" - - name: Get app version (windows) - run: | - $json = (Get-Content "src-tauri\tauri.conf.json" -Raw) | ConvertFrom-Json - $version = $json.version - echo "Version: ${version}" - echo "TAURI_VERSION=${version}" >> $Env:GITHUB_ENV - echo "${Env:TAURI_VERSION}" - shell: pwsh - - name: Move msi - run: Move-Item "src-tauri\target\release\bundle\msi\Cinny_${{ env.TAURI_VERSION }}_x64_en-US.msi" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi" - shell: pwsh - - name: Move msi.zip - run: Move-Item "src-tauri\target\release\bundle\msi\Cinny_${{ env.TAURI_VERSION }}_x64_en-US.msi.zip" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi.zip" - shell: pwsh - - name: Move msi.zip.sig - run: Move-Item "src-tauri\target\release\bundle\msi\Cinny_${{ env.TAURI_VERSION }}_x64_en-US.msi.zip.sig" "src-tauri\target\release\bundle\msi\Cinny_desktop-x86_64.msi.zip.sig" - shell: pwsh - - name: Upload tagged release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 - with: - files: | - src-tauri/target/release/bundle/msi/Cinny_desktop-x86_64.msi - src-tauri/target/release/bundle/msi/Cinny_desktop-x86_64.msi.zip - src-tauri/target/release/bundle/msi/Cinny_desktop-x86_64.msi.zip.sig - - # Linux-x86_64 - linux-x86_64: - runs-on: ubuntu-22.04 - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: true - - name: Setup node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: ".node-version" - package-manager-cache: false - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable # They use branch based releases - - name: Install dependencies - run: | - sudo apt-get update - sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: Install cinny dependencies - run: cd cinny && npm ci - - name: Install tauri dependencies - run: npm ci - - name: Build desktop app with Tauri - uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa # v0.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - NODE_OPTIONS: "--max_old_space_size=4096" - - name: Get app version - id: vars - run: echo "tag=$(jq .version src-tauri/tauri.conf.json | tr -d '"')" >> $GITHUB_OUTPUT - - name: Move deb - run: mv "src-tauri/target/release/bundle/deb/Cinny_${{ steps.vars.outputs.tag }}_amd64.deb" "src-tauri/target/release/bundle/deb/Cinny_desktop-x86_64.deb" - - name: Move AppImage - run: mv "src-tauri/target/release/bundle/appimage/Cinny_${{ steps.vars.outputs.tag }}_amd64.AppImage" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage" - - name: Move AppImage.tar.gz - run: mv "src-tauri/target/release/bundle/appimage/Cinny_${{ steps.vars.outputs.tag }}_amd64.AppImage.tar.gz" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz" - - name: Move AppImage.tar.gz.sig - run: mv "src-tauri/target/release/bundle/appimage/Cinny_${{ steps.vars.outputs.tag }}_amd64.AppImage.tar.gz.sig" "src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz.sig" - - name: Upload tagged release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 - with: - files: | - src-tauri/target/release/bundle/deb/Cinny_desktop-x86_64.deb - src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage - src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz - src-tauri/target/release/bundle/appimage/Cinny_desktop-x86_64.AppImage.tar.gz.sig - - # macos-universal - macos-universal: - runs-on: macos-latest - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - submodules: true - - name: Setup node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version-file: ".node-version" - package-manager-cache: false - - name: Install Rust stable - uses: dtolnay/rust-toolchain@stable # They use branch based releases - with: - targets: aarch64-apple-darwin,x86_64-apple-darwin - - name: Install cinny dependencies - run: cd cinny && npm ci - - name: Install tauri dependencies - run: npm ci - - name: Build desktop app with Tauri - uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa # v0.6.1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - NODE_OPTIONS: "--max_old_space_size=4096" - with: - args: "--target universal-apple-darwin" - - name: Get app version - id: vars - run: echo "tag=$(jq .version src-tauri/tauri.conf.json | tr -d '"')" >> $GITHUB_OUTPUT - - name: Move dmg - run: mv "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_${{ steps.vars.outputs.tag }}_universal.dmg" "src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_desktop-universal.dmg" - - name: Move app.tar.gz - run: mv "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny.app.tar.gz" "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz" - - name: Move app.tar.gz.sig - run: mv "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny.app.tar.gz.sig" "src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz.sig" - - name: Upload tagged release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 - with: - files: | - src-tauri/target/universal-apple-darwin/release/bundle/dmg/Cinny_desktop-universal.dmg - src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz - src-tauri/target/universal-apple-darwin/release/bundle/macos/Cinny_desktop-universal.app.tar.gz.sig - - # Upload release.json - release-update: - if: always() - needs: [windows-x86_64, linux-x86_64, macos-universal] - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install dependencies - run: npm ci - - name: Run release.json - run: npm run release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9d2f994..5469fdf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -2,14 +2,14 @@ [package] name = "cinny" -version = "4.12.2" +version = "4.12.2" # CI patches src-tauri/tauri.conf.json at build time; that file is the source of truth for the shipped version. description = "Yet another matrix client" authors = ["Ajay Bura"] license = "AGPL-3.0-only" repository = "https://github.com/cinnyapp/cinny-desktop" default-run = "cinny" edition = "2021" -rust-version = "1.61" +rust-version = "1.77.2" [build-dependencies] tauri-build = { version = "2", features = [] } @@ -20,14 +20,7 @@ serde = { version = "1.0.193", features = ["derive"] } tauri = { version = "2", features = ["devtools", "wry", "tray-icon", "image-png"] } tauri-plugin-localhost = "2" tauri-plugin-window-state = "2" -tauri-plugin-clipboard-manager = "2" tauri-plugin-notification = "2" -tauri-plugin-fs = "2" -tauri-plugin-shell = "2" -tauri-plugin-http = "2" -tauri-plugin-process = "2" -tauri-plugin-os = "2" -tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-deep-link = "2" @@ -40,7 +33,6 @@ default = [ "custom-protocol" ] custom-protocol = [ "tauri/custom-protocol" ] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] -tauri-plugin-global-shortcut = "2" tauri-plugin-updater = "2" tauri-plugin-single-instance = "2" diff --git a/src-tauri/capabilities/desktop.json b/src-tauri/capabilities/desktop.json index 7564d9f..ac4e105 100644 --- a/src-tauri/capabilities/desktop.json +++ b/src-tauri/capabilities/desktop.json @@ -10,7 +10,6 @@ ], "permissions": [ "updater:default", - "global-shortcut:default", "deep-link:default" ] } \ No newline at end of file diff --git a/src-tauri/capabilities/migrated.json b/src-tauri/capabilities/migrated.json index 8e0427b..7bdd3af 100644 --- a/src-tauri/capabilities/migrated.json +++ b/src-tauri/capabilities/migrated.json @@ -12,15 +12,6 @@ ], "permissions": [ "core:default", - "fs:allow-read-file", - "fs:allow-write-file", - "fs:allow-read-dir", - "fs:allow-copy-file", - "fs:allow-mkdir", - "fs:allow-remove", - "fs:allow-remove", - "fs:allow-rename", - "fs:allow-exists", "core:window:allow-create", "core:window:allow-center", "core:window:allow-request-user-attention", @@ -54,35 +45,9 @@ "core:window:allow-set-ignore-cursor-events", "core:window:allow-start-dragging", "core:webview:allow-print", - "shell:allow-execute", - "shell:allow-open", - "dialog:allow-open", - "dialog:allow-save", - "dialog:allow-message", - "dialog:allow-ask", - "dialog:allow-confirm", - "http:default", "notification:default", - "global-shortcut:allow-is-registered", - "global-shortcut:allow-register", - "global-shortcut:allow-register-all", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "os:allow-platform", - "os:allow-version", - "os:allow-os-type", - "os:allow-family", - "os:allow-arch", - "os:allow-exe-extension", - "os:allow-locale", - "os:allow-hostname", - "process:allow-restart", - "process:allow-exit", - "clipboard-manager:allow-read-text", - "clipboard-manager:allow-write-text", "core:app:allow-app-show", "core:app:allow-app-hide", - "clipboard-manager:default", { "identifier": "opener:allow-open-url", "allow": [{ "url": "http://*" }, { "url": "https://*" }] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3d823f8..30220b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -196,8 +196,14 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { }; let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); let hbm_color = - CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) - .map_err(|e| e.to_string())?; + match CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0) { + Ok(bm) => bm, + Err(e) => { + let _ = DeleteDC(hdc); + let _ = ReleaseDC(None, hdc_screen); + return Err(e.to_string()); + } + }; // Zero-init so undrawn pixels are fully transparent (CreateDIBSection // does not guarantee zeroed memory; garbage bytes cause a black square). if !bits.is_null() { @@ -263,6 +269,8 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { let hbm_mask = CreateBitmap(size, size, 1, 1, None); if hbm_mask.0 as usize == 0 { let _ = DeleteObject(hbm_color.into()); + let _ = DeleteDC(hdc); + let _ = ReleaseDC(None, hdc_screen); return Err("CreateBitmap failed".to_string()); } @@ -276,6 +284,8 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> { let hicon = CreateIconIndirect(&icon_info).map_err(|e| { let _ = DeleteObject(hbm_color.into()); let _ = DeleteObject(hbm_mask.into()); + let _ = DeleteDC(hdc); + let _ = ReleaseDC(None, hdc_screen); e.to_string() })?; @@ -437,51 +447,54 @@ pub fn run() { .setup(move |app| { // --- System tray: keeps Lotus Chat running in the background so // notifications keep arriving after the window is closed-to-tray. --- - let base_icon = app.default_window_icon().cloned(); - let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?; - let separator = PredefinedMenuItem::separator(app)?; - let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?; - let tray = TrayIconBuilder::with_id("main-tray") - .icon(base_icon.clone().expect("bundled window icon")) - .tooltip("Lotus Chat") - .menu(&tray_menu) - .show_menu_on_left_click(false) - .on_menu_event(|app, event| match event.id.as_ref() { - "open" => show_main(app), - "quit" => app.exit(0), - _ => {} - }) - .on_tray_icon_event(|tray, event| { - if let TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } = event - { - let app = tray.app_handle(); - if let Some(window) = app.get_webview_window("main") { - if window.is_visible().unwrap_or(false) { - let _ = window.hide(); - } else { - show_main(app); + // Degrade gracefully if the bundled icon is missing rather than + // panicking at startup (the tray simply isn't created). + if let Some(base_icon) = app.default_window_icon().cloned() { + let open_item = MenuItem::with_id(app, "open", "Open Lotus Chat", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "Quit Lotus Chat", true, None::<&str>)?; + let separator = PredefinedMenuItem::separator(app)?; + let tray_menu = Menu::with_items(app, &[&open_item, &separator, &quit_item])?; + let tray = TrayIconBuilder::with_id("main-tray") + .icon(base_icon.clone()) + .tooltip("Lotus Chat") + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(|app, event| match event.id.as_ref() { + "open" => show_main(app), + "quit" => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + } else { + show_main(app); + } } } - } - }) - .build(app)?; + }) + .build(app)?; - // Keep the tray handle (and base icon pixels) in managed state so - // set_tray_unread can re-render the icon at runtime. - if let Some(img) = base_icon { - let base_rgba = img.rgba().to_vec(); - let (width, height) = (img.width(), img.height()); + // Keep the tray handle (and base icon pixels) in managed state so + // set_tray_unread can re-render the icon at runtime. + let base_rgba = base_icon.rgba().to_vec(); + let (width, height) = (base_icon.width(), base_icon.height()); app.manage(TrayUnreadState { tray, base_rgba, width, height, }); + } else { + eprintln!("tray: no bundled window icon; skipping system tray setup"); } #[cfg(debug_assertions)] @@ -494,6 +507,12 @@ pub fn run() { }; let app_handle = app.handle().clone(); + // Tracks whether the window's visibility has already been decided: + // set true by the on_page_load reveal (window shown) and by the + // close-to-tray handler (window intentionally hidden). The 8s failsafe + // below only reveals the window if neither of those has happened. + let window_settled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let settled_page_load = window_settled.clone(); let window = WebviewWindowBuilder::new(app, "main".to_string(), window_url) .title("Lotus Chat") // First-run defaults; tauri-plugin-window-state restores geometry @@ -513,15 +532,30 @@ pub fn run() { // appends the Chromium background-throttling disables. Windows-only // in effect; harmless elsewhere. Does not block system sleep. .additional_browser_args( - "--disable-features=msWebOOUI,msPdfOOUI,msSmartScreenProtection --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows", + "--disable-features=msWebOOUI,msPdfOOUI --disable-background-timer-throttling --disable-renderer-backgrounding --disable-backgrounding-occluded-windows", ) - .on_page_load(|window, payload| { + .on_page_load(move |window, payload| { if matches!(payload.event(), PageLoadEvent::Finished) { - let _ = window.show(); + // Reveal only on the FIRST settle: later page loads (e.g. a + // logout reload) must not re-show a window the user has + // since closed to the tray. + if !settled_page_load.swap(true, std::sync::atomic::Ordering::SeqCst) { + let _ = window.show(); + } } }) .on_new_window(move |url, _features| { - let _ = app_handle.opener().open_url(url.as_str(), None::<&str>); + // Only hand well-known web/mail schemes to the OS opener. + // Forwarding arbitrary schemes (file://, custom protocols) + // bypasses the opener capability scope and reaches the OS. + match url.scheme() { + "http" | "https" | "mailto" => { + let _ = app_handle.opener().open_url(url.as_str(), None::<&str>); + } + other => { + eprintln!("opener: refusing to open URL with scheme '{other}'"); + } + } NewWindowResponse::Deny }) .build()?; @@ -529,19 +563,27 @@ pub fn run() { // Close-to-tray: hide instead of exiting; the app is quit explicitly // from the tray menu. let window_for_close = window.clone(); + let settled_close = window_settled.clone(); window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); + // Mark the window state as settled so the failsafe below can't + // re-show a window the user just closed to the tray. + settled_close.store(true, std::sync::atomic::Ordering::SeqCst); let _ = window_for_close.hide(); } }); // Failsafe: never leave the window stuck hidden if the page-load - // event doesn't fire for some reason. + // reveal never fires. Skips if the window state was already settled + // (revealed on page load, or intentionally hidden to the tray). let window_for_show = window.clone(); + let settled_failsafe = window_settled.clone(); std::thread::spawn(move || { std::thread::sleep(std::time::Duration::from_secs(8)); - let _ = window_for_show.show(); + if !settled_failsafe.load(std::sync::atomic::Ordering::SeqCst) { + let _ = window_for_show.show(); + } }); // Deep links (matrix:): route both the cold-start case and the diff --git a/src-tauri/src/native/toast.rs b/src-tauri/src/native/toast.rs index f615258..296d9aa 100644 --- a/src-tauri/src/native/toast.rs +++ b/src-tauri/src/native/toast.rs @@ -158,7 +158,15 @@ fn show_windows_toast( let room_id_owned = room_id.map(|s| s.to_string()); let path_owned = path.map(|s| s.to_string()); let activated = TypedEventHandler::::new( - move |_sender, args| { + move |sender, args| { + // Activation means this toast is done; drop it from the keep-alive + // store now. A Dismissed event doesn't reliably fire for a toast the + // user activated, so pruning only on Dismissed would leak it. + if let Some(sender) = sender.as_ref() { + if let Ok(mut store) = toast_store().lock() { + store.retain(|t| t != sender); + } + } let Some(args) = args.as_ref() else { return Ok(()); }; @@ -204,9 +212,16 @@ fn show_windows_toast( ); let _ = toast.Dismissed(&dismissed)?; - // Keep the toast (and its handlers) alive until dismissed. + // Keep the toast (and its handlers) alive until dismissed/activated. if let Ok(mut store) = toast_store().lock() { store.push(toast.clone()); + // Hard cap: if some Dismissed/Activated events are missed, retain only + // the most recent 20 toasts (dropping the oldest) so the store can't + // grow unbounded for the app's lifetime. + let len = store.len(); + if len > 20 { + store.drain(0..len - 20); + } } // No AUMID argument: relies on the process AppUserModelID (see module note).