mirror of
https://github.com/akadeepesh/contextzip.git
synced 2026-10-20 08:36:20 +02:00
fix(clipboard): put the ZIP on the clipboard as a real file on Windows and Linux
Windows had no clipboard step (it went straight to Explorer). Linux wrote the ZIP's raw bytes as application/zip, which browsers ignore, and only if xclip was installed. Windows: set CF_HDROP via ctypes plus Preferred DropEffect, read it back with DragQueryFileW to verify; fall back to PowerShell Set-Clipboard -LiteralPath (path passed via env var); then Explorer. Linux: wl-copy (if present on Wayland) -> built-in X11 owner -> xclip (if present), all setting text/uri-list and read back before reporting success. xclip/wl-copy output is no longer captured, so they can't block the CLI. Also: Explorer /select is now a single quoted command line (paths with spaces/commas), headless/SSH Linux sessions print the path instead of launching xdg-open, and macOS escapes quotes in the path. ClipboardResult gains an optional hint field.
This commit is contained in:
+406
-77
@@ -1,33 +1,47 @@
|
||||
"""
|
||||
clipboard.py — Tiered clipboard strategy for copying a ZIP file.
|
||||
clipboard.py — put the generated ZIP on the clipboard as a real *file*, so it
|
||||
can be pasted straight into an upload box (Claude, ChatGPT, Gemini, ...).
|
||||
|
||||
Tier 1 — Copy the actual file object to clipboard (paste directly into
|
||||
browser upload zones like Claude, ChatGPT, etc.)
|
||||
macOS : osascript + Finder NSPasteboard trick
|
||||
Linux : xclip (if installed)
|
||||
Windows: not possible via CLI — skip to Tier 2
|
||||
"A file on the clipboard" is not the file's bytes. Every OS expects a
|
||||
*reference* to the file in a specific clipboard format, which is what Explorer /
|
||||
Finder / Nautilus put there when you press Ctrl+C on a file:
|
||||
|
||||
Tier 2 — Open the containing folder with the file highlighted/selected,
|
||||
so the user can copy it themselves with one Ctrl+C.
|
||||
macOS : open -R <file>
|
||||
Linux : xdg-open <folder> (can't pre-select a file on Linux)
|
||||
Windows: explorer /select,"<file>" ← selects the file in Explorer
|
||||
Windows : CF_HDROP (a DROPFILES block listing the absolute path)
|
||||
macOS : a file URL on the NSPasteboard
|
||||
Linux : the ``text/uri-list`` target containing ``file:///...``
|
||||
(Chromium reads this target when you paste into a web page)
|
||||
|
||||
Tier 3 — Print the path clearly and tell the user to copy it manually.
|
||||
Always available, never fails.
|
||||
Tier 1 — file reference on the clipboard (paste directly into the browser)
|
||||
Windows: ctypes → CF_HDROP, read back to verify; PowerShell
|
||||
``Set-Clipboard -LiteralPath`` as a second attempt
|
||||
macOS : osascript / Finder
|
||||
Linux : built-in X11 clipboard owner (pure Python, nothing to
|
||||
install; also works on Wayland desktops via XWayland),
|
||||
or wl-copy / xclip if present — all read back to verify
|
||||
Tier 2 — open the containing folder (file selected where the OS allows it)
|
||||
Tier 3 — print the path
|
||||
|
||||
The module returns a ClipboardResult describing which tier succeeded.
|
||||
``handle()`` never raises and only reports Tier 1 when the clipboard was
|
||||
actually set (and, where possible, read back).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import select
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
URI_LIST_MIME = "text/uri-list"
|
||||
_CF_HDROP = 15
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result model
|
||||
@@ -45,6 +59,7 @@ class ClipboardResult:
|
||||
tier: Tier
|
||||
message: str
|
||||
success: bool = True
|
||||
hint: str | None = None # why Tier 1 failed / how to fix it, when known
|
||||
|
||||
|
||||
def handle(zip_path: Path) -> ClipboardResult:
|
||||
@@ -52,52 +67,212 @@ def handle(zip_path: Path) -> ClipboardResult:
|
||||
Attempt to put *zip_path* on the clipboard using the best available tier.
|
||||
Always returns a :class:`ClipboardResult` — never raises.
|
||||
"""
|
||||
system = platform.system()
|
||||
try:
|
||||
return _handle(Path(zip_path))
|
||||
except Exception: # pragma: no cover - last-resort safety net
|
||||
return _tier3(Path(zip_path))
|
||||
|
||||
# ── Tier 1: real file-object on clipboard ───────────────────────────────
|
||||
if system == "Darwin":
|
||||
|
||||
def _handle(zip_path: Path) -> ClipboardResult:
|
||||
system = platform.system()
|
||||
hint: str | None = None
|
||||
|
||||
if system == "Windows":
|
||||
result = _tier1_windows(zip_path)
|
||||
if result:
|
||||
return result
|
||||
hint = "Couldn't access the Windows clipboard — copy the selected file with Ctrl+C."
|
||||
tier2 = _tier2_windows
|
||||
|
||||
elif system == "Darwin":
|
||||
result = _tier1_macos(zip_path)
|
||||
if result:
|
||||
return result
|
||||
tier2 = _tier2_macos
|
||||
|
||||
elif system == "Linux":
|
||||
result = _tier1_linux(zip_path)
|
||||
else: # Linux / BSD
|
||||
if not _linux_has_gui():
|
||||
return _tier3(zip_path) # SSH / headless: nothing to open or paste into
|
||||
result, hint = _tier1_linux(zip_path)
|
||||
if result:
|
||||
return result
|
||||
tier2 = _tier2_linux
|
||||
|
||||
# Windows Tier 1: not possible — fall straight through to Tier 2
|
||||
|
||||
# ── Tier 2: open folder with file selected ──────────────────────────────
|
||||
if system == "Darwin":
|
||||
result = _tier2_macos(zip_path)
|
||||
elif system == "Linux":
|
||||
result = _tier2_linux(zip_path)
|
||||
else:
|
||||
result = _tier2_windows(zip_path)
|
||||
|
||||
result = tier2(zip_path)
|
||||
if result:
|
||||
result.hint = hint
|
||||
return result
|
||||
|
||||
# ── Tier 3: path only ───────────────────────────────────────────────────
|
||||
return _tier3(zip_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 — file object on clipboard
|
||||
# Tier 1 — Windows
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_dropfiles(paths: list[str]) -> bytes:
|
||||
"""
|
||||
Build a CF_HDROP payload: a DROPFILES header followed by UTF-16 paths,
|
||||
each NUL-terminated, with one extra NUL ending the list.
|
||||
|
||||
struct DROPFILES { DWORD pFiles; POINT pt; BOOL fNC; BOOL fWide; }
|
||||
"""
|
||||
header = struct.pack("<IiiII", 20, 0, 0, 0, 1) # pFiles=20, pt=(0,0), fNC=0, fWide=1
|
||||
body = "".join(p + "\0" for p in paths) + "\0"
|
||||
return header + body.encode("utf-16-le")
|
||||
|
||||
|
||||
def _tier1_windows(zip_path: Path) -> ClipboardResult | None:
|
||||
path = os.path.abspath(str(zip_path))
|
||||
for attempt in (_win_ctypes_copy, _win_powershell_copy):
|
||||
try:
|
||||
if attempt(path):
|
||||
return ClipboardResult(
|
||||
tier=Tier.FILE_ON_CLIPBOARD,
|
||||
message="📋 ZIP copied to clipboard — paste into Claude / ChatGPT!",
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _win_ctypes_copy(path: str) -> bool:
|
||||
"""
|
||||
Set CF_HDROP directly through the Win32 API (no subprocess, ~1 ms), then
|
||||
read it back with DragQueryFileW to confirm the clipboard really holds it.
|
||||
"""
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
user32 = ctypes.WinDLL("user32", use_last_error=True)
|
||||
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
||||
|
||||
# Explicit prototypes: without them 64-bit handles get truncated to 32 bits.
|
||||
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
|
||||
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
|
||||
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
|
||||
kernel32.GlobalLock.restype = wintypes.LPVOID
|
||||
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
|
||||
kernel32.GlobalUnlock.restype = wintypes.BOOL
|
||||
kernel32.GlobalFree.argtypes = [wintypes.HGLOBAL]
|
||||
kernel32.GlobalFree.restype = wintypes.HGLOBAL
|
||||
user32.OpenClipboard.argtypes = [wintypes.HWND]
|
||||
user32.OpenClipboard.restype = wintypes.BOOL
|
||||
user32.CloseClipboard.argtypes = []
|
||||
user32.CloseClipboard.restype = wintypes.BOOL
|
||||
user32.EmptyClipboard.argtypes = []
|
||||
user32.EmptyClipboard.restype = wintypes.BOOL
|
||||
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
|
||||
user32.SetClipboardData.restype = wintypes.HANDLE
|
||||
user32.GetClipboardData.argtypes = [wintypes.UINT]
|
||||
user32.GetClipboardData.restype = wintypes.HANDLE
|
||||
user32.RegisterClipboardFormatW.argtypes = [wintypes.LPCWSTR]
|
||||
user32.RegisterClipboardFormatW.restype = wintypes.UINT
|
||||
shell32.DragQueryFileW.argtypes = [
|
||||
wintypes.HANDLE,
|
||||
wintypes.UINT,
|
||||
wintypes.LPWSTR,
|
||||
wintypes.UINT,
|
||||
]
|
||||
shell32.DragQueryFileW.restype = wintypes.UINT
|
||||
|
||||
def alloc(data: bytes):
|
||||
handle_ = kernel32.GlobalAlloc(0x0042, len(data)) # GMEM_MOVEABLE | GMEM_ZEROINIT
|
||||
if not handle_:
|
||||
return None
|
||||
ptr = kernel32.GlobalLock(handle_)
|
||||
if not ptr:
|
||||
kernel32.GlobalFree(handle_)
|
||||
return None
|
||||
ctypes.memmove(ptr, data, len(data))
|
||||
kernel32.GlobalUnlock(handle_)
|
||||
return handle_
|
||||
|
||||
# Clipboard managers / history briefly lock the clipboard — retry.
|
||||
for _ in range(20):
|
||||
if user32.OpenClipboard(None):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
return False
|
||||
|
||||
try:
|
||||
if not user32.EmptyClipboard():
|
||||
return False
|
||||
|
||||
mem = alloc(_build_dropfiles([path]))
|
||||
if not mem:
|
||||
return False
|
||||
if not user32.SetClipboardData(_CF_HDROP, mem):
|
||||
kernel32.GlobalFree(mem) # the clipboard only owns it on success
|
||||
return False
|
||||
|
||||
# Tell Explorer this is a copy, not a move (best effort).
|
||||
effect_fmt = user32.RegisterClipboardFormatW("Preferred DropEffect")
|
||||
if effect_fmt:
|
||||
effect = alloc(struct.pack("<I", 1)) # DROPEFFECT_COPY
|
||||
if effect and not user32.SetClipboardData(effect_fmt, effect):
|
||||
kernel32.GlobalFree(effect)
|
||||
|
||||
# Verify: read the file list back out of the clipboard.
|
||||
hdrop = user32.GetClipboardData(_CF_HDROP)
|
||||
if not hdrop:
|
||||
return False
|
||||
if shell32.DragQueryFileW(hdrop, 0xFFFFFFFF, None, 0) != 1:
|
||||
return False
|
||||
length = shell32.DragQueryFileW(hdrop, 0, None, 0)
|
||||
buf = ctypes.create_unicode_buffer(length + 1)
|
||||
shell32.DragQueryFileW(hdrop, 0, buf, length + 1)
|
||||
return os.path.normcase(os.path.normpath(buf.value)) == os.path.normcase(
|
||||
os.path.normpath(path)
|
||||
)
|
||||
finally:
|
||||
user32.CloseClipboard()
|
||||
|
||||
|
||||
def _win_powershell_copy(path: str) -> bool:
|
||||
"""Second attempt: ``Set-Clipboard -LiteralPath`` (PowerShell 5.0+ / pwsh)."""
|
||||
exe = shutil.which("powershell") or shutil.which("pwsh")
|
||||
if not exe:
|
||||
return False
|
||||
# The path travels in an environment variable, so quotes, spaces, `$`,
|
||||
# brackets and non-ASCII characters in it can't break the command line.
|
||||
env = dict(os.environ, CZ_CLIP_PATH=path)
|
||||
cmd = [
|
||||
exe,
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-STA",
|
||||
"-Command",
|
||||
"$ErrorActionPreference='Stop'; Set-Clipboard -LiteralPath $env:CZ_CLIP_PATH",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return proc.returncode == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 — macOS
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
|
||||
"""
|
||||
Use osascript to ask Finder to copy the file to the clipboard.
|
||||
This puts a real file object on the NSPasteboard — identical to
|
||||
selecting the file in Finder and pressing Cmd+C.
|
||||
Browsers read this as a File object on paste.
|
||||
Ask Finder to put the file on the pasteboard — identical to selecting it in
|
||||
Finder and pressing Cmd+C. Browsers read this as a File object on paste.
|
||||
"""
|
||||
script = (
|
||||
f'tell application "Finder" to set the clipboard to '
|
||||
f'(POSIX file "{zip_path.as_posix()}")'
|
||||
)
|
||||
posix = str(zip_path).replace("\\", "\\\\").replace('"', '\\"')
|
||||
script = f'tell application "Finder" to set the clipboard to (POSIX file "{posix}")'
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["osascript", "-e", script],
|
||||
@@ -115,30 +290,185 @@ def _tier1_macos(zip_path: Path) -> ClipboardResult | None:
|
||||
return None
|
||||
|
||||
|
||||
def _tier1_linux(zip_path: Path) -> ClipboardResult | None:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 1 — Linux
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Writing the ZIP's *bytes* to the clipboard (what this module used to do) gives
|
||||
# the browser an "application/zip" blob it ignores. What browsers read on paste
|
||||
# is ``text/uri-list``: a ``file:///`` URL pointing at the file.
|
||||
#
|
||||
# X11 (and Wayland desktops through XWayland) is handled by contextzip's own
|
||||
# small selection owner in _x11_clip.py, so nothing needs to be installed.
|
||||
# wl-copy / xclip are used when present: wl-copy is the native Wayland route,
|
||||
# xclip is a fallback if the built-in owner can't reach the X server.
|
||||
|
||||
|
||||
def _linux_has_gui() -> bool:
|
||||
return bool(os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY"))
|
||||
|
||||
|
||||
def _tier1_linux(zip_path: Path) -> tuple[ClipboardResult | None, str | None]:
|
||||
"""Returns (result, hint). *hint* explains a failure and how to fix it."""
|
||||
uri_payload = (zip_path.resolve().as_uri() + "\r\n").encode("utf-8")
|
||||
attempts: list[tuple[str, object]] = []
|
||||
|
||||
if os.environ.get("WAYLAND_DISPLAY") and shutil.which("wl-copy"):
|
||||
read = (
|
||||
["wl-paste", "--no-newline", "--type", URI_LIST_MIME]
|
||||
if shutil.which("wl-paste")
|
||||
else None
|
||||
)
|
||||
attempts.append(
|
||||
(
|
||||
"wl-copy",
|
||||
lambda: _run_clipboard_tool(["wl-copy", "--type", URI_LIST_MIME], read, uri_payload),
|
||||
)
|
||||
)
|
||||
if os.environ.get("DISPLAY"):
|
||||
attempts.append(("built-in X11", lambda: _x11_builtin_copy(zip_path, uri_payload)))
|
||||
if shutil.which("xclip"):
|
||||
attempts.append(
|
||||
(
|
||||
"xclip",
|
||||
lambda: _run_clipboard_tool(
|
||||
["xclip", "-selection", "clipboard", "-t", URI_LIST_MIME, "-i"],
|
||||
["xclip", "-selection", "clipboard", "-t", URI_LIST_MIME, "-o"],
|
||||
uri_payload,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
for _name, attempt in attempts:
|
||||
try:
|
||||
ok = attempt() # type: ignore[operator]
|
||||
except Exception:
|
||||
ok = False
|
||||
if ok:
|
||||
return (
|
||||
ClipboardResult(
|
||||
tier=Tier.FILE_ON_CLIPBOARD,
|
||||
message="📋 ZIP copied to clipboard — paste into your AI tool!",
|
||||
),
|
||||
None,
|
||||
)
|
||||
# Only reachable when the X server couldn't be used (e.g. a Wayland-only
|
||||
# session without XWayland) — then a native tool is the way forward.
|
||||
return None, _install_hint()
|
||||
|
||||
|
||||
def _x11_builtin_copy(zip_path: Path, expected: bytes) -> bool:
|
||||
"""
|
||||
Use xclip to write the file bytes with MIME type application/zip.
|
||||
Works in most GTK/Qt browser upload dialogs when pasting.
|
||||
Requires xclip to be installed.
|
||||
Start the background owner (``_x11_clip.py serve``), wait for it to report
|
||||
that it owns the clipboard, then confirm through the X server that the
|
||||
clipboard really serves our file. The owner keeps running after this CLI
|
||||
exits and stops itself when another app copies something.
|
||||
"""
|
||||
if not shutil.which("xclip"):
|
||||
return None
|
||||
if not sys.executable:
|
||||
return False
|
||||
from contextzip import _x11_clip # lazy: Linux-only code path
|
||||
|
||||
try:
|
||||
with zip_path.open("rb") as fh:
|
||||
proc = subprocess.run(
|
||||
["xclip", "-selection", "clipboard", "-t", "application/zip", "-i"],
|
||||
stdin=fh,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
tier=Tier.FILE_ON_CLIPBOARD,
|
||||
message="📋 ZIP copied to clipboard — paste into your AI tool!",
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError, PermissionError):
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, _x11_clip.__file__, "serve", str(zip_path.resolve())],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE, # only used for the one-line "ready" handshake
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, # outlive this process and its terminal
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
try:
|
||||
assert proc.stdout is not None
|
||||
readable, _, _ = select.select([proc.stdout], [], [], 4.0)
|
||||
if not readable or proc.stdout.readline().strip() != b"ready":
|
||||
proc.kill()
|
||||
return False
|
||||
proc.stdout.close()
|
||||
for _ in range(20):
|
||||
if _x11_clip.read_selection(URI_LIST_MIME, timeout=1.0) == expected:
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
proc.kill()
|
||||
return False
|
||||
|
||||
|
||||
def _run_clipboard_tool(
|
||||
write_cmd: list[str], read_cmd: list[str] | None, payload: bytes
|
||||
) -> bool:
|
||||
"""
|
||||
Feed *payload* to a clipboard tool and confirm by reading it back.
|
||||
|
||||
xclip / wl-copy fork a background process that keeps serving the selection.
|
||||
That child inherits our pipes, so stdout/stderr must go to DEVNULL (not
|
||||
capture_output) or reading would block until the clipboard changes, and it
|
||||
gets its own session so it outlives this CLI and its terminal.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
write_cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
assert proc.stdin is not None
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.close()
|
||||
except OSError:
|
||||
return False
|
||||
try:
|
||||
rc: int | None = proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
rc = None # still running in the foreground = still owns the selection
|
||||
if rc not in (None, 0):
|
||||
return False
|
||||
if read_cmd is None:
|
||||
return True
|
||||
|
||||
for _ in range(8): # the selection owner may need a moment to register
|
||||
try:
|
||||
out = subprocess.run(
|
||||
read_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=3
|
||||
).stdout
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
out = b""
|
||||
if out.strip() == payload.strip():
|
||||
return True
|
||||
time.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
def _install_hint() -> str:
|
||||
"""Tell the user which package gives cz a way to set the clipboard."""
|
||||
pkg = "wl-clipboard" if os.environ.get("WAYLAND_DISPLAY") else "xclip"
|
||||
ids: set[str] = set()
|
||||
try:
|
||||
for line in Path("/etc/os-release").read_text().splitlines():
|
||||
key, _, value = line.partition("=")
|
||||
if key in ("ID", "ID_LIKE"):
|
||||
ids.update(value.strip().strip('"').split())
|
||||
except OSError:
|
||||
pass
|
||||
if ids & {"debian", "ubuntu"}:
|
||||
cmd = f"sudo apt install {pkg}"
|
||||
elif ids & {"fedora", "rhel", "centos"}:
|
||||
cmd = f"sudo dnf install {pkg}"
|
||||
elif ids & {"arch", "manjaro"}:
|
||||
cmd = f"sudo pacman -S {pkg}"
|
||||
elif ids & {"opensuse", "suse"}:
|
||||
cmd = f"sudo zypper install {pkg}"
|
||||
elif "alpine" in ids:
|
||||
cmd = f"sudo apk add {pkg}"
|
||||
else:
|
||||
return f"Install '{pkg}' with your package manager so cz can copy the file."
|
||||
return f"Install '{pkg}' once so cz can copy the file: {cmd}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -149,11 +479,7 @@ def _tier1_linux(zip_path: Path) -> ClipboardResult | None:
|
||||
def _tier2_macos(zip_path: Path) -> ClipboardResult | None:
|
||||
"""open -R reveals and selects the file in Finder."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["open", "-R", str(zip_path)],
|
||||
capture_output=True,
|
||||
timeout=6,
|
||||
)
|
||||
proc = subprocess.run(["open", "-R", str(zip_path)], capture_output=True, timeout=6)
|
||||
if proc.returncode == 0:
|
||||
return ClipboardResult(
|
||||
tier=Tier.FOLDER_OPENED,
|
||||
@@ -174,14 +500,15 @@ def _tier2_linux(zip_path: Path) -> ClipboardResult | None:
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["xdg-open", str(zip_path.parent)],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return ClipboardResult(
|
||||
tier=Tier.FOLDER_OPENED,
|
||||
message=(
|
||||
f"📂 Opened folder containing your ZIP.\n"
|
||||
f" File: [cyan]{zip_path.name}[/]"
|
||||
f"📂 Opened folder containing your ZIP.\n File: [cyan]{zip_path.name}[/]"
|
||||
),
|
||||
)
|
||||
except (FileNotFoundError, OSError):
|
||||
@@ -190,20 +517,17 @@ def _tier2_linux(zip_path: Path) -> ClipboardResult | None:
|
||||
|
||||
|
||||
def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
|
||||
"""
|
||||
explorer /select,"<path>" opens Explorer with the file highlighted.
|
||||
The user can then press Ctrl+C and paste it directly into a browser.
|
||||
"""
|
||||
"""explorer /select,"<path>" opens Explorer with the file highlighted."""
|
||||
try:
|
||||
# Use the Windows path format with backslashes
|
||||
win_path = str(zip_path).replace("/", "\\")
|
||||
win_path = os.path.abspath(str(zip_path))
|
||||
# A single command-line string: Explorer needs the quotes around the
|
||||
# path itself, which a list argument would put in the wrong place.
|
||||
subprocess.run(
|
||||
["explorer", f"/select,{win_path}"],
|
||||
# explorer always exits 1 even on success — don't check returncode
|
||||
f'explorer /select,"{win_path}"',
|
||||
# explorer.exe exits 1 even on success — don't check the return code
|
||||
capture_output=True,
|
||||
timeout=8,
|
||||
)
|
||||
# explorer.exe opens asynchronously; a quick return (any code) means it launched
|
||||
return ClipboardResult(
|
||||
tier=Tier.FOLDER_OPENED,
|
||||
message=(
|
||||
@@ -216,8 +540,13 @@ def _tier2_windows(zip_path: Path) -> ClipboardResult | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tier 3 — path only
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _tier3(zip_path: Path) -> ClipboardResult:
|
||||
return ClipboardResult(
|
||||
tier=Tier.PATH_ONLY,
|
||||
message=(f"📄 Copy this path and open it manually:\n [cyan]{zip_path}[/]"),
|
||||
message=f"📄 Copy this path and open it manually:\n [cyan]{zip_path}[/]",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user