Skip to main content

warp_runtime/
system_values.rs

1//! System values (notes/system_signals.md, P136): `battery` (percent), `charging`, `online` and `dark mode` read the
2//! machine's state through the host word `system_value(name)`, an i64 (booleans 0 or 1). Listeners on them
3//! (`whenever battery < 20% {…}`) compare readings at the program's check points, so a reading is kept for
4//! READING_LIFETIME: a sleeping program polls often, the platform is asked at most once a second per value.
5//! macOS asks `pmset` and `defaults`, Linux /sys and `gsettings`; `online` is whether a route to the internet exists
6//! (a UDP connect sends nothing). A value the platform cannot give is a loud error, never a made-up reading.
7//! `mouse_x`, `mouse_y`, `mouse_down`: natively the pointer over the paint window (window_input), read each time;
8//! `key_down` whether a key is held over it, `key code held` which (lowering/signals/system_values.rs names it `held_key`);
9//! `window_open` whether that window shows; read before any paint, it opens the window (blank) where one can show.
10//! `window_width`, `window_height` (window.width, card make-width): the paint window's inner size in points as it reports
11//! it, else as last written, else the view's; writing one resizes the window through the warp binary's resizer.
12//! `display_width`, `display_height`: the main screen's size in points, read only; `display_dpi` its device pixels
13//! per inch (CoreGraphics' physical size, xrandr's millimetres), read only.
14//! The clipboard is watched by its change count, which reads no content: macOS asks the user before a program reads
15//! what another one copied, so the text is read only when the program asks for it (`clipboard`, clipboard_text).
16use crate::host_words::{
17	BATTERY, CHARGING, CLICK_PRESSED, CLICK_X, CLICK_Y, CLIPBOARD_COUNT, DARK_MODE, DISPLAY_DPI, DISPLAY_HEIGHT,
18	DISPLAY_WIDTH, ESCAPE_KEPT, GPU_DRAWING, GPU_PAINTS, KEY_CODE_HELD, KEY_DOWN, KEY_PRESSED, MOUSE_DOWN, MOUSE_X,
19	MOUSE_Y, ONLINE, SYSTEM_VALUES, TIME_OF_DAY, VIEW_HEIGHT, VIEW_WIDTH, WINDOW_HEIGHT, WINDOW_OPEN, WINDOW_WIDTH,
20};
21use std::cell::RefCell;
22use std::collections::{HashMap, VecDeque};
23use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
24use std::sync::{Mutex, OnceLock};
25use std::time::{Duration, Instant};
26
27const NOTIFICATION_TITLE: &str = "warp";
28const READING_LIFETIME: Duration = Duration::from_secs(1);
29/// view_width × view_height natively: the size of a picture before any window shows it (host.js VIEW_DEFAULTS)
30const NATIVE_VIEW: (i64, i64) = (640, 480);
31/// Any public address: connecting a UDP socket only asks the routing table
32const INTERNET_ADDRESS: &str = "1.1.1.1:53";
33
34/// The last input over the paint window (src/graphics/paint_window.rs follow_input), each an f32's bits: pointer x, y in the
35/// painted frame's pixels, a button down (1), the key held; all 0 before a window
36static WINDOW_INPUT: [AtomicU32; 4] = [const { AtomicU32::new(0) }; 4];
37
38/// Whether the paint window shows: set as it gets its first frame, cleared when it closes (src/graphics/paint_window.rs)
39static WINDOW_SHOWN: AtomicBool = AtomicBool::new(false);
40
41pub fn tell_window_open(open: bool) {
42	WINDOW_SHOWN.store(open, Ordering::Relaxed);
43}
44
45pub fn window_shows() -> bool {
46	WINDOW_SHOWN.load(Ordering::Relaxed)
47}
48
49/// Opens the paint window before any frame: the warp binary's (src/graphics/paint.rs allow_windows), none headless
50static WINDOW_OPENER: OnceLock<fn()> = OnceLock::new();
51/// Whether window_open asked the opener: once a run, so a window the user closed stays closed (card window-loop)
52static WINDOW_ASKED: AtomicBool = AtomicBool::new(false);
53
54pub fn tell_window_opener(open: fn()) {
55	let _ = WINDOW_OPENER.set(open);
56}
57
58/// `while window_open { … }` before any paint: the window opens as the loop asks, else the loop would never run
59fn window_open() -> bool {
60	if let Some(open) = WINDOW_OPENER.get() {
61		if !window_shows() && !WINDOW_ASKED.swap(true, Ordering::Relaxed) {
62			open();
63		}
64	}
65	window_shows()
66}
67
68/// view_width × view_height before any window shows a picture
69pub fn native_view() -> (i64, i64) {
70	NATIVE_VIEW
71}
72
73/// The paint window's inner size in points: as it reported it, or as the program wrote it since; none before either
74static WINDOW_SIZE: Mutex<Option<(i64, i64)>> = Mutex::new(None);
75/// Resizes the paint window, opened or not yet: the warp binary's (src/graphics/paint.rs allow_windows), none headless
76static WINDOW_RESIZER: OnceLock<fn(i64, i64)> = OnceLock::new();
77const DISPLAY_READ_ONLY: &str = "a program cannot resize the display: set window.width or canvas.width instead";
78
79pub fn tell_window_resizer(resize: fn(i64, i64)) {
80	let _ = WINDOW_RESIZER.set(resize);
81}
82
83/// The window's size as it reports it (src/graphics/paint_window.rs follow_input)
84pub fn tell_window_size(width: i64, height: i64) {
85	*WINDOW_SIZE.lock().unwrap() = Some((width, height));
86}
87
88/// window.width, window.height: before any window, the size one opens at for the view
89fn window_size() -> (i64, i64) {
90	WINDOW_SIZE.lock().unwrap().unwrap_or(NATIVE_VIEW)
91}
92
93/// display.dpi: the dots per inch where the screen tells none (96, one CSS pixel per CSS inch)
94const DEFAULT_DPI: i64 = 96;
95/// `display.dpi = 300 dpi`: the dpi the program set, in place of the screen's
96static DPI_SET: Mutex<Option<i64>> = Mutex::new(None);
97
98fn set_display_dpi(dpi: i64) -> Result<(), String> {
99	if dpi <= 0 {
100		return Err(format!("display.dpi = {dpi} dpi: a display has a positive number of dots per inch"));
101	}
102	*DPI_SET.lock().unwrap() = Some(dpi);
103	READINGS.with(|readings| readings.borrow_mut().remove(DISPLAY_DPI));
104	Ok(())
105}
106
107/// `set_system_value(name, value)`: window.width = 800 resizes the window, display.dpi = 300 dpi sets the dots per inch
108/// px count in; the others are read only
109pub fn write(name: &str, value: i64) -> Result<(), String> {
110	let (width, height) = match name {
111		WINDOW_WIDTH => (value, window_size().1),
112		WINDOW_HEIGHT => (window_size().0, value),
113		DISPLAY_DPI => return set_display_dpi(value),
114		DISPLAY_WIDTH | DISPLAY_HEIGHT => return Err(DISPLAY_READ_ONLY.to_string()),
115		other => {
116			return Err(format!("{other} cannot be set; settable: {WINDOW_WIDTH}, {WINDOW_HEIGHT}, {DISPLAY_DPI}"))
117		}
118	};
119	if width <= 0 || height <= 0 {
120		return Err(format!("a window of {width}×{height} points cannot show: its sides must be positive"));
121	}
122	tell_window_size(width, height);
123	if let Some(resize) = WINDOW_RESIZER.get() {
124		resize(width, height);
125	}
126	Ok(())
127}
128
129/// The keys pressed over the paint window not read yet (KEY_PRESSED), oldest first; a program that never reads them
130/// keeps the last KEYS_KEPT
131static KEY_PRESSES: Mutex<VecDeque<i64>> = Mutex::new(VecDeque::new());
132/// The program names the Escape key (ESCAPE_KEPT): Escape over the paint window is its key, not the run's end
133static ESCAPE_KEPT_BY_PROGRAM: AtomicBool = AtomicBool::new(false);
134
135pub fn escape_kept() -> bool {
136	ESCAPE_KEPT_BY_PROGRAM.load(Ordering::Relaxed)
137}
138const KEYS_KEPT: usize = 64;
139const KEY_INPUT: usize = 3;
140const BUTTON_INPUT: usize = 2;
141/// The clicks over the paint window not read yet (CLICK_PRESSED), each where the button went down, oldest first, the
142/// last KEYS_KEPT; and the click read last, which CLICK_X and CLICK_Y tell
143static CLICKS: Mutex<VecDeque<(f32, f32)>> = Mutex::new(VecDeque::new());
144static CLICK_READ: Mutex<(f32, f32)> = Mutex::new((0.0, 0.0));
145
146/// Whether `use draw` draws on the GPU: the warp binary decides at its start (src/graphics/paint.rs allow_windows)
147static DRAWING_ON_GPU: AtomicBool = AtomicBool::new(false);
148/// Whether a shader can paint (GPU_PAINTS): the warp library's answer; none without it
149static GPU_PAINTER: OnceLock<fn() -> bool> = OnceLock::new();
150
151pub fn tell_gpu_painter(paints: fn() -> bool) {
152	let _ = GPU_PAINTER.set(paints);
153}
154
155pub fn tell_gpu_drawing(on_gpu: bool) {
156	DRAWING_ON_GPU.store(on_gpu, Ordering::Relaxed);
157}
158
159pub fn window_input() -> [f32; 4] {
160	WINDOW_INPUT.each_ref().map(|value| f32::from_bits(value.load(Ordering::Relaxed)))
161}
162
163pub fn tell_window_input(input: [f32; 4]) {
164	tell_window_key(input[KEY_INPUT]);
165	// a click: the button down that was up
166	if input[BUTTON_INPUT] != 0.0 && window_input()[BUTTON_INPUT] == 0.0 {
167		kept_push(&mut CLICKS.lock().unwrap(), (input[0], input[1]));
168	}
169	for (value, number) in WINDOW_INPUT.iter().zip(input) {
170		value.store(number.to_bits(), Ordering::Relaxed);
171	}
172}
173
174/// A press queued, the oldest dropped beyond KEYS_KEPT: a program that never reads them keeps the last ones
175fn kept_push<T>(presses: &mut VecDeque<T>, press: T) {
176	if presses.len() == KEYS_KEPT {
177		presses.pop_front();
178	}
179	presses.push_back(press);
180}
181
182/// Every key held over the paint window, in the order they went down (card allow-diagonals): the last is KEY_INPUT
183static KEYS_HELD: Mutex<Vec<i64>> = Mutex::new(Vec::new());
184
185pub fn tell_keys_held(codes: &[i64]) {
186	*KEYS_HELD.lock().unwrap() = codes.to_vec();
187}
188
189/// Whether the key of that code is held now, among others (KEY_HELD); code 0: whether none is
190pub fn key_held(code: i64) -> bool {
191	held_among(code, window_input()[KEY_INPUT] as i64, &KEYS_HELD.lock().unwrap())
192}
193
194/// Whether the key of that code is the key held or among the keys held; code 0: whether none is
195pub fn held_among(code: i64, key: i64, held: &[i64]) -> bool {
196	match code {
197		0 => key == 0 && held.is_empty(),
198		_ => key == code || held.contains(&code),
199	}
200}
201
202/// The key held over the paint window alone, the pointer left as it is
203pub fn tell_window_key(key: f32) {
204	// a press: a key held that was not held before (another key, or none)
205	if key != 0.0 && key != window_input()[KEY_INPUT] {
206		kept_push(&mut KEY_PRESSES.lock().unwrap(), key as i64);
207	}
208	WINDOW_INPUT[KEY_INPUT].store(key.to_bits(), Ordering::Relaxed);
209}
210
211thread_local! {
212	static READINGS: RefCell<HashMap<String, (Instant, i64)>> = RefCell::new(HashMap::new());
213}
214
215/// The current value of a system value, read anew at most once per READING_LIFETIME
216pub fn read(name: &str) -> Result<i64, String> {
217	// the clock and the pointer move on: never a kept reading
218	if name == TIME_OF_DAY {
219		return Ok(crate::system_signals::millisecond_of_day());
220	}
221	if name == WINDOW_OPEN {
222		return Ok(window_open() as i64);
223	}
224	if name == KEY_DOWN {
225		return Ok((window_input()[KEY_INPUT] != 0.0) as i64);
226	}
227	if name == KEY_CODE_HELD {
228		return Ok(window_input()[KEY_INPUT] as i64);
229	}
230	if name == CLICK_PRESSED {
231		let click = CLICKS.lock().unwrap().pop_front();
232		if let Some(click) = click {
233			*CLICK_READ.lock().unwrap() = click;
234		}
235		return Ok(click.is_some() as i64);
236	}
237	if name == CLICK_X || name == CLICK_Y {
238		let (x, y) = *CLICK_READ.lock().unwrap();
239		return Ok(if name == CLICK_X { x } else { y } as i64);
240	}
241	if name == ESCAPE_KEPT {
242		ESCAPE_KEPT_BY_PROGRAM.store(true, Ordering::Relaxed);
243		return Ok(1);
244	}
245	if name == GPU_PAINTS {
246		return Ok(GPU_PAINTER.get().is_some_and(|paints| paints()) as i64);
247	}
248	if name == KEY_PRESSED {
249		return Ok(KEY_PRESSES.lock().unwrap().pop_front().unwrap_or(0));
250	}
251	if name == WINDOW_WIDTH || name == WINDOW_HEIGHT {
252		let (width, height) = window_size();
253		return Ok(if name == WINDOW_WIDTH { width } else { height });
254	}
255	if name == GPU_DRAWING {
256		return Ok(DRAWING_ON_GPU.load(Ordering::Relaxed) as i64);
257	}
258	if let Some(at) = [MOUSE_X, MOUSE_Y, MOUSE_DOWN].iter().position(|pointer| *pointer == name) {
259		return Ok(window_input()[at] as i64);
260	}
261	let kept = READINGS.with(|readings| {
262		readings.borrow().get(name).filter(|(when, _)| when.elapsed() < READING_LIFETIME).map(|(_, value)| *value)
263	});
264	if let Some(value) = kept {
265		return Ok(value);
266	}
267	let value = read_now(name)?;
268	READINGS.with(|readings| readings.borrow_mut().insert(name.to_string(), (Instant::now(), value)));
269	Ok(value)
270}
271
272fn read_now(name: &str) -> Result<i64, String> {
273	match name {
274		BATTERY => battery().map(|(percent, _)| percent),
275		CHARGING => battery().map(|(_, charging)| charging as i64),
276		ONLINE => Ok(online() as i64),
277		DARK_MODE => dark_mode().map(|dark| dark as i64),
278		CLIPBOARD_COUNT => clipboard_count(),
279		VIEW_WIDTH => Ok(NATIVE_VIEW.0),
280		VIEW_HEIGHT => Ok(NATIVE_VIEW.1),
281		DISPLAY_WIDTH => display_size().map(|(width, _)| width),
282		DISPLAY_HEIGHT => display_size().map(|(_, height)| height),
283		DISPLAY_DPI => Ok(DPI_SET.lock().unwrap().unwrap_or_else(|| display_dpi().unwrap_or(DEFAULT_DPI))),
284		other => Err(format!("{other} is no system value; known: {}", SYSTEM_VALUES.map(|(name, _)| name).join(", "))),
285	}
286}
287
288/// The main screen's size in points (CoreGraphics: a Retina screen's scaled mode, as windows measure)
289#[cfg(target_os = "macos")]
290fn display_size() -> Result<(i64, i64), String> {
291	#[link(name = "CoreGraphics", kind = "framework")]
292	unsafe extern "C" {
293		fn CGMainDisplayID() -> u32;
294		fn CGDisplayPixelsWide(display: u32) -> usize;
295		fn CGDisplayPixelsHigh(display: u32) -> usize;
296	}
297	// SAFETY: plain queries of the main display, which always exists (a headless Mac reports 0)
298	let (width, height) = unsafe {
299		let display = CGMainDisplayID();
300		(CGDisplayPixelsWide(display) as i64, CGDisplayPixelsHigh(display) as i64)
301	};
302	match width {
303		0 => Err("display.width: this Mac has no display".to_string()),
304		_ => Ok((width, height)),
305	}
306}
307
308/// The screen's size as `xrandr` tells it: "Screen 0: minimum 8 x 8, current 1920 x 1080, maximum …"
309#[cfg(not(target_os = "macos"))]
310fn display_size() -> Result<(i64, i64), String> {
311	let report = output("xrandr", &["--current"])?;
312	let current = report.split("current ").nth(1).ok_or("display.width: no display (xrandr tells no screen)")?;
313	let mut sides = current.split(',').next().unwrap_or_default().split(" x ").map(|side| side.trim().parse::<i64>());
314	match (sides.next(), sides.next()) {
315		(Some(Ok(width)), Some(Ok(height))) => Ok((width, height)),
316		_ => Err(format!("display.width: xrandr's screen is unreadable: {current}")),
317	}
318}
319
320/// The main screen's device pixels per inch: its mode's pixel width over its physical width (CoreGraphics, in mm)
321#[cfg(target_os = "macos")]
322fn display_dpi() -> Result<i64, String> {
323	#[repr(C)]
324	struct Size {
325		width: f64,
326		height: f64,
327	}
328	#[link(name = "CoreGraphics", kind = "framework")]
329	unsafe extern "C" {
330		fn CGMainDisplayID() -> u32;
331		fn CGDisplayScreenSize(display: u32) -> Size;
332		fn CGDisplayCopyDisplayMode(display: u32) -> *mut std::ffi::c_void;
333		fn CGDisplayModeGetPixelWidth(mode: *mut std::ffi::c_void) -> usize;
334		fn CGDisplayModeRelease(mode: *mut std::ffi::c_void);
335	}
336	// SAFETY: plain queries of the main display; the mode copied is released once read
337	let (pixels, millimetres) = unsafe {
338		let display = CGMainDisplayID();
339		let mode = CGDisplayCopyDisplayMode(display);
340		let pixels = if mode.is_null() { 0 } else { CGDisplayModeGetPixelWidth(mode) };
341		if !mode.is_null() {
342			CGDisplayModeRelease(mode);
343		}
344		(pixels as f64, CGDisplayScreenSize(display).width)
345	};
346	dots_per_inch(pixels, millimetres)
347}
348
349/// The connected screen's device pixels per inch as `xrandr` tells it: "eDP-1 connected 1920x1080+0+0 … 344mm x 194mm"
350#[cfg(not(target_os = "macos"))]
351fn display_dpi() -> Result<i64, String> {
352	let report = output("xrandr", &["--current"])?;
353	let screen = report.lines().find(|line| line.contains(" connected") && line.contains("mm x ")).unwrap_or_default();
354	let words: Vec<&str> = screen.split_whitespace().collect();
355	let pixels =
356		words.iter().find_map(|word| word.split_once('x').filter(|(_, rest)| rest.contains('+'))?.0.parse().ok());
357	let millimetres = words.iter().find_map(|word| word.strip_suffix("mm")?.parse().ok());
358	dots_per_inch(pixels.unwrap_or(0.0), millimetres.unwrap_or(0.0))
359}
360
361const MILLIMETRES_PER_INCH: f64 = 25.4;
362
363fn dots_per_inch(pixels: f64, millimetres: f64) -> Result<i64, String> {
364	match pixels > 0.0 && millimetres > 0.0 {
365		true => Ok((pixels * MILLIMETRES_PER_INCH / millimetres).round() as i64),
366		false => Err("display.dpi: the screen tells no physical size".to_string()),
367	}
368}
369
370fn online() -> bool {
371	std::net::UdpSocket::bind("0.0.0.0:0").and_then(|socket| socket.connect(INTERNET_ADDRESS)).is_ok()
372}
373
374fn output(program: &str, arguments: &[&str]) -> Result<String, String> {
375	let output = std::process::Command::new(program)
376		.args(arguments)
377		.output()
378		.map_err(|problem| format!("{program}: {problem}"))?;
379	Ok(String::from_utf8_lossy(&output.stdout).into_owned())
380}
381
382/// The battery's percent and whether it charges
383#[cfg(target_os = "macos")]
384fn battery() -> Result<(i64, bool), String> {
385	// "-InternalBattery-0 (id=…)	87%; charging; 1:02 remaining present: true"
386	let report = output("pmset", &["-g", "batt"])?;
387	let line = report.lines().find(|line| line.contains('%')).ok_or("battery: this Mac has no battery")?;
388	let percent = line
389		.split('%')
390		.next()
391		.and_then(|before| before.rsplit(|letter: char| !letter.is_ascii_digit()).next())
392		.and_then(|digits| digits.parse().ok());
393	let charging = line.contains("; charging") || line.contains("; charged") || report.contains("'AC Power'");
394	Ok((percent.ok_or_else(|| format!("battery: cannot read {line:?}"))?, charging))
395}
396
397#[cfg(target_os = "linux")]
398fn battery() -> Result<(i64, bool), String> {
399	let supply = std::fs::read_dir("/sys/class/power_supply")
400		.map_err(|problem| format!("battery: {problem}"))?
401		.flatten()
402		.map(|entry| entry.path())
403		.find(|path| path.join("capacity").exists())
404		.ok_or("battery: this machine has no battery")?;
405	let read = |file: &str| {
406		std::fs::read_to_string(supply.join(file))
407			.map(|text| text.trim().to_string())
408			.map_err(|problem| format!("battery: {problem}"))
409	};
410	let percent = read("capacity")?.parse().map_err(|_| "battery: cannot read its capacity".to_string())?;
411	Ok((percent, matches!(read("status")?.as_str(), "Charging" | "Full")))
412}
413
414#[cfg(not(any(target_os = "macos", target_os = "linux")))]
415fn battery() -> Result<(i64, bool), String> {
416	Err("battery: not readable on this platform yet".to_string())
417}
418
419#[cfg(target_os = "macos")]
420fn dark_mode() -> Result<bool, String> {
421	// the key exists only in dark mode
422	Ok(output("defaults", &["read", "-g", "AppleInterfaceStyle"])?.trim() == "Dark")
423}
424
425#[cfg(target_os = "linux")]
426fn dark_mode() -> Result<bool, String> {
427	Ok(output("gsettings", &["get", "org.gnome.desktop.interface", "color-scheme"])?.contains("dark"))
428}
429
430#[cfg(not(any(target_os = "macos", target_os = "linux")))]
431fn dark_mode() -> Result<bool, String> {
432	Err("dark mode: not readable on this platform yet".to_string())
433}
434
435/// macOS: `[[NSPasteboard generalPasteboard] changeCount]`, AppKit loaded on first use (a plain run never loads it)
436#[cfg(target_os = "macos")]
437fn clipboard_count() -> Result<i64, String> {
438	use std::ffi::{c_char, c_void, CStr};
439	type Object = *mut c_void;
440	const APPKIT: &CStr = c"/System/Library/Frameworks/AppKit.framework/AppKit";
441	let symbol = |name: &CStr| -> Result<*mut c_void, String> {
442		let found = unsafe { libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()) };
443		if found.is_null() {
444			Err(format!("clipboard: no {name:?}"))
445		} else {
446			Ok(found)
447		}
448	};
449	if unsafe { libc::dlopen(APPKIT.as_ptr(), libc::RTLD_LAZY) }.is_null() {
450		return Err("clipboard: cannot load AppKit".to_string());
451	}
452	// objc_msgSend is called through the exact signature of each message
453	let class: extern "C" fn(*const c_char) -> Object = unsafe { std::mem::transmute(symbol(c"objc_getClass")?) };
454	let selector: extern "C" fn(*const c_char) -> Object = unsafe { std::mem::transmute(symbol(c"sel_registerName")?) };
455	let send = symbol(c"objc_msgSend")?;
456	let send_for_object: extern "C" fn(Object, Object) -> Object = unsafe { std::mem::transmute(send) };
457	let send_for_count: extern "C" fn(Object, Object) -> isize = unsafe { std::mem::transmute(send) };
458	let pasteboard = send_for_object(class(c"NSPasteboard".as_ptr()), selector(c"generalPasteboard".as_ptr()));
459	if pasteboard.is_null() {
460		return Err("clipboard: no pasteboard".to_string());
461	}
462	Ok(send_for_count(pasteboard, selector(c"changeCount".as_ptr())) as i64)
463}
464
465/// Elsewhere the content's hash stands for the count: no prompt guards it there
466#[cfg(not(target_os = "macos"))]
467fn clipboard_count() -> Result<i64, String> {
468	use std::hash::{Hash, Hasher};
469	let mut hasher = std::collections::hash_map::DefaultHasher::new();
470	clipboard_text()?.hash(&mut hasher);
471	Ok(hasher.finish() as i64)
472}
473
474/// `notify "text"`: a desktop notification, macOS through osascript, Linux notify-send; the text goes as an argument,
475/// so no quoting can break it
476pub fn notify(text: &str) -> Result<(), String> {
477	#[cfg(target_os = "macos")]
478	let shown = std::process::Command::new("osascript")
479		.args([
480			"-e",
481			"on run argv",
482			"-e",
483			&format!("display notification (item 1 of argv) with title \"{NOTIFICATION_TITLE}\""),
484			"-e",
485			"end run",
486			text,
487		])
488		.output();
489	#[cfg(not(target_os = "macos"))]
490	let shown = std::process::Command::new("notify-send").args([NOTIFICATION_TITLE, text]).output();
491	match shown {
492		Ok(output) if output.status.success() => Ok(()),
493		Ok(output) => Err(format!("notify: {}", String::from_utf8_lossy(&output.stderr).trim())),
494		Err(problem) => Err(format!("notify: no notifier on this machine ({problem})")),
495	}
496}
497
498/// Puts text on the clipboard: macOS `pbcopy`, Linux `wl-copy` or `xclip`
499pub fn write_clipboard(text: &str) -> Result<(), String> {
500	#[cfg(target_os = "macos")]
501	return input("pbcopy", &[], text);
502	#[cfg(not(target_os = "macos"))]
503	input("wl-copy", &[], text)
504		.or_else(|_| input("xclip", &["-selection", "clipboard", "-i"], text))
505		.map_err(|problem| format!("clipboard: needs wl-copy or xclip ({problem})"))
506}
507
508/// Runs a program with `text` as its input
509fn input(program: &str, arguments: &[&str], text: &str) -> Result<(), String> {
510	use std::io::Write;
511	let mut child = std::process::Command::new(program)
512		.args(arguments)
513		.stdin(std::process::Stdio::piped())
514		.spawn()
515		.map_err(|problem| format!("{program}: {problem}"))?;
516	child
517		.stdin
518		.take()
519		.expect("a piped input")
520		.write_all(text.as_bytes())
521		.map_err(|problem| format!("{program}: {problem}"))?;
522	let status = child.wait().map_err(|problem| format!("{program}: {problem}"))?;
523	status.success().then_some(()).ok_or_else(|| format!("{program} failed: {status}"))
524}
525
526/// The clipboard's text: macOS `pbpaste`, Linux `wl-paste` or `xclip`
527pub fn clipboard_text() -> Result<String, String> {
528	#[cfg(target_os = "macos")]
529	return output("pbpaste", &[]);
530	#[cfg(not(target_os = "macos"))]
531	output("wl-paste", &["--no-newline"])
532		.or_else(|_| output("xclip", &["-selection", "clipboard", "-o"]))
533		.map_err(|problem| format!("clipboard: needs wl-paste or xclip ({problem})"))
534}