lingxia-platform 0.18.0

Platform abstraction layer for LingXia framework (Android, iOS, HarmonyOS)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
//! Local toasts: replace-by-tag, skip the banner when this process is frontmost.
//!
//! Every tap — immediate, scheduled, or a notification-centre history item
//! long after this process exited — arrives through the COM activator in
//! [`super::toast_activator`], so the OS owns the schedule and the process
//! never has to still be running for a tap to find its target.

use super::app::Platform;
use super::toast_activator;
use crate::error::PlatformError;
use crate::traits::app_runtime::{LocalNotificationShow, LocalNotificationStatus};
use std::sync::Mutex;
use windows::Data::Xml::Dom::XmlDocument;
use windows::Foundation::DateTime;
use windows::UI::Notifications::{
    NotificationSetting, ScheduledToastNotification, ToastNotification, ToastNotificationManager,
};
use windows::Win32::Foundation::HWND;
use windows::Win32::Foundation::LPARAM;
use windows::Win32::System::Threading::GetCurrentProcessId;
use windows::Win32::UI::WindowsAndMessaging::{
    EnumWindows, GetForegroundWindow, GetWindowThreadProcessId, IsWindowVisible,
    SetForegroundWindow,
};
use windows::core::BOOL;
use windows::core::HSTRING;

const TOAST_GROUP: &str = "lingxia.local";
const UNIX_MS_TO_WINRT: u64 = 11_644_473_600_000;

type ToastActivateHandler = fn(&str);
static TOAST_ACTIVATE_HANDLER: Mutex<Option<ToastActivateHandler>> = Mutex::new(None);

pub fn set_toast_activate_handler(handler: ToastActivateHandler) {
    if let Ok(mut slot) = TOAST_ACTIVATE_HANDLER.lock() {
        *slot = Some(handler);
    }
}

/// A toast was tapped. Brings the product forward first: a token that no
/// longer resolves still has to land the user on a visible product.
pub(super) fn on_toast_activated(args: &str) {
    activate_host_windows();
    let Some(token) = toast_activator::token_from_launch(args) else {
        log::debug!("ignoring a toast activation that is not ours: {args:?}");
        return;
    };
    if let Some(handler) = TOAST_ACTIVATE_HANDLER.lock().ok().and_then(|slot| *slot) {
        handler(token);
    }
}

pub(super) fn aumid_from_identity(identity: &str) -> String {
    identity
        .chars()
        .map(|c| if c.is_whitespace() { '.' } else { c })
        .take(127)
        .collect()
}

pub(super) fn permission(platform: &Platform) -> Result<String, PlatformError> {
    ensure_start_menu_shortcut(platform);
    // Unpackaged Win32 often reports DisabledForApplication even when Show
    // still posts. Only treat explicit user / policy blocks as denied.
    match toast_setting(platform) {
        Ok(NotificationSetting::DisabledForUser)
        | Ok(NotificationSetting::DisabledByGroupPolicy) => Ok("denied".into()),
        Ok(_) => Ok("granted".into()),
        Err(_) => {
            if notifier(platform).is_ok() {
                Ok("granted".into())
            } else {
                Ok("denied".into())
            }
        }
    }
}

fn toast_setting(platform: &Platform) -> Result<NotificationSetting, PlatformError> {
    notifier(platform)?.Setting().map_err(win_err)
}

pub(super) fn show(
    platform: &Platform,
    request: &LocalNotificationShow,
) -> Result<LocalNotificationStatus, PlatformError> {
    let now_ms = unix_now_ms();
    let scheduled = request.deliver_at_ms.filter(|at| *at > now_ms + 500);
    let id = request.id.clone();
    if scheduled.is_none() && process_is_frontmost() {
        // Still an upsert: whatever the id held must not outlive this call.
        cancel(platform, &id)?;
        return Ok(LocalNotificationStatus::Suppressed);
    }
    if permission(platform)? != "granted" {
        return Err(PlatformError::Platform(
            "notifications are disabled for this app in Windows Settings".into(),
        ));
    }
    // Before anything is posted: a deployment that cannot take the tap back
    // must fail here rather than show a toast whose tap loses its target.
    toast_activator::ensure_registered(&aumid_from_identity(&platform.autostart_value_name()))?;
    cancel(platform, &id)?;

    let Some(at_ms) = scheduled else {
        show_now(platform, request)?;
        return Ok(LocalNotificationStatus::Posted);
    };

    let toast = ScheduledToastNotification::CreateScheduledToastNotification(
        &toast_document(request)?,
        DateTime {
            UniversalTime: unix_ms_to_winrt(at_ms),
        },
    )
    .map_err(win_err)?;
    toast.SetTag(&HSTRING::from(&id)).map_err(win_err)?;
    toast
        .SetGroup(&HSTRING::from(TOAST_GROUP))
        .map_err(win_err)?;
    toast
        .SetId(&HSTRING::from(schedule_id()))
        .map_err(win_err)?;
    // The OS owns the schedule from here: nothing takes it back early, so an
    // exit before it is due no longer drops it.
    notifier(platform)?.AddToSchedule(&toast).map_err(win_err)?;
    Ok(LocalNotificationStatus::Scheduled)
}

