Compare commits

..

67 Commits

Author SHA1 Message Date
jared e9132b4489 feat(native): P5-42 keep webview unthrottled in tray (instant background sync)
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 22m17s
Build Lotus Chat Desktop / build-windows (push) Failing after 17m9s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
Add WebView2 additional_browser_args to disable Chromium background throttling
(--disable-background-timer-throttling / -renderer-backgrounding /
-backgrounding-occluded-windows) so the existing JS Matrix /sync loop and
notifications keep running full-speed when the app is closed to the tray, instead
of standing up a second headless Rust sync client. Tauri's default WebView2 args
are preserved (setting this overrides them). Windows/WebView2 only; does not block
system sleep (that's P5-46, calls-only). CI Windows compile pending.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:04:24 -04:00
jared a923650d02 feat(native): Tier B — WinRT rich toasts (P5-41/35) + Focus Assist sync (P5-56)
- toast.rs: Windows.UI.Notifications rich toast (reply input + Send action);
  in-process Activated event → emit lotus-notification-activate {path} (click) /
  lotus-notification-reply {roomId,text}. Falls back to tauri-plugin-notification
  (WinRT error / non-Windows). The NOTIFICATION_BRIDGE now routes notifications
  carrying a roomId (tag) to show_rich_toast. Features: UI_Notifications,
  Data_Xml_Dom, Foundation_Collections.
- focus_assist.rs: SHQueryUserNotificationState poll thread → emit
  focus-assist-changed {active} on QUNS_QUIET_TIME/PRESENTATION/D3D_FULLSCREEN/BUSY.
  No new Cargo features.

CI Windows compile pending (no local Rust toolchain). Runtime caveat: WinRT toasts
need a Start-menu shortcut + matching AppUserModelID (org.lotusguild.lotus-chat);
without it CreateToastNotifier errors and the code falls back to the plugin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:04:24 -04:00
jared 73d15b13d2 feat(native): Tier A desktop features (P5-46/36/44/43/49) + window chrome (P5-47)
Adds a `native/` module system (each feature = its own module exposing
`#[tauri::command]`s + optional `setup`; `emit_to_web` pushes DOM CustomEvents to
the web like `forward_deeplink`). Wired into generate_handler! + native::setup;
windows-crate feature union added to Cargo.toml.

- power.rs (P5-46): SetThreadExecutionState held on the main thread while a call
  is active; released on end. Cross-platform (no-op off Windows).
- jumplist.rs (P5-36): ICustomDestinationList "Recent Rooms" of IShellLink tasks
  launching the exe with a matrix: arg (existing deep-link handler opens the room).
- thumbbar.rs (P5-44): ITaskbarList3 ThumbBar Mute/Deafen/End (GDI HICONs) + a
  window subclass catching THBN_CLICKED → emit thumbbar-action.
- smtc.rs (P5-43): WinRT SystemMediaTransportControls via GetForWindow; ButtonPressed
  → smtc-action; call-state command. (Experimental for a non-media app.)
- network.rs (P5-49): INetworkListManager poll thread → emit network-changed.
- chrome.rs (P5-47): cross-platform window-control commands + set_custom_chrome
  (set_decorations) for the opt-in TDS titlebar.

NOT compile-verified locally (no Rust/Windows toolchain on the dev box) — this is
for the CI Windows compile pass (GitHub test.yml / Gitea windows runner). Expect a
possible fixup round (windows-crate feature/namespace paths, e.g. subclass APIs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 11:04:24 -04:00
Lotus CI eeb842c7b1 chore: bump cinny submodule to 258e3ec6
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 20m39s
Build Lotus Chat Desktop / build-windows (push) Successful in 21m43s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-07-01 14:52:56 +00:00
Lotus CI 8c8a6e485b chore: bump cinny submodule to a0fcdf74
Build Lotus Chat Desktop / prepare (push) Successful in 10s
Build Lotus Chat Desktop / build-linux (push) Successful in 34m51s
Build Lotus Chat Desktop / build-windows (push) Successful in 45m32s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-07-01 05:22:56 +00:00
Lotus CI f7714a0393 chore: bump cinny submodule to ebc782b1
Build Lotus Chat Desktop / prepare (push) Successful in 16s
Build Lotus Chat Desktop / build-linux (push) Successful in 31m22s
Build Lotus Chat Desktop / build-windows (push) Successful in 43m20s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-07-01 04:36:53 +00:00
Lotus CI ef147ea060 chore: bump cinny submodule to 7c06b27c
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m32s
Build Lotus Chat Desktop / build-windows (push) Successful in 40m2s
Build Lotus Chat Desktop / update-manifest (push) Successful in 7s
2026-07-01 02:47:52 +00:00
jared 324a27577c fix(notify): make TauriNotification.permission assignable (no-op setter)
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 28m55s
Build Lotus Chat Desktop / build-windows (push) Successful in 40m21s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
The injected notification bridge defined `permission` as a getter-only property.
When the notification plugin / a polyfill assigned `Notification.permission`, it
threw "Cannot set property permission of function TauriNotification ... which has
only a getter" at page load. Add a no-op setter so it still reads 'granted' but
assignment can't crash.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:35:21 -04:00
Lotus CI 49496678ba chore: bump cinny submodule to 26f998d2
Build Lotus Chat Desktop / prepare (push) Successful in 8s
Build Lotus Chat Desktop / build-linux (push) Successful in 30m28s
Build Lotus Chat Desktop / build-windows (push) Successful in 40m59s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-30 23:54:16 +00:00
Lotus CI b063d56ac9 chore: bump cinny submodule to 5af024f7
Build Lotus Chat Desktop / prepare (push) Successful in 19s
Build Lotus Chat Desktop / build-linux (push) Successful in 32m29s
Build Lotus Chat Desktop / build-windows (push) Successful in 44m25s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-30 22:29:13 +00:00
Lotus CI 2cde005578 chore: bump cinny submodule to efcee88f
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 30m10s
Build Lotus Chat Desktop / build-windows (push) Successful in 43m5s
Build Lotus Chat Desktop / update-manifest (push) Successful in 9s
2026-06-30 21:31:51 +00:00
Lotus CI d8f73537e2 chore: bump cinny submodule to 24662fa9
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Failing after 20m22s
Build Lotus Chat Desktop / build-windows (push) Successful in 44m55s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
2026-06-30 19:11:57 +00:00
Lotus CI 6985d7d23b chore: bump cinny submodule to 4d55e459
Build Lotus Chat Desktop / prepare (push) Successful in 11s
Build Lotus Chat Desktop / build-linux (push) Successful in 30m19s
Build Lotus Chat Desktop / build-windows (push) Successful in 44m49s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-30 17:54:34 +00:00
Lotus CI 542956cbc1 chore: bump cinny submodule to 36343bae
Build Lotus Chat Desktop / prepare (push) Successful in 14s
Build Lotus Chat Desktop / build-linux (push) Successful in 34m43s
Build Lotus Chat Desktop / build-windows (push) Successful in 45m42s
Build Lotus Chat Desktop / update-manifest (push) Successful in 7s
2026-06-30 06:27:56 +00:00
Lotus CI 5d9813db57 chore: bump cinny submodule to 89cf171e
Build Lotus Chat Desktop / prepare (push) Successful in 17s
Build Lotus Chat Desktop / build-linux (push) Successful in 29m52s
Build Lotus Chat Desktop / build-windows (push) Successful in 39m24s
Build Lotus Chat Desktop / update-manifest (push) Successful in 10s
2026-06-30 05:46:16 +00:00
Lotus CI 3845c1a6c8 chore: bump cinny submodule to 149ec8e4
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m43s
Build Lotus Chat Desktop / build-windows (push) Successful in 35m20s
Build Lotus Chat Desktop / update-manifest (push) Successful in 12s
2026-06-30 01:11:17 +00:00
Lotus CI 8409d9d6e3 chore: bump cinny submodule to 6ace96f2
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m35s
Build Lotus Chat Desktop / build-windows (push) Successful in 31m23s
Build Lotus Chat Desktop / update-manifest (push) Successful in 8s
2026-06-29 22:32:54 +00:00
Lotus CI 48aba2b395 chore: bump cinny submodule to 9bf56d57
Build Lotus Chat Desktop / prepare (push) Successful in 7s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m48s
Build Lotus Chat Desktop / build-windows (push) Successful in 36m20s
Build Lotus Chat Desktop / update-manifest (push) Successful in 11s
2026-06-29 03:21:58 +00:00
Lotus CI 0126d7c2c8 chore: bump cinny submodule to 349194e7
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 26m4s
Build Lotus Chat Desktop / build-windows (push) Successful in 35m58s
Build Lotus Chat Desktop / update-manifest (push) Successful in 17s
2026-06-29 02:44:08 +00:00
Lotus CI d75a94853b chore: bump cinny submodule to 52047662
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m46s
Build Lotus Chat Desktop / build-windows (push) Successful in 37m0s
Build Lotus Chat Desktop / update-manifest (push) Successful in 6s
2026-06-29 01:12:47 +00:00
Lotus CI 89d70981e5 chore: bump cinny submodule to 4d0e34c4
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-windows (push) Successful in 39m41s
Build Lotus Chat Desktop / build-linux (push) Failing after 42m12s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
2026-06-28 18:08:08 +00:00
Lotus CI 6a49e92e01 chore: bump cinny submodule to 84a2e7a9
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m41s
Build Lotus Chat Desktop / build-windows (push) Successful in 39m8s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-28 01:41:17 +00:00
Lotus CI 8309a21029 chore: bump cinny submodule to 31cf3534
Build Lotus Chat Desktop / prepare (push) Successful in 7s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m2s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m10s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-26 22:29:16 +00:00
Lotus CI b632e875b8 chore: bump cinny submodule to fc8eb706
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 22m58s
Build Lotus Chat Desktop / build-windows (push) Successful in 31m17s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-26 21:55:28 +00:00
Lotus CI 5603ba8f33 chore: bump cinny submodule to 7b94eeaa
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 22m59s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m46s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-26 20:40:38 +00:00
Lotus CI 1966d9c429 chore: bump cinny submodule to d39aef0a
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 22m51s
Build Lotus Chat Desktop / build-windows (push) Successful in 33m13s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-25 08:19:17 +00:00
Lotus CI ee665eab0e chore: bump cinny submodule to 66cc51d6
Build Lotus Chat Desktop / prepare (push) Successful in 13s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m22s
Build Lotus Chat Desktop / build-windows (push) Successful in 36m48s
Build Lotus Chat Desktop / update-manifest (push) Successful in 10s
2026-06-24 21:59:22 +00:00
Lotus CI 3836de7ea0 chore: bump cinny submodule to c0fd3725
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 30m44s
Build Lotus Chat Desktop / build-windows (push) Successful in 54m27s
Build Lotus Chat Desktop / update-manifest (push) Successful in 16s
2026-06-24 16:11:43 +00:00
Lotus CI b0b8ca84b8 chore: bump cinny submodule to 203568c9
Build Lotus Chat Desktop / prepare (push) Successful in 8s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m43s
Build Lotus Chat Desktop / build-windows (push) Successful in 37m9s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-24 12:54:11 +00:00
Lotus CI 475e353339 chore: bump cinny submodule to c0f98672
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m45s
Build Lotus Chat Desktop / build-windows (push) Successful in 37m28s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-23 10:33:29 +00:00
Lotus CI af6615ca98 chore: bump cinny submodule to 79f8fabb
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m15s
Build Lotus Chat Desktop / build-windows (push) Successful in 32m14s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-20 01:28:16 +00:00
Lotus CI bfd176e338 chore: bump cinny submodule to 5470e25b
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m23s
Build Lotus Chat Desktop / build-windows (push) Successful in 31m2s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-20 00:53:45 +00:00
Lotus CI 0800cf2327 chore: bump cinny submodule to e713d473
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m10s
Build Lotus Chat Desktop / build-windows (push) Successful in 34m45s
Build Lotus Chat Desktop / update-manifest (push) Successful in 13s
2026-06-19 22:38:16 +00:00
Lotus CI 32dd6561ab chore: bump cinny submodule to 4a4dede1
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m7s
Build Lotus Chat Desktop / build-windows (push) Successful in 34m37s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-19 20:34:48 +00:00
Lotus CI 690be31a1d chore: bump cinny submodule to b818d3fc
Build Lotus Chat Desktop / prepare (push) Successful in 7s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m24s
Build Lotus Chat Desktop / build-windows (push) Successful in 33m30s
Build Lotus Chat Desktop / update-manifest (push) Successful in 8s
2026-06-19 17:21:35 +00:00
Lotus CI 5cb6b44744 chore: bump cinny submodule to cf839e73
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m45s
Build Lotus Chat Desktop / build-windows (push) Successful in 35m10s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-19 15:33:28 +00:00
Lotus CI 0c315e9250 chore: bump cinny submodule to c54cb126
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m5s
Build Lotus Chat Desktop / build-windows (push) Successful in 33m18s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-19 04:29:27 +00:00
Lotus CI ff4a04265b chore: bump cinny submodule to 8dc4c4d0
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m31s
Build Lotus Chat Desktop / build-windows (push) Successful in 35m28s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-19 02:57:38 +00:00
Lotus CI 040c8afef2 chore: bump cinny submodule to 9742eaea
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m57s
Build Lotus Chat Desktop / build-windows (push) Successful in 35m28s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-19 01:05:02 +00:00
Lotus CI de00d087f6 chore: bump cinny submodule to fb66c0ed
Build Lotus Chat Desktop / prepare (push) Successful in 9s
Build Lotus Chat Desktop / build-linux (push) Successful in 26m47s
Build Lotus Chat Desktop / build-windows (push) Successful in 38m24s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-19 00:02:13 +00:00
Lotus CI afffe6958b chore: bump cinny submodule to e2b957b6
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m57s
Build Lotus Chat Desktop / build-windows (push) Successful in 46m23s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-19 00:02:05 +00:00
Lotus CI 09e489a79b chore: bump cinny submodule to a77c4b6d
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m25s
Build Lotus Chat Desktop / build-windows (push) Successful in 38m2s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-18 23:15:48 +00:00
Lotus CI 3c7ea720f7 chore: bump cinny submodule to f054abfb
Build Lotus Chat Desktop / prepare (push) Successful in 12s
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
Build Lotus Chat Desktop / build-linux (push) Has been cancelled
2026-06-18 23:15:38 +00:00
Lotus CI a52ee06d73 chore: bump cinny submodule to ffa490e7
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 21m15s
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
2026-06-18 22:32:59 +00:00
Lotus CI 744e608b1d chore: bump cinny submodule to 8c711f5f
Build Lotus Chat Desktop / prepare (push) Successful in 9s
Build Lotus Chat Desktop / build-linux (push) Successful in 21m23s
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
2026-06-18 19:29:32 +00:00
Lotus CI c1e46ddedf chore: bump cinny submodule to c395f7d1
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 20m39s
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
2026-06-18 17:46:37 +00:00
Lotus CI ca612b33bb chore: bump cinny submodule to 26f90087
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 21m27s
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
2026-06-18 14:48:32 +00:00
Lotus CI 8ada70dcf7 chore: bump cinny submodule to bb99ad56
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m8s
Build Lotus Chat Desktop / build-windows (push) Successful in 29m57s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-18 01:13:07 +00:00
Lotus CI d21553088e chore: bump cinny submodule to b24ab838
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m17s
Build Lotus Chat Desktop / build-windows (push) Successful in 26m16s
Build Lotus Chat Desktop / update-manifest (push) Successful in 5s
2026-06-18 00:37:57 +00:00
Lotus CI f37ef1df8f chore: bump cinny submodule to abb7f743
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m4s
Build Lotus Chat Desktop / build-windows (push) Successful in 27m28s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-17 22:00:00 +00:00
Lotus CI df9cc34c5d chore: bump cinny submodule to 14cfa021
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m14s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m4s
Build Lotus Chat Desktop / update-manifest (push) Successful in 8s
2026-06-16 22:06:54 +00:00
Lotus CI d176ae396c chore: bump cinny submodule to 86272b6b
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-windows (push) Successful in 24m21s
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
Build Lotus Chat Desktop / build-linux (push) Has been cancelled
2026-06-16 21:25:02 +00:00
Lotus CI 66190fc7af chore: bump cinny submodule to 6634b2b8
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 22m24s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m38s
Build Lotus Chat Desktop / update-manifest (push) Successful in 4s
2026-06-16 05:54:12 +00:00
Lotus CI c92b1f1e8b chore: bump cinny submodule to 5d5f5f45
Build Lotus Chat Desktop / prepare (push) Successful in 6s
Build Lotus Chat Desktop / build-windows (push) Failing after 37s
Build Lotus Chat Desktop / build-linux (push) Failing after 9m40s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
2026-06-16 04:55:31 +00:00
Lotus CI d0700d0475 chore: bump cinny submodule to 938ead79
Build Lotus Chat Desktop / prepare (push) Successful in 5s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m30s
Build Lotus Chat Desktop / build-windows (push) Successful in 25m51s
Build Lotus Chat Desktop / update-manifest (push) Successful in 7s
2026-06-16 03:01:14 +00:00
Lotus CI 095783baa9 chore: bump cinny submodule to 4a401cf8
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
Build Lotus Chat Desktop / build-linux (push) Has been cancelled
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
2026-06-16 01:03:46 +00:00
Lotus CI de38fceff2 chore: bump cinny submodule to 5deed79b
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
Build Lotus Chat Desktop / build-linux (push) Has been cancelled
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
2026-06-16 00:41:19 +00:00
jared 5cc84991f2 fix(badge): zero-init DIB bits buffer to eliminate black square
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / update-manifest (push) Has been cancelled
Build Lotus Chat Desktop / build-windows (push) Has been cancelled
Build Lotus Chat Desktop / build-linux (push) Has been cancelled
CreateDIBSection does not guarantee zeroed memory. Uninitialized bytes
with non-zero RGB but zero alpha were getting alpha=255 set by the
existing pixel loop, causing a black square around the badge circle.
Zeroing with write_bytes before GDI drawing ensures only explicitly
painted pixels are opaque.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 20:35:31 -04:00
Lotus CI 150a1921f9 chore: bump cinny submodule to f9edd202
Build Lotus Chat Desktop / prepare (push) Successful in 11s
Build Lotus Chat Desktop / build-linux (push) Successful in 25m46s
Build Lotus Chat Desktop / build-windows (push) Successful in 29m18s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
2026-06-15 05:27:19 +00:00
Lotus CI a7aad94755 chore: bump cinny submodule to 10f6544e
Build Lotus Chat Desktop / prepare (push) Successful in 4s
Build Lotus Chat Desktop / build-linux (push) Successful in 28m14s
Build Lotus Chat Desktop / build-windows (push) Successful in 33m35s
Build Lotus Chat Desktop / update-manifest (push) Successful in 9s
2026-06-15 04:48:17 +00:00
jared ff3d4b4a18 fix(ci): use USERPROFILE and add rustup toolchain bin to PATH
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m56s
Build Lotus Chat Desktop / build-windows (push) Successful in 30m50s
Build Lotus Chat Desktop / update-manifest (push) Successful in 3s
The rustup shim at .cargo\bin\cargo.exe was failing with
"No application is associated with the specified file for
this operation" — a Windows error indicating the shim proxy
could not resolve the toolchain. Fix by also adding the
actual stable toolchain bin dir to PATH, bypassing the shim.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:54:01 -04:00
jared f70f749216 fix(ci): set cargo PATH inline in Build step
Build Lotus Chat Desktop / prepare (push) Successful in 8s
Build Lotus Chat Desktop / build-windows (push) Failing after 14m12s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m37s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
GITHUB_PATH written by a prior step is not reliably picked up by act
(the local runner). Set PATH directly in the Build step's PowerShell
session so cargo.exe is visible to tauri build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 23:33:37 -04:00
jared fd565e1edc fix(ci): fix PowerShell parse error in Add Rust to PATH step
Build Lotus Chat Desktop / prepare (push) Successful in 25s
Build Lotus Chat Desktop / build-windows (push) Failing after 16m57s
Build Lotus Chat Desktop / build-linux (push) Successful in 23m50s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
Em dash in Write-Error string broke PowerShell string termination.
Also try C:\Users\%USERNAME%\.cargo\bin as fallback since act may
override USERPROFILE to a temp directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 21:37:25 -04:00
jared 83725e1a2a fix(ci): add Rust to PATH on Windows runner before cargo steps
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-windows (push) Failing after 1m18s
Build Lotus Chat Desktop / build-linux (push) Successful in 24m31s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
cargo.exe lives in %USERPROFILE%\.cargo\bin but act does not
automatically export it into the CI subprocess PATH. Added an explicit
step to append that directory to GITHUB_PATH, matching what the Linux
job already does for $HOME/.cargo/bin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 20:51:04 -04:00
Lotus CI a543c98ae1 chore: bump cinny submodule to 7f329e3b
Build Lotus Chat Desktop / prepare (push) Successful in 8s
Build Lotus Chat Desktop / build-windows (push) Failing after 20m35s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m43s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
2026-06-14 23:07:37 +00:00
Lotus CI a386226073 chore: bump cinny submodule to 97d80858
Build Lotus Chat Desktop / prepare (push) Successful in 9s
Build Lotus Chat Desktop / build-windows (push) Failing after 20m1s
Build Lotus Chat Desktop / build-linux (push) Successful in 27m45s
Build Lotus Chat Desktop / update-manifest (push) Has been skipped
2026-06-14 22:56:53 +00:00
Lotus CI 6f9db2187f chore: bump cinny submodule to 4bb7c1ff
Build Lotus Chat Desktop / prepare (push) Successful in 3s
Build Lotus Chat Desktop / build-windows (push) Successful in 35m51s
Build Lotus Chat Desktop / build-linux (push) Successful in 28m28s
Build Lotus Chat Desktop / update-manifest (push) Successful in 19s
2026-06-14 21:49:34 +00:00
13 changed files with 1394 additions and 7 deletions
+10 -1
View File
@@ -88,7 +88,16 @@ jobs:
CARGO_HTTP_MULTIPLEXING: 'false'
# Retry transient network errors before failing.
CARGO_NET_RETRY: '5'
run: npm run tauri -- build --bundles nsis
run: |
# USERPROFILE is set by Windows directly; more reliable than C:\Users\$USERNAME
$env:PATH = "$env:USERPROFILE\.cargo\bin;$env:PATH"
# Also add the actual toolchain bin to bypass rustup shim execution issues
$toolchain = Get-ChildItem "$env:USERPROFILE\.rustup\toolchains" -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'stable' } | Select-Object -First 1
if ($toolchain) { $env:PATH = "$($toolchain.FullName)\bin;$env:PATH" }
Write-Host "cargo: $((Get-Command cargo -ErrorAction SilentlyContinue).Source)"
cargo --version
npm run tauri -- build --bundles nsis
- name: Upload to release
shell: powershell
+1 -1
Submodule cinny updated: 107921e0d0...258e3ec620
+13
View File
@@ -48,9 +48,22 @@ tauri-plugin-single-instance = "2"
webview2-com = "0.38"
window-vibrancy = "0.6"
windows = { version = "0.61", features = [
# WinRT namespaces
"Data_Xml_Dom", # P5-41 toast XML
"Foundation",
"Foundation_Collections", # P5-41 toast UserInput IMap
"Media",
"UI_Notifications", # P5-41 WinRT toast notifications
# Win32 namespaces
"Win32_Foundation",
"Win32_Graphics_Gdi",
"Win32_Networking_NetworkListManager", # P5-49 network awareness
"Win32_System_Com",
"Win32_System_Com_StructuredStorage", # P5-36 jump list (PROPVARIANT)
"Win32_System_Power", # P5-46 no-sleep
"Win32_System_WinRT", # P5-43 SMTC interop
"Win32_UI_Shell",
"Win32_UI_Shell_PropertiesSystem", # P5-36 jump list (IPropertyStore/PKEY_Title)
"Win32_UI_WindowsAndMessaging",
] }
+47 -4
View File
@@ -11,6 +11,8 @@ use tauri::{
};
use tauri_plugin_opener::OpenerExt;
mod native;
/// Bring the main window to the foreground from the tray / a hidden /
/// minimized state. Shared by the tray, single-instance, and deep-link paths.
fn show_main(app: &tauri::AppHandle) {
@@ -49,16 +51,30 @@ const NOTIFICATION_BRIDGE: &str = r#"(function(){
function TauriNotification(title,options){
var opts=options||{};
try{
window.__TAURI_INTERNALS__.invoke('send_notification',{
title:String(title),
body:opts.body!=null?String(opts.body):undefined
var body=opts.body!=null?String(opts.body):undefined;
// cinny tags message notifications with the roomId (options.tag) and the
// in-app route (options.data.path). When present, route to the rich WinRT
// toast (click-opens-room + quick reply); otherwise a plain toast.
var roomId=opts.tag!=null?String(opts.tag):undefined;
var path=(opts.data&&opts.data.path!=null)?String(opts.data.path):undefined;
if(roomId){
window.__TAURI_INTERNALS__.invoke('show_rich_toast',{
title:String(title),body:body,roomId:roomId,path:path
}).catch(function(){});
}else{
window.__TAURI_INTERNALS__.invoke('send_notification',{
title:String(title),body:body
}).catch(function(){});
}
}catch(_){}
}
TauriNotification.prototype=Object.create(EventTarget.prototype);
TauriNotification.prototype.constructor=TauriNotification;
TauriNotification.prototype.close=function(){};
Object.defineProperty(TauriNotification,'permission',{get:function(){return 'granted';},configurable:true});
// get-only 'permission' threw "Cannot set property permission ... which has
// only a getter" when the notification plugin / a polyfill assigned it. Add a
// no-op setter so the value stays 'granted' but assignment can't crash.
Object.defineProperty(TauriNotification,'permission',{get:function(){return 'granted';},set:function(){},configurable:true});
TauriNotification.requestPermission=function(){return Promise.resolve('granted');};
TauriNotification.maxActions=0;
Object.defineProperty(window,'Notification',{value:TauriNotification,writable:true,configurable:true});
@@ -182,6 +198,11 @@ fn set_badge_count(count: u32, window: tauri::Window) -> Result<(), String> {
let hbm_color =
CreateDIBSection(Some(hdc), &bmi, DIB_RGB_COLORS, &mut bits, None, 0)
.map_err(|e| 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() {
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
}
let old_bm = SelectObject(hdc, hbm_color.into());
let hbrush = CreateSolidBrush(COLORREF(0x003030DD));
@@ -378,6 +399,16 @@ pub fn run() {
send_notification,
check_for_update,
install_update,
native::power::set_call_active,
native::jumplist::set_jump_list,
native::thumbbar::set_thumbbar,
native::smtc::set_smtc_call_state,
native::chrome::set_custom_chrome,
native::chrome::window_minimize,
native::chrome::window_toggle_maximize,
native::chrome::window_start_drag,
native::chrome::window_close,
native::toast::show_rich_toast,
])
.plugin(tauri_plugin_localhost::Builder::new(port).build())
.plugin(tauri_plugin_window_state::Builder::default().build())
@@ -463,6 +494,15 @@ pub fn run() {
.visible(false)
.initialization_script(NOTIFICATION_BRIDGE)
.disable_drag_drop_handler()
// P5-42: keep the WebView2 renderer running full-speed while the
// app is closed to the tray, so the Matrix /sync loop and
// notifications aren't throttled/backgrounded by Chromium. Preserves
// Tauri's default WebView2 args (setting this overrides them) and
// 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",
)
.on_page_load(|window, payload| {
if matches!(payload.event(), PageLoadEvent::Finished) {
let _ = window.show();
@@ -557,6 +597,9 @@ pub fn run() {
}
})?;
// Native desktop feature modules (power/call-continuity, etc.).
native::setup(app.handle())?;
Ok(())
})
.run(context)
+68
View File
@@ -0,0 +1,68 @@
//! P5-47 — TDS Custom Window Chrome (opt-in, runtime-reversible).
//!
//! When the user opts into custom window chrome, the web client renders its own
//! `<TitleBar/>` (folds/TDS styled) and we strip the OS-native window frame so
//! the two don't stack. This is entirely opt-in: the window is built with native
//! `decorations(true)` and only `set_custom_chrome(true)` makes it frameless, so
//! the safe default is the untouched native frame.
//!
//! Everything here goes through the cross-platform Tauri v2 window API — there is
//! no `windows` crate dependency, so the same code path runs on Windows, macOS
//! and Linux. Each command resolves the "main" window and silently no-ops if it
//! isn't present (e.g. during teardown); the `Result`s are intentionally ignored
//! since a failed chrome tweak should never surface as an error to the user.
use tauri::{AppHandle, Manager};
/// Toggle the native window frame. `enabled` = custom chrome on, which means the
/// OS decorations must come **off** (`set_decorations(!enabled)`). Passing
/// `false` restores the native frame, making the feature fully reversible at
/// runtime without a restart.
#[tauri::command]
pub fn set_custom_chrome(app: AppHandle, enabled: bool) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_decorations(!enabled);
}
}
/// Minimize the main window (custom titlebar min button).
#[tauri::command]
pub fn window_minimize(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.minimize();
}
}
/// Toggle maximize/restore the main window (custom titlebar max button and
/// drag-region double-click).
#[tauri::command]
pub fn window_toggle_maximize(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
if window.is_maximized().unwrap_or(false) {
let _ = window.unmaximize();
} else {
let _ = window.maximize();
}
}
}
/// Begin an OS-level window drag from the custom titlebar drag region. The web
/// side also marks the drag area with `data-tauri-drag-region`; this command is
/// the explicit fallback so behaviour is identical across platforms.
#[tauri::command]
pub fn window_start_drag(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.start_dragging();
}
}
/// Close from the custom titlebar. Mirrors the app's close-to-tray behaviour
/// (see the `CloseRequested` handler in `lib.rs`): we `hide()` the window rather
/// than exiting, so the tray keeps the app running and the tray menu remains the
/// single explicit quit path.
#[tauri::command]
pub fn window_close(app: AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.hide();
}
}
+100
View File
@@ -0,0 +1,100 @@
//! P5-56 — Windows Focus Assist ↔ Do-Not-Disturb sync.
//!
//! Mirrors the shell's own notification-suppression state so Lotus Chat stops
//! popping desktop notifications while the user is in Focus Assist / Quiet Hours,
//! presenting, gaming full-screen, or otherwise "busy". The web client keeps this
//! in a live jotai atom (`focusAssistActiveAtom`) that the notification gate reads
//! alongside its existing quiet-hours check.
//!
//! Windows: a lightweight background thread polls `SHQueryUserNotificationState`
//! (the same API the shell exposes for "should I show a toast right now?") every
//! ~5 seconds. We prefer a robust poll over hooking shell events — the poll is
//! trivial to reason about and a 5s cadence is more than responsive enough for a
//! notification-suppression hint. We emit **only on a boolean transition**, so the
//! web side gets one event per change rather than a steady heartbeat; the first
//! read always emits so the frontend learns the initial state.
//!
//! Other platforms are a no-op: there's no equivalent cross-platform signal, and
//! the web hook stays unconditional so nothing there needs guarding.
use tauri::AppHandle;
/// Payload for the `focus-assist-changed` DOM event (`{ active: bool }`).
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct St {
active: bool,
}
/// Called once from lib.rs `native::setup()`. On Windows, spawns the poll
/// thread; elsewhere it does nothing.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
// Own a handle inside the thread so the poll outlives this call and runs
// for the lifetime of the app.
let app = app.clone();
std::thread::spawn(move || watch_focus_assist(app));
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the arg so the
// signature stays identical cross-platform with no unused warning.
let _ = app;
}
Ok(())
}
/// Poll loop, runs on its own thread for the app's lifetime.
#[cfg(target_os = "windows")]
fn watch_focus_assist(app: AppHandle) {
use std::time::Duration;
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
use windows::Win32::UI::Shell::{
SHQueryUserNotificationState, QUNS_BUSY, QUNS_PRESENTATION_MODE, QUNS_QUIET_TIME,
QUNS_RUNNING_D3D_FULL_SCREEN,
};
// Initialize COM for this thread in the multithreaded apartment. This is a
// dedicated thread, so it should be the first to init and succeed (S_FALSE —
// "already initialized, same mode" — also counts as success). If it fails
// outright (e.g. RPC_E_CHANGED_MODE) we can't proceed, so bail.
// Safety: FFI call; `None` reserved param per the API contract.
if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() {
return;
}
// `None` = unknown; the first successful read is treated as a transition so
// the web side always learns the initial suppression state.
let mut last: Option<bool> = None;
loop {
// `SHQueryUserNotificationState` reports the shell's current
// notification-presentation state. Treat the states where the shell
// itself suppresses toasts as "focus/DND active". Skip transient read
// errors without emitting.
// Safety: FFI call; writes the state into the provided out-param.
if let Ok(state) = unsafe { SHQueryUserNotificationState() } {
let active = state == QUNS_QUIET_TIME
|| state == QUNS_PRESENTATION_MODE
|| state == QUNS_RUNNING_D3D_FULL_SCREEN
|| state == QUNS_BUSY;
if last != Some(active) {
last = Some(active);
super::emit_to_web(
&app,
"focus-assist-changed",
&serde_json::to_string(&St { active }).unwrap_or_default(),
);
}
}
std::thread::sleep(Duration::from_secs(5));
}
// Note: the loop never returns, so we intentionally don't call
// `CoUninitialize` here — COM stays initialized for this thread until the
// process exits, which is exactly the desired lifetime.
}
+143
View File
@@ -0,0 +1,143 @@
//! P5-36 — Windows taskbar Jump List ("Recent Rooms").
//!
//! Publishes a custom Jump List category so users can right-click the taskbar
//! (or Start) icon and jump straight into a recently-active room. The web client
//! calls `set_jump_list([{ title, uri }])` from `useTauriJumpList` whenever its
//! recent-room list changes; each `uri` is a `matrix:` deep link.
//!
//! Windows: builds an `ICustomDestinationList` with an `IObjectCollection` of
//! `IShellLinkW` task links. Each link relaunches the current executable with the
//! room's `matrix:` URI as its single argument — the existing deep-link handler
//! in lib.rs (`forward_deeplink` → `lotus-deeplink`) then routes it to the room.
//! The link's visible label is set via `IPropertyStore` + `PKEY_Title`
//! (System.Title) using a `PROPVARIANT`.
//!
//! COM here runs on the command's (thread-pool) thread, so we initialize an STA
//! apartment with `CoInitializeEx` and balance it with `CoUninitialize` only when
//! we were the ones that initialized it (mirrors the COM usage in
//! `set_badge_count`). All COM interfaces are scoped so they release before the
//! apartment is torn down.
//!
//! Other platforms are a no-op (macOS has no direct equivalent; Linux desktop
//! files differ) — the command stays cross-platform so the web side is
//! unconditional.
use tauri::AppHandle;
/// One Jump List entry supplied by the web client. `uri` is a `matrix:` deep
/// link accepted by the deep-link handler in lib.rs.
#[derive(serde::Deserialize)]
pub struct JumpItem {
pub title: String,
pub uri: String,
}
#[tauri::command]
pub fn set_jump_list(app: AppHandle, items: Vec<JumpItem>) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
// `app` is unused on Windows (COM runs on the calling thread); bind it so
// the signature stays identical cross-platform and no warning fires.
let _ = &app;
use std::os::windows::ffi::OsStrExt;
use windows::{
core::{w, Interface, HSTRING, PCWSTR, PROPVARIANT},
Win32::{
System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_APARTMENTTHREADED,
},
UI::Shell::{
DestinationList, EnumerableObjectCollection, ICustomDestinationList,
IObjectArray, IObjectCollection, IShellLinkW, ShellLink,
PropertiesSystem::{IPropertyStore, PKEY_Title},
},
},
};
// Wide, NUL-terminated path to the running executable; reused for every
// link's target and icon. Computed before touching COM so a failure here
// doesn't leak an initialized apartment.
let exe = std::env::current_exe().map_err(|e| e.to_string())?;
let exe_wide: Vec<u16> = exe
.as_os_str()
.encode_wide()
.chain(std::iter::once(0))
.collect();
// STA is required for the shell Jump List objects. S_OK means we
// initialized (and must uninitialize); S_FALSE means it was already
// initialized on this thread (still balance it); RPC_E_CHANGED_MODE (an
// error) means don't touch it. Note: `unsafe` does not reach into the
// closure below, so its body carries its own `unsafe` block; the COM
// interfaces it creates are all released (dropped) before we uninitialize.
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let result = (|| -> windows::core::Result<()> {
unsafe {
let list: ICustomDestinationList =
CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?;
if items.is_empty() {
// Nothing to show — clear any list we previously published.
list.DeleteList(PCWSTR::null())?;
return Ok(());
}
// BeginList hands back the items the user manually removed; we
// don't re-add anything, so we can ignore it. `min_slots` is the
// max entries the shell will display.
let mut min_slots: u32 = 0;
let _removed: IObjectArray = list.BeginList(&mut min_slots)?;
let collection: IObjectCollection =
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
for item in &items {
let link: IShellLinkW =
CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
// Relaunch this exe with the matrix: URI as its only argument.
link.SetPath(PCWSTR(exe_wide.as_ptr()))?;
let arg_wide: Vec<u16> =
item.uri.encode_utf16().chain(std::iter::once(0)).collect();
link.SetArguments(PCWSTR(arg_wide.as_ptr()))?;
// Use the app's own icon for the entry.
link.SetIconLocation(PCWSTR(exe_wide.as_ptr()), 0)?;
// The visible label comes from System.Title on the link's
// property store (a bare IShellLink has no display name).
let store: IPropertyStore = link.cast()?;
let title = PROPVARIANT::from(&HSTRING::from(item.title.as_str()));
store.SetValue(&PKEY_Title, &title)?;
store.Commit()?;
collection.AddObject(&link)?;
}
let array: IObjectArray = collection.cast()?;
list.AppendCategory(w!("Recent Rooms"), &array)?;
list.CommitList()?;
Ok(())
}
})();
// All interfaces above are dropped (released) by the time we get here, so
// it's safe to tear the apartment down.
if hr.is_ok() {
unsafe { CoUninitialize() };
}
result.map_err(|e| e.to_string())?;
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the args so the
// signature stays identical cross-platform and no unused warnings fire.
let _ = (&app, &items);
}
Ok(())
}
+42
View File
@@ -0,0 +1,42 @@
//! Native desktop feature modules (Lotus Chat).
//!
//! Each feature lives in its own submodule exposing `#[tauri::command]`(s) and,
//! when it needs to register listeners/state, a `setup(&AppHandle)`. lib.rs adds
//! the commands to `generate_handler!` and calls `native::setup()` once during
//! app setup. Windows-only pieces are guarded with `#[cfg(target_os = "windows")]`
//! and compile-verified in CI (Gitea `windows` runner / GitHub `windows-latest`).
use tauri::{AppHandle, Manager};
pub mod chrome;
pub mod focus_assist;
pub mod jumplist;
pub mod network;
pub mod power;
pub mod smtc;
pub mod thumbbar;
pub mod toast;
/// Dispatch a DOM `CustomEvent` to the web client (mirrors `forward_deeplink` in
/// lib.rs) so native modules can push data to the frontend without pulling in
/// `@tauri-apps/api` on the web side. `detail_json` MUST be valid JSON (use
/// `serde_json::to_string`). `event` is a static, trusted name.
#[allow(dead_code)]
pub fn emit_to_web(app: &AppHandle, event: &str, detail_json: &str) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.eval(&format!(
"window.dispatchEvent(new CustomEvent('{event}',{{detail:{detail_json}}}))"
));
}
}
/// Called once from lib.rs `.setup()`. Feature modules that need to register OS
/// listeners or managed state get initialized here. (power/jumplist/chrome are
/// command-only and need no setup.)
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
thumbbar::setup(app)?;
smtc::setup(app)?;
network::setup(app)?;
focus_assist::setup(app)?;
Ok(())
}
+109
View File
@@ -0,0 +1,109 @@
//! P5-49 — Network awareness (Windows connectivity / NCSI).
//!
//! Proactively detects when the machine gains or loses internet connectivity so
//! the web client can surface an offline state and, more importantly, nudge the
//! matrix client to retry its backed-off `/sync` the instant the network comes
//! back instead of waiting out the sync-loop backoff timer.
//!
//! Windows: a lightweight background thread polls the Network List Manager
//! (`INetworkListManager::IsConnectedToInternet`, the same NCSI signal the shell
//! uses) every ~3 seconds. We prefer a robust poll over a COM event sink
//! (`INetworkEvents`) — the poll is far simpler to reason about, needs no
//! connection-point plumbing, and a 3s cadence is more than responsive enough
//! for a "retry sync now" hint. We emit **only on a state transition**, so the
//! web side gets one event per change rather than a steady heartbeat.
//!
//! Other platforms are a no-op: the browser already fires `online`/`offline`
//! events, and the desktop shells (macOS/Linux) can adopt their own reachability
//! APIs later; the web hook stays unconditional so nothing there needs guarding.
use tauri::AppHandle;
/// Payload for the `network-changed` DOM event (`{ online: bool }`).
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct NetworkState {
online: bool,
}
/// Called once from lib.rs `native::setup()`. On Windows, spawns the poll
/// thread; elsewhere it does nothing.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
// Own a handle inside the thread so the poll outlives this call and runs
// for the lifetime of the app.
let app = app.clone();
std::thread::spawn(move || watch_network(app));
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the arg so the
// signature stays identical cross-platform with no unused warning.
let _ = app;
}
Ok(())
}
/// Poll loop, runs on its own thread for the app's lifetime.
#[cfg(target_os = "windows")]
fn watch_network(app: AppHandle) {
use std::time::Duration;
use windows::Win32::Networking::NetworkListManager::{INetworkListManager, NetworkListManager};
use windows::Win32::System::Com::{
CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER,
COINIT_MULTITHREADED,
};
// Initialize COM for this thread in the multithreaded apartment. This is a
// dedicated thread, so it should be the first to init and succeed (S_FALSE —
// "already initialized, same mode" — also counts as success). If it fails
// outright (e.g. RPC_E_CHANGED_MODE) we can't proceed, so bail.
// Safety: FFI call; `None` reserved param per the API contract.
if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() {
return;
}
// Create the Network List Manager COM object (mirrors the `CoCreateInstance`
// idiom in lib.rs `set_badge_count`). If it's unavailable, tear COM back down
// and stop the thread cleanly.
// Safety: standard COM instantiation; type is inferred from the annotation.
let manager: INetworkListManager =
match unsafe { CoCreateInstance(&NetworkListManager, None, CLSCTX_INPROC_SERVER) } {
Ok(manager) => manager,
Err(_) => {
// Safety: balances the successful CoInitializeEx above.
unsafe { CoUninitialize() };
return;
}
};
// `None` = unknown; the first successful read is treated as a transition so
// the web side always learns the initial connectivity state.
let mut last: Option<bool> = None;
loop {
// `IsConnectedToInternet` yields a VARIANT_BOOL (VARIANT_TRUE == -1 when
// connected). Skip transient read errors without emitting.
// Safety: FFI call on a live COM interface owned by this thread.
if let Ok(connected) = unsafe { manager.IsConnectedToInternet() } {
let online = connected.as_bool();
if last != Some(online) {
last = Some(online);
super::emit_to_web(
&app,
"network-changed",
&serde_json::to_string(&NetworkState { online }).unwrap_or_default(),
);
}
}
std::thread::sleep(Duration::from_secs(3));
}
// Note: the loop never returns, so we intentionally don't call
// `CoUninitialize` here — COM stays initialized for this thread until the
// process exits, which is exactly the desired lifetime.
}
+48
View File
@@ -0,0 +1,48 @@
//! P5-46 — System power management (call continuity).
//!
//! Prevents the system from sleeping / turning off the display while a voice or
//! video call is active, then releases the request when the call ends. The web
//! client calls `set_call_active(true|false)` from `useTauriCallPower` as the
//! call-embed atom transitions.
//!
//! Windows: `SetThreadExecutionState`. The request is per-thread and persists
//! until cleared, so we run every set/clear on the **main thread** (via
//! `run_on_main_thread`) to guarantee the clear cancels the matching set even
//! though Tauri commands otherwise run on a pool thread.
//!
//! Other platforms are a no-op for now (macOS would use `IOPMAssertionCreate`,
//! Linux `org.freedesktop.ScreenSaver`/`login1` inhibit) — tracked as a future
//! extension; the command stays cross-platform so the web side is unconditional.
use tauri::AppHandle;
#[tauri::command]
pub fn set_call_active(app: AppHandle, active: bool) {
#[cfg(target_os = "windows")]
{
let _ = app.run_on_main_thread(move || {
use windows::Win32::System::Power::{
SetThreadExecutionState, ES_CONTINUOUS, ES_DISPLAY_REQUIRED, ES_SYSTEM_REQUIRED,
};
let flags = if active {
ES_CONTINUOUS | ES_SYSTEM_REQUIRED | ES_DISPLAY_REQUIRED
} else {
// Clearing to ES_CONTINUOUS alone releases the sleep/display
// requirement while leaving no lingering per-thread state.
ES_CONTINUOUS
};
// Safety: FFI call with no pointers; returns the previous state,
// which we don't need.
unsafe {
SetThreadExecutionState(flags);
}
});
}
#[cfg(not(target_os = "windows"))]
{
// No-op on non-Windows platforms (see module docs). Bind the args so the
// signature stays identical cross-platform and no unused warnings fire.
let _ = (&app, active);
}
}
+189
View File
@@ -0,0 +1,189 @@
//! P5-43 — System Media Transport Controls (SMTC) call surface (Windows).
//!
//! Surfaces the active voice/video call to the Windows media overlay (the
//! volume flyout / SMTC card) so the user can mute or hang up from the OS
//! media controls. We create the SMTC via the WinRT interop factory
//! (`ISystemMediaTransportControlsInterop::GetForWindow`), keep the object in
//! managed state, and let the web client drive its state through
//! `set_smtc_call_state` as the call-embed atom / mic state changes.
//!
//! Button mapping: **Play/Pause → mute toggle**, **Stop → end call**. Presses
//! are forwarded to the web client as a `smtc-action` DOM CustomEvent (see
//! `super::emit_to_web`) with `action` in `"mute" | "end"`; the web hook
//! (`useTauriSmtc`) translates them into `CallControl.toggleMicrophone()` /
//! `CallEmbed.hangup()`.
//!
//! RUNTIME NOTE: SMTC is designed for real media apps. For a non-media app the
//! card may not actually appear unless the process owns an active audio session
//! recognised by the system. This module prioritises a clean compile and
//! correct WinRT API usage; visibility of the overlay at runtime is uncertain
//! and may depend on the embedded Element Call iframe holding an audio session.
//!
//! Other platforms are a no-op (SMTC is Windows-only); the command keeps an
//! identical cross-platform signature so the web side stays unconditional.
use tauri::AppHandle;
/// Payload for the `smtc-action` DOM event forwarded to the web client.
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct Ev {
action: String,
}
/// Holds the SMTC object (and its `ButtonPressed` registration token) in Tauri
/// managed state so `set_smtc_call_state` can update it at runtime. Mirrors the
/// `TrayUnreadState` managed-state pattern in lib.rs.
#[cfg(target_os = "windows")]
struct SmtcState {
controls: std::sync::Mutex<Option<windows::Media::SystemMediaTransportControls>>,
// Kept alive so the ButtonPressed handler stays registered for the app's
// lifetime; never unregistered.
_token: windows::Foundation::EventRegistrationToken,
}
/// Called once from `native::setup()`. Creates and configures the SMTC on
/// Windows; no-op elsewhere. SMTC init failures are logged and swallowed so a
/// missing/unsupported overlay never blocks app startup.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
#[cfg(target_os = "windows")]
{
if let Err(err) = init_smtc(app) {
eprintln!("smtc: failed to initialize System Media Transport Controls: {err:?}");
}
}
#[cfg(not(target_os = "windows"))]
{
let _ = app;
}
Ok(())
}
#[cfg(target_os = "windows")]
fn init_smtc(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use tauri::Manager;
use windows::core::{factory, HSTRING};
use windows::Foundation::TypedEventHandler;
use windows::Media::{
MediaPlaybackStatus, MediaPlaybackType, SystemMediaTransportControls,
SystemMediaTransportControlsButton, SystemMediaTransportControlsButtonPressedEventArgs,
};
use windows::Win32::Foundation::HWND;
use windows::Win32::System::WinRT::ISystemMediaTransportControlsInterop;
let window = app
.get_webview_window("main")
.ok_or("smtc: main window not found")?;
// Match the HWND conversion used by set_badge_count in lib.rs.
let hwnd = HWND(window.hwnd()?.0 as _);
// SMTC has no WinRT constructor; it's obtained per-window via the interop
// factory. `factory::<C, I>()` fetches the activation factory for the
// runtime class `C` cast to the classic COM interop interface `I`.
let interop =
factory::<SystemMediaTransportControls, ISystemMediaTransportControlsInterop>()?;
let controls: SystemMediaTransportControls = unsafe { interop.GetForWindow(hwnd)? };
controls.SetIsEnabled(true)?;
controls.SetIsPlayEnabled(true)?;
controls.SetIsPauseEnabled(true)?;
controls.SetIsStopEnabled(true)?;
// Configure the card metadata once ("In call"); the web side only toggles
// playback status afterwards.
let updater = controls.DisplayUpdater()?;
updater.SetType(MediaPlaybackType::Music)?;
let music = updater.MusicProperties()?;
music.SetTitle(&HSTRING::from("In call"))?;
updater.Update()?;
// Idle until a call becomes active (set_smtc_call_state flips this).
controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?;
// ButtonPressed → forward a normalized action to the web client.
let app_for_handler = app.clone();
let handler = TypedEventHandler::<
SystemMediaTransportControls,
SystemMediaTransportControlsButtonPressedEventArgs,
>::new(move |_sender, args| {
if let Some(args) = args.as_ref() {
let button = args.Button()?;
let action = if button == SystemMediaTransportControlsButton::Play
|| button == SystemMediaTransportControlsButton::Pause
{
Some("mute")
} else if button == SystemMediaTransportControlsButton::Stop {
Some("end")
} else {
None
};
if let Some(action) = action {
let payload = serde_json::to_string(&Ev {
action: action.to_string(),
})
.unwrap_or_default();
super::emit_to_web(&app_for_handler, "smtc-action", &payload);
}
}
Ok(())
});
let token = controls.ButtonPressed(&handler)?;
app.manage(SmtcState {
controls: std::sync::Mutex::new(Some(controls)),
_token: token,
});
Ok(())
}
/// Reflect the call state onto the SMTC. When `active`, enable the controls and
/// set playback status to Playing (unmuted) / Paused (muted); when inactive,
/// mark the card Closed and disable it. Windows-only; no-op elsewhere.
#[tauri::command]
pub fn set_smtc_call_state(app: AppHandle, active: bool, muted: bool) {
#[cfg(target_os = "windows")]
{
use tauri::Manager;
if let Some(state) = app.try_state::<SmtcState>() {
if let Ok(guard) = state.controls.lock() {
if let Some(controls) = guard.as_ref() {
let _ = apply_call_state(controls, active, muted);
}
}
}
}
#[cfg(not(target_os = "windows"))]
{
// No-op off Windows; bind args so the signature is identical everywhere
// and no unused warnings fire.
let _ = (&app, active, muted);
}
}
#[cfg(target_os = "windows")]
fn apply_call_state(
controls: &windows::Media::SystemMediaTransportControls,
active: bool,
muted: bool,
) -> windows::core::Result<()> {
use windows::Media::MediaPlaybackStatus;
if active {
controls.SetIsEnabled(true)?;
let status = if muted {
MediaPlaybackStatus::Paused
} else {
MediaPlaybackStatus::Playing
};
controls.SetPlaybackStatus(status)?;
} else {
controls.SetPlaybackStatus(MediaPlaybackStatus::Closed)?;
controls.SetIsEnabled(false)?;
}
Ok(())
}
+379
View File
@@ -0,0 +1,379 @@
//! P5-44 — Taskbar thumbnail toolbar (call controls).
//!
//! While a voice/video call is active the web client calls `set_thumbbar` from
//! `useTauriThumbbar`, which mirrors the call-embed atom + mic/sound state onto
//! three buttons on the taskbar thumbnail toolbar: **Mute/Unmute**,
//! **Deafen/Undeafen** and **End Call**. Clicking a button pushes a
//! `thumbbar-action` DOM event back to the web side (`"mute" | "deafen" | "end"`)
//! which drives the real call controls.
//!
//! Windows: `ITaskbarList3::ThumbBarAddButtons` (first call for the window) then
//! `ThumbBarUpdateButtons` (subsequent calls) — mirrors the COM + GDI/HICON idiom
//! in `set_badge_count`. Thumb-button clicks arrive as `WM_COMMAND` with
//! `HIWORD(wParam) == THBN_CLICKED`, so we subclass the main window (installed
//! once in `setup`) to catch them. The main window HWND comes from the "main"
//! webview window; the "buttons added" flag lives in managed `ThumbbarState`
//! (like lib.rs's `TrayUnreadState`) so add-vs-update works across calls.
//!
//! Other platforms are a no-op — the command stays cross-platform so the web
//! side is unconditional.
use tauri::{AppHandle, Manager};
/// Managed state shared with lib.rs (registered in `setup`). Only the Windows
/// path reads `added`; kept cross-platform so `set_thumbbar` can inject it.
#[derive(Default)]
pub struct ThumbbarState {
#[allow(dead_code)]
added: std::sync::atomic::AtomicBool,
}
// Thumb-button ids (LOWORD of wParam on WM_COMMAND / THBN_CLICKED).
#[cfg(target_os = "windows")]
const BTN_MUTE: u32 = 1;
#[cfg(target_os = "windows")]
const BTN_DEAFEN: u32 = 2;
#[cfg(target_os = "windows")]
const BTN_END: u32 = 3;
/// HIWORD(wParam) value on a thumb-button click (CommCtrl `THBN_CLICKED`).
#[cfg(target_os = "windows")]
const THBN_CLICKED: u16 = 0x1800;
/// uIdSubclass passed to SetWindowSubclass — identifies our subclass instance.
#[cfg(target_os = "windows")]
const SUBCLASS_ID: usize = 1;
/// Payload emitted to the web on a thumb-button click.
#[cfg(target_os = "windows")]
#[derive(serde::Serialize)]
struct Action<'a> {
action: &'a str,
}
/// Build a single THUMBBUTTON, attaching an icon (and the THB_ICON mask) when one
/// was created. Always carries a tooltip and enabled/hidden flags.
#[cfg(target_os = "windows")]
fn thumb_button(
id: u32,
hidden: bool,
tip: &str,
icon: Option<windows::Win32::UI::WindowsAndMessaging::HICON>,
) -> windows::Win32::UI::Shell::THUMBBUTTON {
use windows::Win32::UI::Shell::{
THUMBBUTTON, THUMBBUTTONFLAGS, THUMBBUTTONMASK, THBF_ENABLED, THBF_HIDDEN, THB_FLAGS,
THB_ICON, THB_TOOLTIP,
};
use windows::Win32::UI::WindowsAndMessaging::HICON;
let mut mask: THUMBBUTTONMASK = THB_TOOLTIP | THB_FLAGS;
let flags: THUMBBUTTONFLAGS = if hidden { THBF_HIDDEN } else { THBF_ENABLED };
let mut hicon = HICON::default();
if let Some(i) = icon {
mask = mask | THB_ICON;
hicon = i;
}
let mut sz_tip = [0u16; 260];
for (dst, ch) in sz_tip.iter_mut().zip(tip.encode_utf16().take(259)) {
*dst = ch;
}
THUMBBUTTON {
dwMask: mask,
iId: id,
iBitmap: 0,
hIcon: hicon,
szTip: sz_tip,
dwFlags: flags,
}
}
/// Update (or hide) the three thumb-toolbar buttons for the given call state.
#[tauri::command]
pub fn set_thumbbar(
app: AppHandle,
state: tauri::State<'_, ThumbbarState>,
active: bool,
muted: bool,
deafened: bool,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::sync::atomic::Ordering;
use windows::Win32::{
Foundation::HWND,
System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER},
UI::{
Shell::{ITaskbarList3, TaskbarList},
WindowsAndMessaging::DestroyIcon,
},
};
// Nothing to do (and nothing to hide) if a toolbar was never added.
if !active && !state.added.load(Ordering::SeqCst) {
return Ok(());
}
let window = app
.get_webview_window("main")
.ok_or_else(|| "no main window".to_string())?;
let hwnd = HWND(window.hwnd().map_err(|e| e.to_string())?.0 as _);
let mic_icon = make_icon(Glyph::Mic, muted);
let deaf_icon = make_icon(Glyph::Head, deafened);
let end_icon = make_icon(Glyph::End, false);
let buttons = [
thumb_button(BTN_MUTE, !active, if muted { "Unmute" } else { "Mute" }, mic_icon),
thumb_button(
BTN_DEAFEN,
!active,
if deafened { "Undeafen" } else { "Deafen" },
deaf_icon,
),
thumb_button(BTN_END, !active, "End Call", end_icon),
];
let result = unsafe {
let taskbar: ITaskbarList3 = CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)
.map_err(|e| e.to_string())?;
taskbar.HrInit().map_err(|e| e.to_string())?;
let r = if state.added.load(Ordering::SeqCst) {
taskbar.ThumbBarUpdateButtons(hwnd, &buttons)
} else {
let r = taskbar.ThumbBarAddButtons(hwnd, &buttons);
if r.is_ok() {
state.added.store(true, Ordering::SeqCst);
}
r
};
r.map_err(|e| e.to_string())
};
// The shell copies the icons on add/update, so release ours (mirrors the
// DestroyIcon after SetOverlayIcon in set_badge_count).
for icon in [mic_icon, deaf_icon, end_icon].into_iter().flatten() {
unsafe {
let _ = DestroyIcon(icon);
}
}
result?;
}
#[cfg(not(target_os = "windows"))]
{
// No-op elsewhere; bind args so the signature stays identical and no
// unused warnings fire.
let _ = (&app, &state, active, muted, deafened);
}
Ok(())
}
/// Which glyph a thumb-button icon draws.
#[cfg(target_os = "windows")]
#[derive(Clone, Copy)]
enum Glyph {
Mic,
Head,
End,
}
/// Draw a simple white monochrome glyph onto a 32x32 32-bpp DIB and wrap it in an
/// `HICON`. Mirrors the CreateDIBSection → alpha-fixup → CreateIconIndirect idiom
/// in `set_badge_count`. Returns `None` on any GDI failure (the button is then
/// added tooltip-only). `slashed` overlays a transparent diagonal cut to signal
/// the muted / deafened state.
#[cfg(target_os = "windows")]
fn make_icon(glyph: Glyph, slashed: bool) -> Option<HICON> {
use windows::core::BOOL;
use windows::Win32::Foundation::COLORREF;
use windows::Win32::Graphics::Gdi::{
Arc, CreateBitmap, CreateCompatibleDC, CreateDIBSection, CreatePen, CreateSolidBrush,
DeleteDC, DeleteObject, Ellipse, GetDC, LineTo, MoveToEx, ReleaseDC, RoundRect,
SelectObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB, DIB_RGB_COLORS, PS_SOLID,
};
use windows::Win32::UI::WindowsAndMessaging::{CreateIconIndirect, HICON, ICONINFO};
unsafe {
let size = 32i32;
let hdc_screen = GetDC(None);
let hdc = CreateCompatibleDC(Some(hdc_screen));
let bmi = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: size,
biHeight: -size,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
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).ok()?;
if !bits.is_null() {
std::ptr::write_bytes(bits as *mut u8, 0, (size * size * 4) as usize);
}
let old_bm = SelectObject(hdc, hbm_color.into());
let white = COLORREF(0x00FF_FFFF);
let hbrush = CreateSolidBrush(white);
let old_brush = SelectObject(hdc, hbrush.into());
let hpen = CreatePen(PS_SOLID, 2, white);
let old_pen = SelectObject(hdc, hpen.into());
match glyph {
Glyph::Mic => {
// Capsule mic head + stand.
let _ = RoundRect(hdc, 13, 5, 19, 19, 6, 6);
let _ = MoveToEx(hdc, 16, 19, None);
let _ = LineTo(hdc, 16, 25);
let _ = MoveToEx(hdc, 11, 25, None);
let _ = LineTo(hdc, 21, 25);
}
Glyph::Head => {
// Headphone band + two ear cups.
let _ = Arc(hdc, 6, 7, 26, 27, 6, 17, 26, 17);
let _ = RoundRect(hdc, 6, 16, 11, 26, 2, 2);
let _ = RoundRect(hdc, 21, 16, 26, 26, 2, 2);
}
Glyph::End => {
// Filled disc (end-call button).
let _ = Ellipse(hdc, 6, 6, 26, 26);
}
}
if slashed {
// Draw the slash in black (pixel 0), which the alpha fixup below
// leaves fully transparent — carving a visible diagonal gap.
let black_pen = CreatePen(PS_SOLID, 4, COLORREF(0));
let prev = SelectObject(hdc, black_pen.into());
let _ = MoveToEx(hdc, 6, 6, None);
let _ = LineTo(hdc, 26, 26);
SelectObject(hdc, prev);
let _ = DeleteObject(black_pen.into());
}
SelectObject(hdc, old_brush);
SelectObject(hdc, old_pen);
SelectObject(hdc, old_bm);
let _ = DeleteObject(hbrush.into());
let _ = DeleteObject(hpen.into());
// GDI leaves alpha at 0; mark every painted pixel opaque so Windows uses
// per-pixel alpha instead of the opaque mask (same fix as set_badge_count).
let pixel_count = (size * size) as usize;
let pixels = std::slice::from_raw_parts_mut(bits as *mut u32, pixel_count);
for pixel in pixels.iter_mut() {
if *pixel != 0 {
*pixel |= 0xFF00_0000u32;
}
}
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 None;
}
let icon_info = ICONINFO {
fIcon: BOOL(1),
xHotspot: 0,
yHotspot: 0,
hbmMask: hbm_mask,
hbmColor: hbm_color,
};
let hicon = CreateIconIndirect(&icon_info).ok();
let _ = DeleteObject(hbm_color.into());
let _ = DeleteObject(hbm_mask.into());
let _ = DeleteDC(hdc);
let _ = ReleaseDC(None, hdc_screen);
hicon
}
}
/// Window subclass proc: catches thumb-button clicks (`WM_COMMAND` /
/// `THBN_CLICKED`) and forwards them to the web as `thumbbar-action`. `dwrefdata`
/// is a leaked `Box<AppHandle>` installed by `setup`; it is reclaimed on
/// `WM_NCDESTROY`.
#[cfg(target_os = "windows")]
unsafe extern "system" fn subclass_proc(
hwnd: windows::Win32::Foundation::HWND,
umsg: u32,
wparam: windows::Win32::Foundation::WPARAM,
lparam: windows::Win32::Foundation::LPARAM,
_uidsubclass: usize,
dwrefdata: usize,
) -> windows::Win32::Foundation::LRESULT {
use windows::Win32::Foundation::LRESULT;
use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass};
use windows::Win32::UI::WindowsAndMessaging::{WM_COMMAND, WM_NCDESTROY};
match umsg {
WM_COMMAND => {
let w = wparam.0;
let notif = ((w >> 16) & 0xFFFF) as u16;
let id = (w & 0xFFFF) as u32;
if notif == THBN_CLICKED {
let action = match id {
BTN_MUTE => Some("mute"),
BTN_DEAFEN => Some("deafen"),
BTN_END => Some("end"),
_ => None,
};
if let Some(action) = action {
if dwrefdata != 0 {
// Borrow (do not take ownership of) the leaked AppHandle.
let app = &*(dwrefdata as *const AppHandle);
let detail =
serde_json::to_string(&Action { action }).unwrap_or_default();
super::emit_to_web(app, "thumbbar-action", &detail);
}
return LRESULT(0);
}
}
DefSubclassProc(hwnd, umsg, wparam, lparam)
}
WM_NCDESTROY => {
let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID);
if dwrefdata != 0 {
drop(Box::from_raw(dwrefdata as *mut AppHandle));
}
DefSubclassProc(hwnd, umsg, wparam, lparam)
}
_ => DefSubclassProc(hwnd, umsg, wparam, lparam),
}
}
/// Called once from `native::setup`. Registers `ThumbbarState` and, on Windows,
/// subclasses the main window so thumb-button clicks reach the web client.
pub fn setup(app: &AppHandle) -> tauri::Result<()> {
app.manage(ThumbbarState::default());
#[cfg(target_os = "windows")]
{
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::Shell::SetWindowSubclass;
if let Some(window) = app.get_webview_window("main") {
if let Ok(handle) = window.hwnd() {
let hwnd = HWND(handle.0 as _);
// Leak an AppHandle for the proc; reclaimed on WM_NCDESTROY.
let refdata = Box::into_raw(Box::new(app.clone())) as usize;
unsafe {
let _ = SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, refdata);
}
}
}
}
Ok(())
}
+244
View File
@@ -0,0 +1,244 @@
//! P5-41 — Native WinRT toast notifications (+ P5-35 click-opens-room, P5-41 quick reply).
//!
//! The web notification bridge calls `show_rich_toast` (see lib.rs
//! `NOTIFICATION_BRIDGE`) instead of the basic plugin notification so desktop
//! notifications gain a text reply box and a body-click that reopens the room.
//!
//! Windows: we build a `Windows.UI.Notifications.ToastNotification` from a toast
//! XML document (`Windows.Data.Xml.Dom.XmlDocument`) carrying the title + body,
//! an inline `<input id="reply" type="text"/>` and a Send `<action>`. Because the
//! app lives in the tray (always alive) we subscribe to the toast's **in-process**
//! `Activated` event rather than relying on COM activation: the handler downcasts
//! the event args to `ToastActivatedEventArgs`, reads the reply text from
//! `UserInput()` (keyed `"reply"`) and forwards it to the web client. A body click
//! (no reply text) forwards the launch `path` so the web side can route to the
//! room. Live `ToastNotification` objects are parked in a process-global `Vec`
//! (behind a `Mutex`) so their handlers survive until the toast is dismissed.
//!
//! If ANY WinRT step fails (most importantly: no registered AppUserModelID — see
//! the runtime note below), we fall back to the plain `tauri-plugin-notification`
//! notification so notifications always work.
//!
//! Other platforms always take the fallback path; the command keeps an identical
//! cross-platform signature so the web bridge stays unconditional.
//!
//! RUNTIME NOTE (AppUserModelID): WinRT toasts require the process to run under an
//! AppUserModelID that maps to a Start-menu shortcut. The installed app's bundle
//! id is `org.lotusguild.lotus-chat`; if no matching shortcut/AUMID is registered,
//! `CreateToastNotifier()` / `Show()` will error and we silently fall back. Wiring
//! `SetCurrentProcessExplicitAppUserModelID` (+ shortcut install) is handled
//! separately.
use tauri::AppHandle;
/// Show a rich desktop notification. On Windows this is a WinRT toast with a
/// reply box and click-to-open; elsewhere (or on any WinRT error) it degrades to
/// a basic plugin notification. `room_id` is the raw Matrix room id used for the
/// reply payload; `path` is the web hash route used for a body click.
#[tauri::command]
pub fn show_rich_toast(
app: AppHandle,
title: String,
body: Option<String>,
room_id: Option<String>,
path: Option<String>,
) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
match show_windows_toast(
&app,
&title,
body.as_deref(),
room_id.as_deref(),
path.as_deref(),
) {
Ok(()) => return Ok(()),
Err(err) => {
// Most commonly a missing AppUserModelID (see module note). Fall
// through to the plugin notification so the user still sees it.
eprintln!("toast: WinRT toast failed, falling back to plugin: {err:?}");
}
}
}
// Bind the routing args so the signature is identical cross-platform and no
// unused warnings fire on the fallback (non-Windows) path.
let _ = (&room_id, &path);
show_fallback(&app, &title, body.as_deref())
}
/// Cross-platform fallback: a basic notification via `tauri-plugin-notification`
/// (mirrors `send_notification` in lib.rs). Used off Windows and whenever the
/// WinRT toast path errors.
fn show_fallback(app: &AppHandle, title: &str, body: Option<&str>) -> Result<(), String> {
use tauri_plugin_notification::NotificationExt;
let mut builder = app.notification().builder().title(title);
if let Some(b) = body {
builder = builder.body(b);
}
builder.show().map_err(|e| e.to_string())
}
/// Process-global store keeping live `ToastNotification` objects (and therefore
/// their `Activated`/`Dismissed` handler registrations) alive until dismissed.
/// Lazily initialized so no `native::setup()` wiring is required.
#[cfg(target_os = "windows")]
fn toast_store() -> &'static std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>> {
static STORE: std::sync::OnceLock<
std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>>,
> = std::sync::OnceLock::new();
STORE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
/// Escape text for inclusion in the toast XML (attribute or element content).
#[cfg(target_os = "windows")]
fn xml_escape(input: &str) -> String {
input
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(target_os = "windows")]
fn show_windows_toast(
app: &AppHandle,
title: &str,
body: Option<&str>,
room_id: Option<&str>,
path: Option<&str>,
) -> windows::core::Result<()> {
use windows::core::{HSTRING, IInspectable, Interface};
use windows::Data::Xml::Dom::XmlDocument;
use windows::Foundation::TypedEventHandler;
use windows::UI::Notifications::{
ToastActivatedEventArgs, ToastDismissedEventArgs, ToastNotification,
ToastNotificationManager,
};
// A body click carries the launch arguments back to us; prefer the web hash
// route (`path`), falling back to the raw room id so clicks are never inert.
let launch = path.or(room_id).unwrap_or_default();
let body_line = match body {
Some(b) if !b.is_empty() => format!("<text>{}</text>", xml_escape(b)),
_ => String::new(),
};
// ToastGeneric visual + an inline reply input and a foreground Send action.
// `hint-inputId="reply"` binds the Send button to the text box so the reply
// text arrives in `UserInput()` keyed "reply".
let xml = format!(
r#"<toast activationType="foreground" launch="{launch}">
<visual>
<binding template="ToastGeneric">
<text>{title}</text>
{body_line}
</binding>
</visual>
<actions>
<input id="reply" type="text" placeHolder="Reply..."/>
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
</actions>
</toast>"#,
launch = xml_escape(launch),
title = xml_escape(title),
body_line = body_line,
);
let doc = XmlDocument::new()?;
doc.LoadXml(&HSTRING::from(xml))?;
let toast = ToastNotification::CreateToastNotification(&doc)?;
// In-process activation: the app is always alive in the tray, so we handle
// clicks/replies directly instead of via COM activation.
let app_activated = app.clone();
let room_id_owned = room_id.map(|s| s.to_string());
let path_owned = path.map(|s| s.to_string());
let activated = TypedEventHandler::<ToastNotification, IInspectable>::new(
move |_sender, args| {
let Some(args) = args.as_ref() else {
return Ok(());
};
let Ok(activated_args) = args.cast::<ToastActivatedEventArgs>() else {
return Ok(());
};
// Extract the reply text (if the Send action / input was used).
let reply = read_reply(&activated_args).unwrap_or_default();
if !reply.is_empty() {
// Quick reply: forward the room id + text to the web client.
let payload = serde_json::json!({
"roomId": room_id_owned.as_deref(),
"text": reply,
})
.to_string();
super::emit_to_web(&app_activated, "lotus-notification-reply", &payload);
} else {
// Plain body click: forward the launch path so the web routes to it.
let payload = serde_json::json!({
"path": path_owned.as_deref(),
})
.to_string();
super::emit_to_web(&app_activated, "lotus-notification-activate", &payload);
}
Ok(())
},
);
let _ = toast.Activated(&activated)?;
// Prune the store once the toast leaves the action center so we don't leak
// handler registrations for the app's lifetime.
let dismissed = TypedEventHandler::<ToastNotification, ToastDismissedEventArgs>::new(
move |sender, _args| {
if let Some(sender) = sender.as_ref() {
if let Ok(mut store) = toast_store().lock() {
store.retain(|t| t != sender);
}
}
Ok(())
},
);
let _ = toast.Dismissed(&dismissed)?;
// Keep the toast (and its handlers) alive until dismissed.
if let Ok(mut store) = toast_store().lock() {
store.push(toast.clone());
}
// No AUMID argument: relies on the process AppUserModelID (see module note).
let notifier = ToastNotificationManager::CreateToastNotifier()?;
notifier.Show(&toast)?;
Ok(())
}
/// Read the quick-reply text from a toast activation. Returns `None` when the
/// toast was activated without submitting the "reply" input (a plain click).
#[cfg(target_os = "windows")]
fn read_reply(
args: &windows::UI::Notifications::ToastActivatedEventArgs,
) -> Option<String> {
use windows::core::{HSTRING, IInspectable, Interface};
use windows::Foundation::Collections::IMap;
use windows::Foundation::IReference;
// UserInput() is an IPropertySet (IMap<String, Object>); the text input value
// is boxed as an IReference<HSTRING>.
let inputs: IMap<HSTRING, IInspectable> = args.UserInput().ok()?.cast().ok()?;
let key = HSTRING::from("reply");
if !inputs.HasKey(&key).ok()? {
return None;
}
let value = inputs.Lookup(&key).ok()?;
let reference: IReference<HSTRING> = value.cast().ok()?;
let text = reference.Value().ok()?.to_string();
if text.is_empty() {
None
} else {
Some(text)
}
}