Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/interactive-capabilities/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a
- Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json`
- Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json`

说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **663** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。
说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **664** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。

Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **663** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.
Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **664** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes.

## 控制边界 / Control boundary

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@
},
"evidence": [
"command:get_clipboard_files",
"command:get_clipboard_image",
"command:resolve_browser_dropped_file_paths",
"command:set_file_drop_preview_target",
"command:upload_image_contexts",
Expand Down
22 changes: 19 additions & 3 deletions docs/interactive-capabilities/technical/tauri-command-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
"schemaVersion": 2,
"generatedFrom": "src/shared/interactive-capabilities/catalog.json",
"catalogDigest": "6587344a6b5e75a80457ea4ba2670bf37cf3038bdb436089ae402eb7b5a5a025",
"commandCount": 663,
"commandCount": 664,
"coverage": {
"commandCount": 663,
"documentedCommandCount": 615,
"commandCount": 664,
"documentedCommandCount": 616,
"implementationCommandCount": 48,
"implementationDigest": "f6d38a24a70708988cb47ada81d07eccf0668684e8734c9ee5155b9ffa7e3db8"
},
Expand Down Expand Up @@ -2824,6 +2824,22 @@
"signature": "fn get_clipboard_files() -> Result<ClipboardFilesResponse, String>",
"remoteWorkspacePolicy": "LocalOnly"
},
{
"id": "get_clipboard_image",
"moduleId": "clipboard_file",
"capabilityId": "feature.files-editor",
"capabilityIds": [
"feature.files-editor"
],
"documentedItemIds": [
"feature.files-editor:attachments"
],
"visibility": "documented",
"rustPath": "get_clipboard_image",
"sourceFile": "src/apps/desktop/src/api/clipboard_file_api.rs",
"signature": "fn get_clipboard_image() -> Result<ClipboardImageResponse, String>",
"remoteWorkspacePolicy": "LocalOnly"
},
{
"id": "get_config",
"moduleId": "config",
Expand Down
158 changes: 154 additions & 4 deletions src/apps/desktop/src/api/clipboard_file_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,9 @@ mod macos_clipboard {
#[cfg(target_os = "linux")]
mod linux_clipboard {
use super::parse_uri_list;
use std::process::Command;

fn read_xclip_uri_list() -> Option<String> {
let output = Command::new("xclip")
let output = openbitfun_core::util::process_manager::create_command("xclip")
.args(["-selection", "clipboard", "-t", "text/uri-list", "-o"])
.output()
.ok()?;
Expand All @@ -267,7 +266,7 @@ mod linux_clipboard {
}

fn read_wl_paste_uri_list() -> Option<String> {
let output = Command::new("wl-paste")
let output = openbitfun_core::util::process_manager::create_command("wl-paste")
.args(["-t", "text/uri-list"])
.output()
.ok()?;
Expand Down Expand Up @@ -324,6 +323,135 @@ pub async fn get_clipboard_files() -> Result<ClipboardFilesResponse, String> {
}
}

/// Image bytes read from the system clipboard, base64-encoded for the webview.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ClipboardImageResponse {
pub base64: Option<String>,
pub mime_type: Option<String>,
}

impl Default for ClipboardImageResponse {
fn default() -> Self {
Self {
base64: None,
mime_type: None,
}
}
}

/// Sniffs the image format from magic bytes so a tool that misreports success
/// cannot inject arbitrary text as an attachment payload.
pub(crate) fn sniff_image_mime(bytes: &[u8]) -> Option<&'static str> {
if bytes.starts_with(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]) {
Some("image/png")
} else if bytes.len() >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF {
Some("image/jpeg")
} else {
None
}
}

/// Outcome of a Linux clipboard-image probe.
enum ClipboardImageRead {
/// An image payload, base64-encoded with its sniffed MIME type.
Image(String, String),
/// The reader tools ran; the clipboard simply holds no image payload.
Empty,
/// Neither `wl-paste` nor `xclip` could be spawned. Surfaced to the user
/// instead of silently reporting "no image": a plain-text clipboard and a
/// machine missing the reader tools must stay distinguishable.
ToolsUnavailable(String),
}

/// Reads a clipboard image on Linux.
///
/// WebKitGTK delivers paste events with empty `DataTransfer` items, so the
/// webview itself can never see a pasted image; reading the Wayland/X11
/// clipboard through the same tools as `get_clipboard_files` is the only
/// delivery path.
#[cfg(target_os = "linux")]
fn read_clipboard_image_internal() -> ClipboardImageRead {
use base64::Engine as _;

/// `Ok(None)` = the tool ran and reported no such payload;
/// `Err` = the tool could not be run at all.
let read_target = |program: &str, args: &[&str]| -> Result<Option<Vec<u8>>, String> {
let output = openbitfun_core::util::process_manager::create_command(program)
.args(args)
.output()
.map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
format!("{program} is not installed")
} else {
format!("failed to spawn {program}: {error}")
}
})?;
Ok(output
.status
.success()
.then_some(output.stdout)
.filter(|stdout| !stdout.is_empty()))
};

