|
| 1 | +"""Reveals a rendered output file in the OS file manager, from a history row's |
| 2 | +stored thumbnail_path (e.g. "filename=a.png&subfolder=&type=output"). |
| 3 | +
|
| 4 | +Path resolution mirrors ComfyUI's own view_image handler (server.py) so the |
| 5 | +same query string that already renders a thumbnail resolves to the same file. |
| 6 | +""" |
| 7 | + |
| 8 | +import asyncio |
| 9 | +import logging |
| 10 | +import os |
| 11 | +import subprocess |
| 12 | +import sys |
| 13 | +from typing import Callable |
| 14 | +from urllib.parse import parse_qs |
| 15 | + |
| 16 | +from aiohttp import web |
| 17 | + |
| 18 | +logger = logging.getLogger(__name__) |
| 19 | + |
| 20 | + |
| 21 | +def _resolve_output_path(query: str, get_directory_by_type: Callable[[str], str | None] | None = None) -> str | None: |
| 22 | + if get_directory_by_type is None: |
| 23 | + import folder_paths # type: ignore[import-not-found] |
| 24 | + get_directory_by_type = folder_paths.get_directory_by_type |
| 25 | + |
| 26 | + params = parse_qs(query) |
| 27 | + filename = params.get("filename", [""])[0] |
| 28 | + if not filename or filename[0] == "/" or ".." in filename: |
| 29 | + return None |
| 30 | + |
| 31 | + output_dir = get_directory_by_type(params.get("type", ["output"])[0]) |
| 32 | + if output_dir is None: |
| 33 | + return None |
| 34 | + |
| 35 | + subfolder = params.get("subfolder", [""])[0] |
| 36 | + if subfolder: |
| 37 | + full_dir = os.path.join(output_dir, subfolder) |
| 38 | + if os.path.commonpath((os.path.abspath(full_dir), output_dir)) != output_dir: |
| 39 | + return None |
| 40 | + output_dir = full_dir |
| 41 | + |
| 42 | + return os.path.join(output_dir, os.path.basename(filename)) |
| 43 | + |
| 44 | + |
| 45 | +# PowerShell script (same COM/P-Invoke style as native_dialog.py's file |
| 46 | +# picker): finds the Explorer window actually showing our target folder via |
| 47 | +# Shell.Application's Windows() collection — matching on the real folder |
| 48 | +# path rather than guessing from window title text — and foregrounds it. |
| 49 | +# |
| 50 | +# SetForegroundWindow alone would silently no-op here: Windows enforces a |
| 51 | +# "foreground lock" that refuses it for a background process (this Python |
| 52 | +# server owns no window of its own) and just flashes the taskbar icon |
| 53 | +# instead. A synthetic Alt keypress is the standard, minimal way to reset |
| 54 | +# that lock's timer so the very next SetForegroundWindow call is honored. |
| 55 | +_FOREGROUND_EXPLORER_PS = r""" |
| 56 | +Add-Type @" |
| 57 | +using System; |
| 58 | +using System.Runtime.InteropServices; |
| 59 | +public class SmartQueueForeground { |
| 60 | + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); |
| 61 | + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); |
| 62 | + [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); |
| 63 | +} |
| 64 | +"@ |
| 65 | +
|
| 66 | +$target = '__FOLDER__' |
| 67 | +$shell = New-Object -ComObject Shell.Application |
| 68 | +$deadline = (Get-Date).AddSeconds(2) |
| 69 | +$hwnd = [IntPtr]::Zero |
| 70 | +while ((Get-Date) -lt $deadline -and $hwnd -eq [IntPtr]::Zero) { |
| 71 | + foreach ($w in @($shell.Windows())) { |
| 72 | + try { |
| 73 | + if ($w.Document.Folder.Self.Path -eq $target) { $hwnd = [IntPtr]$w.HWND; break } |
| 74 | + } catch {} |
| 75 | + } |
| 76 | + if ($hwnd -eq [IntPtr]::Zero) { Start-Sleep -Milliseconds 150 } |
| 77 | +} |
| 78 | +
|
| 79 | +if ($hwnd -ne [IntPtr]::Zero) { |
| 80 | + [SmartQueueForeground]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero) # Alt down |
| 81 | + [SmartQueueForeground]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero) # Alt up |
| 82 | + [SmartQueueForeground]::ShowWindow($hwnd, 9) | Out-Null # SW_RESTORE |
| 83 | + [SmartQueueForeground]::SetForegroundWindow($hwnd) | Out-Null |
| 84 | +} |
| 85 | +""" |
| 86 | + |
| 87 | + |
| 88 | +def _bring_explorer_to_foreground(folder: str) -> None: |
| 89 | + script = _FOREGROUND_EXPLORER_PS.replace("__FOLDER__", folder.replace("'", "''")) |
| 90 | + subprocess.run( |
| 91 | + ["powershell", "-NoProfile", "-Command", script], |
| 92 | + capture_output=True, timeout=5, |
| 93 | + ) |
| 94 | + |
| 95 | + |
| 96 | +def _reveal(path: str) -> None: |
| 97 | + if sys.platform == "win32": |
| 98 | + # Passed as a raw command-line string (not a list) so only the path |
| 99 | + # itself is quoted, matching Explorer's documented "/select,<quoted |
| 100 | + # path>" syntax exactly. A list argument here would go through |
| 101 | + # Python's own list2cmdline quoting, which wraps the *entire* |
| 102 | + # "/select,<path with spaces>" token in an outer pair of quotes — |
| 103 | + # Explorer parses that malformed form inconsistently and can open a |
| 104 | + # second, unselected fallback window alongside the real one. |
| 105 | + subprocess.Popen(f'explorer /select,"{path}"') |
| 106 | + # Best-effort UI polish only — the file is already selected in |
| 107 | + # Explorer at this point regardless of whether this succeeds, so a |
| 108 | + # failure here must never turn into an error response for the user. |
| 109 | + try: |
| 110 | + _bring_explorer_to_foreground(os.path.dirname(path)) |
| 111 | + except (OSError, subprocess.TimeoutExpired): |
| 112 | + logger.exception("[Smart Queue] could not bring Explorer to the foreground") |
| 113 | + elif sys.platform == "darwin": |
| 114 | + subprocess.Popen(["open", "-R", path]) |
| 115 | + else: |
| 116 | + # No universal "select this file" affordance on Linux file managers; |
| 117 | + # opening the containing folder is the best portable fallback. |
| 118 | + subprocess.Popen(["xdg-open", os.path.dirname(path)]) |
| 119 | + |
| 120 | + |
| 121 | +async def post_reveal_in_explorer(request: web.Request) -> web.Response: |
| 122 | + if request.remote not in ("127.0.0.1", "::1"): |
| 123 | + return web.json_response({"ok": False, "error": "forbidden"}, status=403) |
| 124 | + |
| 125 | + payload = await request.json() |
| 126 | + query = str(payload.get("thumbnail_path", "")) |
| 127 | + if not query: |
| 128 | + return web.json_response({"ok": False, "error": "no file for this item"}, status=400) |
| 129 | + |
| 130 | + path = _resolve_output_path(query) |
| 131 | + if path is None or not os.path.isfile(path): |
| 132 | + return web.json_response({"ok": False, "error": "file not found on disk"}, status=404) |
| 133 | + |
| 134 | + try: |
| 135 | + # _reveal briefly polls for the Explorer window on win32 (up to |
| 136 | + # ~2s) — keep that off the event loop. |
| 137 | + loop = asyncio.get_running_loop() |
| 138 | + await loop.run_in_executor(None, _reveal, path) |
| 139 | + except OSError as exc: |
| 140 | + logger.error("[Smart Queue] failed to open file manager: %s", exc) |
| 141 | + return web.json_response({"ok": False, "error": str(exc)}, status=500) |
| 142 | + |
| 143 | + return web.json_response({"ok": True}) |
0 commit comments