"""Read/write OS clipboard (file paths + plain text).""" from __future__ import annotations import subprocess import sys from pathlib import Path from typing import Any from urllib.parse import unquote, urlparse _IS_WIN = sys.platform == "win32" _IS_MAC = sys.platform == "darwin" def list_clipboard_files() -> list[Path]: """Return existing file/dir paths currently on the clipboard.""" raw: list[str] = [] if _IS_MAC: raw = _mac_filenames() elif _IS_WIN: raw = _win_filenames() out: list[Path] = [] seen: set[str] = set() for item in raw: p = _normalize_path(item) if p is None: continue key = str(p) if key in seen: continue seen.add(key) out.append(p) return out def clipboard_preview(limit: int = 8) -> dict[str, Any]: files = list_clipboard_files() names = [p.name for p in files[:limit]] return { "count": len(files), "names": names, "truncated": len(files) > limit, "paths": [str(p) for p in files[:limit]], } def get_clipboard_text() -> str: """Best-effort plain text from the OS clipboard.""" if _IS_MAC: return _mac_get_text() if _IS_WIN: return _win_get_text() return "" def set_clipboard_text(text: str) -> bool: """Set peer OS clipboard to plain text (for remote paste).""" if text is None: return False if _IS_MAC: return _mac_set_text(text) if _IS_WIN: return _win_set_text(text) return False def _normalize_path(item: str) -> Path | None: s = (item or "").strip() if not s: return None if s.startswith("file:"): parsed = urlparse(s) path = unquote(parsed.path or "") if _IS_MAC and parsed.netloc and parsed.netloc not in ("", "localhost"): path = f"/{parsed.netloc}{path}" s = path if _IS_WIN and s.startswith("/") and len(s) > 2 and s[2] == ":": s = s[1:] try: p = Path(s) if p.exists(): return p.resolve() except OSError: return None return None def _mac_filenames() -> list[str]: # Prefer AppKit NSFilenamesPboardType try: from AppKit import NSPasteboard # type: ignore pb = NSPasteboard.generalPasteboard() for typ in ( "NSFilenamesPboardType", "public.file-url", ): try: if typ == "NSFilenamesPboardType": from AppKit import NSFilenamesPboardType # type: ignore items = pb.propertyListForType_(NSFilenamesPboardType) if items: return [str(x) for x in items] else: # public.file-url may be multiple items classes_out = [] try: from Foundation import NSURL # type: ignore objs = pb.readObjectsForClasses_options_([NSURL], None) if objs: for u in objs: try: if hasattr(u, "isFileURL") and u.isFileURL(): classes_out.append(str(u.path())) except Exception: continue except Exception: pass if classes_out: return classes_out except Exception: continue except Exception: pass return _mac_filenames_osascript() def _mac_filenames_osascript() -> list[str]: ascript = """ try set clip to the clipboard as «class furl» return POSIX path of clip on error try set out to {} set clip to the clipboard if class of clip is list then repeat with i in clip try set end of out to POSIX path of (i as alias) end try end repeat end if set AppleScript's text item delimiters to linefeed return out as text on error return "" end try end try """ try: proc = subprocess.run( ["osascript", "-e", ascript], capture_output=True, text=True, timeout=3, check=False, ) text = (proc.stdout or "").strip() if not text: return [] return [ln.strip() for ln in text.splitlines() if ln.strip()] except Exception: return [] def _win_filenames() -> list[str]: try: import ctypes user32 = ctypes.windll.user32 shell32 = ctypes.windll.shell32 CF_HDROP = 15 if not user32.OpenClipboard(None): return [] try: handle = user32.GetClipboardData(CF_HDROP) if not handle: return [] count = shell32.DragQueryFileW(handle, 0xFFFFFFFF, None, 0) out: list[str] = [] buf = ctypes.create_unicode_buffer(1024) for i in range(int(count)): n = shell32.DragQueryFileW(handle, i, buf, 1024) if n: out.append(buf.value) return out finally: user32.CloseClipboard() except Exception: return [] def _mac_get_text() -> str: try: from AppKit import NSPasteboard, NSPasteboardTypeString # type: ignore pb = NSPasteboard.generalPasteboard() s = pb.stringForType_(NSPasteboardTypeString) if s: return str(s) except Exception: pass try: proc = subprocess.run( ["pbpaste"], capture_output=True, text=True, timeout=2, check=False, ) return proc.stdout or "" except Exception: return "" def _mac_set_text(text: str) -> bool: try: from AppKit import NSPasteboard, NSPasteboardTypeString # type: ignore pb = NSPasteboard.generalPasteboard() pb.clearContents() return bool(pb.setString_forType_(text, NSPasteboardTypeString)) except Exception: pass try: proc = subprocess.run( ["pbcopy"], input=text, text=True, timeout=2, check=False, ) return proc.returncode == 0 except Exception: return False def _win_get_text() -> str: try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 CF_UNICODETEXT = 13 if not user32.OpenClipboard(None): return "" try: handle = user32.GetClipboardData(CF_UNICODETEXT) if not handle: return "" ptr = kernel32.GlobalLock(handle) if not ptr: return "" try: return ctypes.wstring_at(ptr) finally: kernel32.GlobalUnlock(handle) finally: user32.CloseClipboard() except Exception: return "" def _win_set_text(text: str) -> bool: try: import ctypes from ctypes import wintypes user32 = ctypes.windll.user32 kernel32 = ctypes.windll.kernel32 CF_UNICODETEXT = 13 GMEM_MOVEABLE = 0x0002 data = (text.replace("\n", "\r\n") + "\0").encode("utf-16-le") if not user32.OpenClipboard(None): return False try: user32.EmptyClipboard() h = kernel32.GlobalAlloc(GMEM_MOVEABLE, len(data)) if not h: return False ptr = kernel32.GlobalLock(h) if not ptr: kernel32.GlobalFree(h) return False ctypes.memmove(ptr, data, len(data)) kernel32.GlobalUnlock(h) if not user32.SetClipboardData(CF_UNICODETEXT, h): kernel32.GlobalFree(h) return False return True finally: user32.CloseClipboard() except Exception: return False