From 757ce90b41b4ece6de413ac221f3aa0ed70ba039 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:43:14 +0000 Subject: [PATCH] plans: open the chat when a Windows notification is clicked --- .../2026-10-17-windows-notification-click.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 plans/2026-10-17-windows-notification-click.md diff --git a/plans/2026-10-17-windows-notification-click.md b/plans/2026-10-17-windows-notification-click.md new file mode 100644 index 0000000000..eb17423740 --- /dev/null +++ b/plans/2026-10-17-windows-notification-click.md @@ -0,0 +1,279 @@ +# Windows: clicking a notification opens the chat + +Status: draft rev 10 (after review round 7). Toast history model decided 2026-10-17: Android-parity clearing plus a 1-hour expiry. All decisions taken. Implementation is gated by G1. Base: `origin/master` a932c8298. Paths are relative to `apps/multiplatform` unless they start with `src/` (the Haskell core). +Related plans: [sound](2026-10-17-desktop-notification-sound.md), [turn off](2026-10-17-desktop-notifications-turn-off.md). Research notes: `~/ntf-research/windows-findings.md`, `twoslices-findings.md`. + +## Problem + +On Windows, clicking a SimpleX notification does nothing: +- The chat doesn't open. +- The window isn't raised. +- From Action Center, nothing happens at all. + +Related: #4201 (SimpleX is not a stable app in Windows notification settings; its sound switch resets), #3301, closed PR #7182. No issue states the click bug directly; the reports come from users. + +## Cause + +- INFERENCE [`twoslices-findings.md:48`]: on Windows, two-slices selects `AWTToaster` (GNTP only if Snarl or Growl listens on port 23053). +- FACT [`AWTToaster.java:103, 173-175`, md5-identical in 0.9.1–0.9.7]: + - It registers itself as the `TrayIcon` ActionListener, but `actionPerformed` is empty. + - `builder.defaultAction()` and `builder.actions()` are never read. + - So the click and the Accept/Reject buttons can never reach the app. Upgrading the library doesn't help. +- FACT [`AWTToaster.java:92-128`]: it adds a second tray icon for toasts and removes it 10 s after the last toast. INFERENCE: after that, an Action Center click has no target. +- FACT [`NtfManager.kt:70-83`]: `openChatAction` never raises the window. It is effectively unused on desktop today, since no toaster in use invokes it, and it hard-codes the local host (`changeActiveUser(null, …)` `:76`, `openChat(rhId = null)` `:81`, TODO at `:74`). + +## Existing structure to reuse + +- FACT [`AppLinkScheme.desktop.kt:67-71, 97-106, 168+`]: + - On each start, a background thread registers `simplexchat:` and `simplex:` per user under `HKCU\Software\Classes` for any jpackage launcher on Windows, with the command `"" "%1"`. + - A scheme held by another program is left alone. `registered` reflects `simplexchat:` (`:180`). + - The app already uses `chat.simplex.app` as its Windows capabilities name (`:26`) and the display name `APP_DISPLAY_NAME = "SimpleX Chat"` (`:43`). +- FACT [`SingleInstance.kt:49-66, 139-157, 205-214, 227-245`; `Main.kt:28-32`]: + - A second launch writes its link to `simplex.show`, allows the running instance the foreground (`AllowSetForegroundWindow`), and exits. + - The running instance takes the signal on the Swing thread and calls `openDesktopLink(link)` (`AppLinks.kt:19-22`), which stores `appOpenUrl` and calls `showWindow()` (`DesktopApp.kt:259-273`). + - The watcher starts before `initApp` (`Main.kt:32` vs `:41`). On a cold start, `Main.kt:30` stores the link from args. +- FACT [`AppLinks.kt:12-13`, `ChatListView.kt:797-798`]: an accepted link is a case-insensitive `simplexchat:` prefix (or a connection link), at most 8192 bytes. Signal files are re-checked in `takeSignal`. +- FACT [`App.kt:240-249`, `ChatListView.kt:775-789, 804-814`]: + - `appOpenUrl` is processed only when authorized, onboarding is complete and chat is running. + - `simplexchat:` links go to `openAppLink` unless a badge redemption is in flight (`isBadgeLinkIssuing()`), in which case the link is dropped. + - `openAppLink` (commonMain) shows "not supported" for unknown paths. +- FACT [`SimpleXAPI.kt:2937`]: `rhId` of every event comes from the desktop's own core. +- FACT [`src/Simplex/Chat/Library/Commands.hs:561, 574, 4456`]: hiding a profile sets `showNtfs = False`, unhiding sets it back, and muting is refused while hidden. +- FACT [`SimpleXAPI.kt:787-789`, `HiddenProfileView.kt:38-39`, `UserProfilesView.kt:371`, `LocalAuthView.kt:73`, `DatabaseView.kt:598`]: these are the clearing calls, the same set Android has: + - `cancelNotificationsForUser` when leaving a hidden active profile, when hiding a non-active profile, and on user deletion; + - `cancelAllNotifications` on self-destruct and database deletion. +- FACT [`AppLock.kt:23-24, 271-284`; `DesktopApp.kt:184-205`; `PrivacySettings.kt:487`]: + - `userAuthorized` / `enteredBackground` are the lock state. + - With lock delay 0, re-locking happens only in the focus handler. + - `clearOverlays` closes the passcode prompt (`App.kt:111-115`), and ChatView stays composed under the lock screen (`App.kt:157-172`), where it marks visible items read (`ChatView.kt:3090-3094`). +- FACT [`desktop/build.gradle.kts:70-78`]: the MSI makes a desktop shortcut only (no `menu`) and sets no AppUserModelID. + +## Ideal final result + +Clicking a SimpleX toast from the current run, on screen or in Action Center, behaves like opening a `simplexchat:` link: Windows starts SimpleX with the link, and the existing single-instance path raises the running window, which then opens the chat. A toast from an earlier run starts or raises the app. + +Microsoft documents a "stub" CLSID with protocol-activation toasts for unpackaged apps, in the form where the AUMID and CLSID sit on a Start-menu shortcut. The registry-only form (`HKCU\Software\Classes\AppUserModelId\` with `CustomActivator`, as the Windows App SDK and CommunityToolkit write it, but without their `LocalServer32`) is **INFERENCE**. Hence G1. + +## Options + +1. **WinRT toasts with protocol activation** (chosen, D1). The click works from the popup and from Action Center, and SimpleX becomes a stable app in Windows notification settings (#4201, INFERENCE). Cost: about 300–400 lines of Windows-only JNA/COM code, verifiable only on Windows. Each click starts a second JVM to forward the link (about 1–3 s, INFERENCE). +2. **Code-only tray-icon stopgap.** It works only while the balloon is on screen and only for the last notification. Rejected. +3. **A third-party WinRT library.** A new native dependency in trusted code. Rejected. +4. **PowerShell per toast.** Slow, blockable, and message content would reach command lines and logs. Rejected. + +## Design (option 1) + +Narrative: on Windows, each notification becomes a Windows toast whose click opens `simplexchat:/notification/`. When the desktop app receives such a link, it raises the window and, if the app is unlocked, opens the chat the token stands for, on the host and in the profile it came from. + +Commits, in this order: +- (a) add the `rhId` parameter and pass it through: structure only, no behaviour change; +- (b) `openChatAction` routes by `rhId`; +- (c) the AUMID registration; +- (d) `WindowsToast`, tokens and the link handler; +- (e) docs. + +1. **AUMID registration** in `AppLinkScheme.desktop.kt`, on the same thread and through the same `WindowsShell`/`RegistryValue` abstraction as `registerWindowsSchemes`. It runs only after `simplexchat:` registered successfully, because without the scheme a click can't come back. + - Key: `Software\Classes\AppUserModelId\chat.simplex.app`. + - `DisplayName = APP_DISPLAY_NAME`. + - `IconUri` = a PNG written to the data dir when missing (not `tmpDir`, which is wiped at start, `Main.kt:43`). + - `CustomActivator = {fixed GUID}`. No `LocalServer32` (stub). + - The outcome (ready or failed) is published to `WindowsToast`. +2. **`WindowsToast`** (new `platform/WindowsToast.desktop.kt`, Windows only). + - **Thread and commands.** + - One daemon thread takes commands from one **unbounded** `LinkedBlockingDeque`. + - Clear commands and the registration outcome go to the front (`offerFirst`); toast commands go to the back. + - A separate `AtomicInteger` counts toasts waiting, including the held list below. Offering a toast, in order: + 1. Reserve a slot: `incrementAndGet() <= 32`. Otherwise `decrementAndGet()` and drop the toast without touching the token map. + 2. Put its token in the map. + 3. `offerLast`. + - Accepted toasts are processed in order and at most 32 wait, so a waiting toast's token is never evicted by the 256-entry map. + - The count is decremented in a `finally` around routing each toast, whatever the exit: dropped, `DisabledFor*`, shown, or fallback. No exit leaks a slot. + - Clears are never dropped. + - The thread blocks on the deque, so a clear runs as soon as it is queued, even with no toast after it. Callers never block. + - **Registration wait**: one deadline, 10 s after the thread starts. + - Until the outcome arrives, toasts taken from the deque are moved to a local held list (they still count toward the 32), and the thread waits with `pollFirst(deadline - now)`, so clears keep running. + - On "ready", held toasts are shown in order. On "failed" or at the deadline, they go to the fallback, and the thread returns to a blocking `takeFirst`. + - The deadline is not an outcome. Toasts arriving after it, while no outcome has arrived, go straight to the fallback. A late "ready" switches every later toast to WinRT, so a slow first-run registration costs only the toasts before it. "Failed" is final. + - The first outcome wins. The registration code publishes "ready" after writing the key. Its `finally` publishes "failed" only if nothing was published yet, which covers `Unpackaged`, a scheme held by another program, and exceptions. + - **Thread start**: `RoInitialize(RO_INIT_MULTITHREADED)`. Accept `S_OK` and `S_FALSE`; `RPC_E_CHANGED_MODE` or any other error disables the toaster, and every toast goes to the fallback. + - **Once**, with each interface's IID and the vtable slot of each call written in the code next to the call: + - `RoGetActivationFactory("Windows.UI.Notifications.ToastNotificationManager", IToastNotificationManagerStatics {50AC103F-D235-4598-BBEF-98FE4D1A3AD4})` → `CreateToastNotifierWithId(aumid)` (slot 7) → `IToastNotifier`. + - QI `IToastNotificationManagerStatics2` → `get_History` → `IToastNotificationHistory`: `RemoveGroupWithId(group, aumid)` and `ClearWithId(aumid)`. The `…WithId` forms are needed without package identity. + - `RoGetActivationFactory("Windows.UI.Notifications.ToastNotification", IToastNotificationFactory)`. + - `RoGetActivationFactory("Windows.Foundation.PropertyValue", IPropertyValueStatics)`. + - **Before routing** each toast, whether to WinRT or to the fallback (held toasts, a failed registration, a disabled toaster): if it has a token and that token is no longer in the map, drop it. Its user, or everything, was cleared after it was queued. + - **Per WinRT toast**: + 1. Read `IToastNotifier.Setting` (slot 8). + - `Enabled`: continue. + - `DisabledForApplication` / `DisabledForUser` / `DisabledByGroupPolicy`: drop the toast, and no sound. The user turned SimpleX off in Windows. + - `DisabledByManifest` or an error: fallback. + 2. `RoActivateInstance("Windows.Data.Xml.Dom.XmlDocument")` → QI `IXmlDocumentIO` → `LoadXml`. + 3. QI `IXmlDocument` {F7F3A506-1E87-42D6-BCFB-B8C809FA5494} → `IToastNotificationFactory::CreateToastNotification` (slot 6). + 4. On the new toast: + - `put_ExpirationTime` (`IToastNotification`, slot 7) = now + 1 h. The value is `IPropertyValueStatics::CreateDateTime` → QI `IReference` {5541D8A7-497C-5AA4-86FC-7713ADBF2A2C}, with `DateTime.UniversalTime` in 100 ns ticks since 1601-01-01 UTC. + - `put_Group` (`IToastNotification2`, slot 8) = `"-"`. + - `put_ExpiresOnReboot(true)` through QI `IToastNotification6` {43EBFE53-89AE-5C1E-A279-3AECFE9B6F54}. That interface exists only on Windows 10 1903+; `E_NOINTERFACE` just skips the step, since the 1 h expiry still applies. + - No Tag. + 5. `IToastNotifier::Show` (slot 6), then run the toast's `onShown`. + - A failure in steps 2–5 sends that toast to the fallback. Only factory or apartment failures disable the toaster for the session. + - **Fallback** = `displayViaTwoSlices(…)`: today's two-slices code, renamed in place, which never routes back to `WindowsToast`. `WindowsToast` passes it `onShown.takeIf { BALLOON_SILENT }`, where `BALLOON_SILENT` is a constant set from V1 (sound plan). The balloon's own sound and the app's sound never both play. + - **XML**, one function, in this order: + 1. Truncate the title to 128 and the body to 512 code points. + 2. Remove characters XML 1.0 forbids (U+0000–0008, 000B, 000C, 000E–001F, U+FFFE/FFFF, lone surrogates). + 3. Escape `& < > " '`. + - All values go through it: title, body, the `launch` attribute, and the image URI (`file.toPath().toUri()`, i.e. `file:///C:/…`). + - Elements: `ToastGeneric`, two ``, optional `appLogoOverride` from the existing `prepareIconPath`, and `activationType="protocol" launch="…"` on ``. + - **Audio** is decided once, at the `displayNotification` call site, and passed in the request as `ToastAudio.Silent` or `ToastAudio.Default`. Every toast from `displayNotification` (messages, contact requests, contact-connected, missed calls) gets `Silent` (``, `&`, quotes, `]]>`, `{title}`, NUL and each control character, lone surrogates, an emoji at the cut point, bidi overrides (U+202E, U+2066–2069) and 100 KB strings. + - Bidi can't cross between the separate `` elements. +- **Link forgery** (web pages, local programs, or a process writing `simplex.show`, which is re-checked by `isAcceptedLink`). + - Tokens are unguessable, so a forged link at most raises the window. + - Launch strings also sit in the Windows notification database, readable by the same user. Such a process can already do more. +- **Remote host.** userIds collide across hosts; the token carries the core-assigned rhId. A disconnected host's token only raises the window. A malicious mobile controls only its own users, chats and text, and escaping covers the text. +- **Hidden profiles.** Clearing follows Android, plus a 1 h expiry and expiry on reboot. The known limits above are the residual risk, and the preview mode ("hide content") applies to every toast. +- **App lock.** A click never opens a chat, switches profile or closes the passcode prompt while the app is or would be locked. The lock is checked before raising the window and again right before `clearOverlays`. +- **The Windows OS switch.** Respected: no toast and no sound when SimpleX is disabled in Windows. Startup toasts wait for registration (bounded) instead of going around it through the balloon. +- **Floods.** At most 32 toasts wait; the rest are dropped. Clears are never dropped, and tokens are kept only for accepted toasts. +- **Registry.** HKCU, the same trust level as the existing scheme keys. +- **Native code.** A wrong vtable slot crashes the process. Mitigations: ABI slots documented per call, one isolated thread, and G1 plus the test plan on two Windows versions before merge. No `Native.setProtected`: it is process-wide and unreliable with the app's other JNA users. + +## Test plan + +MSI from fork CI (a one-job workflow on a fork branch, as for PR #7581). Run by you on Windows 10 and 11: +- Running app, window behind another app: click the popup → the window comes forward and the chat opens. The same from Action Center. Note the delay. +- Minimized, and hidden in the tray: the same. +- App closed: an Action Center click starts the app on the chat list. +- Locked, with lock delay 0 and with 30 s: a click only raises the window. The lock screen stays, the passcode prompt is not closed, and no items are marked read. +- Lock on (delay 30 s), app focused and unlocked: a popup for another chat opens it, if clicking the popup doesn't take focus from the app (observe and record). With delay 0, a click that takes focus re-locks, as any focus loss does today. +- Chat stopped (Settings → Database → stop): a click on an earlier toast only raises the window. +- During a call: an incoming call toast and message toasts are silent. +- Clearing races: self-destruct at the startup lock screen during a startup flood. Afterwards, Action Center is empty and no queued chat toast appears. A queued error toast (`showMessage`, no token) may still appear; it carries no chat content. +- A background profile's chat: the profile switches. +- A remote host (mobile) connected: + - A mobile notification while local is shown: switches to the mobile and opens the chat. + - A local notification while the mobile is shown: switches back. + - The mobile shown, and a click for another profile on the mobile: that mobile profile opens, and the local profile is unchanged. + - After a disconnect, the mobile's old toasts only raise the window. +- Clearing: + - Leave a hidden active profile, hide a non-active profile, delete a profile: their toasts disappear right away, with no new toast arriving. + - Self-destruct and database delete: Action Center is empty. +- A bot sends hostile names and texts: shown literally, with no extra button or image, and later toasts still work. +- SimpleX turned off in Windows Settings: no toast, no balloon, no sound. SimpleX is listed there, and its switches survive a restart (#4201). +- Sound: + - App sound off (default): the Windows default sound per toast, under SimpleX's switch, except while recording or in a call. If V1 found today's balloon silent, this is a new sound; say so in the PR. + - App sound on: exactly one sound, the app's, at most once per 3 s. + - Fallback toasts never add a second sound. +- Startup flood (100 messages while registering): toasts appear after registration; the app stays responsive; clicks on them work. +- Linux AppImage regression: notifications unchanged. A forged notification link raises the window without the alert. + +## Decisions + +- **D1. Approach.** DECIDED: option 1, gated by G1. +- **D2. Remote host.** DECIDED: pass `rhId` through common `displayNotification`, so remote clicks open the right host's chat (§3). +- **D3. Action buttons.** DECIDED: not in this change; body click only. +- **D4. Clearing or replacing a chat's toasts when it is opened.** DECIDED: a follow-up. +- **D5. macOS and Linux clicks.** DECIDED: a separate plan. +- **D6. Toast history.** DECIDED: Android-parity clearing plus a 1 h expiry (and on reboot), with the known limits documented.