fn toast_document(request: &LocalNotificationShow) -> Result<XmlDocument, PlatformError> {
    let xml = toast_xml(
        &request.title,
        &request.body,
        &toast_activator::launch_payload(&request.activation_token),
        request.silent,
    );
    let document = XmlDocument::new().map_err(win_err)?;
    document.LoadXml(&HSTRING::from(xml)).map_err(win_err)?;
    Ok(document)
}

fn show_now(platform: &Platform, request: &LocalNotificationShow) -> Result<(), PlatformError> {
    let toast =
        ToastNotification::CreateToastNotification(&toast_document(request)?).map_err(win_err)?;
    toast.SetTag(&HSTRING::from(&request.id)).map_err(win_err)?;
    toast
        .SetGroup(&HSTRING::from(TOAST_GROUP))
        .map_err(win_err)?;
    // No in-process Activated handler: this toast outlives the process in the
    // notification centre, and one activation path has to serve both.
    notifier(platform)?.Show(&toast).map_err(win_err)
}

/// `ScheduledToastNotification.Id` rejects 16 characters (0x803E0120); eight
/// hex digits of a per-process counter are unique for as long as we run. It
/// only has to be unique among pending schedules — the tag is the replace key
/// and the `launch` token is what a tap resolves.
fn schedule_id() -> String {
    static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
    let sequence = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
    format!(
        "{:08x}",
        (unix_now_ms() as u32).wrapping_add(sequence.wrapping_mul(0x9E37_79B9))
    )
}

pub(super) fn cancel(platform: &Platform, id: &str) -> Result<(), PlatformError> {
    let aumid = aumid_from_identity(&platform.autostart_value_name());
    if let Ok(history) = ToastNotificationManager::History() {
        let _ = history.RemoveGroupedTagWithId(
            &HSTRING::from(id),
            &HSTRING::from(TOAST_GROUP),
            &HSTRING::from(&aumid),
        );
    }
    if let Ok(notifier) = notifier(platform)
        && let Ok(scheduled) = notifier.GetScheduledToastNotifications()
    {
        let count = scheduled.Size().unwrap_or(0);
        for index in 0..count {
            if let Ok(toast) = scheduled.GetAt(index)
                && toast.Tag().ok().map(|s| s.to_string()).as_deref() == Some(id)
            {
                let _ = notifier.RemoveFromSchedule(&toast);
            }
        }
    }
    Ok(())
}

pub(super) fn cancel_all(platform: &Platform) -> Result<(), PlatformError> {
    let aumid = aumid_from_identity(&platform.autostart_value_name());
    if let Ok(history) = ToastNotificationManager::History() {
        let _ = history.RemoveGroupWithId(&HSTRING::from(TOAST_GROUP), &HSTRING::from(&aumid));
    }
    if let Ok(notifier) = notifier(platform)
        && let Ok(scheduled) = notifier.GetScheduledToastNotifications()
    {
        let count = scheduled.Size().unwrap_or(0);
        for index in 0..count {
            if let Ok(toast) = scheduled.GetAt(index)
                && toast.Group().ok().map(|s| s.to_string()).as_deref() == Some(TOAST_GROUP)
            {
                let _ = notifier.RemoveFromSchedule(&toast);
            }
        }
    }
    Ok(())
}

