Files
ComfyAssets-ComfyUI_PromptM…/utils/trash_macos.py
T
Vito Sansevero e59206b099 feat(images): move deleted image files to the macOS Trash (#499)
Trash mode on macOS now calls -[NSFileManager trashItemAtURL:resultingItemURL:error:]
through ctypes (no dependency), in utils/trash_macos.py. Before any move it refuses
what it cannot show to be recoverable, already in the review dialog: network
volumes, read-only volumes, iCloud Drive and File Provider folders (cloud-sync),
a trash folder inside an output root (by file identity), and files macOS would
refuse for permission (no-permission). A refused move keeps the file and logs the
NSError; a move macOS reports done but whose place cannot be shown goes under
errors as trash-unconfirmed, keeps its records and stops the job.

The native proof runs in portability.yml's new native-trash job on macos-latest
and macos-15-intel (PM_NATIVE_TRASH=macos). Windows still reports no-trash (#500).
2026-10-29 13:46:22 -07:00

490 lines
18 KiB
Python

"""The macOS Trash backend (#499): NSFileManager through ctypes, or refuse.
``-[NSFileManager trashItemAtURL:resultingItemURL:error:]`` moves a file into
its volume's trash folder (``~/.Trash`` on the startup volume,
``<volume>/.Trashes/<uid>`` elsewhere) or fails with an ``NSError``; it renames
name clashes itself and never replaces a file. Before calling it,
:class:`MacTrashBackend` refuses what it cannot show to be recoverable:
network volumes, read-only volumes, iCloud Drive and File Provider sync
folders, a trash folder inside an output root, and files macOS would refuse
for permission. Nothing here unlinks, copies or overwrites a file.
The :class:`Bridge` is the only code that touches the Objective-C runtime. It
sends ten fixed messages, each through ``objc_msgSend`` cast to its exact
prototype (required on arm64), checks every object for nil before a send (a
nil can raise an Objective-C exception, which would abort the process), and
runs each call inside its own autorelease pool. It is loaded lazily, on the
first ``available``/``move``; when it cannot load, every file answers
``no-trash`` as before. The facts behind these rules were measured on macOS
14, 15 (x86_64) and 26 (docs/egm/499-macos-trash/design.md §4).
"""
from __future__ import annotations
import ctypes
import logging
import os
import stat
import sys
import threading
from dataclasses import dataclass
from . import trash
logger = logging.getLogger("prompt_manager.trash")
COCOA = "NSCocoaErrorDomain"
POSIX = "NSPOSIXErrorDomain"
FEATURE_UNSUPPORTED = 3328
_ENOENT = 2
# Top-level Cocoa codes of a refused move (design §7.5); anything else is "failed".
_MOVE_CODES = {
FEATURE_UNSUPPORTED: trash.NO_TRASH,
642: trash.NO_TRASH, # NSFileWriteVolumeReadOnlyError
513: trash.NO_PERMISSION, # NSFileWriteNoPermissionError
257: trash.NO_PERMISSION, # NSFileReadNoPermissionError
}
_LOCK_FLAGS = stat.UF_IMMUTABLE | stat.SF_IMMUTABLE | stat.UF_APPEND | stat.SF_APPEND
_CLOUD_FOLDERS = (("Library", "Mobile Documents"), ("Library", "CloudStorage"))
@dataclass(frozen=True)
class NSErr:
domain: str
code: int
under_domain: str | None = None
under_code: int | None = None
@dataclass(frozen=True)
class Facts:
"""Resource values of a file's URL; ``None`` means the lookup failed or was nil."""
local: bool | None
read_only: bool | None
root_fs: bool | None
volume: str | None
ubiquitous: bool | None
def error_detail(error: NSErr | None) -> str:
"""``"<domain> <code> (<under_domain> <under_code>)"`` for the log."""
if error is None:
return "no NSError"
text = f"{error.domain} {error.code}"
if error.under_domain is not None:
text += f" ({error.under_domain} {error.under_code})"
return text
def refusal_code(error: NSErr | None) -> str:
if error is None or error.domain != COCOA:
return trash.FAILED
return _MOVE_CODES.get(error.code, trash.FAILED)
def _within(path: str, folder: str) -> bool:
path = os.path.normcase(path)
folder = os.path.normcase(folder)
trimmed = folder.rstrip("\\/") or folder
return path == trimmed or path.startswith(trimmed.rstrip(os.sep) + os.sep)
def _identity(path: str):
info = os.stat(path)
return info.st_dev, info.st_ino
def inside_any_root(path: str, roots) -> bool:
"""Whether *path* (existing or not) lies in an output root, by file identity.
Every existing folder from *path* up to the top is compared with the
roots' ``(st_dev, st_ino)``, so case, NFC/NFD and symlinked spellings of a
root all match.
"""
identities = set()
for root in roots:
try:
identities.add(_identity(str(root)))
except OSError:
continue
current = os.path.abspath(path)
while True:
try:
if _identity(current) in identities:
return True
except OSError:
pass
parent = os.path.dirname(current)
if parent == current:
return False
current = parent
def _same_folder(left: str, right: str) -> bool:
try:
return _identity(left) == _identity(right)
except OSError:
return os.path.realpath(left) == os.path.realpath(right)
class MacTrashBackend:
"""``available``/``move`` for macOS; the bridge and file checks are seams."""
def __init__(
self, bridge=None, uid=None, home=None, lstat=os.lstat, access=os.access
):
self._bridge = bridge
self._load_failed = False
self._lock = threading.Lock()
if uid is None:
uid = os.getuid() if hasattr(os, "getuid") else -1
self._uid = uid
self._home = home
self._lstat = lstat
self._access = access
def _get_bridge(self):
if self._bridge is None and not self._load_failed:
with self._lock:
if self._bridge is None and not self._load_failed:
try:
self._bridge = load_bridge()
except (OSError, AttributeError, ValueError) as error:
self._load_failed = True
logger.warning("The macOS trash is unavailable: %s", error)
if self._bridge is None:
raise trash.TrashRefused(trash.NO_TRASH, "Foundation bridge not loaded")
return self._bridge
def _home_dir(self) -> str:
return os.path.realpath(self._home or os.path.expanduser("~"))
def _plan(self, path: str, roots):
"""``(trash folder, facts)`` for *path*; raises TrashRefused (design §7.2)."""
bridge = self._get_bridge()
real = os.path.realpath(path)
facts = bridge.facts(real)
if facts.local is not True:
raise trash.TrashRefused(trash.NETWORK)
if facts.read_only is not False:
raise trash.TrashRefused(trash.NO_TRASH, "read-only volume")
home = self._home_dir()
if facts.ubiquitous is True or any(
_within(real, os.path.join(home, *parts)) for parts in _CLOUD_FOLDERS
):
raise trash.TrashRefused(trash.CLOUD_SYNC)
folder, error = bridge.trash_dir(real)
if folder is None:
folder = self._predict(facts, error)
if inside_any_root(folder, roots):
raise trash.TrashRefused(trash.TRASH_IN_ROOT)
self._check_permission(real)
return folder, facts
def _predict(self, facts: Facts, error: NSErr | None) -> str:
"""``<volume>/.Trashes/<uid>`` for a volume whose trash is not made yet."""
missing = (
error is not None
and error.domain == COCOA
and error.code == FEATURE_UNSUPPORTED
and error.under_domain == POSIX
and error.under_code == _ENOENT
)
if not missing or facts.root_fs is not False or not facts.volume:
raise trash.TrashRefused(trash.NO_TRASH, error_detail(error))
trashes = os.path.join(facts.volume, ".Trashes")
try:
info = self._lstat(trashes)
except FileNotFoundError:
info = None
except OSError as failure:
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
if info is not None and not stat.S_ISDIR(info.st_mode):
raise trash.TrashRefused(trash.NO_TRASH, ".Trashes is not a folder")
return os.path.join(trashes, str(self._uid))
def _check_permission(self, real: str) -> None:
parent = os.path.dirname(real)
if not self._access(parent, os.W_OK | os.X_OK):
raise trash.TrashRefused(trash.NO_PERMISSION, "folder not writable")
try:
file_info = self._lstat(real)
parent_info = self._lstat(parent)
except OSError as failure:
raise trash.TrashRefused(trash.FAILED, str(failure)) from failure
for info in (file_info, parent_info):
if getattr(info, "st_flags", 0) & _LOCK_FLAGS:
raise trash.TrashRefused(trash.NO_PERMISSION, "immutable flag")
if parent_info.st_mode & stat.S_ISVTX and file_info.st_uid != self._uid:
raise trash.TrashRefused(trash.NO_PERMISSION, "sticky folder, foreign file")
def _label(self, folder: str, facts: Facts) -> str:
if _same_folder(folder, os.path.join(self._home_dir(), ".Trash")):
return trash.HOME_TRASH_LABEL
real = os.path.realpath(folder)
volume = os.path.realpath(facts.volume) if facts.volume else None
if volume and volume != os.path.realpath(os.sep) and _within(real, volume):
relative = os.path.relpath(real, volume).replace(os.sep, "/")
return f"{relative} on its drive"
return real
def available(self, path: str, roots) -> str | None:
"""None when *path* can be trashed, else the refusal code (no writes)."""
try:
self._plan(path, roots)
except trash.TrashRefused as refused:
return refused.code
except OSError:
return trash.NO_TRASH
return None
def move(self, path: str, roots) -> str:
"""Move *path* into its trash folder and return the label (design §7.4)."""
try:
planned, facts = self._plan(path, roots)
except OSError as failure:
raise trash.TrashRefused(trash.NO_TRASH, str(failure)) from failure
real = os.path.realpath(path)
ok, resulting, error = self._get_bridge().trash(real)
if not ok:
raise trash.TrashRefused(refusal_code(error), error_detail(error))
if os.path.lexists(real):
raise trash.TrashRefused(trash.FAILED, "reported moved, still in place")
if not resulting or inside_any_root(os.path.dirname(resulting), roots):
raise trash.TrashUnconfirmed(f"{real} -> {resulting!r}")
folder = os.path.dirname(resulting)
if not _same_folder(folder, planned):
logger.warning(
"macOS trashed %s to %s, not the planned %s", real, resulting, planned
)
return self._label(folder, facts)
# ── the Foundation bridge ────────────────────────────────────────────────
_NS_TRASH_DIRECTORY = 102
_NS_USER_DOMAIN_MASK = 1
_LIBOBJC = "/usr/lib/libobjc.A.dylib"
_FOUNDATION = "/System/Library/Frameworks/Foundation.framework/Foundation"
_KEYS = (
"NSURLVolumeIsLocalKey",
"NSURLVolumeIsReadOnlyKey",
"NSURLVolumeIsRootFileSystemKey",
"NSURLVolumeURLKey",
"NSURLIsUbiquitousItemKey",
"NSUnderlyingErrorKey",
)
_REQUIRED_SELECTORS = (
("NSURL", "fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"),
("NSURL", "getResourceValue:forKey:error:"),
("NSFileManager", "URLForDirectory:inDomain:appropriateForURL:create:error:"),
("NSFileManager", "trashItemAtURL:resultingItemURL:error:"),
)
class _Nil(Exception):
"""An Objective-C object was nil where a send needs one."""
class Bridge: # pragma: no cover - macOS only; the native-trash job runs it
"""Foundation through ctypes: ``facts``, ``trash_dir`` and ``trash`` (design §6)."""
def __init__(self):
if sys.platform != "darwin":
raise OSError("the Foundation bridge exists only on macOS")
objc = ctypes.CDLL(_LIBOBJC)
foundation = ctypes.CDLL(_FOUNDATION)
void_p, char_p, byte = ctypes.c_void_p, ctypes.c_char_p, ctypes.c_byte
objc.objc_getClass.argtypes = [char_p]
objc.objc_getClass.restype = void_p
objc.sel_registerName.argtypes = [char_p]
objc.sel_registerName.restype = void_p
objc.objc_autoreleasePoolPush.argtypes = []
objc.objc_autoreleasePoolPush.restype = void_p
objc.objc_autoreleasePoolPop.argtypes = [void_p]
objc.objc_autoreleasePoolPop.restype = None
self._objc = objc
address = ctypes.cast(objc.objc_msgSend, void_p).value
def send(restype, *argtypes):
return ctypes.CFUNCTYPE(restype, void_p, void_p, *argtypes)(address)
out = ctypes.POINTER(void_p)
self._send_id = send(void_p)
self._send_responds = send(byte, void_p)
self._send_url = send(void_p, char_p, byte, void_p)
self._send_resource = send(byte, out, void_p, out)
self._send_bool = send(byte)
self._send_dir = send(void_p, ctypes.c_ulong, ctypes.c_ulong, void_p, byte, out)
self._send_trash = send(byte, void_p, out, out)
self._send_cstr = send(char_p)
self._send_long = send(ctypes.c_long)
self._send_object = send(void_p, void_p)
self._keys = {}
for name in _KEYS:
value = void_p.in_dll(foundation, name).value
if not value:
raise ValueError(f"Foundation constant {name} is nil")
self._keys[name] = value
for class_name, selector in _REQUIRED_SELECTORS:
if not self._send_responds(
self._class(class_name),
self._sel("respondsToSelector:"),
self._sel(selector),
) and not self._instances_respond(class_name, selector):
raise ValueError(f"{class_name} does not respond to {selector}")
# helpers (call inside a pool) ------------------------------------------------
def _class(self, name: str):
value = self._objc.objc_getClass(name.encode())
if not value:
raise ValueError(f"no Objective-C class {name}")
return value
def _sel(self, name: str):
return self._objc.sel_registerName(name.encode())
def _instances_respond(self, class_name: str, selector: str) -> bool:
return (
self._send_responds(
self._class(class_name),
self._sel("instancesRespondToSelector:"),
self._sel(selector),
)
!= 0
)
@staticmethod
def _need(value):
if not value:
raise _Nil()
return value
def _manager(self):
return self._need(
self._send_id(self._class("NSFileManager"), self._sel("defaultManager"))
)
def _url(self, path: str):
return self._need(
self._send_url(
self._class("NSURL"),
self._sel(
"fileURLWithFileSystemRepresentation:isDirectory:relativeToURL:"
),
os.fsencode(path),
0,
None,
)
)
def _path(self, url) -> str | None:
if not url:
return None
raw = self._send_cstr(url, self._sel("fileSystemRepresentation"))
return os.fsdecode(raw) if raw else None
def _text(self, obj) -> str | None:
if not obj:
return None
raw = self._send_cstr(obj, self._sel("UTF8String"))
return raw.decode("utf-8", "replace") if raw else None
def _error(self, error) -> NSErr | None:
if not error:
return None
domain = self._text(self._send_id(error, self._sel("domain"))) or "?"
code = self._send_long(error, self._sel("code"))
under_domain = under_code = None
info = self._send_id(error, self._sel("userInfo"))
if info:
under = self._send_object(
info, self._sel("objectForKey:"), self._keys["NSUnderlyingErrorKey"]
)
if under:
under_domain = self._text(self._send_id(under, self._sel("domain")))
under_code = self._send_long(under, self._sel("code"))
return NSErr(domain, code, under_domain, under_code)
def _resource(self, url, key_name: str):
value, error = ctypes.c_void_p(), ctypes.c_void_p()
ok = self._send_resource(
url,
self._sel("getResourceValue:forKey:error:"),
ctypes.byref(value),
self._keys[key_name],
ctypes.byref(error),
)
return value.value if ok != 0 and value.value else None
def _flag(self, url, key_name: str) -> bool | None:
value = self._resource(url, key_name)
if value is None:
return None
return self._send_bool(value, self._sel("boolValue")) != 0
def _pooled(self, work):
pool = self._objc.objc_autoreleasePoolPush()
try:
return work()
except _Nil as nil:
raise trash.TrashRefused(trash.NO_TRASH, "nil Objective-C object") from nil
finally:
self._objc.objc_autoreleasePoolPop(pool)
# the bridge API (design §6.3) ----------------------------------------------
def facts(self, path: str) -> Facts:
def work():
url = self._url(path)
return Facts(
local=self._flag(url, "NSURLVolumeIsLocalKey"),
read_only=self._flag(url, "NSURLVolumeIsReadOnlyKey"),
root_fs=self._flag(url, "NSURLVolumeIsRootFileSystemKey"),
volume=self._path(self._resource(url, "NSURLVolumeURLKey")),
ubiquitous=self._flag(url, "NSURLIsUbiquitousItemKey"),
)
return self._pooled(work)
def trash_dir(self, path: str):
def work():
error = ctypes.c_void_p()
folder = self._send_dir(
self._manager(),
self._sel("URLForDirectory:inDomain:appropriateForURL:create:error:"),
_NS_TRASH_DIRECTORY,
_NS_USER_DOMAIN_MASK,
self._url(path),
0,
ctypes.byref(error),
)
if folder:
return self._path(folder), None
return None, self._error(error.value)
return self._pooled(work)
def trash(self, path: str):
def work():
resulting, error = ctypes.c_void_p(), ctypes.c_void_p()
ok = self._send_trash(
self._manager(),
self._sel("trashItemAtURL:resultingItemURL:error:"),
self._url(path),
ctypes.byref(resulting),
ctypes.byref(error),
)
if ok != 0:
return True, self._path(resulting.value), None
return False, None, self._error(error.value)
return self._pooled(work)
def load_bridge() -> Bridge:
"""The real Foundation bridge; raises OSError/ValueError where it cannot load."""
return Bridge()