let mut runnable_tools = 0usize;
let mut last_tool_error = String::new();
for mime in ["image/png", "image/jpeg"] {
for (program, args) in [
("wl-paste", vec!["-t", mime]),
("xclip", vec!["-selection", "clipboard", "-t", mime, "-o"]),
] {
match read_target(program, &args) {
Ok(Some(bytes)) => {
if let Some(sniffed) = sniff_image_mime(&bytes) {
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
return ClipboardImageRead::Image(encoded, sniffed.to_string());
}
runnable_tools += 1;
}
Ok(None) => runnable_tools += 1,
Err(error) => last_tool_error = error,
}
}
}

if runnable_tools > 0 {
return ClipboardImageRead::Empty;
}
ClipboardImageRead::ToolsUnavailable(format!(
"clipboard_image_unsupported: reading a clipboard image needs wl-paste (Wayland) or \
xclip (X11), but neither is available ({last_tool_error}); install wl-clipboard or \
xclip and retry"
))
}

#[cfg(target_os = "linux")]
fn get_clipboard_image_internal() -> ClipboardImageRead {
read_clipboard_image_internal()
}

#[cfg(not(target_os = "linux"))]
fn get_clipboard_image_internal() -> ClipboardImageRead {
// Other platforms deliver clipboard images to the page directly and never
// need the host fallback.
ClipboardImageRead::Empty
}

#[tauri::command]
pub async fn get_clipboard_image() -> Result<ClipboardImageResponse, String> {
match get_clipboard_image_internal() {
ClipboardImageRead::Image(base64, mime_type) => Ok(ClipboardImageResponse {
base64: Some(base64),
mime_type: Some(mime_type),
}),
ClipboardImageRead::Empty => Ok(ClipboardImageResponse::default()),
ClipboardImageRead::ToolsUnavailable(error) => {
log::warn!("Clipboard image read unsupported: {}", error);
Err(error)
}
}
}

/// Pastes clipboard files between controller-local paths.
///
/// The remote file provider exposes no copy primitive, so a remote workspace path is refused here
Expand Down Expand Up @@ -485,10 +613,32 @@ pub(crate) fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(
mod tests {
use super::{
copy_directory_recursive, decode_file_uri, generate_unique_path,
parse_clipboard_path_segments, parse_uri_list,
parse_clipboard_path_segments, parse_uri_list, sniff_image_mime,
};
use std::path::Path;

#[test]
fn sniff_image_mime_detects_png_header() {
assert_eq!(
sniff_image_mime(&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]),
Some("image/png")
);
}

#[test]
fn sniff_image_mime_detects_jpeg_header() {
assert_eq!(
sniff_image_mime(&[0xFF, 0xD8, 0xFF, 0xE0, 0, 0]),
Some("image/jpeg")
);
}

#[test]
fn sniff_image_mime_rejects_non_image_payloads() {
assert_eq!(sniff_image_mime(b"image/png but not really"), None);
assert_eq!(sniff_image_mime(&[]), None);
}

#[test]
fn decode_unix_file_uri() {
assert_eq!(
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1431,6 +1431,7 @@ pub async fn run() {
stop_file_watch,
get_watched_paths,
get_clipboard_files,
get_clipboard_image,
api::browser_file_drop_api::resolve_browser_dropped_file_paths,
api::file_drop_preview_api::set_file_drop_preview_target,
paste_files,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"digest": "fnv1a64:d3892fea19448d1f",
"digest": "fnv1a64:01a7a1c7756afe23",
"retiredCommandPrefixes": [
{
"prefix": "lsp_",
Expand Down Expand Up @@ -2306,6 +2306,18 @@
"reason": "the CLI peer host has no handler for this command"
}
},
{
"id": "get_clipboard_image",
"surface": "tauri_command",
"remoteWorkspace": "LocalOnly",
"peer": {
"kind": "proxied"
},
"cliPeer": {
"kind": "unsupported",
"reason": "the CLI peer host has no handler for this command"
}
},
{
"id": "get_config",
"surface": "tauri_command",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[
op("get_baseline_snapshot_diff", Unaudited, Proxied, CLI_NOT_IMPLEMENTED),
op("get_chat_mcp_catalog", Unsupported, Proxied, HANDLED),
op("get_clipboard_files", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED),
op("get_clipboard_image", LocalOnly, Proxied, CLI_NOT_IMPLEMENTED),
op("get_config", Unaudited, Proxied, HANDLED),
op("get_configs", Unaudited, Proxied, HANDLED),
op("get_current_workspace", Agnostic, Proxied, HANDLED),
Expand Down
1 change: 1 addition & 0 deletions src/shared/interactive-capabilities/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -1542,6 +1542,7 @@
},
"evidence": [
"command:get_clipboard_files",
"command:get_clipboard_image",
"command:resolve_browser_dropped_file_paths",
"command:set_file_drop_preview_target",
"command:upload_image_contexts",
Expand Down
Loading
Loading