use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use serde::{Deserialize, Serialize};
pub type CGSConnectionID = u32;
pub type CGSSpaceID = u64;
#[repr(u32)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum CGSSpaceType {
User = 0,
FullScreen = 1,
System = 2,
Unknown = 0xFFFF_FFFF,
}
impl CGSSpaceType {
fn from_raw(v: u32) -> Self {
match v {
0 => Self::User,
1 => Self::FullScreen,
2 => Self::System,
_ => Self::Unknown,
}
}
}
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGSMainConnectionID() -> CGSConnectionID;
fn CGSGetActiveSpace(cid: CGSConnectionID) -> CGSSpaceID;
fn CGSSpaceGetType(cid: CGSConnectionID, sid: CGSSpaceID) -> u32;
fn CGSSpaceCreate(cid: CGSConnectionID, options: *const std::ffi::c_void) -> CGSSpaceID;
fn CGSSpaceDestroy(cid: CGSConnectionID, sid: CGSSpaceID);
fn CGSShowSpaces(cid: CGSConnectionID, spaces: *const CGSSpaceID, count: i32);
fn CGSAddWindowsToSpaces(
cid: CGSConnectionID,
wids: *const u32,
wid_count: i32,
sids: *const CGSSpaceID,
sid_count: i32,
);
fn CGSRemoveWindowsFromSpaces(
cid: CGSConnectionID,
wids: *const u32,
wid_count: i32,
sids: *const CGSSpaceID,
sid_count: i32,
);
fn CGSCopySpaces(cid: CGSConnectionID, mask: u32) -> *const std::ffi::c_void;
fn CFArrayGetCount(arr: *const std::ffi::c_void) -> i64;
fn CFArrayGetValueAtIndex(arr: *const std::ffi::c_void, index: i64) -> *const std::ffi::c_void;
fn CFNumberGetValue(
number: *const std::ffi::c_void,
type_code: i32,
value_ptr: *mut std::ffi::c_void,
) -> bool;
fn CFRelease(obj: *const std::ffi::c_void);
}
const CF_NUMBER_SINT64_TYPE: i32 = 11;
const CGS_SPACE_ALL: u32 = 0x7;
#[derive(Debug, thiserror::Error)]
pub enum SpaceError {
#[error("CGSSpace API error: {0}")]
ApiError(String),
#[error("CGSSpace API unavailable: {0}")]
ApiUnavailable(String),
#[error("space {0} was not created by the agent — refusing to destroy")]
NotAgentSpace(CGSSpaceID),
#[error("space {0} not found")]
NotFound(CGSSpaceID),
#[error("no windows found for the operation")]
NoWindows,
}
pub type SpaceResult<T> = Result<T, SpaceError>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Space {
pub id: CGSSpaceID,
pub is_active: bool,
pub space_type: CGSSpaceType,
pub is_agent_created: bool,
}
#[derive(Clone, Debug)]
pub struct SpaceManager {
inner: Arc<Mutex<SpaceManagerInner>>,
}
#[derive(Debug)]
struct SpaceManagerInner {
cid: CGSConnectionID,
agent_spaces: HashSet<CGSSpaceID>,
}
impl SpaceManager {
#[must_use]
pub fn new() -> Self {
let cid = unsafe { CGSMainConnectionID() };
Self {
inner: Arc::new(Mutex::new(SpaceManagerInner {
cid,
agent_spaces: HashSet::new(),
})),
}
}
pub fn list_spaces(&self) -> SpaceResult<Vec<Space>> {
let inner = self.lock();
list_spaces_impl(inner.cid, &inner.agent_spaces)
}
pub fn create_space(&self) -> SpaceResult<Space> {
let mut inner = self.lock();
let sid = unsafe { CGSSpaceCreate(inner.cid, std::ptr::null()) };
if sid == 0 {
return Err(SpaceError::ApiError(
"CGSSpaceCreate returned 0 — API may be unavailable on this macOS version".into(),
));
}
inner.agent_spaces.insert(sid);
let cid = inner.cid;
let agent_spaces = &inner.agent_spaces;
let space_type = CGSSpaceType::from_raw(unsafe { CGSSpaceGetType(cid, sid) });
let is_active = unsafe { CGSGetActiveSpace(cid) } == sid;
Ok(Space {
id: sid,
is_active,
space_type,
is_agent_created: agent_spaces.contains(&sid),
})
}
pub fn destroy_space(&self, sid: CGSSpaceID) -> SpaceResult<()> {
let mut inner = self.lock();
if !inner.agent_spaces.contains(&sid) {
return Err(SpaceError::NotAgentSpace(sid));
}
unsafe { CGSSpaceDestroy(inner.cid, sid) };
inner.agent_spaces.remove(&sid);
Ok(())
}
pub fn switch_to_space(&self, sid: CGSSpaceID) -> SpaceResult<()> {
let inner = self.lock();
let spaces = list_spaces_impl(inner.cid, &inner.agent_spaces)?;
spaces
.iter()
.find(|s| s.id == sid)
.ok_or(SpaceError::NotFound(sid))?;
unsafe { CGSShowSpaces(inner.cid, &raw const sid, 1) };
Ok(())
}
pub fn move_windows_to_space(&self, window_ids: &[u32], sid: CGSSpaceID) -> SpaceResult<usize> {
if window_ids.is_empty() {
return Err(SpaceError::NoWindows);
}
let inner = self.lock();
let spaces = list_spaces_impl(inner.cid, &inner.agent_spaces)?;
if !spaces.iter().any(|s| s.id == sid) {
return Err(SpaceError::NotFound(sid));
}
let other_space_ids: Vec<CGSSpaceID> = spaces
.iter()
.filter(|s| s.id != sid)
.map(|s| s.id)
.collect();
if !other_space_ids.is_empty() {
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let wid_count = window_ids.len() as i32;
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let sid_count = other_space_ids.len() as i32;
unsafe {
CGSRemoveWindowsFromSpaces(
inner.cid,
window_ids.as_ptr(),
wid_count,
other_space_ids.as_ptr(),
sid_count,
);
}
}
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
let wid_count = window_ids.len() as i32;
unsafe {
CGSAddWindowsToSpaces(inner.cid, window_ids.as_ptr(), wid_count, &raw const sid, 1);
}
Ok(window_ids.len())
}
pub fn destroy_all_agent_spaces(&self) {
let mut inner = self.lock();
let to_destroy: Vec<CGSSpaceID> = inner.agent_spaces.iter().copied().collect();
for sid in to_destroy {
unsafe { CGSSpaceDestroy(inner.cid, sid) };
}
inner.agent_spaces.clear();
}
#[must_use]
pub fn agent_space_ids(&self) -> HashSet<CGSSpaceID> {
self.lock().agent_spaces.clone()
}
fn lock(&self) -> std::sync::MutexGuard<'_, SpaceManagerInner> {
self.inner.lock().expect("SpaceManager mutex poisoned")
}
}
impl Default for SpaceManager {
fn default() -> Self {
Self::new()
}
}
impl Drop for SpaceManager {
fn drop(&mut self) {
if Arc::strong_count(&self.inner) == 1 {
self.destroy_all_agent_spaces();
}
}
}
fn list_spaces_impl(
cid: CGSConnectionID,
agent_spaces: &HashSet<CGSSpaceID>,
) -> SpaceResult<Vec<Space>> {
let arr = unsafe { CGSCopySpaces(cid, CGS_SPACE_ALL) };
if arr.is_null() {
return Err(SpaceError::ApiError(
"CGSCopySpaces returned null — verify Accessibility permission is granted".into(),
));
}
let count = unsafe { CFArrayGetCount(arr) };
let active_sid = unsafe { CGSGetActiveSpace(cid) };
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let mut spaces = Vec::with_capacity(count as usize);
for i in 0..count {
let num_ptr = unsafe { CFArrayGetValueAtIndex(arr, i) };
if num_ptr.is_null() {
continue;
}
let mut sid_raw: i64 = 0;
let ok = unsafe {
CFNumberGetValue(
num_ptr,
CF_NUMBER_SINT64_TYPE,
std::ptr::addr_of_mut!(sid_raw).cast(),
)
};
if !ok || sid_raw <= 0 {
continue;
}
#[allow(clippy::cast_sign_loss)]
let sid = sid_raw as CGSSpaceID;
let space_type = CGSSpaceType::from_raw(unsafe { CGSSpaceGetType(cid, sid) });
spaces.push(Space {
id: sid,
is_active: sid == active_sid,
space_type,
is_agent_created: agent_spaces.contains(&sid),
});
}
unsafe { CFRelease(arr) };
Ok(spaces)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn space_type_from_raw_user_is_zero() {
assert_eq!(CGSSpaceType::from_raw(0), CGSSpaceType::User);
}
#[test]
fn space_type_from_raw_fullscreen_is_one() {
assert_eq!(CGSSpaceType::from_raw(1), CGSSpaceType::FullScreen);
}
#[test]
fn space_type_from_raw_system_is_two() {
assert_eq!(CGSSpaceType::from_raw(2), CGSSpaceType::System);
}
#[test]
fn space_type_from_raw_unknown_for_high_value() {
assert_eq!(CGSSpaceType::from_raw(99), CGSSpaceType::Unknown);
}
#[test]
fn destroy_space_rejects_non_agent_space() {
let mgr = SpaceManager::new();
let result = mgr.destroy_space(12345);
assert!(matches!(result, Err(SpaceError::NotAgentSpace(12345))));
}
#[test]
fn agent_space_ids_empty_on_new_manager() {
let mgr = SpaceManager::new();
assert!(mgr.agent_space_ids().is_empty());
}
#[test]
fn move_windows_to_space_rejects_empty_window_list() {
let mgr = SpaceManager::new();
let result = mgr.move_windows_to_space(&[], 42);
assert!(matches!(result, Err(SpaceError::NoWindows)));
}
#[test]
fn space_error_not_agent_space_display_contains_id() {
let err = SpaceError::NotAgentSpace(9876);
let s = err.to_string();
assert!(s.contains("9876"));
}
#[test]
fn space_error_not_found_display_contains_id() {
let err = SpaceError::NotFound(42);
assert!(err.to_string().contains("42"));
}
#[test]
fn list_spaces_returns_at_least_one_space() {
let mgr = SpaceManager::new();
let spaces = mgr.list_spaces().expect("must list spaces");
assert!(!spaces.is_empty(), "at least one Space must exist");
}
#[test]
fn list_spaces_has_exactly_one_active() {
let mgr = SpaceManager::new();
let spaces = mgr.list_spaces().expect("must list spaces");
let active_count = spaces.iter().filter(|s| s.is_active).count();
assert_eq!(active_count, 1, "exactly one Space is active at a time");
}
#[test]
fn list_spaces_active_space_type_is_user_or_fullscreen() {
let mgr = SpaceManager::new();
let spaces = mgr.list_spaces().expect("must list spaces");
let active = spaces.iter().find(|s| s.is_active).unwrap();
assert!(
matches!(
active.space_type,
CGSSpaceType::User | CGSSpaceType::FullScreen
),
"active space must be user or full-screen, got {:?}",
active.space_type
);
}
#[test]
fn list_spaces_none_are_agent_created_on_fresh_manager() {
let mgr = SpaceManager::new();
let spaces = mgr.list_spaces().expect("must list spaces");
assert!(
spaces.iter().all(|s| !s.is_agent_created),
"no spaces should be agent-created on a fresh manager"
);
}
#[test]
#[ignore = "requires WindowServer session (interactive macOS only)"]
fn create_and_destroy_space_round_trip() {
let mgr = SpaceManager::new();
let space = mgr.create_space().expect("must create space");
assert_ne!(space.id, 0);
assert!(mgr.agent_space_ids().contains(&space.id));
let spaces = mgr.list_spaces().expect("must list spaces after create");
let found = spaces.iter().find(|s| s.id == space.id);
assert!(found.is_some(), "created space must appear in list");
assert!(found.unwrap().is_agent_created);
mgr.destroy_space(space.id).expect("must destroy own space");
assert!(!mgr.agent_space_ids().contains(&space.id));
let spaces_after = mgr.list_spaces().expect("must list spaces after destroy");
assert!(
!spaces_after.iter().any(|s| s.id == space.id),
"destroyed space must not appear in list"
);
}
#[test]
#[ignore = "requires WindowServer session (interactive macOS only)"]
fn destroy_all_agent_spaces_removes_all() {
let mgr = SpaceManager::new();
let s1 = mgr.create_space().expect("create space 1");
let s2 = mgr.create_space().expect("create space 2");
assert_eq!(mgr.agent_space_ids().len(), 2);
mgr.destroy_all_agent_spaces();
assert!(mgr.agent_space_ids().is_empty());
let spaces = mgr.list_spaces().expect("list after destroy_all");
assert!(!spaces.iter().any(|s| s.id == s1.id || s.id == s2.id));
}
#[test]
#[ignore = "requires WindowServer session (interactive macOS only)"]
fn drop_destroys_agent_spaces() {
let sid;
{
let mgr = SpaceManager::new();
let space = mgr.create_space().expect("create space");
sid = space.id;
}
let checker = SpaceManager::new();
let spaces = checker.list_spaces().expect("list spaces after drop");
assert!(
!spaces.iter().any(|s| s.id == sid),
"dropped manager should have destroyed its spaces"
);
}
#[test]
fn destroy_space_with_plausible_id_rejects_if_not_agent_space() {
let mgr = SpaceManager::new();
let spaces = mgr.list_spaces().unwrap_or_default();
if let Some(s) = spaces.first() {
let result = mgr.destroy_space(s.id);
assert!(
matches!(result, Err(SpaceError::NotAgentSpace(_))),
"must refuse to destroy a user-created space"
);
}
}
}