blob: 013bdb4d8193e1e181ea2876609c38ae248be0b4 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use super::{MAX_MESSAGE, RenderState, RenderStatus, Request, Spec};
use cursor_overlay::{CursorConfig, OverlayCommand, ReducedMotion};
use std::{
collections::HashMap,
io::{BufRead, Read, Write},
time::Duration,
};
#[cfg(target_os = "linux")]
use platform_linux::overlay as platform;
#[cfg(target_os = "macos")]
use platform_macos::cursor::overlay as platform;
#[cfg(target_os = "windows")]
use platform_windows::overlay as platform;
fn send(id: &str, command: OverlayCommand) {
#[cfg(target_os = "linux")]
platform::send_command_for(id.into(), command);
#[cfg(not(target_os = "linux"))]
platform::send_command(id.into(), command);
}
fn validate(cursor: &Spec, point: Option<[f64; 2]>) -> Result<(), String> {
if uuid::Uuid::parse_str(&cursor.id).is_err()
|| cursor.label.chars().count() > 48
|| cursor.label.chars().any(char::is_control)
|| point.is_some_and(|p| p.iter().any(|v| !v.is_finite() || v.abs() > 1_000_000.0))
{
return Err("invalid cursor update".into());
}
Ok(())
}
struct Entry {
spec: Spec,
point: Option<[f64; 2]>,
}
fn active_theme(id: &str) -> Option<(String, Option<String>)> {
#[cfg(target_os = "linux")]
let state = platform::current_theme_state_for(id);
#[cfg(not(target_os = "linux"))]
let state = platform::current_theme_state(id);
state.map(|(id, _, _, fallback, _)| (id, fallback))
}
fn status(id: &str, entries: &HashMap<String, Entry>) -> RenderState {
let entry = entries.get(id);
let visible = platform::is_visible_for_session(id);
let theme = entry.and_then(|_| active_theme(id));
RenderState {
status: RenderStatus::Ready,
visible,
requested_position: entry.and_then(|e| e.point),
theme: theme.as_ref().map(|(id, _)| id.clone()),
renderer_pid: Some(std::process::id()),
error: theme.and_then(|(_, fallback)| fallback),
}
}
pub(super) fn run() -> Result<(), String> {
#[cfg(target_os = "macos")]
if !platform_macos::session::has_graphic_access() {
return Err("cursor_unavailable: no interactive graphic session".into());
}
#[cfg(target_os = "linux")]
if std::env::var_os("DISPLAY").is_none() || std::env::var_os("WAYLAND_DISPLAY").is_some() {
return Err(
"cursor_unavailable: native overlay requires X11; Wayland overlay is not enabled"
.into(),
);
}
platform::init(CursorConfig {
motion: cursor_overlay::MotionConfig {
// Visibility belongs to the Session, not to an individual action.
idle_hide_ms: 0.0,
glide_duration_ms: 250.0,
..Default::default()
},
..Default::default()
});
#[cfg(not(target_os = "macos"))]
platform::run_on_thread();
let reader = || {
let result = std::panic::catch_unwind(serve)
.unwrap_or_else(|_| Err("cursor control worker panicked".into()));
if let Err(error) = &result {
let _ = writeln!(
std::io::stdout(),
"{}",
serde_json::to_string(&RenderState::unavailable(error)).unwrap()
);
let _ = writeln!(std::io::stderr(), "Maka cursor: {error}");
}
// This process owns only display surfaces. EOF removes every surface
// even if its parent was killed before sending session retirement.
std::process::exit(if result.is_ok() { 0 } else { 1 });
};
#[cfg(target_os = "macos")]
{
std::thread::Builder::new()
.name("maka-cursor-control".into())
.spawn(reader)
.map_err(|e| e.to_string())?;
platform::run_on_main_thread();
Ok(())
}
#[cfg(not(target_os = "macos"))]
{
reader()
}
}
fn serve() -> Result<(), String> {
let stdin = std::io::stdin();
let mut reader = stdin.lock();
let stdout = std::io::stdout();
let mut writer = stdout.lock();
let mut entries: HashMap<String, Entry> = HashMap::new();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.map_err(|error| error.to_string())?;
loop {
let mut line = String::new();
let count = reader
.by_ref()
.take(MAX_MESSAGE + 1)
.read_line(&mut line)
.map_err(|e| e.to_string())?;
if count == 0 {
return Ok(());
}
if count as u64 > MAX_MESSAGE {
return Err("cursor request exceeds size limit".into());
}
let request: Request = serde_json::from_str(&line).map_err(|e| e.to_string())?;
let response = match request {
Request::State { id } => status(&id, &entries),
Request::Remove { id } => {
platform::remove_cursor(id.clone());
entries.remove(&id);
let end = std::time::Instant::now() + Duration::from_millis(300);
while platform::is_visible_for_session(&id) && std::time::Instant::now() < end {
std::thread::sleep(Duration::from_millis(8));
}
status(&id, &entries)
}
Request::Update {
cursor,
point,
action,
window,
} => {
validate(&cursor, point)?;
if entries.len() >= 64 && !entries.contains_key(&cursor.id) {
return Err("cursor capacity reached".into());
}
let id = &cursor.id;
let fresh = !entries.contains_key(id);
if fresh {
platform::revive_cursor(id.clone());
}
send(
id,
OverlayCommand::SetTheme {
theme_id: cursor.theme_id(),
reduced_motion: if cursor.reduced_motion {
ReducedMotion::On
} else {
ReducedMotion::Auto
},
},
);
send(id, OverlayCommand::SetSessionLabel(cursor.label.clone()));
if let Some(window) = window {
send(id, OverlayCommand::PinAbove(window));
}
let previous = entries.get(id).and_then(|e| e.point);
if let Some([x, y]) = point
&& (previous.is_none() || cursor.reduced_motion)
{
send(id, cursor_overlay::track_pointer_command(x, y));
}
send(id, OverlayCommand::SetEnabled(cursor.enabled));
let id = id.clone();
entries.insert(
id.clone(),
Entry {
spec: cursor,
point: point.or(previous),
},
);
let desired_theme = entries[&id].spec.theme_id();
let end = std::time::Instant::now() + Duration::from_millis(400);
while active_theme(&id).is_none_or(|(theme, _)| theme != desired_theme)
&& std::time::Instant::now() < end
{
std::thread::sleep(Duration::from_millis(8));
}
if active_theme(&id).is_none_or(|(theme, _)| theme != desired_theme) {
return Err("cursor renderer did not apply the requested theme".into());
}
// Configuration can create a cursor before it has a position.
// Its first position needs the same visibility acknowledgement
// as a newly created cursor.
if point.is_some() && entries[&id].spec.enabled {
let end = std::time::Instant::now() + Duration::from_millis(400);
while !platform::is_visible_for_session(&id) && std::time::Instant::now() < end
{
std::thread::sleep(Duration::from_millis(8));
}
}
if !entries[&id].spec.enabled {
let end = std::time::Instant::now() + Duration::from_millis(300);
while platform::is_visible_for_session(&id) && std::time::Instant::now() < end {
std::thread::sleep(Duration::from_millis(8));
}
}
if let Some([x, y]) = point
&& entries[&id].spec.enabled
{
// The renderer owns arrival. A queued MoveTo or a visible
// cursor does not acknowledge completion of its glide.
runtime.block_on(async {
#[cfg(target_os = "linux")]
let movement = platform::animate_cursor_to_for(id.clone(), x, y);
#[cfg(not(target_os = "linux"))]
let movement = platform::animate_cursor_to(id.clone(), x, y);
tokio::time::timeout(Duration::from_secs(2), movement)
.await
.map_err(|_| "cursor renderer did not acknowledge arrival".to_owned())
})?;
}
send(
&id,
OverlayCommand::BeginAction {
action,
delivery: None,
target: None,
},
);
status(&id, &entries)
}
};
serde_json::to_writer(&mut writer, &response).map_err(|e| e.to_string())?;
writer
.write_all(b"\n")
.and_then(|_| writer.flush())
.map_err(|e| e.to_string())?;
}
}