Files
ComfyAssets-ComfyUI_PromptM…/utils/file_reveal.py
T

450 lines
15 KiB
Python

"""Show one gallery file in this computer's file manager (TESTING.md D38).
The route that calls this takes a same-origin browser request from anywhere,
directly or through a proxy (``is_same_origin_request``), and the folder opens on
the computer running ComfyUI (maintainer decision, #533). It is single-flight
(``RevealGate``) and hands in a path it has already resolved inside a gallery
root, which is re-checked just before launching. Windows
goes through the shell API (no command line exists to parse); macOS and Linux
openers run as an argument list with an absolute executable from a fixed
system PATH, no shell, no inherited streams and a minimal environment, so
neither a file name nor ``PATH`` can change what runs or what it parses.
"""
import os
import posixpath
import shutil
# Only POSIX argv launches of a fixed opener (see _launch); Windows never
# starts a process (see _select_item).
import subprocess # nosec B404
import sys
import threading
import time
from collections.abc import Mapping
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from pathlib import Path
from urllib.parse import urlsplit
DEFAULT_PORTS = {"http": 80, "https": 443}
# One reveal at a time per process, then a pause before the next.
REVEAL_COOLDOWN = 1.0
# dbus-send waits this long for the FileManager1 reply, so its exit status
# says whether a file manager really took the request.
DBUS_REPLY_TIMEOUT_MS = 2000
# The dbus-send process itself gets a little longer before it is killed.
DBUS_WAIT = 3.0
# An opener that fails says so at once; one still running after this long has
# started a file manager (it may stay in the foreground).
OPENER_WAIT = 1.0
FILE_MANAGER_DEST = "org.freedesktop.FileManager1"
# Openers are looked up here only, never on ComfyUI's (venv) PATH, and the
# children get this as their PATH.
SYSTEM_PATH = os.pathsep.join(
("/usr/local/bin", "/usr/bin", "/bin", "/run/current-system/sw/bin")
)
MACOS_OPEN = "/usr/bin/open"
# What a desktop opener needs from ComfyUI's environment; everything else
# (venv PATH, PYTHONPATH, LD_LIBRARY_PATH, API keys) stays behind.
CHILD_ENV_NAMES = (
"HOME",
"DISPLAY",
"WAYLAND_DISPLAY",
"XDG_RUNTIME_DIR",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_TYPE",
"XDG_SESSION_DESKTOP",
"DESKTOP_SESSION",
"XDG_DATA_DIRS",
"XDG_CONFIG_DIRS",
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
# The X11 cookie: a non-default path under GDM or SDDM.
"XAUTHORITY",
"DBUS_SESSION_BUS_ADDRESS",
"LANG",
"LC_ALL",
# What xdg-open reads to choose an opener (kde-open6 on Plasma 6).
"KDE_FULL_SESSION",
"KDE_SESSION_VERSION",
"GNOME_DESKTOP_SESSION_ID",
"MATE_DESKTOP_SESSION_ID",
"LXQT_SESSION_CONFIG",
)
class OpenerUnavailable(Exception):
"""No file manager can be started on the computer running ComfyUI."""
class OpenerFailed(Exception):
"""The file manager's opener ran and reported a failure."""
class RevealTargetChanged(Exception):
"""The file is no longer a regular file inside its root."""
_executor_lock = threading.Lock()
_executor: ThreadPoolExecutor | None = None
def reveal_executor() -> ThreadPoolExecutor:
"""The one worker that runs openers, so a slow file manager or dbus reply
never holds a thread of ComfyUI's shared default executor."""
global _executor
with _executor_lock:
if _executor is None:
_executor = ThreadPoolExecutor(
max_workers=1, thread_name_prefix="pm-reveal"
)
return _executor
def _authority(authority: str) -> tuple[str, int | None] | None:
"""``(host, port)`` of a Host value, or None when it does not parse."""
try:
parts = urlsplit(f"//{authority}")
host, port = parts.hostname, parts.port
except ValueError:
return None
return (host, port) if host else None
def _first_value(headers: Mapping, name: str) -> str | None:
"""The client-side entry of a comma-separated forwarding header, if any."""
value = headers.get(name)
return (value.split(",")[0].strip() or None) if value else None
def _same_origin(
origin: str, proto: str | None, scheme: str, host: tuple[str, int | None]
) -> bool:
"""True when *origin* names the page at *host*.
The port is compared after filling in the Origin scheme's default. The
scheme must be the one the proxy reports, else this socket's; an https page
on an http socket with no report passes, as a proxy ended TLS before us.
"""
try:
parts = urlsplit(origin)
origin_host, origin_port = parts.hostname, parts.port
except ValueError:
return False
default = DEFAULT_PORTS.get(parts.scheme)
if (
default is None
or not origin_host
or parts.username is not None
or parts.password is not None
or parts.path
or parts.query
or parts.fragment
):
return False
if proto is not None:
scheme_matches = parts.scheme == proto
else:
scheme_matches = parts.scheme == scheme or (
scheme == "http" and parts.scheme == "https"
)
host_name, host_port = host
return scheme_matches and (origin_host.rstrip("."), origin_port or default) == (
host_name.rstrip("."),
host_port or default,
)
def is_same_origin_request(headers: Mapping, scheme="http") -> bool:
"""True only for a request whose page is this server, as the browser sees it.
Compares ``Origin`` (browsers send it on every fetch POST, so a missing one
is refused) with ``X-Forwarded-Host`` when present, else ``Host``, and with
``X-Forwarded-Proto`` when present, else *scheme*. ``Sec-Fetch-Site`` must
be ``same-origin`` whenever it is sent. That stops a plain cross-site POST,
whose browser sets ``Origin`` and ``Sec-Fetch-Site`` itself. It does not stop
a page whose name was rebound to this server, as it trusts ``Host`` and
``X-Forwarded-Host`` (TESTING.md D38): what limits that is the path check.
"""
origin = headers.get("Origin")
if not origin:
return False
site = headers.get("Sec-Fetch-Site")
if site is not None and site != "same-origin":
return False
authority = _first_value(headers, "X-Forwarded-Host") or headers.get("Host", "")
host = _authority(authority)
if host is None:
return False
proto = _first_value(headers, "X-Forwarded-Proto")
return _same_origin(origin, proto.lower() if proto else None, scheme, host)
class RevealGate:
"""Single flight plus a cooldown: at most one reveal runs per process,
and the next may start only *cooldown* seconds after it finished."""
def __init__(self, cooldown: float = REVEAL_COOLDOWN, clock=time.monotonic):
self.cooldown = cooldown
self._clock = clock
self._lock = threading.Lock()
self._busy = False
self._finished: float | None = None
def try_enter(self) -> bool:
with self._lock:
if self._busy:
return False
if (
self._finished is not None
and self._clock() - self._finished < self.cooldown
):
return False
self._busy = True
return True
def leave(self) -> None:
with self._lock:
self._busy = False
self._finished = self._clock()
def _executable(name: str) -> str | None:
"""Absolute path of *name* on the fixed SYSTEM_PATH, never a relative hit.
Only the Linux and macOS openers are looked up here, so absoluteness follows
POSIX rules rather than the host's (Python 3.13 on Windows no longer counts
"/usr/bin/x" as absolute).
"""
found = shutil.which(name, path=SYSTEM_PATH)
return found if found and posixpath.isabs(found) else None
def _macos_open() -> str | None:
"""macOS's own ``open``, at its fixed system path."""
if os.path.isfile(MACOS_OPEN) and os.access(MACOS_OPEN, os.X_OK):
return MACOS_OPEN
return None
def _child_env() -> dict[str, str]:
env = {name: os.environ[name] for name in CHILD_ENV_NAMES if name in os.environ}
return {"PATH": SYSTEM_PATH, **env}
def _reap(process: subprocess.Popen) -> None:
"""Wait for *process* on a daemon thread, so it never lingers as a zombie
and is never waited on by the caller."""
threading.Thread(target=process.wait, name="pm-reveal-reaper", daemon=True).start()
def _contained_file(path: Path, root: Path) -> Path:
"""*path*'s real path when it is still a regular file inside *root*.
Run right before launching: a parent swapped for a symlink since the
route resolved the path must not open a folder outside the root.
"""
real = os.path.realpath(path)
real_root = os.path.realpath(root)
try:
inside = os.path.commonpath(
[os.path.normcase(real), os.path.normcase(real_root)]
) == os.path.normcase(real_root)
except ValueError:
inside = False
if not inside or real == real_root or not os.path.isfile(real):
raise RevealTargetChanged()
return Path(real)
# HRESULTs as the signed 32-bit values ctypes.c_long returns.
S_OK = 0
S_FALSE = 1
RPC_E_CHANGED_MODE = -2147417850 # 0x80010106: COM already set up otherwise
COINIT_APARTMENTTHREADED = 0x2
COINIT_DISABLE_OLE1DDE = 0x4
def _windows_libraries():
"""Private ole32 and shell32 handles, so the signatures set here never
change another library's view of ``ctypes.windll``."""
import ctypes
return ctypes.WinDLL("ole32"), ctypes.WinDLL("shell32")
def _declare_shell_signatures(ole32, shell32) -> None:
import ctypes
ole32.CoInitializeEx.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
ole32.CoInitializeEx.restype = ctypes.c_long
ole32.CoUninitialize.argtypes = []
ole32.CoUninitialize.restype = None
shell32.ILCreateFromPathW.argtypes = [ctypes.c_wchar_p]
shell32.ILCreateFromPathW.restype = ctypes.c_void_p
shell32.SHOpenFolderAndSelectItems.argtypes = [
ctypes.c_void_p,
ctypes.c_uint,
ctypes.c_void_p,
ctypes.c_ulong,
]
shell32.SHOpenFolderAndSelectItems.restype = ctypes.c_long
shell32.ILFree.argtypes = [ctypes.c_void_p]
shell32.ILFree.restype = None
def _shell_libraries():
"""The declared ole32 and shell32, or None where they cannot be loaded."""
try:
ole32, shell32 = _windows_libraries()
_declare_shell_signatures(ole32, shell32)
except Exception: # no shell libraries: the folder fallback still runs
return None
return ole32, shell32
@contextmanager
def _com_apartment(ole32):
"""The calling thread inside COM for the block; yields whether COM is
usable. Only a successful CoInitializeEx (S_OK or S_FALSE) is balanced,
so the persistent reveal thread never drifts."""
try:
result = ole32.CoInitializeEx(
None, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE
)
except Exception: # an unusable ole32 leaves COM alone
result = None
try:
yield result in (S_OK, S_FALSE, RPC_E_CHANGED_MODE)
finally:
if result in (S_OK, S_FALSE):
ole32.CoUninitialize()
def _select_item(shell32, path: Path) -> bool:
"""Open Explorer on *path*'s folder with *path* selected, via the shell
API. The path is one wide-string value, so no command line is ever
parsed (Explorer splits its own on commas). False on any failure; never
raises into the route."""
try:
pidl = shell32.ILCreateFromPathW(str(path))
if not pidl:
return False
try:
return shell32.SHOpenFolderAndSelectItems(pidl, 0, None, 0) >= 0
finally:
shell32.ILFree(pidl)
except Exception: # any shell failure falls back to the folder
return False
def _explore(folder: Path) -> None:
startfile = getattr(os, "startfile", None)
if startfile is None:
raise OpenerUnavailable()
# ShellExecute on the folder itself: no command line is built or parsed.
startfile(str(folder), "explore")
def _reveal_windows(path: Path) -> None:
libraries = _shell_libraries()
if libraries is None:
_explore(path.parent)
return
ole32, shell32 = libraries
with _com_apartment(ole32) as ready:
if ready and _select_item(shell32, path):
return
# ShellExecute inside the same apartment: shell extensions may need COM.
_explore(path.parent)
def _launch(argv: list[str]) -> subprocess.Popen:
options = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
# Its own session, so stopping ComfyUI does not take the window along.
"start_new_session": True,
"env": _child_env(),
}
# POSIX only (Windows uses the shell API): argv[0] is an absolute opener,
# the rest are fixed flags and one absolute path inside a gallery root,
# passed to execve as separate strings with no shell to re-parse them.
return subprocess.Popen(argv, **options) # nosec B603
def _launch_opener(argv: list[str]) -> None:
"""Start an opener and report its failure (OpenerFailed), never waiting on
a file manager it stays attached to: that one is left to the reaper."""
process = _launch(argv)
try:
status = process.wait(timeout=OPENER_WAIT)
except subprocess.TimeoutExpired:
_reap(process)
return
if status != 0:
raise OpenerFailed(f"exit status {status}")
def _show_items(path: Path) -> bool:
"""Ask the freedesktop FileManager1 service to select *path*; True only
when the service replied (``--print-reply`` makes the exit status real)."""
dbus = _executable("dbus-send")
if dbus is None:
return False
argv = [
dbus,
"--session",
"--print-reply",
f"--reply-timeout={DBUS_REPLY_TIMEOUT_MS}",
f"--dest={FILE_MANAGER_DEST}",
"--type=method_call",
"/org/freedesktop/FileManager1",
f"{FILE_MANAGER_DEST}.ShowItems",
f"array:string:{path.as_uri()}",
"string:",
]
try:
process = _launch(argv)
except OSError:
return False
try:
return process.wait(timeout=DBUS_WAIT) == 0
except subprocess.TimeoutExpired:
process.kill()
process.wait()
return False
def reveal_in_file_manager(path: Path, root: Path) -> None:
"""Open the file manager at *path*, a regular file inside *root*.
Re-checks that containment first (RevealTargetChanged). Raises
OpenerUnavailable when no opener exists or Linux has no graphical session,
OSError when the one found cannot start and OpenerFailed when it reports a
failure. Never waits on the file manager itself.
"""
path = Path(path)
if not path.is_absolute():
raise ValueError("path must be absolute")
path = _contained_file(path, Path(root))
if sys.platform == "win32":
_reveal_windows(path)
return
if sys.platform == "darwin":
opener = _macos_open()
if opener is None:
raise OpenerUnavailable()
_launch_opener([opener, "-R", str(path)])
return
if _show_items(path):
return
opener = _executable("xdg-open")
has_display = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
if opener is None or not has_display:
raise OpenerUnavailable()
_launch_opener([opener, str(path.parent)])