Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ objc2-app-kit = { version = "0.2.2", default-features = false, features = [
"NSButton", "NSPopUpButton", "NSScrollView", "NSTextView", "NSFont", "NSBox", "NSColor",
"NSOpenPanel", "NSSavePanel", "NSPanel",
"NSPasteboard",
"NSBitmapImageRep", "NSImageRep",
"NSCursor",
"NSImage",
"NSEvent", "NSRunningApplication", "block2", "libc",
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ This keeps classic SSH and local-socket workflows available while making Apple C
| Runtimes | Apple Container, Docker-compatible engines, OrbStack, SSH, and local Waypipe sockets |
| Displays | Automatic assignment, named displays, and isolated workers for concurrent applications |
| Transport | Apple Container Transport V2 over `--publish-socket`, with a compatibility relay fallback |
| Integration | Bidirectional text clipboard, low-latency CoreAudio forwarding, keyboard, pointer, and gestures |
| Integration | Bidirectional text clipboard, macOS-to-Wayland PNG images, low-latency CoreAudio forwarding, keyboard, pointer, and gestures |
| Control plane | Native runtime panels, `cocoa-wayctl --json`, diagnostics, tasks, logs, and resource warnings |
| Automation | Optional read-only MCP server and onboarding skill; launch, stop, and deletion remain explicit user actions |

Expand Down
1 change: 1 addition & 0 deletions src/control_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,7 @@ fn feature_matrix_snapshot() -> Value {
"apple_container_socket_v2": { "status": "supported", "fallback": "stdio relay" },
"classic_waypipe": { "status": "supported", "targets": ["SSH", "Docker", "OrbStack"] },
"clipboard_text": { "status": "supported", "scope": "text MIME types" },
"clipboard_image": { "status": "supported", "scope": "macOS to Wayland as image/png" },
"audio": { "status": "supported_default_on", "format": "s16le/48000/2", "note": "Apple Container playback uses an independent published socket and macOS CoreAudio. Profiles can explicitly disable it; Metal rendering is unchanged." },
},
"runtime_control": {
Expand Down
104 changes: 79 additions & 25 deletions src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ pub struct AppState {
/// performance diagnostics in Container Mode.
pub commit_counter: u64,
host_clipboard_text: Option<String>,
host_clipboard_image_png: Option<Vec<u8>>,
pending_guest_clipboard_mime: Option<String>,
pasteboard_change_count: isize,
last_pasteboard_poll: std::time::Instant,
Expand Down Expand Up @@ -308,6 +309,7 @@ impl AppState {
rootless_dirty_surfaces: std::collections::HashSet::new(),
commit_counter: 0,
host_clipboard_text: None,
host_clipboard_image_png: None,
pending_guest_clipboard_mime: None,
pasteboard_change_count: -1,
last_pasteboard_poll: std::time::Instant::now() - std::time::Duration::from_millis(100),
Expand Down Expand Up @@ -600,29 +602,42 @@ impl AppState {
}
self.last_pasteboard_poll = now;

let (change_count, text) = pasteboard_snapshot();
let (change_count, text, image_png) = pasteboard_snapshot();
if change_count == self.pasteboard_change_count {
return;
}
self.pasteboard_change_count = change_count;
let Some(text) = text else {
self.host_clipboard_text = None;
crate::diagnostics::record_clipboard_host_change(0);
if self.host_clipboard_text == text && self.host_clipboard_image_png == image_png {
return;
};
if self.host_clipboard_text.as_deref() == Some(text.as_str()) {
}

let byte_count =
text.as_ref().map_or(0, String::len) + image_png.as_ref().map_or(0, Vec::len);
self.host_clipboard_text = text;
self.host_clipboard_image_png = image_png;
crate::diagnostics::record_clipboard_host_change(byte_count);

let mime_types = clipboard_mime_types(
self.host_clipboard_text.is_some(),
self.host_clipboard_image_png.is_some(),
);
if mime_types.is_empty() {
log::info!("Clipboard: macOS pasteboard has no supported contents");
smithay::wayland::selection::data_device::clear_data_device_selection::<Self>(
&self.display_handle,
&self.seat,
);
return;
}

self.host_clipboard_text = Some(text);
crate::diagnostics::record_clipboard_host_change(
self.host_clipboard_text.as_ref().map_or(0, String::len),
log::info!(
"Clipboard: publishing changed macOS contents to Wayland clients as {}",
mime_types.join(", ")
);
log::info!("Clipboard: publishing changed macOS text to Wayland clients");
smithay::wayland::selection::data_device::set_data_device_selection::<Self>(
&self.display_handle,
&self.seat,
clipboard_text_mime_types(),
mime_types,
(),
);
}
Expand All @@ -634,6 +649,7 @@ impl AppState {
self.pasteboard_change_count = write_to_pasteboard(&text);
crate::diagnostics::record_clipboard_guest_install(text.len());
self.host_clipboard_text = Some(text);
self.host_clipboard_image_png = None;
log::info!("Clipboard: installed Wayland text on the macOS pasteboard");
smithay::wayland::selection::data_device::set_data_device_selection::<Self>(
&self.display_handle,
Expand Down Expand Up @@ -1165,16 +1181,21 @@ impl SelectionHandler for AppState {
if ty != SelectionTarget::Clipboard {
return;
}
if !is_clipboard_text_mime(&mime_type) {
let contents = if mime_type.trim().eq_ignore_ascii_case("image/png") {
self.host_clipboard_image_png.clone()
} else if is_clipboard_text_mime(&mime_type) {
self.host_clipboard_text
.as_ref()
.map(|text| text.as_bytes().to_vec())
} else {
return;
}
log::info!("Clipboard: Wayland client requested macOS text as {mime_type}");
let text = self.host_clipboard_text.clone();
};
log::info!("Clipboard: Wayland client requested macOS contents as {mime_type}");
std::thread::spawn(move || {
use std::io::Write;
if let Some(text) = text {
if let Some(contents) = contents {
let mut f = std::fs::File::from(fd);
let _ = f.write_all(text.as_bytes());
let _ = f.write_all(&contents);
}
});
}
Expand Down Expand Up @@ -1283,6 +1304,17 @@ fn clipboard_text_mime_types() -> Vec<String> {
.collect()
}

fn clipboard_mime_types(has_text: bool, has_image_png: bool) -> Vec<String> {
let mut mime_types = Vec::new();
if has_image_png {
mime_types.push("image/png".to_owned());
}
if has_text {
mime_types.extend(clipboard_text_mime_types());
}
mime_types
}

fn is_clipboard_text_mime(mime: &str) -> bool {
let normalized = mime.trim().to_ascii_lowercase();
normalized == "utf8_string"
Expand Down Expand Up @@ -1397,7 +1429,14 @@ mod pointer_axis_tests {

#[cfg(test)]
mod clipboard_tests {
use super::{is_clipboard_text_mime, preferred_clipboard_text_mime};
use super::{clipboard_mime_types, is_clipboard_text_mime, preferred_clipboard_text_mime};

#[test]
fn advertises_png_before_text_when_both_are_available() {
let offered = clipboard_mime_types(true, true);
assert_eq!(offered.first().map(String::as_str), Some("image/png"));
assert!(offered.iter().any(|mime| mime == "text/plain"));
}

#[test]
fn chooses_the_exact_mime_offered_by_the_client() {
Expand Down Expand Up @@ -1440,14 +1479,29 @@ fn write_to_pasteboard(text: &str) -> isize {
}
}

fn pasteboard_snapshot() -> (isize, Option<String>) {
use objc2_app_kit::NSPasteboard;
fn pasteboard_snapshot() -> (isize, Option<String>, Option<Vec<u8>>) {
use objc2_app_kit::{
NSBitmapImageRep, NSPNGFileType, NSPasteboard, NSPasteboardTypePNG, NSPasteboardTypeTIFF,
};
use objc2_foundation::NSDictionary;
unsafe {
let pb = NSPasteboard::generalPasteboard();
let pb_type = objc2_app_kit::NSPasteboardTypeString;
(
pb.changeCount(),
pb.stringForType(pb_type).map(|s| s.to_string()),
)
let text = pb
.stringForType(objc2_app_kit::NSPasteboardTypeString)
.map(|s| s.to_string());
let image_png = pb
.dataForType(NSPasteboardTypePNG)
.map(|data| data.bytes().to_vec())
.filter(|data| !data.is_empty())
.or_else(|| {
let tiff = pb.dataForType(NSPasteboardTypeTIFF)?;
let image = NSBitmapImageRep::imageRepWithData(&tiff)?;
let properties = NSDictionary::new();
image
.representationUsingType_properties(NSPNGFileType, &properties)
.map(|data| data.bytes().to_vec())
.filter(|data| !data.is_empty())
});
(pb.changeCount(), text, image_png)
}
}
Loading