use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::{AppHandle, Manager, Runtime};
use crate::error::{Error, Result};
use crate::{CallAction, Callbacks, EngineState, NotificationClick, Shared};
pub mod commands;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "linux")]
use linux::Backend;
#[cfg(target_os = "macos")]
use macos::Backend;
#[cfg(target_os = "windows")]
use self::windows::Backend;
const CLAIM_WAIT: Duration = Duration::from_millis(300);
const LAUNCH_WAIT: Duration = Duration::from_secs(30);
pub const LAUNCH_ARG: &str = "--drim-notification=";
const MAX_DATA_BYTES: usize = 1024;
const KINDS: [&str; 5] = ["message", "notice", "call", "session", "custom"];
#[derive(Clone, Debug, Default)]
pub struct Request {
pub id: String,
pub title: String,
pub body: String,
pub kind: String,
pub conversation_id: Option<String>,
pub seq: Option<i64>,
pub data: Option<Map<String, Value>>,
pub sound: bool,
pub actions: Option<Vec<Action>>,
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub group: Option<String>,
}
#[derive(Clone, Debug, Deserialize, PartialEq)]
pub struct Action {
pub id: String,
pub title: String,
}
impl Request {
pub fn validate(&self) -> Result<()> {
if self.id.is_empty() || self.id.len() > 256 {
return Err(Error::validation("invalid_id", "通知的 id 不能为空,最长 256 个字符"));
}
if !KINDS.contains(&self.kind.as_str()) {
return Err(Error::validation("invalid_kind", format!("不认识的通知类型 {}", self.kind)));
}
if self.actions.as_ref().is_some_and(|a| a.len() > 2) {
return Err(Error::validation("too_many_actions", "通知上最多 2 个按钮"));
}
if let Some(data) = &self.data {
if serde_json::to_vec(data).map(|v| v.len()).unwrap_or(0) > MAX_DATA_BYTES {
return Err(Error::validation("data_too_large", "通知的 data 最多 1 KB"));
}
}
Ok(())
}
pub fn effective_actions(&self) -> Vec<Action> {
match &self.actions {
Some(actions) => actions.clone(),
None if self.kind == "call" => vec![
Action { id: "accept".into(), title: "接听".into() },
Action { id: "decline".into(), title: "拒绝".into() },
],
None => Vec::new(),
}
}
#[cfg_attr(target_os = "linux", allow(dead_code))]
pub fn group_key(&self) -> &str {
self.group.as_deref().or(self.conversation_id.as_deref()).unwrap_or(&self.kind)
}
pub fn payload(&self) -> Payload {
Payload {
id: self.id.clone(),
kind: self.kind.clone(),
conversation_id: self.conversation_id.clone(),
seq: self.seq,
data: self.data.clone(),
action: None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct Payload {
pub id: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<Map<String, Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
}
impl Payload {
pub fn with_action(&self, action: Option<String>) -> Payload {
Payload { action, ..self.clone() }
}
fn into_click(self, launched: bool) -> NotificationClick {
NotificationClick {
id: self.id,
kind: self.kind,
conversation_id: self.conversation_id,
seq: self.seq,
action: self.action,
data: self.data,
launched,
}
}
}
pub fn encode_arg(payload: &Payload) -> String {
let json = serde_json::to_vec(payload).unwrap_or_default();
format!("{LAUNCH_ARG}{}", base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))
}
pub fn decode_arg(arg: &str) -> Option<Payload> {
let start = arg.find("drim-notification=")? + "drim-notification=".len();
let rest = &arg[start..];
let end = rest.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=')).unwrap_or(rest.len());
let encoded = rest[..end].trim_end_matches('=');
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(encoded).ok()?;
serde_json::from_slice(&bytes).ok()
}
#[derive(Clone, Debug, PartialEq)]
pub enum Clear {
Id(String),
Conversation(String),
All,
}
pub(crate) type Sink = Arc<dyn Fn(Payload, bool) + Send + Sync>;
#[derive(Default)]
pub(crate) struct Clicks {
next: u64,
pending: HashMap<String, Pending>,
}
struct Pending {
click: NotificationClick,
claimed: bool,
fallback: bool,
created: Instant,
}
impl Clicks {
pub fn register(&mut self, click: NotificationClick, fallback: bool) -> String {
self.pending.retain(|_, p| p.created.elapsed() < Duration::from_secs(60));
self.next += 1;
let id = format!("c{}", self.next);
self.pending.insert(id.clone(), Pending { click, claimed: false, fallback, created: Instant::now() });
id
}
pub fn claim(&mut self, click_id: &str) -> bool {
match self.pending.get_mut(click_id) {
Some(p) if !p.claimed => {
p.claimed = true;
true
}
_ => false,
}
}
pub fn is_claimed(&self, click_id: &str) -> bool {
self.pending.get(click_id).is_some_and(|p| p.claimed)
}
pub fn expire(&mut self, click_id: &str) -> Option<NotificationClick> {
let p = self.pending.remove(click_id)?;
(!p.claimed && p.fallback).then_some(p.click)
}
}
pub(crate) struct Core {
clicks: Mutex<Clicks>,
launch: Mutex<Option<Value>>,
}
pub struct Notifier {
core: Arc<Core>,
backend: Backend,
}
impl Notifier {
pub fn new<R: Runtime>(app: &AppHandle<R>, identifier: &str) -> Self {
let core = Arc::new(Core { clicks: Mutex::new(Clicks::default()), launch: Mutex::new(None) });
let (handle, sink_core) = (app.clone(), core.clone());
let sink: Sink = Arc::new(move |payload, launched| activated(&handle, &sink_core, payload, launched));
let backend = Backend::new(app, identifier, sink.clone());
for arg in std::env::args().skip(1) {
if let Some(payload) = decode_arg(&arg) {
sink(payload, true);
}
}
Self { core, backend }
}
pub fn launch_click(&self) -> Option<Value> {
self.core.launch.lock().unwrap().take()
}
pub fn claim(&self, click_id: &str) -> bool {
self.core.clicks.lock().unwrap().claim(click_id)
}
pub async fn show(&self, req: &Request) -> Result<()> {
req.validate()?;
self.backend.show(req, &req.payload()).await
}
pub async fn clear(&self, target: Clear) -> Result<()> {
self.backend.clear(&target).await
}
pub async fn permission(&self) -> &'static str {
self.backend.permission().await
}
pub async fn request_permission(&self) -> &'static str {
self.backend.request_permission().await
}
}
pub(crate) fn handle_args<R: Runtime>(app: &AppHandle<R>, argv: &[String]) {
let Some(shared) = app.try_state::<Arc<Shared>>() else { return };
let shared = shared.inner().clone();
for arg in argv {
if let Some(payload) = decode_arg(arg) {
activated(app, &shared.notify.core, payload, false);
}
}
}
fn click_event(click_id: &str, click: &NotificationClick) -> Value {
json!({
"t": "notification_click",
"click_id": click_id,
"id": click.id,
"kind": click.kind,
"conversation_id": click.conversation_id,
"seq": click.seq,
"action": click.action,
"data": click.data,
"launched": click.launched,
})
}
fn activated<R: Runtime>(app: &AppHandle<R>, core: &Arc<Core>, payload: Payload, launched: bool) {
let shared = app.try_state::<Arc<Shared>>().map(|s| s.inner().clone());
match shared {
Some(shared) if !launched => dispatch(app, &shared, core, payload.into_click(false)),
_ => {
let click = payload.into_click(true);
let click_id = core.clicks.lock().unwrap().register(click.clone(), true);
*core.launch.lock().unwrap() = Some(click_event(&click_id, &click));
deliver_launch(app.clone(), core.clone(), click_id, click);
}
}
}
fn deliver_launch<R: Runtime>(app: AppHandle<R>, core: Arc<Core>, click_id: String, click: NotificationClick) {
tauri::async_runtime::spawn(async move {
let started = Instant::now();
let shared = loop {
if let Some(shared) = app.try_state::<Arc<Shared>>() {
let state = shared.engine.state();
if state == EngineState::Ready || state == EngineState::Failed || started.elapsed() >= LAUNCH_WAIT {
break shared.inner().clone();
}
} else if started.elapsed() >= LAUNCH_WAIT {
return;
}
tokio::time::sleep(Duration::from_millis(200)).await;
};
core.launch.lock().unwrap().take();
if core.clicks.lock().unwrap().is_claimed(&click_id) {
core.clicks.lock().unwrap().expire(&click_id);
return;
}
if decline_call(&app, &shared, &click) {
core.clicks.lock().unwrap().expire(&click_id);
return;
}
shared.hub.broadcast(click_event(&click_id, &click));
wait_claim(app, shared, core, click_id);
});
}
fn dispatch<R: Runtime>(app: &AppHandle<R>, shared: &Arc<Shared>, core: &Arc<Core>, click: NotificationClick) {
if decline_call(app, shared, &click) {
return;
}
let mut fallback = true;
if click.kind == "call" && click.action.as_deref() == Some("accept") {
if !call_action(app, "accept", &click) {
crate::engine::show_main_window(app, shared);
}
fallback = false;
}
let click_id = core.clicks.lock().unwrap().register(click.clone(), fallback);
shared.hub.broadcast(click_event(&click_id, &click));
wait_claim(app.clone(), shared.clone(), core.clone(), click_id);
}
fn decline_call<R: Runtime>(app: &AppHandle<R>, shared: &Shared, click: &NotificationClick) -> bool {
if click.kind != "call" || click.action.as_deref() != Some("decline") {
return false;
}
let Some(call_id) = call_id_of(click) else {
shared.logs.plugin("warn", "来电通知的“拒绝”没有 data.call_id,按普通点击处理");
return false;
};
shared.hub.send_engine(json!({ "t": "call_action", "action": "decline", "call_id": call_id }));
call_action(app, "decline", click);
true
}
fn call_id_of(click: &NotificationClick) -> Option<String> {
click.data.as_ref()?.get("call_id")?.as_str().map(str::to_string)
}
fn call_action<R: Runtime>(app: &AppHandle<R>, action: &str, click: &NotificationClick) -> bool {
let Some(callbacks) = app.try_state::<Callbacks<R>>() else { return false };
let Some(f) = &callbacks.on_call_action else { return false };
f(app, CallAction { action: action.to_string(), call_id: call_id_of(click).unwrap_or_default(), data: click.data.clone() });
true
}
fn wait_claim<R: Runtime>(app: AppHandle<R>, shared: Arc<Shared>, core: Arc<Core>, click_id: String) {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(CLAIM_WAIT).await;
let Some(click) = core.clicks.lock().unwrap().expire(&click_id) else { return };
if let Some(callbacks) = app.try_state::<Callbacks<R>>() {
if let Some(f) = &callbacks.on_notification_click {
f(&app, click);
return;
}
}
crate::engine::show_main_window(&app, &shared);
});
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) mod toast {
use sha2::{Digest, Sha256};
use super::{encode_arg, Payload, Request};
pub fn hash_key(prefix: &str, text: &str) -> String {
let digest = Sha256::digest(format!("{prefix}:{text}").as_bytes());
hex::encode(&digest[..16])
}
pub const POWERSHELL_AUMID: &str = "{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe";
pub fn is_dev_build(exe_dir: &str) -> bool {
let dir = exe_dir.trim_end_matches(['\\', '/']).to_ascii_lowercase().replace('/', "\\");
dir.contains("\\target\\") && (dir.ends_with("\\debug") || dir.ends_with("\\release"))
}
pub fn pick_aumid(identifier: &str, registered: bool, dev_build: bool) -> String {
if registered || !dev_build {
identifier.to_string()
} else {
POWERSHELL_AUMID.to_string()
}
}
pub fn tag(req_id: &str) -> String {
hash_key("id", req_id)
}
pub fn group(group_key: &str) -> String {
hash_key("group", group_key)
}
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
c if (c as u32) < 0x20 && c != '\n' && c != '\t' => {}
c => out.push(c),
}
}
out
}
pub fn xml(req: &Request, payload: &Payload) -> String {
let call = req.kind == "call";
let mut out = format!(
"<toast launch=\"{}\" activationType=\"foreground\"{}><visual><binding template=\"ToastGeneric\"><text hint-maxLines=\"1\">{}</text><text>{}</text></binding></visual>",
escape(&encode_arg(payload)),
if call { " scenario=\"incomingCall\"" } else { "" },
escape(&req.title),
escape(&req.body),
);
let actions = req.effective_actions();
if !actions.is_empty() {
out.push_str("<actions>");
for action in &actions {
let args = encode_arg(&payload.with_action(Some(action.id.clone())));
out.push_str(&format!("<action content=\"{}\" arguments=\"{}\" activationType=\"foreground\"/>", escape(&action.title), escape(&args)));
}
out.push_str("</actions>");
}
if !req.sound {
out.push_str("<audio silent=\"true\"/>");
} else if req.kind == "message" {
out.push_str("<audio src=\"ms-winsoundevent:Notification.IM\"/>");
} else {
out.push_str("<audio src=\"ms-winsoundevent:Notification.Default\"/>");
}
out.push_str("</toast>");
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn click(kind: &str) -> NotificationClick {
NotificationClick { id: "dr:c1".into(), kind: kind.into(), conversation_id: Some("c1".into()), seq: Some(5), action: None, data: None, launched: false }
}
fn request() -> Request {
Request { id: "dr:conv:c1".into(), title: "张三".into(), body: "你好 <b>&</b>".into(), kind: "message".into(), conversation_id: Some("c1".into()), seq: Some(42), sound: true, ..Default::default() }
}
#[test]
fn first_claimer_wins() {
let mut clicks = Clicks::default();
let id = clicks.register(click("message"), true);
assert!(clicks.claim(&id));
assert!(!clicks.claim(&id));
assert!(clicks.is_claimed(&id));
assert!(clicks.expire(&id).is_none());
assert!(!clicks.claim(&id));
}
#[test]
fn unclaimed_click_falls_back() {
let mut clicks = Clicks::default();
let a = clicks.register(click("message"), true);
let b = clicks.register(click("call"), false);
assert_ne!(a, b);
assert_eq!(clicks.expire(&a).map(|c| c.id), Some("dr:c1".to_string()));
assert!(clicks.expire(&a).is_none());
assert!(clicks.expire(&b).is_none());
assert!(!clicks.claim("c999"));
}
#[test]
fn launch_arg_roundtrip() {
let mut data = Map::new();
data.insert("call_id".into(), json!("call-1"));
let payload = Payload { id: "dr:call:1".into(), kind: "call".into(), conversation_id: None, seq: None, data: Some(data), action: Some("accept".into()) };
let arg = encode_arg(&payload);
assert!(arg.starts_with(LAUNCH_ARG));
assert!(arg[LAUNCH_ARG.len()..].chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
assert_eq!(decode_arg(&arg), Some(payload.clone()));
let uri = format!("myapp://open?drim-notification={}&x=1", &arg[LAUNCH_ARG.len()..]);
assert_eq!(decode_arg(&uri), Some(payload));
assert_eq!(decode_arg("--other"), None);
assert_eq!(decode_arg("--drim-notification=!!!"), None);
}
#[test]
fn request_validation() {
assert!(request().validate().is_ok());
let mut bad = request();
bad.kind = "other".into();
assert_eq!(bad.validate().unwrap_err().reason.as_deref(), Some("invalid_kind"));
let mut bad = request();
bad.actions = Some(vec![Action { id: "a".into(), title: "A".into() }; 3]);
assert_eq!(bad.validate().unwrap_err().reason.as_deref(), Some("too_many_actions"));
let mut bad = request();
let mut data = Map::new();
data.insert("x".into(), json!("y".repeat(1100)));
bad.data = Some(data);
assert_eq!(bad.validate().unwrap_err().reason.as_deref(), Some("data_too_large"));
let mut bad = request();
bad.id.clear();
assert!(bad.validate().is_err());
}
#[test]
fn call_gets_default_buttons() {
let mut req = request();
assert!(req.effective_actions().is_empty());
req.kind = "call".into();
let ids: Vec<String> = req.effective_actions().into_iter().map(|a| a.id).collect();
assert_eq!(ids, vec!["accept", "decline"]);
req.actions = Some(vec![]);
assert!(req.effective_actions().is_empty());
}
#[test]
fn toast_tag_is_short_and_stable() {
let long = "dr:conv:".to_string() + &"x".repeat(500);
let tag = toast::tag(&long);
assert_eq!(tag.len(), 32);
assert!(tag.chars().all(|c| c.is_ascii_hexdigit()));
assert_eq!(tag, toast::tag(&long));
assert_ne!(tag, toast::tag("dr:conv:y"));
assert_ne!(toast::tag("c1"), toast::group("c1"));
let mut req = request();
assert_eq!(req.group_key(), "c1");
req.group = Some("g".into());
assert_eq!(req.group_key(), "g");
req.group = None;
req.conversation_id = None;
assert_eq!(req.group_key(), "message");
}
#[test]
fn aumid_choice() {
assert!(toast::is_dev_build(r"C:\work\app\src-tauri\target\debug"));
assert!(toast::is_dev_build(r"C:\work\app\target\x86_64-pc-windows-msvc\Release\"));
assert!(!toast::is_dev_build(r"C:\Program Files\Example IM"));
assert_eq!(toast::pick_aumid("com.example.im", false, false), "com.example.im");
assert_eq!(toast::pick_aumid("com.example.im", true, true), "com.example.im");
assert_eq!(toast::pick_aumid("com.example.im", false, true), toast::POWERSHELL_AUMID);
assert!(toast::POWERSHELL_AUMID.ends_with(r"\WindowsPowerShell\v1.0\powershell.exe"));
}
#[test]
fn toast_xml_escapes_and_encodes() {
let req = request();
let xml = toast::xml(&req, &req.payload());
assert!(xml.contains("你好 <b>&</b>"));
assert!(xml.contains("ms-winsoundevent:Notification.IM"));
assert!(!xml.contains("<actions>"));
let launch = xml.split("launch=\"").nth(1).unwrap().split('"').next().unwrap();
assert_eq!(decode_arg(launch), Some(req.payload()));
let mut call = request();
call.kind = "call".into();
call.sound = false;
let xml = toast::xml(&call, &call.payload());
assert!(xml.contains("scenario=\"incomingCall\""));
assert!(xml.contains("<audio silent=\"true\"/>"));
let decline = xml.split("content=\"拒绝\" arguments=\"").nth(1).unwrap().split('"').next().unwrap();
assert_eq!(decode_arg(decline).and_then(|p| p.action), Some("decline".to_string()));
}
}