170 lines
5.8 KiB
Python
170 lines
5.8 KiB
Python
"""Cross-platform helpers for revealing local files and opening directories."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
from typing import Any, Dict
|
|
|
|
LAUNCH_HANDOFF_TIMEOUT_SECONDS = 2.0
|
|
|
|
|
|
class FileManagerError(RuntimeError):
|
|
"""Base error raised when a local file manager cannot be opened."""
|
|
|
|
|
|
class FileManagerUnavailableError(FileManagerError):
|
|
"""Raised when the platform has no supported file-manager launcher."""
|
|
|
|
|
|
class UnsupportedFileManagerPlatformError(FileManagerError):
|
|
"""Raised when the current operating system is not supported."""
|
|
|
|
|
|
def _normalized_absolute_path(path_value: str) -> str:
|
|
return os.path.realpath(os.path.abspath(os.path.normpath(path_value)))
|
|
|
|
|
|
def normalize_file_manager_path(path_value: Any) -> str:
|
|
"""Normalize a local path and recover UI-style separators when necessary.
|
|
|
|
Legacy frontend state may contain backslashes as subfolder separators. On
|
|
POSIX, a backslash is also a valid filename character, so the literal path
|
|
is always checked first. It is interpreted as a separator only when the
|
|
literal path does not exist and the converted path does.
|
|
"""
|
|
if not isinstance(path_value, (str, os.PathLike)):
|
|
raise ValueError("path must be a string")
|
|
|
|
raw_path = os.fspath(path_value)
|
|
if isinstance(raw_path, bytes):
|
|
raise ValueError("path must be a string")
|
|
if not raw_path.strip():
|
|
raise ValueError("path is required")
|
|
if "\x00" in raw_path:
|
|
raise ValueError("path contains a null byte")
|
|
|
|
normalized_path = _normalized_absolute_path(raw_path)
|
|
if os.path.exists(normalized_path):
|
|
return normalized_path
|
|
|
|
if os.name != "nt" and "\\" in raw_path:
|
|
separator_path = _normalized_absolute_path(raw_path.replace("\\", os.sep))
|
|
if os.path.exists(separator_path):
|
|
return separator_path
|
|
|
|
return normalized_path
|
|
|
|
|
|
def _find_windows_explorer() -> str:
|
|
explorer = shutil.which("explorer.exe") or shutil.which("explorer")
|
|
if explorer:
|
|
return explorer
|
|
|
|
system_root = str(os.environ.get("SYSTEMROOT") or "").strip()
|
|
if system_root:
|
|
candidate = os.path.join(system_root, "explorer.exe")
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
|
|
raise FileManagerUnavailableError("Windows Explorer could not be found.")
|
|
|
|
|
|
def _find_launcher(*names: str) -> tuple[str, str]:
|
|
for name in names:
|
|
executable = shutil.which(name)
|
|
if executable:
|
|
return name, executable
|
|
raise FileManagerUnavailableError(
|
|
f"No supported file manager launcher was found ({', '.join(names)})."
|
|
)
|
|
|
|
|
|
def _launch(command: list[str], check_exit_code: bool = True) -> None:
|
|
kwargs: Dict[str, Any] = {
|
|
"shell": False,
|
|
"stdin": subprocess.DEVNULL,
|
|
"stdout": subprocess.DEVNULL,
|
|
"stderr": subprocess.DEVNULL,
|
|
}
|
|
if os.name == "nt":
|
|
kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
|
|
try:
|
|
process = subprocess.Popen(command, **kwargs)
|
|
except (OSError, ValueError) as exc:
|
|
raise FileManagerError(f"Could not start the system file manager: {exc}") from exc
|
|
|
|
# Explorer commonly delegates the request to an existing shell process and
|
|
# exits with code 1 even though the folder opened successfully. A successful
|
|
# process creation is therefore the only reliable Windows signal.
|
|
if not check_exit_code:
|
|
return
|
|
|
|
# Most launcher commands exit immediately after handing the request to the
|
|
# desktop. Catch a fast failure without waiting for a long-running manager.
|
|
try:
|
|
return_code = process.wait(timeout=LAUNCH_HANDOFF_TIMEOUT_SECONDS)
|
|
except subprocess.TimeoutExpired:
|
|
return
|
|
except OSError as exc:
|
|
raise FileManagerError(
|
|
f"Could not confirm that the system file manager started: {exc}"
|
|
) from exc
|
|
if return_code != 0:
|
|
raise FileManagerError(
|
|
f"The system file manager launcher exited with code {return_code}."
|
|
)
|
|
|
|
|
|
def open_in_file_manager(path_value: Any, system: str | None = None) -> Dict[str, Any]:
|
|
"""Reveal a file or open a directory using the host operating system."""
|
|
target_path = normalize_file_manager_path(path_value)
|
|
if not os.path.exists(target_path):
|
|
raise FileNotFoundError(f"Path does not exist: {target_path}")
|
|
|
|
is_file = os.path.isfile(target_path)
|
|
is_directory = os.path.isdir(target_path)
|
|
if not is_file and not is_directory:
|
|
raise FileManagerError("Only regular files and directories can be opened.")
|
|
|
|
platform_name = str(system or platform.system()).strip().lower()
|
|
opened_path = os.path.dirname(target_path) if is_file else target_path
|
|
selected = False
|
|
|
|
if platform_name == "windows":
|
|
explorer = _find_windows_explorer()
|
|
command = (
|
|
[explorer, "/select,", target_path]
|
|
if is_file
|
|
else [explorer, target_path]
|
|
)
|
|
selected = is_file
|
|
launcher = "explorer"
|
|
elif platform_name == "darwin":
|
|
launcher, executable = _find_launcher("open")
|
|
command = [executable, "-R", target_path] if is_file else [executable, target_path]
|
|
selected = is_file
|
|
elif platform_name == "linux":
|
|
launcher, executable = _find_launcher("xdg-open", "gio")
|
|
command = (
|
|
[executable, opened_path]
|
|
if launcher == "xdg-open"
|
|
else [executable, "open", opened_path]
|
|
)
|
|
else:
|
|
display_name = system or platform.system() or "unknown"
|
|
raise UnsupportedFileManagerPlatformError(
|
|
f"Opening folders is not supported on {display_name}."
|
|
)
|
|
|
|
_launch(command, check_exit_code=platform_name != "windows")
|
|
return {
|
|
"platform": platform_name,
|
|
"launcher": launcher,
|
|
"opened_path": opened_path,
|
|
"selected": selected,
|
|
}
|