fix(notify): reveal the pane from toasts clicked in notification history

Windows only reports a click on a toast that has left the screen through
COM: the shell activates the ToastActivatorCLSID named on the shortcut
that carries the AUMID. tty7 had no such class, so clicks from Action
Center did nothing.

- Stamp a fixed ToastActivatorCLSID on the Start Menu shortcut (runtime
  and installer); a shortcut lacking it is rewritten.
- Register the class per-user as a LocalServer32 (never from a cargo
  build directory) and as a running class object at GUI startup.
- Implement INotificationActivationCallback; the toast's launch string
  carries the pid-checked pane identifier, so a click on a toast from an
  earlier process only brings the window forward.
- Queue tray actions that arrive before the dispatch loop exists, which
  is the cold-start case when COM launches tty7 to deliver the click.
- Deduplicate a popup click that arrives through both COM and the
  in-process Activated event.
- The uninstaller removes the CLSID registration.

Fixes #1179
This commit is contained in:
l0ng-ai
2026-10-15 15:18:19 +08:00
parent 8c5ea523b3
commit 80ab718ac3
9 changed files with 617 additions and 61 deletions
+22 -2
View File
@@ -120,9 +120,15 @@ Source: "{#StageDir}\server\*"; DestDir: "{app}\server"; Flags: ignoreversion re
; all-users install alone — it cannot write {commonprograms} unelevated, and a
; per-user twin would both duplicate the Start Menu entry and outlive this
; uninstaller — so an elevated install depends on the stamp right here.
;
; AppUserModelToastActivatorCLSID names the COM class a click on a toast in
; notification history activates; without it that click goes nowhere (#1179).
; Must match `core::aumid::TOAST_ACTIVATOR_CLSID`. The app registers the class
; itself (HKCU, at every launch, so it always names the running exe); the
; uninstaller below removes that registration.
[Icons]
Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe"; AppUserModelID: "com.github.tty7"
Name: "{autodesktop}\tty7"; Filename: "{app}\tty7-app.exe"; Tasks: desktopicon; AppUserModelID: "com.github.tty7"
Name: "{autoprograms}\tty7"; Filename: "{app}\tty7-app.exe"; AppUserModelID: "com.github.tty7"; AppUserModelToastActivatorCLSID: "4E1F6381-E90B-4BED-9156-A9013F718FE6"
Name: "{autodesktop}\tty7"; Filename: "{app}\tty7-app.exe"; Tasks: desktopicon; AppUserModelID: "com.github.tty7"; AppUserModelToastActivatorCLSID: "4E1F6381-E90B-4BED-9156-A9013F718FE6"
[Run]
; The registry shape lives in core::explorer_context_menu, not here: the app
@@ -253,11 +259,25 @@ begin
HKEY_CURRENT_USER, 'Software\Classes\Directory\Background\shell\tty7');
end;
(* Remove the toast activator's COM registration.
`core::aumid` writes HKCU\Software\Classes\CLSID\{…}\LocalServer32 at launch
so that a click on a toast in notification history can start tty7. Left
behind, it would point COM at a deleted exe. Only the class's own key is
deleted; a missing key is a no-op. HKCU for the same reason as the PATH
entry above: that is the only hive the app writes. *)
procedure RemoveToastActivator();
begin
RegDeleteKeyIncludingSubkeys(
HKEY_CURRENT_USER, 'Software\Classes\CLSID\{4E1F6381-E90B-4BED-9156-A9013F718FE6}');
end;
procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep);
begin
if CurUninstallStep = usUninstall then
begin
RemoveAppDirFromUserPath();
RemoveExplorerContextMenu();
RemoveToastActivator();
end;
end;
Generated
+1
View File
@@ -11575,6 +11575,7 @@ dependencies = [
"url",
"uuid",
"windows 0.58.0",
"windows-core 0.58.0",
"windows-sys 0.61.2",
"winresource",
]
+8
View File
@@ -220,7 +220,15 @@ windows = { version = "0.58", features = [
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_Shell_PropertiesSystem",
# INotificationActivationCallback, the COM class a click on a toast in
# notification history activates (`core::toast_activator`); `implement`
# is what lets us provide it and its class factory.
"Win32_UI_Notifications",
"implement",
] }
# `#[implement]` expands to paths under `::windows_core`, so the crate has to
# be a direct dependency. Same version `windows` 0.58 already pulls in.
windows-core = "0.58"
# Embeds `assets/favicon.ico` into the `.exe` so Windows shows the tty7 logo in
# the taskbar / window / Explorer (macOS gets its icon from the `.app` bundle via
+244 -11
View File
@@ -16,6 +16,14 @@
//! anything at all from a `cargo` build directory (which would repoint the
//! user's installed shortcut at `target\debug`).
//!
//! The same shortcut also names tty7's toast activator
//! (`System.AppUserModel.ToastActivatorCLSID`), and `init()` registers that
//! class as a per-user COM local server. That pair is what makes a click on a
//! toast sitting in notification history reach us: the shell activates the
//! class over COM — launching tty7 when it is not running — instead of firing
//! the `Activated` event of a `ToastNotification` object that is long gone.
//! See `core::toast_activator`.
//!
//! Everything is best-effort. `toast_app_id()` yields the AUMID only once a
//! shortcut carrying it is in place *and* the shell has had time to index it;
//! otherwise callers keep the PowerShell identity, which looks wrong but still
@@ -33,6 +41,33 @@ use std::time::{Duration, Instant};
/// mismatch silently splits the identity in two (a unit test checks this).
pub(crate) const AUMID: &str = "com.github.tty7";
/// The toast activator class (`core::toast_activator`). Keep in sync with the
/// `AppUserModelToastActivatorCLSID` on the installer shortcuts and the key
/// its uninstaller removes — a unit test checks both. Never change it: older
/// shortcuts and registrations in the field name this exact class.
pub(crate) const TOAST_ACTIVATOR_CLSID: windows::core::GUID =
windows::core::GUID::from_u128(0x4E1F6381_E90B_4BED_9156_A9013F718FE6);
/// The argument the COM registration launches us with. COM appends
/// `-Embedding` after it; neither changes how the launch is handled — it is a
/// pathless launch whose window the activation then brings forward — but they
/// make the process's origin legible in a process list or a crash report.
const TOAST_ACTIVATED_ARG: &str = "--toast-activated";
/// The CLSID as the registry and the installer spell it, without braces:
/// `4E1F6381-E90B-4BED-9156-A9013F718FE6`.
fn clsid_text(clsid: &windows::core::GUID) -> String {
let hex = format!("{:032X}", clsid.to_u128());
format!(
"{}-{}-{}-{}-{}",
&hex[..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..]
)
}
/// How long we keep using the PowerShell identity after writing the shortcut
/// ourselves. The shell picks a new `.lnk` up asynchronously and silently
/// discards toasts for an AUMID it has not indexed yet; the measured lag on
@@ -42,6 +77,9 @@ const INDEX_GRACE: Duration = Duration::from_secs(30);
/// One-time GUI-startup hook; see the module docs. Cheap after the first call.
pub(crate) fn init() {
// First, so a launch that COM made to deliver a toast click has its class
// object up as early as possible; COM is waiting on it.
crate::core::toast_activator::start();
let _ = toast_app_id();
}
@@ -74,13 +112,17 @@ fn setup() -> Branding {
// directories the shortcut half below refuses to touch.
let _ = SetCurrentProcessExplicitAppUserModelID(&windows::core::HSTRING::from(AUMID));
}
register_local_server();
match decide() {
Ok(Decision::Branded) => Branding::Ready,
Ok(Decision::Skip(why)) => {
log::debug!("keeping the PowerShell toast identity: {why}");
Branding::Unavailable
}
Ok(Decision::Write(lnk)) => match write_shortcut(&lnk) {
Ok(Decision::Write { lnk, indexed }) => match write_shortcut(&lnk) {
// Refreshing a shortcut that already carried the AUMID (to add the
// toast activator) leaves the identity the shell indexed intact.
Ok(()) if indexed => Branding::Ready,
Ok(()) => Branding::Pending(Instant::now()),
Err(e) => {
log::warn!("toast branding disabled, keeping the PowerShell identity: {e}");
@@ -97,8 +139,9 @@ fn setup() -> Branding {
enum Decision {
/// A shortcut already carries our AUMID; write nothing.
Branded,
/// Create or refresh this per-user shortcut, in place.
Write(PathBuf),
/// Create or refresh this per-user shortcut, in place. `indexed` when it
/// already carries our AUMID, so the shell knows the identity already.
Write { lnk: PathBuf, indexed: bool },
/// Nothing we may safely write. The string is for the log.
Skip(&'static str),
}
@@ -135,11 +178,17 @@ fn decide() -> Result<Decision, String> {
let stamped = existing
.as_ref()
.is_some_and(|s| s.aumid.as_deref() == Some(AUMID));
// A shortcut from before the toast activator existed brands toasts fine,
// but a click on one in notification history goes nowhere until the
// shortcut also names the class — so it is rewritten like an off-target one.
let activates = existing
.as_ref()
.is_some_and(|s| s.toast_activator == Some(TOAST_ACTIVATOR_CLSID));
let on_target = existing
.as_ref()
.and_then(|s| s.target.as_deref())
.is_some_and(|t| same_path(t, &exe));
if stamped && on_target {
if stamped && activates && on_target {
return Ok(Decision::Branded);
}
@@ -155,7 +204,115 @@ fn decide() -> Result<Decision, String> {
});
}
Ok(Decision::Write(lnk))
Ok(Decision::Write {
lnk,
indexed: stamped,
})
}
/// `HKCU\Software\Classes\CLSID\{…}\LocalServer32` — what COM runs to
/// deliver a toast click while tty7 is not running. Per-user, so it needs no
/// elevation whatever the install shape; the uninstaller removes the CLSID key.
fn local_server_key() -> String {
format!(
r"Software\Classes\CLSID\{{{}}}\LocalServer32",
clsid_text(&TOAST_ACTIVATOR_CLSID)
)
}
fn local_server_command(exe: &Path) -> std::ffi::OsString {
let mut command = std::ffi::OsString::from("\"");
command.push(exe.as_os_str());
command.push("\" ");
command.push(TOAST_ACTIVATED_ARG);
command
}
/// Point the toast activator's COM registration at the running exe, unless
/// it already does. Never from a cargo build directory, for the same reason
/// `decide()` never writes the shortcut from one: a `cargo run` must not leave
/// the user's toast clicks launching `target\debug`. A dev build still
/// receives clicks while it is running, through its class object.
fn register_local_server() {
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
log::warn!("toast activator not registered: current exe: {e}");
return;
}
};
if is_build_output(&exe) {
return;
}
let key = local_server_key();
let command = local_server_command(&exe);
if read_default_string(&key).as_deref() == Some(command.as_os_str()) {
return;
}
if let Err(e) = write_default_string(&key, &command) {
log::warn!("toast activator not registered: {e}");
}
}
fn read_default_string(subkey: &str) -> Option<std::ffi::OsString> {
use std::os::windows::ffi::OsStringExt;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, RRF_RT_REG_SZ, RegGetValueW};
let subkey = wide_z(OsStr::new(subkey));
let mut buf = vec![0u16; 1024];
let mut bytes = u32::try_from(buf.len() * 2).ok()?;
// SAFETY: `subkey` is a live NUL-terminated UTF-16 string; the default
// value is named by a null pointer; `buf` holds `bytes` writable bytes.
let code = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
subkey.as_ptr(),
std::ptr::null(),
RRF_RT_REG_SZ,
std::ptr::null_mut(),
buf.as_mut_ptr().cast(),
&mut bytes,
)
};
if code != ERROR_SUCCESS {
return None;
}
buf.truncate(bytes as usize / 2);
while buf.last() == Some(&0) {
buf.pop();
}
Some(std::ffi::OsString::from_wide(&buf))
}
fn write_default_string(subkey: &str, value: &OsStr) -> Result<(), String> {
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::System::Registry::{HKEY_CURRENT_USER, REG_SZ, RegSetKeyValueW};
let subkey_w = wide_z(OsStr::new(subkey));
let value = wide_z(value);
let bytes = u32::try_from(value.len() * 2).map_err(|_| "value too long".to_string())?;
// SAFETY: both buffers are live NUL-terminated UTF-16 strings, `bytes`
// covers `value` including its terminator, and a null value name is the
// key's default value. `RegSetKeyValueW` creates missing keys.
let code = unsafe {
RegSetKeyValueW(
HKEY_CURRENT_USER,
subkey_w.as_ptr(),
std::ptr::null(),
REG_SZ,
value.as_ptr().cast(),
bytes,
)
};
if code == ERROR_SUCCESS {
Ok(())
} else {
Err(format!(
"write HKCU\\{subkey}: {}",
std::io::Error::from_raw_os_error(code as i32)
))
}
}
/// True when `exe` sits in a `cargo` build directory rather than an install.
@@ -218,6 +375,7 @@ fn all_users_shortcut_path() -> Option<PathBuf> {
#[derive(Default)]
struct Shortcut {
aumid: Option<String>,
toast_activator: Option<windows::core::GUID>,
target: Option<PathBuf>,
}
@@ -226,6 +384,12 @@ fn wide(s: &OsStr) -> Vec<u16> {
s.encode_wide().collect()
}
fn wide_z(s: &OsStr) -> Vec<u16> {
let mut w = wide(s);
w.push(0);
w
}
fn hstring(path: &Path) -> Result<windows::core::HSTRING, String> {
windows::core::HSTRING::from_wide(&wide(path.as_os_str()))
.map_err(|e| format!("{}: {e}", path.display()))
@@ -241,10 +405,13 @@ fn co_init() {
}
}
/// Read back the two properties `decide()` cares about. Absent ones come back
/// Read back the properties `decide()` cares about. Absent ones come back
/// as `None`; only genuine COM failures are errors.
fn read_shortcut(lnk: &Path) -> Result<Shortcut, String> {
use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID;
use windows::Win32::Storage::EnhancedStorage::{
PKEY_AppUserModel_ID, PKEY_AppUserModel_ToastActivatorCLSID,
};
use windows::Win32::System::Com::StructuredStorage::PropVariantToGUID;
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, CoCreateInstance, IPersistFile, STGM_READ,
};
@@ -271,6 +438,14 @@ fn read_shortcut(lnk: &Path) -> Result<Shortcut, String> {
.and_then(|v| BSTR::try_from(&v).ok())
.map(|s| s.to_string())
.filter(|s| !s.is_empty());
// VT_CLSID whichever side wrote it. An absent property is VT_EMPTY,
// which `PropVariantToGUID` converts to the nil GUID rather than an
// error, so that is what "not set" looks like here.
let toast_activator = store
.GetValue(&PKEY_AppUserModel_ToastActivatorCLSID)
.ok()
.and_then(|v| PropVariantToGUID(&v).ok())
.filter(|clsid| *clsid != windows::core::GUID::zeroed());
// SLGP_RAWPATH returns the stored target verbatim; without it the
// shell may go looking for a moved file, including over the network.
@@ -284,15 +459,22 @@ fn read_shortcut(lnk: &Path) -> Result<Shortcut, String> {
})
.filter(|p| !p.as_os_str().is_empty());
Ok(Shortcut { aumid, target })
Ok(Shortcut {
aumid,
toast_activator,
target,
})
}
}
/// Create or overwrite `lnk`, pointing at the running exe and carrying our
/// AUMID. The exe has the icon compiled in (build.rs), which is what the toast
/// AUMID and toast activator. The exe has the icon compiled in (build.rs), which is what the toast
/// header shows.
fn write_shortcut(lnk: &Path) -> Result<(), String> {
use windows::Win32::Storage::EnhancedStorage::PKEY_AppUserModel_ID;
use windows::Win32::Storage::EnhancedStorage::{
PKEY_AppUserModel_ID, PKEY_AppUserModel_ToastActivatorCLSID,
};
use windows::Win32::System::Com::StructuredStorage::InitPropVariantFromCLSID;
use windows::Win32::System::Com::{CLSCTX_INPROC_SERVER, CoCreateInstance, IPersistFile};
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
@@ -314,6 +496,11 @@ fn write_shortcut(lnk: &Path) -> Result<(), String> {
store
.SetValue(&PKEY_AppUserModel_ID, &PROPVARIANT::from(AUMID))
.map_err(|e| format!("SetValue: {e}"))?;
let clsid = InitPropVariantFromCLSID(&TOAST_ACTIVATOR_CLSID)
.map_err(|e| format!("InitPropVariantFromCLSID: {e}"))?;
store
.SetValue(&PKEY_AppUserModel_ToastActivatorCLSID, &clsid)
.map_err(|e| format!("SetValue: {e}"))?;
store.Commit().map_err(|e| format!("Commit: {e}"))?;
let persist: IPersistFile = link.cast().map_err(|e| format!("IPersistFile: {e}"))?;
persist
@@ -341,6 +528,49 @@ mod tests {
);
}
#[test]
fn toast_activator_matches_the_installer() {
let iss = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/.github/scripts/windows-installer.iss"
))
.expect("read windows-installer.iss");
let clsid = super::clsid_text(&super::TOAST_ACTIVATOR_CLSID);
let stamp = format!("AppUserModelToastActivatorCLSID: \"{clsid}\"");
assert_eq!(
iss.matches(&stamp).count(),
iss.matches("AppUserModelID:").count(),
"every windows-installer.iss shortcut must set {stamp}"
);
let cleanup = format!(r"'Software\Classes\CLSID\{{{clsid}}}'");
assert!(
iss.contains(&cleanup),
"the uninstaller must remove {cleanup}"
);
}
#[test]
fn the_clsid_is_spelled_the_registry_way() {
assert_eq!(
super::clsid_text(&super::TOAST_ACTIVATOR_CLSID),
"4E1F6381-E90B-4BED-9156-A9013F718FE6"
);
assert_eq!(
super::local_server_key(),
r"Software\Classes\CLSID\{4E1F6381-E90B-4BED-9156-A9013F718FE6}\LocalServer32"
);
}
/// COM runs this string after appending `-Embedding`; an unquoted path
/// with a space in it would launch `C:\Program` instead.
#[test]
fn the_local_server_command_quotes_the_exe() {
assert_eq!(
super::local_server_command(Path::new(r"C:\Program Files\tty7\tty7-app.exe")),
r#""C:\Program Files\tty7\tty7-app.exe" --toast-activated"#
);
}
#[test]
fn start_menu_shortcut_lives_under_programs() {
let Some(lnk) = super::start_menu_shortcut_path() else {
@@ -441,6 +671,9 @@ mod tests {
written.expect("write shortcut");
let shortcut = read.expect("read attempted").expect("read shortcut");
assert_eq!(shortcut.aumid.as_deref(), Some(super::AUMID));
// Without it a click on the toast in notification history reaches
// nobody (#1179), and `decide()` would rewrite the shortcut forever.
assert_eq!(shortcut.toast_activator, Some(super::TOAST_ACTIVATOR_CLSID));
let exe = std::env::current_exe().expect("current exe");
let target = shortcut.target.unwrap_or_default();
assert!(
@@ -461,7 +694,7 @@ mod tests {
fn sends_a_branded_toast() {
let decision = match super::decide().expect("decide") {
super::Decision::Branded => "already branded".to_string(),
super::Decision::Write(lnk) => format!("writing {}", lnk.display()),
super::Decision::Write { lnk, .. } => format!("writing {}", lnk.display()),
super::Decision::Skip(why) => format!("skipping: {why}"),
};
println!("decision: {decision}");
+2
View File
@@ -12,5 +12,7 @@ pub mod keychain;
pub mod rate_meter;
pub mod session;
pub mod ssh_config;
#[cfg(target_os = "windows")]
pub mod toast_activator;
pub mod update;
pub mod window_state;
+117
View File
@@ -0,0 +1,117 @@
//! The COM class Windows activates when a tty7 toast is clicked.
//!
//! An unpackaged app has two ways to hear about a click. The `Activated` event
//! on a `ToastNotification` only fires while that very object is alive in the
//! process that showed it, and in practice only for the on-screen popup. A
//! toast clicked later from notification history (Action Center) goes the
//! other way: the shell looks up the `ToastActivatorCLSID` on the shortcut
//! that carries our AUMID and calls `INotificationActivationCallback::Activate`
//! on that class — in the running tty7 if it has registered a class object,
//! otherwise by launching the `LocalServer32` command `core::aumid` registers.
//! Without this class, such a click did nothing at all (#1179).
//!
//! The toast's `launch` attribute arrives as `invokedArgs`. It is the same
//! pid-checked identifier macOS uses, so a click on a toast from an earlier
//! process — the only kind a launch made by COM can receive — brings tty7
//! forward without guessing at a pane.
use windows::Win32::Foundation::{BOOL, CLASS_E_NOAGGREGATION, E_POINTER};
use windows::Win32::System::Com::{
CLSCTX_LOCAL_SERVER, COINIT_MULTITHREADED, CoInitializeEx, CoRegisterClassObject,
IClassFactory, IClassFactory_Impl, REGCLS_MULTIPLEUSE,
};
use windows::Win32::UI::Notifications::{
INotificationActivationCallback, INotificationActivationCallback_Impl,
NOTIFICATION_USER_INPUT_DATA,
};
use windows::core::{GUID, IUnknown, Interface, PCWSTR, implement};
/// Register the class object for this process. Idempotent; never blocks.
///
/// The registration lives on a thread of its own that joins the MTA and then
/// parks for good: COM calls `Activate` on an RPC thread without needing a
/// message loop, and the parked thread keeps the apartment — and with it the
/// registration — alive for the life of the process.
pub(crate) fn start() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let spawned = std::thread::Builder::new()
.name("tty7-toast-activator".into())
.spawn(|| {
// SAFETY: plain COM initialization of this new thread.
let _ = unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) };
let factory: IClassFactory = Factory.into();
// SAFETY: the CLSID is a live constant and `factory` a valid
// class object; COM holds its own reference from here on.
let registered = unsafe {
CoRegisterClassObject(
&crate::core::aumid::TOAST_ACTIVATOR_CLSID,
&factory,
CLSCTX_LOCAL_SERVER,
REGCLS_MULTIPLEUSE,
)
};
if let Err(e) = registered {
log::warn!("toast clicks from notification history will be ignored: {e}");
return;
}
loop {
std::thread::park();
}
});
if let Err(e) = spawned {
log::warn!("failed to start the toast activator thread: {e}");
}
});
}
#[implement(IClassFactory)]
struct Factory;
impl IClassFactory_Impl for Factory_Impl {
fn CreateInstance(
&self,
outer: Option<&IUnknown>,
iid: *const GUID,
object: *mut *mut core::ffi::c_void,
) -> windows::core::Result<()> {
if object.is_null() || iid.is_null() {
return Err(E_POINTER.into());
}
// SAFETY: checked non-null above; COM's contract is that it is writable.
unsafe { *object = std::ptr::null_mut() };
if outer.is_some() {
return Err(CLASS_E_NOAGGREGATION.into());
}
let callback: INotificationActivationCallback = Activator.into();
// SAFETY: `iid` and `object` come straight from COM and were checked.
unsafe { callback.query(iid, object).ok() }
}
fn LockServer(&self, _lock: BOOL) -> windows::core::Result<()> {
Ok(())
}
}
#[implement(INotificationActivationCallback)]
struct Activator;
impl INotificationActivationCallback_Impl for Activator_Impl {
fn Activate(
&self,
_app_user_model_id: &PCWSTR,
invoked_args: &PCWSTR,
_data: *const NOTIFICATION_USER_INPUT_DATA,
_count: u32,
) -> windows::core::Result<()> {
let launch = if invoked_args.is_null() {
String::new()
} else {
// SAFETY: a non-null PCWSTR from COM is NUL-terminated and lives
// for the duration of the call.
unsafe { invoked_args.to_string() }.unwrap_or_default()
};
crate::terminal::toast_activated(&launch);
Ok(())
}
}
+2
View File
@@ -33,5 +33,7 @@ mod typeahead;
pub mod view;
pub(crate) use remote::notify_desktop;
#[cfg(target_os = "windows")]
pub(crate) use remote::toast_activated;
pub use remote::{PaneRoute, PaneWorkspace, RemoteTerminal, attach_refused, attach_unanswered};
pub use size::TermSize;
+185 -41
View File
@@ -2905,10 +2905,12 @@ fn sanitize_notification_text(s: &str, max_chars: usize) -> String {
out
}
/// What a click on a macOS notification has to carry back: the pane to reveal.
/// What a click on a notification has to carry back: the pane to reveal.
///
/// It rides in the notification's `identifier`, which is the one field the
/// center hands back verbatim on activation without a dictionary round trip.
/// On macOS it rides in the notification's `identifier`, which is the one
/// field the center hands back verbatim on activation without a dictionary
/// round trip. On Windows it is the toast's `launch` attribute, which comes
/// back as the `invokedArgs` of a COM activation (`core::toast_activator`).
/// Shape: `tty7-pane-<pid>-<leaf>-<seq>`.
///
/// The pid is what makes a stale identifier fail closed. Notifications outlive
@@ -2916,13 +2918,14 @@ fn sanitize_notification_text(s: &str, max_chars: usize) -> String {
/// whatever process now owns the bundle id — a relaunched tty7, or a second
/// instance running alongside. A leaf id is a gpui entity id, which a fresh
/// process hands out again in the same order, so without the pid that click
/// would reveal an unrelated pane. The sequence number keeps two notifications
/// for the same pane apart — the center treats a repeated identifier as
/// "replace the earlier one".
#[cfg(any(target_os = "macos", test))]
/// would reveal an unrelated pane. On Windows that is the *only* kind of click
/// a process COM launched for the purpose can receive. The sequence number
/// keeps two notifications for the same pane apart — the center treats a
/// repeated identifier as "replace the earlier one".
#[cfg(any(target_os = "macos", target_os = "windows", test))]
const NOTIFICATION_ID_PREFIX: &str = "tty7-pane-";
#[cfg(any(target_os = "macos", test))]
#[cfg(any(target_os = "macos", target_os = "windows", test))]
fn notification_identifier(leaf_id: u64) -> String {
use std::sync::atomic::AtomicU64;
static SEQ: AtomicU64 = AtomicU64::new(0);
@@ -2931,7 +2934,7 @@ fn notification_identifier(leaf_id: u64) -> String {
format!("{NOTIFICATION_ID_PREFIX}{pid}-{leaf_id}-{seq}")
}
#[cfg(any(target_os = "macos", test))]
#[cfg(any(target_os = "macos", target_os = "windows", test))]
fn leaf_id_in_identifier(identifier: &str) -> Option<u64> {
let rest = identifier.strip_prefix(NOTIFICATION_ID_PREFIX)?;
let (pid, rest) = rest.split_once('-')?;
@@ -3066,20 +3069,30 @@ fn try_clickable_notification(title: &str, body: &str, leaf_id: u64) -> bool {
let Some(tx) = toast_thread() else {
return false;
};
tx.try_send((title.to_string(), body.to_string(), leaf_id))
.is_ok()
tx.try_send((
title.to_string(),
body.to_string(),
notification_identifier(leaf_id),
))
.is_ok()
}
/// Title, body, and the `launch` identifier a click hands back.
#[cfg(all(target_os = "windows", not(test)))]
type ToastRequest = (String, String, u64);
type ToastRequest = (String, String, String);
/// Toasts are built, shown and *kept* on one dedicated thread.
/// Toasts are built, shown and kept on one dedicated thread.
///
/// A `ToastNotification` has to stay alive for its `Activated` event to reach
/// us, and Windows parks toasts in Action Center long after they leave the
/// screen — a thread per toast sleeping out a fixed window would both leak
/// threads and stop working the moment the timer expired. Keeping them on a
/// single thread also means the WinRT objects never cross a thread boundary.
/// A click reaches us two ways. The one that always works is COM activation
/// (`core::toast_activator`): the shell calls our registered class with the
/// toast's `launch` string, whether the click lands on the popup or on the
/// toast in notification history, and whether or not this process is still
/// the one that sent it. The other is the toast object's own `Activated`
/// event, which fires only for the popup and only while the object is alive
/// here — it is kept as the fallback for a shortcut that predates the toast
/// activator (an all-users install from an older installer, which we cannot
/// rewrite unelevated). Keeping the objects on a single thread also means the
/// WinRT objects never cross a thread boundary.
#[cfg(all(target_os = "windows", not(test)))]
fn toast_thread() -> Option<&'static std::sync::mpsc::SyncSender<ToastRequest>> {
use std::sync::OnceLock;
@@ -3110,13 +3123,14 @@ fn toast_loop(rx: std::sync::mpsc::Receiver<ToastRequest>) {
let _ = CoInitializeEx(None, COINIT_MULTITHREADED);
}
// How many past toasts stay clickable. Dropping the oldest is what bounds
// the memory here; there is no useful deadline to expire them on.
// How many recent popups keep their `Activated` fallback. Dropping the
// oldest is what bounds the memory here; the popup is long gone by then,
// and the COM path does not depend on these objects at all.
const MAX_LIVE_TOASTS: usize = 32;
let mut live: VecDeque<ToastNotification> = VecDeque::new();
while let Ok((title, body, leaf_id)) = rx.recv() {
if let Some(toast) = show_windows_toast(&title, &body, leaf_id) {
while let Ok((title, body, launch)) = rx.recv() {
if let Some(toast) = show_windows_toast(&title, &body, launch) {
if live.len() == MAX_LIVE_TOASTS {
live.pop_front();
}
@@ -3125,11 +3139,31 @@ fn toast_loop(rx: std::sync::mpsc::Receiver<ToastRequest>) {
}
}
/// The toast's XML. `launch` is what Windows hands back on a click — as the
/// `invokedArgs` of a COM activation, and as the `Arguments` of the in-process
/// `Activated` event.
#[cfg(any(target_os = "windows", test))]
fn toast_xml(title: &str, body: &str, launch: &str) -> String {
format!(
r#"<toast launch="{}" activationType="foreground">
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
</toast>"#,
xml_escape(launch),
xml_escape(title),
xml_escape(body)
)
}
#[cfg(all(target_os = "windows", not(test)))]
fn show_windows_toast(
title: &str,
body: &str,
leaf_id: u64,
launch: String,
) -> Option<windows::UI::Notifications::ToastNotification> {
use windows::Data::Xml::Dom::XmlDocument;
use windows::Foundation::TypedEventHandler;
@@ -3138,18 +3172,7 @@ fn show_windows_toast(
let app_id = crate::core::aumid::toast_app_id()?;
let xml = format!(
r#"<toast>
<visual>
<binding template="ToastText02">
<text id="1">{}</text>
<text id="2">{}</text>
</binding>
</visual>
</toast>"#,
xml_escape(title),
xml_escape(body)
);
let xml = toast_xml(title, body, &launch);
let doc = match XmlDocument::new() {
Ok(d) => {
@@ -3175,7 +3198,7 @@ fn show_windows_toast(
if let Err(e) = toast.Activated(&TypedEventHandler::new(
move |_sender: &Option<ToastNotification>, _args: &Option<IInspectable>| {
reveal_pane(leaf_id);
toast_activated(&launch);
Ok(())
},
)) {
@@ -3198,13 +3221,69 @@ fn show_windows_toast(
Some(toast)
}
/// What a click on a Windows toast asks for. A pane this process notified
/// about is revealed; anything else — a toast from an earlier tty7, one
/// without a pane, a launch string we do not recognize — still brings tty7
/// forward, which is what the user clicked a tty7 toast for.
#[cfg(any(target_os = "windows", test))]
fn toast_click_action(launch: &str) -> crate::ui::tray::TrayAction {
use crate::ui::tray::TrayAction;
match leaf_id_in_identifier(launch) {
Some(leaf_id) => TrayAction::RevealPane { leaf_id },
None => TrayAction::ShowWindow,
}
}
/// The last few `launch` strings acted on. A click on the popup can arrive
/// twice — through COM and through the toast object's `Activated` event — and
/// must reveal once. Identifiers are unique per toast, so a repeat is that
/// same click; an empty one (a toast without a pane) is never deduplicated.
#[cfg(any(target_os = "windows", test))]
#[derive(Default)]
struct RecentActivations(std::collections::VecDeque<String>);
#[cfg(any(target_os = "windows", test))]
impl RecentActivations {
const KEEP: usize = 8;
fn first_time(&mut self, launch: &str) -> bool {
if launch.is_empty() {
return true;
}
if self.0.iter().any(|seen| seen == launch) {
return false;
}
if self.0.len() == Self::KEEP {
self.0.pop_front();
}
self.0.push_back(launch.to_string());
true
}
}
/// A click on a tty7 toast, from whichever path delivered it. Runs on a COM or
/// WinRT callback thread, so it only touches the tray channel — queued there
/// when this process has no dispatch loop yet (a launch COM made to deliver
/// the click).
#[cfg(target_os = "windows")]
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn toast_activated(launch: &str) {
static RECENT: std::sync::Mutex<RecentActivations> =
std::sync::Mutex::new(RecentActivations(std::collections::VecDeque::new()));
let first = RECENT
.lock()
.map(|mut recent| recent.first_time(launch))
.unwrap_or(true);
if first {
crate::ui::tray::send_or_queue(toast_click_action(launch));
}
}
/// Ask the tray dispatch loop to bring `leaf_id` to the front. Runs on whatever
/// thread the platform hands the activation to, so it only touches the channel.
#[cfg(any(target_os = "macos", all(target_os = "windows", not(test))))]
#[cfg(target_os = "macos")]
fn reveal_pane(leaf_id: u64) {
if let Some(tx) = crate::ui::tray::sender() {
let _ = tx.try_send(crate::ui::tray::TrayAction::RevealPane { leaf_id });
}
crate::ui::tray::send_or_queue(crate::ui::tray::TrayAction::RevealPane { leaf_id });
}
#[cfg(not(any(target_os = "macos", all(target_os = "windows", not(test)))))]
@@ -3214,7 +3293,7 @@ fn try_clickable_notification(_title: &str, _body: &str, _leaf_id: u64) -> bool
false
}
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", test))]
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
@@ -3282,7 +3361,72 @@ mod notification_tests {
assert_eq!(sanitize_notification_text("short", 8), "short");
}
/// Pulls the `launch` attribute back out the way the shell would see it.
fn launch_attribute(xml: &str) -> String {
let start = xml
.find(r#"launch=""#)
.expect("toast has a launch attribute")
+ 8;
let len = xml[start..].find('"').expect("closed attribute");
xml[start..start + len].to_string()
}
/// #1179: a toast clicked from notification history is only reported over
/// COM, as the toast's `launch` string. Without one the pane is lost.
#[test]
fn a_toast_carries_its_pane_in_the_launch_attribute() {
let xml = toast_xml("title", "body", &notification_identifier(42));
assert_eq!(
toast_click_action(&launch_attribute(&xml)),
crate::ui::tray::TrayAction::RevealPane { leaf_id: 42 }
);
}
#[test]
fn a_toast_from_another_process_only_shows_the_window() {
let other_pid = std::process::id().wrapping_add(1);
let stale = format!("{NOTIFICATION_ID_PREFIX}{other_pid}-42-0");
let xml = toast_xml("title", "body", &stale);
assert_eq!(
toast_click_action(&launch_attribute(&xml)),
crate::ui::tray::TrayAction::ShowWindow
);
assert_eq!(
toast_click_action(""),
crate::ui::tray::TrayAction::ShowWindow
);
}
#[test]
fn one_click_delivered_twice_reveals_once() {
let mut recent = RecentActivations::default();
let a = notification_identifier(1);
let b = notification_identifier(1);
assert!(recent.first_time(&a));
assert!(!recent.first_time(&a));
assert!(recent.first_time(&b));
// Toasts without a pane all share the empty launch string.
assert!(recent.first_time(""));
assert!(recent.first_time(""));
}
/// The real WinRT XML parser must accept the document and hand the launch
/// string back verbatim. Parses only; shows nothing.
#[cfg(target_os = "windows")]
#[test]
fn windows_parses_the_toast_and_keeps_its_launch_string() {
use windows::Data::Xml::Dom::XmlDocument;
let id = notification_identifier(7);
let doc = XmlDocument::new().expect("XmlDocument");
doc.LoadXml(&toast_xml("a & <b>", "\"c\" 'd'", &id).into())
.expect("toast xml parses");
let launch = doc
.DocumentElement()
.and_then(|root| root.GetAttribute(&"launch".into()))
.expect("launch attribute");
assert_eq!(launch.to_string(), id);
}
#[test]
fn xml_escaping_covers_every_entity_once() {
assert_eq!(
+36 -7
View File
@@ -42,13 +42,37 @@ pub(crate) fn icon_is_up() -> bool {
ICON_UP.load(std::sync::atomic::Ordering::Relaxed)
}
/// A sender for the current tray dispatch loop, if one is running.
// Only the platform notification callbacks call this. The Windows one is
// compiled out of test builds so unit tests never raise a real toast; the macOS
// one is not, but no test sends a notification there either.
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn sender() -> Option<smol::channel::Sender<TrayAction>> {
SENDER.lock().ok()?.clone()
/// Actions that arrived before the dispatch loop existed, replayed by `init`.
///
/// A toast click can reach a process before it has a window: Windows launches
/// tty7 over COM to deliver a click on a toast in notification history, and
/// calls in as soon as the class object is registered — well before the first
/// window, and with it this loop, is up. Bounded so a process whose loop never
/// starts cannot grow it without limit; the oldest click is the one to lose.
static PENDING: Mutex<std::collections::VecDeque<TrayAction>> =
Mutex::new(std::collections::VecDeque::new());
const MAX_PENDING: usize = 8;
/// Hand `action` to the tray dispatch loop, or hold it until the loop starts.
// Only the platform notification callbacks call this, and Linux has none.
#[cfg_attr(
any(test, not(any(target_os = "macos", target_os = "windows"))),
allow(dead_code)
)]
pub(crate) fn send_or_queue(action: TrayAction) {
// Checked under the SENDER lock, which `init` also holds while it drains
// the queue, so an action can never land in the queue after the drain.
let Ok(sender) = SENDER.lock() else { return };
if let Some(tx) = sender.as_ref() {
let _ = tx.try_send(action);
return;
}
if let Ok(mut pending) = PENDING.lock() {
if pending.len() == MAX_PENDING {
pending.pop_front();
}
pending.push_back(action);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -407,6 +431,11 @@ pub(crate) fn init(cx: &mut App) {
let (tx, rx) = smol::channel::unbounded::<TrayAction>();
if let Ok(mut slot) = SENDER.lock() {
if let Ok(mut pending) = PENDING.lock() {
for action in pending.drain(..) {
let _ = tx.try_send(action);
}
}
*slot = Some(tx.clone());
}