use crate::platform::PlatformError;
use crate::platform::event::Event;
use std::path::Path;
use std::sync::Mutex;
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
mod linux;
#[cfg(target_os = "linux")]
#[allow(unsafe_code)]
pub(crate) mod sni;
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos;
#[cfg(target_os = "ios")]
#[allow(unsafe_code)]
mod ios;
#[cfg(target_os = "windows")]
#[allow(unsafe_code)]
mod windows;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Notification {
pub title: String,
pub body: String,
}
impl Notification {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
body: String::new(),
}
}
pub fn body(mut self, body: impl Into<String>) -> Self {
self.body = body.into();
self
}
}
pub fn open_url(url: &str) -> Result<(), PlatformError> {
if !has_scheme(url) || url.chars().any(char::is_control) {
return Err(PlatformError::Os(format!("not a URL: {url:?}")));
}
os_open_url(url)
}
pub fn open_path(path: impl AsRef<Path>) -> Result<(), PlatformError> {
let path = path.as_ref();
let abs = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| PlatformError::Os(e.to_string()))?
.join(path)
};
open_url(&file_url(&abs))
}
pub fn notify(notification: &Notification) -> Result<(), PlatformError> {
os_notify(notification)
}
pub fn tray_shown() -> bool {
TRAY_SHOWN.load(std::sync::atomic::Ordering::Acquire)
}
static TRAY_SHOWN: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub(crate) fn set_tray_shown(shown: bool) {
TRAY_SHOWN.store(shown, std::sync::atomic::Ordering::Release);
}
static PENDING: Mutex<Vec<Event>> = Mutex::new(Vec::new());
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub(crate) fn post(event: Event) {
if let Ok(mut p) = PENDING.lock() {
p.push(event);
}
crate::platform::wake::wake();
}
pub(crate) fn take_posted() -> Vec<Event> {
PENDING
.lock()
.map(|mut p| std::mem::take(&mut *p))
.unwrap_or_default()
}
fn has_scheme(s: &str) -> bool {
let Some((scheme, _)) = s.split_once(':') else {
return false;
};
let mut chars = scheme.chars();
scheme.len() > 1
&& chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
pub(crate) fn file_url(path: &Path) -> String {
let s = path.to_string_lossy().replace('\\', "/");
let mut out = String::from("file://");
if !s.starts_with('/') {
out.push('/');
}
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
out.push(b as char)
}
b':' if cfg!(windows) => out.push(':'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
#[cfg(target_os = "linux")]
use linux::{notify as os_notify, open_url as os_open_url};
#[cfg(target_os = "macos")]
use macos::{notify as os_notify, open_url as os_open_url};
#[cfg(target_os = "windows")]
use windows::{notify as os_notify, open_url as os_open_url};
#[cfg(target_os = "ios")]
use ios::open_url as os_open_url;
#[cfg(target_os = "android")]
fn os_open_url(url: &str) -> Result<(), PlatformError> {
if crate::platform::backend::android::open_url(url) {
Ok(())
} else {
Err(PlatformError::Os("no activity could open the URL".into()))
}
}
#[cfg(target_arch = "wasm32")]
fn os_open_url(url: &str) -> Result<(), PlatformError> {
crate::platform::backend::web::open_url(url);
Ok(())
}
#[cfg(target_arch = "wasm32")]
fn os_notify(n: &Notification) -> Result<(), PlatformError> {
crate::platform::backend::web::notify(&n.title, &n.body);
Ok(())
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios",
target_os = "android",
target_arch = "wasm32"
)))]
fn os_open_url(_url: &str) -> Result<(), PlatformError> {
Err(PlatformError::NoBackend("opening URLs"))
}
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_arch = "wasm32"
)))]
fn os_notify(_n: &Notification) -> Result<(), PlatformError> {
Err(PlatformError::NoBackend("notifications"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn urls_need_a_scheme() {
assert!(has_scheme("https://example.com"));
assert!(has_scheme("mailto:a@b.c"));
assert!(has_scheme("x-stipple+test.1:go"));
assert!(!has_scheme("example.com"));
assert!(!has_scheme("-o/etc/passwd"));
assert!(!has_scheme("C:\\Users"));
assert!(!has_scheme("1http://x"));
assert!(!has_scheme(""));
assert!(open_url("--help").is_err());
assert!(open_url("https://a\n-b").is_err());
}
#[test]
fn file_urls_are_percent_encoded() {
assert_eq!(
file_url(Path::new("/tmp/a b/ü.txt")),
"file:///tmp/a%20b/%C3%BC.txt"
);
assert_eq!(file_url(Path::new("/x#y?z")), "file:///x%23y%3Fz");
}
#[test]
fn posted_events_are_taken_once() {
post(Event::TrayActivated);
let got = take_posted();
assert!(got.contains(&Event::TrayActivated));
assert!(!take_posted().contains(&Event::TrayActivated));
}
}