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