fn notifier(
    platform: &Platform,
) -> Result<windows::UI::Notifications::ToastNotifier, PlatformError> {
    if let Ok(notifier) = ToastNotificationManager::CreateToastNotifier() {
        return Ok(notifier);
    }
    let aumid = aumid_from_identity(&platform.autostart_value_name());
    ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(aumid)).map_err(win_err)
}

fn ensure_start_menu_shortcut(platform: &Platform) {
    static DONE: std::sync::Once = std::sync::Once::new();
    DONE.call_once(|| write_start_menu_shortcut(platform));
}

/// Register the COM activator so a cold tap can reach this product.
///
/// Called from the host bootstrap when the product declared notifications:
/// COM starts the exe for a tap and then waits for the class object, which a
/// registration deferred to the first `show` would never reach.
pub fn ensure_toast_activator(platform: &Platform) -> Result<(), PlatformError> {
    ensure_start_menu_shortcut(platform);
    toast_activator::ensure_registered(&aumid_from_identity(&platform.autostart_value_name()))
}

/// Remove this product's toast registration. For an uninstaller.
pub fn remove_toast_registration(platform: &Platform) {
    let aumid = aumid_from_identity(&platform.autostart_value_name());
    toast_activator::unregister_toast_activator(&aumid);
    if let Some(link) = start_menu_link(platform) {
        let _ = std::fs::remove_file(link);
    }
}

fn start_menu_link(platform: &Platform) -> Option<std::path::PathBuf> {
    let stem: String = platform
        .product_name()
        .chars()
        .map(|c| {
            if matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*') {
                '-'
            } else {
                c
            }
        })
        .collect();
    Some(programs_folder()?.join(format!("{stem}.lnk")))
}

fn write_start_menu_shortcut(platform: &Platform) {
    let aumid = aumid_from_identity(&platform.autostart_value_name());
    let Ok(exe) = std::env::current_exe() else {
        return;
    };
    let Some(link) = start_menu_link(platform) else {
        return;
    };
    // Rewritten every run, including over an installer's shortcut: the whole
    // file is derived from the exe path, the AUMID and the activator CLSID, so
    // this is how a shortcut written before the activator existed — or one
    // left behind by a moved exe — gets fixed.
    if let Err(error) = write_aumid_shortcut(&link, &exe, &aumid) {
        log::warn!("failed to register toast Start Menu shortcut: {error}");
    }
}

fn programs_folder() -> Option<std::path::PathBuf> {
    use windows::Win32::System::Com::CoTaskMemFree;
    use windows::Win32::UI::Shell::{FOLDERID_Programs, KF_FLAG_DEFAULT, SHGetKnownFolderPath};
    use windows::core::PWSTR;

    unsafe {
        let pwstr: PWSTR = SHGetKnownFolderPath(&FOLDERID_Programs, KF_FLAG_DEFAULT, None).ok()?;
        let path = pwstr.to_string().ok().map(std::path::PathBuf::from);
        CoTaskMemFree(Some(pwstr.0.cast()));
        path
    }
}

