Files
ComfyAssets-ComfyUI_PromptM…/utils/file_reveal.py
Vito 3476f8ede9 feat(inspector): concept comparison, Show in folder and toast placement (#264)
* test(inspector): concept comparison for the panel and viewer details

Compare the Images side panel and the viewer's full details with the
concept (1600x1100 and 390x844, both themes): structure, tokens, geometry
and controls, plus copy, the acknowledged pair send, the tag chip filter
and Prompt history. mapInspectorConcept applies only D3, D20, D21 and D22;
a mutation test shows the comparison notices a changed role label and a
removed footer.

The comparison showed the role copy and send names lacked the concept's
": <title>" subject, so the inspector now passes its title to the prompt
blocks. It also showed that a recipe-linked image shows its recipe's own
category and tags (#224), which the concept fixture's recipes lack; that
is recorded as D38, and the fixture's new positive_links option links the
compared image to its positive alone.

* docs(inspector): renumber the recipe-category decision to D39

* feat(images): local-only Show in folder route

POST /prompt_manager/images/reveal takes the listing identity
{root_index, root_scope, path} and opens this computer's file manager at
that file. It answers 403 not-local unless the client is loopback, no
proxy header is present and Host/Origin name this computer; the scope is
checked with _gallery_scope_error (409) and the path resolves only inside
that root to a regular media file. Openers run as argv lists with an
absolute executable, no shell and DEVNULL streams (D38).

* feat(inspector): Show in folder after the File & provenance path

The viewer's File & provenance offers "Show in folder" right after the
Path value for a listed file only, POSTs its listing identity and toasts
the outcome (opened, not-local, unavailable or failed). The browser test
server stubs the opener; TESTING.md records the decision as D38.

* fix(images): keep the discovery alert clear of the Rescan toast

A rescan that leaves the Images discovery alert visible could show its
fixed bottom toast over the alert on a short window. The concept's toast
keeps its placement; the production-only alert now scrolls into view only
as far as needed, with a 96px block-end scroll margin (D42).

* docs(inspector): concept panel and viewer details

* fix(images): open Windows folders through the shell API, not an Explorer command line

Explorer splits its own command line on commas, so a gallery path such as
output\a,\Windows\System32\calc.exe,b\x.png escaped the roots through
[explorer, "/select,", path] (security review H1). Windows now selects the
file with ILCreateFromPathW and SHOpenFolderAndSelectItems, and on any shell
failure falls back to os.startfile on the folder. No process is started.

* fix(images): re-check, isolate and reap the Show in folder opener

- re-check that the real path is still a regular file inside its root
  right before launching; a changed target answers 404 (review L4)
- run openers on a dedicated single worker, not ComfyUI's default
  executor, and make dbus-send wait for the FileManager1 reply
  (--print-reply --reply-timeout=2000) so a missing service falls back
  to xdg-open (L1)
- reap launched processes on a daemon thread, give them a minimal
  environment with a fixed system PATH, and use /usr/bin/open on macOS
  and dbus-send/xdg-open resolved only on that PATH (L5)

* fix(images): accept Show in folder only as a same-origin JSON request, one at a time

- require an Origin equal to the request's own scheme://host:port and,
  when sent, Sec-Fetch-Site: same-origin; refuse every forwarding-style
  header (Via, X-Forwarded-*, X-Original-*, *-Client-IP, CF-Connecting-IP
  and the rest) as remote (security review M1, L2)
- answer a non-JSON body with 415, so a cross-origin request always needs
  a CORS preflight (L2)
- single flight plus a 1 s cooldown per process: 429 code busy (L1)
- path resolution errors such as ENAMETOOLONG are a JSON 404, and neither
  that nor a start failure logs the client's path at error level (L3)

* fix(inspector): say the folder is already opening on a busy Show in folder

A 429 busy from the reveal route now toasts "The folder is already
opening." The browser workflow checks that the reveal POST carries the
page's own Origin and an application/json body, and TESTING.md D38 records
the tightened route, the toast table and the accepted residual risk of a
header-less local proxy.

* test(inspector): compare the viewer details under a coarse pointer and prove its mutations

The touch project measured production's D34 40px disclosure targets against
the concept's fine-pointer summaries. The concept's plain-chip copy now also
carries the recorded D34 coarse rule, so both documents are measured under the
same emulation. The open File & provenance is compared too (D38 Show in folder
and D20 Download original mapped), and the mutation check now removes the
viewer's pair actions and Show in folder.

* refactor(inspector): attach Show in folder to the Path value it follows

The button was placed on the second fact row by position; the Path dd is now
built by the caller and kept, so reordering the facts cannot move it.

* test(inspector): let the uncertain-file send check fail again

Inspector send buttons are named "... to ComfyUI: <title>", so the anchored
regex could never match and the no-send assertion always passed.

* fix(images): run the Windows folder fallback inside COM and keep desktop session variables

Show in folder's os.startfile fallback now runs inside the same
CoInitializeEx/CoUninitialize bracket as the shell selection, as ShellExecute
callers should. The Linux opener keeps XAUTHORITY, DESKTOP_SESSION,
XDG_SESSION_DESKTOP and XDG_CONFIG_HOME, so a file manager can reach the X11
display under GDM or SDDM and honour the user's mimeapps.list.

A Windows-only test calls the real ILCreateFromPathW and ILFree on a temp file
and pairs CoInitializeEx with CoUninitialize through the module's helper,
without SHOpenFolderAndSelectItems, so no window opens on CI.

* fix(images): scroll to the rescan's discovery alert only when the toast band covers it

D42 scrolled the alert into view after every rescan, so a user scrolled down
the grid was sent back to the top. It now scrolls only when the alert
intersects the toast's band (its scroll-margin-block-end).

* refactor(inspector): name prompt actions as PR #263 does

The title option keeps its name, default and output ("Copy positive prompt:
<title>") and now uses #263's actionName helper; the region-name arrow no
longer shadows the title option.

* test(inspector): the comparison opens Filters by its D44 active-count name

After a tag chip applies its filter, the shared toolbar's Filters button reads
"Filters · 1" (D44, #263), so the comparison opens it by that name.

* fix(images): POSIX opener paths stay absolute under Python 3.13 on Windows

Python 3.13 on Windows no longer counts "/usr/bin/x" as absolute, so the
faked-Linux reveal tests failed on windows-latest py3.13 (the Linux and macOS
openers are POSIX paths whatever host evaluates them). Use posixpath.isabs for
those lookups.
2026-10-21 00:53:04 -07:00

452 lines
15 KiB
Python

"""Show one gallery file in this computer's file manager (TESTING.md D38).
The route that calls this is local-only (``is_local_request``) and
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 ipaddress
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
# A local reverse proxy makes every client look like loopback, so a request
# that carries any forwarding-style header counts as remote: these names, and
# every X-Forwarded-*, X-Original-* and *-Client-IP header.
FORWARDING_HEADERS = frozenset(
(
"forwarded",
"via",
"x-real-ip",
"cf-connecting-ip",
"true-client-ip",
"x-client-ip",
"x-cluster-client-ip",
"fastly-client-ip",
)
)
FORWARDING_PREFIXES = ("x-forwarded-", "x-original-")
FORWARDING_SUFFIXES = ("-client-ip",)
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
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",
# The X11 cookie: a non-default path under GDM or SDDM.
"XAUTHORITY",
"DBUS_SESSION_BUS_ADDRESS",
"LANG",
"LC_ALL",
)
class OpenerUnavailable(Exception):
"""No file manager can be started on this computer."""
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 _loopback_address(value: str | None) -> bool:
if not value:
return False
try:
address = ipaddress.ip_address(value)
except ValueError:
return False
mapped = getattr(address, "ipv4_mapped", None)
return (mapped or address).is_loopback
def _loopback_host(host: str) -> bool:
"""True for a Host name naming this computer.
Rejects every other name, so a DNS-rebinding page whose name now resolves
to 127.0.0.1 still reads as remote.
"""
host = host.rstrip(".").lower()
if host == "localhost" or host.endswith(".localhost"):
return True
return _loopback_address(host.strip("[]"))
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 _forwarded(headers: Mapping) -> bool:
for name in headers.keys():
name = name.lower()
if (
name in FORWARDING_HEADERS
or name.startswith(FORWARDING_PREFIXES)
or name.endswith(FORWARDING_SUFFIXES)
):
return True
return False
def _same_origin(origin: str, scheme: str, host: tuple[str, int | None]) -> bool:
"""True when *origin* is exactly this request's scheme://host:port."""
default = DEFAULT_PORTS.get(scheme)
if default is None:
return False
try:
parts = urlsplit(origin)
origin_host, origin_port = parts.hostname, parts.port
except ValueError:
return False
if (
parts.scheme != scheme
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
host_name, host_port = host
return (origin_host.rstrip("."), origin_port or default) == (
host_name.rstrip("."),
host_port or default,
)
def is_local_request(remote: str | None, headers: Mapping, scheme="http") -> bool:
"""True only for a same-origin browser request from this computer.
That is: no forwarding-style header, a loopback client, a loopback name
in Host, an ``Origin`` that is exactly this request's scheme://host:port
(browsers send it on every fetch POST, so a missing one is refused), and
``Sec-Fetch-Site: same-origin`` whenever that header is sent.
"""
if _forwarded(headers):
return False
if not _loopback_address(remote):
return False
host = _authority(headers.get("Host", ""))
if host is None or not _loopback_host(host[0]):
return False
origin = headers.get("Origin")
if not origin or not _same_origin(origin, scheme, host):
return False
site = headers.get("Sec-Fetch-Site")
return site is None or site == "same-origin"
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 _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 and OSError when the one found
cannot start. 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()
_reap(_launch([opener, "-R", str(path)]))
return
if _show_items(path):
return
opener = _executable("xdg-open")
if opener is None:
raise OpenerUnavailable()
_reap(_launch([opener, str(path.parent)]))