299 lines
8.1 KiB
Python
299 lines
8.1 KiB
Python
"""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
|