fn write_aumid_shortcut(
    link: &std::path::Path,
    exe: &std::path::Path,
    aumid: &str,
) -> windows::core::Result<()> {
    use std::os::windows::ffi::OsStrExt;
    use windows::Win32::Foundation::PROPERTYKEY;
    use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
    use windows::Win32::System::Com::{
        CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx,
        IPersistFile,
    };
    use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
    use windows::Win32::UI::Shell::{IShellLinkW, ShellLink};
    use windows::core::{Interface, PCWSTR};

    // IShellLink is STA. Bootstrap has not entered an apartment yet; without
    // this the Start Menu link is never written and a cold toast tap has
    // nowhere to go.
    let _ = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };

    // {9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}, 5
    const PKEY_APP_USER_MODEL_ID: PROPERTYKEY = PROPERTYKEY {
        fmtid: windows::core::GUID::from_u128(0x9F4C2855_9F79_4B39_A8D0_E1D42DE1D5F3),
        pid: 5,
    };
    // Same fmtid, pid 26: System.AppUserModel.ToastActivatorCLSID. Without it
    // the shell has no way to start us for a tap, and the toast is inert.
    const PKEY_TOAST_ACTIVATOR_CLSID: PROPERTYKEY = PROPERTYKEY {
        fmtid: windows::core::GUID::from_u128(0x9F4C2855_9F79_4B39_A8D0_E1D42DE1D5F3),
        pid: 26,
    };

    let exe_wide: Vec<u16> = OsStrExt::encode_wide(exe.as_os_str())
        .chain(std::iter::once(0))
        .collect();
    let dir_wide: Option<Vec<u16>> = exe.parent().map(|dir| {
        OsStrExt::encode_wide(dir.as_os_str())
            .chain(std::iter::once(0))
            .collect()
    });
    let link_wide: Vec<u16> = OsStrExt::encode_wide(link.as_os_str())
        .chain(std::iter::once(0))
        .collect();

    unsafe {
        let shell: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
        shell.SetPath(PCWSTR(exe_wide.as_ptr()))?;
        if let Some(dir) = dir_wide.as_ref() {
            shell.SetWorkingDirectory(PCWSTR(dir.as_ptr()))?;
        }
        // Unpackaged toasts resolve through this shortcut's AppUserModel.ID.
        // Saving without it leaves CreateToastNotifierWithId orphaned.
        let store: IPropertyStore = shell.cast()?;
        let prop = PROPVARIANT::from(aumid);
        store.SetValue(&PKEY_APP_USER_MODEL_ID, &prop)?;
        let activator = clsid_propvariant(toast_activator::clsid_for(aumid))?;
        store.SetValue(&PKEY_TOAST_ACTIVATOR_CLSID, &activator)?;
        store.Commit()?;
        let persist: IPersistFile = shell.cast()?;
        persist.Save(PCWSTR(link_wide.as_ptr()), true)?;
    }
    Ok(())
}

/// A `VT_CLSID` value. `propvarutil.h` spells this inline, so there is no
/// exported helper to call: the GUID has to be task-allocated, because
/// `PropVariantClear` frees it when the value drops.
fn clsid_propvariant(
    clsid: windows::core::GUID,
) -> windows::core::Result<windows::Win32::System::Com::StructuredStorage::PROPVARIANT> {
    use windows::Win32::Foundation::E_OUTOFMEMORY;
    use windows::Win32::System::Com::CoTaskMemAlloc;
    use windows::Win32::System::Com::StructuredStorage::PROPVARIANT;
    use windows::Win32::System::Variant::VT_CLSID;

    unsafe {
        let slot = CoTaskMemAlloc(std::mem::size_of::<windows::core::GUID>())
            .cast::<windows::core::GUID>();
        if slot.is_null() {
            return Err(E_OUTOFMEMORY.into());
        }
        slot.write(clsid);
        let mut value = PROPVARIANT::default();
        let inner = &mut *value.Anonymous.Anonymous;
        inner.vt = VT_CLSID;
        inner.Anonymous.puuid = slot;
        Ok(value)
    }
}

fn toast_xml(title: &str, body: &str, launch: &str, silent: bool) -> String {
    let audio = if silent {
        r#"<audio silent="true"/>"#
    } else {
        ""
    };
    format!(
        r#"<toast launch="{}"><visual><binding template="ToastGeneric"><text>{}</text><text>{}</text></binding></visual>{}</toast>"#,
        xml_escape(launch),
        xml_escape(title),
        xml_escape(body),
        audio
    )
}

fn xml_escape(value: &str) -> String {
    value
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
}

fn unix_now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

fn unix_ms_to_winrt(ms: u64) -> i64 {
    ((ms.saturating_add(UNIX_MS_TO_WINRT)) * 10_000) as i64
}

fn win_err(error: windows::core::Error) -> PlatformError {
    PlatformError::Platform(error.to_string())
}

fn process_is_frontmost() -> bool {
    unsafe {
        let hwnd = GetForegroundWindow();
        if hwnd.0.is_null() {
            return false;
        }
        let mut pid = 0u32;
        GetWindowThreadProcessId(hwnd, Some(&mut pid));
        pid == GetCurrentProcessId()
    }
}

fn activate_host_windows() {
    unsafe {
        let _ = EnumWindows(Some(activate_enum), LPARAM(0));
    }
}

unsafe extern "system" fn activate_enum(hwnd: HWND, _: LPARAM) -> BOOL {
    unsafe {
        let mut pid = 0u32;
        GetWindowThreadProcessId(hwnd, Some(&mut pid));
        if pid == GetCurrentProcessId() && IsWindowVisible(hwnd).as_bool() {
            let _ = SetForegroundWindow(hwnd);
        }
    }
    BOOL(1)
}