From 50ddd40e0d132084d3b62968a112fcd3ae0cadb2 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Sun, 2 Aug 2026 09:20:34 -0400 Subject: [PATCH] Add unified community Tool Center --- ARCHITECTURE.md | 4 + CHANGELOG.md | 7 + DOCS_INDEX.md | 2 + EXTERNAL_TOOLS.md | 139 ++++++++++----- PROJECT_STATUS.md | 3 + README.md | 9 +- THIRD_PARTY_NOTICES.md | 23 +++ UnityScraper.spec | 1 + external_tools.py | 80 ++++++++- external_tools_gui.py | 371 ++++++++++++++++++++++------------------- i18n.py | 2 +- main.py | 82 +++++++++ modern_gui.py | 2 +- tests.py | 97 +++++++++++ tool_catalog.py | 311 ++++++++++++++++++++++++++++++++++ 15 files changed, 906 insertions(+), 227 deletions(-) create mode 100644 tool_catalog.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 839a4a8..fa2fe9d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -52,6 +52,10 @@ results to Tk's main loop. - `unified_search.py` ranks local results across the application domains. - `structured_knowledge.py` extracts typed records from cached source documents while retaining document and source relationships. +- `tool_catalog.py` declares supported community tools, reviewed operations, + platform constraints, executable discovery, saved paths, and checksums. +- `external_tools.py` validates path contracts and runs argument vectors without + a command shell; detached GUI launches remain separate from captured CLI jobs. ### Domain and Adapters diff --git a/CHANGELOG.md b/CHANGELOG.md index d366c29..0d3cc0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ Notable changes to UnityScraper are documented here. The project follows ### Added +- A catalog-backed Tool Center with persistent executable discovery, SHA-256 + identification, platform-aware availability, command previews, and CLI parity. +- Native extract-xiso list, extract, create, and guarded rewrite workflows, + plus direct Xenia and Xenia Canary launching. +- Managed launch integrations for user-supplied Velocity, Iso2God, God2ISO, + Xbox Image Browser, and Le Fluffie installations. + - Additive schema migration 11 for offline knowledge archive builds, rendered document state, and browser-saved wiki import history. - A self-contained offline Knowledge library with local search, source and diff --git a/DOCS_INDEX.md b/DOCS_INDEX.md index 6e79eac..5ea8549 100644 --- a/DOCS_INDEX.md +++ b/DOCS_INDEX.md @@ -9,6 +9,8 @@ snapshots, privacy, restore behavior, and Le Fluffie attribution - [Profile Intelligence and Xenia](PROFILE_INTELLIGENCE.md) - read-only GPD achievements, profile comparison, and snapshot-first Xenia migration +- [Tool Center](EXTERNAL_TOOLS.md) - supported community utilities, setup, + command-line use, provenance, and safety boundaries - [Collection Intelligence](COLLECTION_INTELLIGENCE.md) - storage discovery, XEX identity, Title Update compatibility, preservation, and repair previews - [Console Sync](CONSOLE_SYNC.md) - persistent transfers, resume, snapshots, diff --git a/EXTERNAL_TOOLS.md b/EXTERNAL_TOOLS.md index 810e400..6453c64 100644 --- a/EXTERNAL_TOOLS.md +++ b/EXTERNAL_TOOLS.md @@ -1,61 +1,110 @@ -# External Tools +# Tool Center -UnityScraper can run trusted Xbox command-line utilities from the desktop -interface. Open **External Tools** from the sidebar. +UnityScraper's **Tool Center** provides one place to discover, configure, and +run trusted Xbox 360 community utilities. Except for the documented XeXTool +copy, users provide their own lawfully obtained executables. UnityScraper does +not download tools, pass commands through a shell, or hide the command that is +about to run. -## XeXTool +## Supported Tools -The Windows build includes XeXTool 6.3, created by **xorloser**, so the XeXTool -preset works without separate setup. The executable was sourced from the -GPL-3.0-licensed -[XboxChef/XexToolGUI](https://github.com/XboxChef/XexToolGUI) project. -UnityScraper preserves its source license and records the exact binary checksum -in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +| Tool | Integration | Platforms | Credit | +| --- | --- | --- | --- | +| XeXTool | XEX information presets and custom arguments | Windows | xorloser | +| extract-xiso | List, extract, create, and guarded rewrite operations | Windows, Linux, macOS | XboxDev | +| Xenia | Launch a selected game or open the emulator | Windows | Xenia Project | +| Xenia Canary | Launch a selected game or open the emulator | Windows | Xenia Canary Project | +| Velocity | Detect, configure, verify, and launch | Windows | Velocity contributors | +| Iso2God | Detect, configure, verify, and launch | Windows | Iso2God contributors | +| God2ISO | Detect, configure, verify, and launch | Windows | Community utility | +| Xbox Image Browser | Detect, configure, verify, and launch | Windows | Community utility | +| Le Fluffie | Detect, configure, verify, and launch | Windows | Dalavin / DJ SkunkieButt | +| Custom CLI tool | User-defined argument vector with input/output placeholders | Current native platform | User supplied | -Use **Browse** to select a different lawfully obtained build at any time. A -missing or invalid saved path automatically falls back to the bundled copy. +Legacy GUI utilities are launch-managed because their command-line contracts +are not stable or publicly documented. UnityScraper deliberately does not +invent arguments for them. Native operation presets are only supplied where a +reviewable command-line interface exists. -The XeXTool preset provides: +FATXplorer and J-Runner are intentionally not integrated. -- **Extended information** using `-l "{input}"` -- **Basic information** using `"{input}"` -- **Custom arguments** for advanced users +## Setup -Choose an XEX file, review the command shown in the output panel, and select -**Run Tool**. Standard output and standard error remain visible in UnityScraper. +Open **Tool Center**, choose a tool, then select **Detect**. Discovery checks a +saved path, bundled resources where applicable, the system path, and a small +set of conventional tool folders. Use **Browse** when a tool is elsewhere. -## Other CLI Tools +The selected executable path is saved in the normal application configuration. +Its SHA-256 checksum is displayed after selection so the same binary can be +identified later. UnityScraper never treats that checksum as a publisher +signature or proof that an executable is safe. -Choose **Custom CLI tool**, select an executable, and enter its argument -template. Two placeholders are supported: +## Native Workflows -```text -{input} selected input file -{output} selected output path -``` +### XeXTool + +The Windows build includes XeXTool 6.3 by **xorloser** and provides basic and +extended XEX information presets. A different lawfully obtained build can be +selected. Output and errors remain visible in Tool Center. + +### extract-xiso + +The XboxDev command-line tool receives structured argument vectors for: -Each placeholder becomes part of one argument after parsing. UnityScraper -starts the executable directly with `shell=False`; it does not send the command -through PowerShell, Command Prompt, Bash, or another command shell. +- listing image contents; +- extracting an image to a selected folder; +- creating an image from a selected folder; and +- rewriting an image only after a destructive-operation confirmation. -Custom XeXTool and CLI paths are stored in the normal application -configuration. The bundled executable remains inside UnityScraper's packaged -resources and is not copied into the user data folder. +Keep a backup before any rewrite. The desktop asks for confirmation and the +CLI requires `--tool-allow-modify`. -## Safety +### Xenia and Xenia Canary -- Use tools and files you are legally entitled to use. -- Keep backups before running commands that modify content. -- Prefer the read-only information presets when inspecting an unfamiliar XEX. -- Review custom arguments before running them. -- Do not run executables from an untrusted source. -- Cancellation requests terminate the active process, but a tool may already - have changed its output before termination. +Tool Center can launch either emulator with a selected game path or open it +without a game. Existing Profiles & Saves migration features remain separate: +they preview mappings, create a verified snapshot, and avoid overwriting +different save files. -## Platform Notes +## Command Line + +List integrations and their operation IDs: + +```powershell +python main.py --list-tools +``` + +Run a configured operation: + +```powershell +python main.py --tool-id extract-xiso --tool-operation list --tool-input game.iso +``` + +Select and remember an executable explicitly: + +```powershell +python main.py --tool-id xenia --tool-operation launch-game ` + --tool-executable C:\Tools\Xenia\xenia.exe --tool-input default.xex +``` -The external tool must be executable on the current operating system. -The bundled XeXTool executable is enabled on Windows. Windows `.exe` files do -not run natively on Linux, so Linux users can select a native tool or a trusted -wrapper they configured themselves. UnityScraper does not automatically -install or invoke Wine. +`--tool-arg` may be repeated to replace a preset with an advanced argument +vector and requires `--tool-allow-modify`. `{input}` and `{output}` placeholders +are resolved as individual arguments. No PowerShell, Command Prompt, Bash, or +other shell interprets them. Reviewed built-in presets are locked in the GUI; +choose a custom operation when an editable argument vector is required. + +## Safety and Platform Boundaries + +- Use only software and content you are legally entitled to use. +- Obtain third-party tools from a source you trust and review their licenses. +- Keep backups before conversion or modification operations. +- Review the displayed executable, checksum, operation, and command. +- Cancellation stops a captured command-line process but cannot undo changes + the external program already made. +- Windows executables are not automatically installed or run through Wine on + Linux or macOS. Choose a native build where one exists. +- UnityScraper does not bundle game images, keys, firmware, SDK material, or + closed-source legacy utilities. + +See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for project links, +licenses, provenance, and the exact bundled XeXTool checksum. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 9ced133..a0ae2f1 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -77,6 +77,9 @@ backup-management, and source-attributed knowledge application. parity for search and preservation, FATX geometry reports, and bounded STFS file-table inventory. - Windows, Linux, and unsigned Apple Silicon macOS CI packaging. +- Catalog-backed Tool Center with native extract-xiso and Xenia workflows, + managed launch support for selected legacy GUI utilities, executable + discovery, checksums, CLI parity, and destructive-operation confirmation. ## Validation diff --git a/README.md b/README.md index 357d2fd..6e46d8a 100644 --- a/README.md +++ b/README.md @@ -88,10 +88,13 @@ SkunkieButt, and the GPLv3 X360 library and Le Fluffie source. See ### External Tools -- Runs Xbox command-line utilities inside the desktop interface. +- Provides a unified Tool Center for Xbox community utilities. - Includes XeXTool 6.3 by xorloser for basic or extended XEX information on Windows. -- Supports custom executables and argument templates for other community tools. +- Adds native workflows for extract-xiso, Xenia, and Xenia Canary. +- Detects, verifies, and launches user-supplied Velocity, Iso2God, God2ISO, + Xbox Image Browser, and Le Fluffie installations. +- Supports custom executables and argument templates for additional tools. - Shows the exact command, captures standard output and errors, and supports cancellation and timeouts. - Executes argument vectors directly without using a command shell. @@ -220,7 +223,7 @@ Linux source setup: | Downloads | Review and manage download activity | | Backup Manager | Scan, install, verify, export, convert, and transfer owned content | | Profiles & Saves | Inventory profiles, inspect achievements, compare, snapshot, restore, and migrate to Xenia | -| External Tools | Run XeXTool and other user-supplied command-line utilities | +| Tool Center | Run and manage supported Xbox 360 community utilities | | Collections | Identify storage, compare Title Updates, verify preservation data, and preview repairs | | Knowledge | Search sources, facts, citations, imports, and conflicts | | Community Hub | Unified search, console plans, profiles, preservation, plugins, recovery, compatibility, and release toolkit | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ad10c58..df43f04 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -38,3 +38,26 @@ UnityScraper does not include Le Fluffie's executable, updater, embedded key resources, account-modification code, or artwork. The application credits Dalavin prominently and links to the archived corresponding source. +## Tool Center Interoperability + +The following projects are supported through user-selected executables. Their +binaries and licenses are not bundled by UnityScraper: + +- **extract-xiso**, XboxDev: +- **Xenia**, Xenia Project: +- **Xenia Canary**, Xenia Canary Project: + +- **Velocity**, Velocity contributors: + (archived GPL-3.0 project) +- **Iso2God**, Iso2God contributors: + +Tool Center can also launch user-supplied God2ISO and Xbox Image Browser +installations. These legacy utilities have varied distribution histories, so +UnityScraper does not bundle them or claim a canonical download. Users are +responsible for obtaining lawful copies and reviewing the terms that accompany +their chosen builds. + +An integration means that UnityScraper can locate or launch a program; it does +not imply endorsement by, affiliation with, or redistribution permission from +the program's authors. + diff --git a/UnityScraper.spec b/UnityScraper.spec index f1eac0a..60deab4 100644 --- a/UnityScraper.spec +++ b/UnityScraper.spec @@ -41,6 +41,7 @@ a = Analysis( 'knowledge_scheduler', 'knowledge_sync', 'structured_knowledge', + 'tool_catalog', 'unified_search', 'plugins', 'plugin_worker', diff --git a/external_tools.py b/external_tools.py index 3680e45..fdbd59b 100644 --- a/external_tools.py +++ b/external_tools.py @@ -28,6 +28,14 @@ class ToolResult: cancelled: bool +@dataclass(frozen=True) +class ToolLaunch: + """Details for a detached graphical tool launch.""" + + command: tuple[str, ...] + pid: int + + def split_arguments(value: str, *, windows: bool | None = None) -> list[str]: """Split an editable argument template without passing it through a shell.""" use_windows_rules = os.name == "nt" if windows is None else windows @@ -64,13 +72,15 @@ def build_command( *, input_path: str | Path | None = None, output_path: str | Path | None = None, + input_kind: str = "file", + output_kind: str = "optional", ) -> tuple[str, ...]: tool = Path(executable).expanduser().resolve() if not tool.is_file(): raise ExternalToolError(f"Tool executable was not found: {tool}") - source = self._resolve_input(input_path) - output = self._resolve_output(output_path) + source = self._resolve_input(input_path, input_kind) + output = self._resolve_output(output_path, output_kind) arguments: list[str] = [] for value in argument_template: if "{input}" in value and source is None: @@ -83,6 +93,40 @@ def build_command( ) return (str(tool), *arguments) + def launch_detached( + self, + executable: str | Path, + argument_template: Iterable[str] = (), + *, + input_path: str | Path | None = None, + output_path: str | Path | None = None, + input_kind: str = "none", + output_kind: str = "none", + ) -> ToolLaunch: + """Launch a GUI utility without waiting for it to exit.""" + command = self.build_command( + executable, + argument_template, + input_path=input_path, + output_path=output_path, + input_kind=input_kind, + output_kind=output_kind, + ) + creation_flags = 0 + if os.name == "nt": + creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP + try: + process = subprocess.Popen( + command, + cwd=Path(command[0]).parent, + shell=False, + creationflags=creation_flags, + close_fds=os.name != "nt", + ) + except OSError as exc: + raise ExternalToolError(f"Could not start external tool: {exc}") from exc + return ToolLaunch(command, process.pid) + def run( self, executable: str | Path, @@ -91,14 +135,18 @@ def run( input_path: str | Path | None = None, output_path: str | Path | None = None, timeout: float = 300, + input_kind: str = "file", + output_kind: str = "optional", ) -> ToolResult: command = self.build_command( executable, argument_template, input_path=input_path, output_path=output_path, + input_kind=input_kind, + output_kind=output_kind, ) - source = self._resolve_input(input_path) + source = self._resolve_input(input_path, input_kind) working_directory = source.parent if source else Path(command[0]).parent creation_flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 started = time.monotonic() @@ -153,19 +201,37 @@ def cancel(self) -> bool: return True @staticmethod - def _resolve_input(value: str | Path | None) -> Path | None: + def _resolve_input(value: str | Path | None, kind: str = "file") -> Path | None: + if kind not in {"file", "directory", "any", "optional", "none"}: + raise ExternalToolError(f"Unsupported input path kind: {kind}") + if kind == "none": + return None if value is None or not str(value).strip(): + if kind not in {"none", "optional"}: + raise ExternalToolError("This command requires an input path") return None path = Path(value).expanduser().resolve() - if not path.is_file(): + if kind == "file" and not path.is_file(): raise ExternalToolError(f"Input file was not found: {path}") + if kind == "directory" and not path.is_dir(): + raise ExternalToolError(f"Input folder was not found: {path}") + if kind in {"any", "optional"} and not path.exists(): + raise ExternalToolError(f"Input path was not found: {path}") return path @staticmethod - def _resolve_output(value: str | Path | None) -> Path | None: + def _resolve_output(value: str | Path | None, kind: str = "file") -> Path | None: + if kind not in {"file", "directory", "optional", "none"}: + raise ExternalToolError(f"Unsupported output path kind: {kind}") + if kind == "none": + return None if value is None or not str(value).strip(): + if kind not in {"none", "optional"}: + raise ExternalToolError("This command requires an output path") return None path = Path(value).expanduser().resolve() - if not path.parent.is_dir(): + if kind == "directory" and not path.is_dir(): + raise ExternalToolError(f"Output folder was not found: {path}") + if kind != "directory" and not path.parent.is_dir(): raise ExternalToolError(f"Output folder was not found: {path.parent}") return path diff --git a/external_tools_gui.py b/external_tools_gui.py index c7e31e4..05c4caa 100644 --- a/external_tools_gui.py +++ b/external_tools_gui.py @@ -1,12 +1,12 @@ -"""Dark desktop workspace for user-supplied Xbox command-line tools.""" +"""Desktop Tool Center for trusted Xbox 360 community utilities.""" from __future__ import annotations -import json import os import queue import threading import tkinter as tk +import webbrowser from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Any, Callable @@ -15,10 +15,12 @@ from external_tools import ( ExternalToolError, ExternalToolRunner, + ToolLaunch, ToolResult, format_command, split_arguments, ) +from tool_catalog import ToolCatalog, ToolDefinition, ToolOperation from ui_theme import PALETTE @@ -39,7 +41,7 @@ def bundled_xextool_path() -> Path | None: class ExternalToolsPage: - """Build and coordinate the external tools workspace.""" + """Configure, discover, and run the supported community tool catalog.""" def __init__( self, @@ -50,13 +52,13 @@ def __init__( ) -> None: self.root = root self.parent = parent - self.config_path = config_path + self.catalog = ToolCatalog(config_path) self.runner = ExternalToolRunner() self.events: queue.Queue[tuple[str, Any]] = queue.Queue() self.running = False - + self.tools_by_name = {tool.name: tool for tool in self.catalog.definitions()} page_header( - "External Tools", + "Tool Center", "Run trusted Xbox utilities from one workspace and keep their output with your library.", ) self._build() @@ -71,87 +73,83 @@ def _build(self) -> None: setup.grid(row=0, column=0, sticky="ew", pady=(0, 10)) setup.columnconfigure(1, weight=1) - self.config = self._read_config() - tool_config = self.config.get("external_tools", {}) - if not isinstance(tool_config, dict): - tool_config = {} - self.bundled_xextool = bundled_xextool_path() - self.tool_type_var = tk.StringVar(value="XeXTool") - self.executable_var = tk.StringVar( - value=self._saved_or_bundled_path(tool_config) - ) - self.operation_var = tk.StringVar(value="Extended information") - self.arguments_var = tk.StringVar( - value=XEXTOOL_PRESETS["Extended information"] - ) + first = self.catalog.get("xextool") + self.tool_type_var = tk.StringVar(value=first.name) + self.executable_var = tk.StringVar() + self.operation_var = tk.StringVar() + self.arguments_var = tk.StringVar() self.input_var = tk.StringVar() self.output_var = tk.StringVar() - self.timeout_var = tk.IntVar(value=300) + self.timeout_var = tk.IntVar(value=600) + self.credit_var = tk.StringVar() + self.risk_var = tk.StringVar() - ttk.Label(setup, text="Tool preset").grid(row=0, column=0, sticky=tk.W, pady=4) - tool_type = ttk.Combobox( + ttk.Label(setup, text="Tool").grid(row=0, column=0, sticky=tk.W, pady=4) + tool_box = ttk.Combobox( setup, textvariable=self.tool_type_var, - values=("XeXTool", "Custom CLI tool"), + values=tuple(self.tools_by_name), state="readonly", - width=24, + width=25, ) - tool_type.grid(row=0, column=1, sticky=tk.W, padx=(10, 0), pady=4) - tool_type.bind("<>", self._tool_type_changed) - - ttk.Label( - setup, - text=XEXTOOL_CREDIT, - style="Subheader.TLabel", - ).grid(row=0, column=2, sticky=tk.E, pady=4) + tool_box.grid(row=0, column=1, sticky=tk.W, padx=(10, 0), pady=4) + tool_box.bind("<>", self._tool_changed) + ttk.Label(setup, textvariable=self.credit_var, style="Subheader.TLabel").grid( + row=0, column=2, sticky=tk.E, pady=4 + ) + self.source_button = ttk.Button(setup, text="Source", command=self._open_source) + self.source_button.grid(row=0, column=3, padx=(6, 0), pady=4) - self._path_row( - setup, - 1, - "Executable", - self.executable_var, - self._choose_executable, + self._path_row(setup, 1, "Executable", self.executable_var, self._choose_executable) + ttk.Button(setup, text="Detect", command=self._detect_executable).grid( + row=1, column=3, padx=(6, 0), pady=4 ) ttk.Label(setup, text="Operation").grid(row=2, column=0, sticky=tk.W, pady=4) self.operation_box = ttk.Combobox( - setup, - textvariable=self.operation_var, - values=tuple(XEXTOOL_PRESETS), - state="readonly", - width=24, + setup, textvariable=self.operation_var, state="readonly", width=25 ) self.operation_box.grid(row=2, column=1, sticky=tk.W, padx=(10, 0), pady=4) self.operation_box.bind("<>", self._operation_changed) + ttk.Label(setup, textvariable=self.risk_var, style="Subheader.TLabel").grid( + row=2, column=2, columnspan=2, sticky=tk.E, pady=4 + ) - self._path_row(setup, 3, "Input file", self.input_var, self._choose_input) - self._path_row(setup, 4, "Output path", self.output_var, self._choose_output) + self.input_label = ttk.Label(setup, text="Input path") + self.input_label.grid(row=3, column=0, sticky=tk.W, pady=4) + self.input_entry = ttk.Entry(setup, textvariable=self.input_var) + self.input_entry.grid( + row=3, column=1, columnspan=2, sticky="ew", padx=(10, 8), pady=4 + ) + self.input_button = ttk.Button(setup, text="Browse", command=self._choose_input) + self.input_button.grid(row=3, column=3, pady=4) + + self.output_label = ttk.Label(setup, text="Output path") + self.output_label.grid(row=4, column=0, sticky=tk.W, pady=4) + self.output_entry = ttk.Entry(setup, textvariable=self.output_var) + self.output_entry.grid( + row=4, column=1, columnspan=2, sticky="ew", padx=(10, 8), pady=4 + ) + self.output_button = ttk.Button(setup, text="Browse", command=self._choose_output) + self.output_button.grid(row=4, column=3, pady=4) ttk.Label(setup, text="Arguments").grid(row=5, column=0, sticky=tk.W, pady=4) self.arguments_entry = ttk.Entry(setup, textvariable=self.arguments_var) self.arguments_entry.grid( - row=5, column=1, columnspan=2, sticky="ew", padx=(10, 0), pady=4 + row=5, column=1, columnspan=3, sticky="ew", padx=(10, 0), pady=4 ) - ttk.Label(setup, text="Timeout seconds").grid( - row=6, column=0, sticky=tk.W, pady=4 - ) + ttk.Label(setup, text="Timeout seconds").grid(row=6, column=0, sticky=tk.W, pady=4) ttk.Spinbox( - setup, - from_=1, - to=3600, - textvariable=self.timeout_var, - width=10, + setup, from_=1, to=86400, textvariable=self.timeout_var, width=10 ).grid(row=6, column=1, sticky=tk.W, padx=(10, 0), pady=4) controls = ttk.Frame(setup) - controls.grid(row=7, column=0, columnspan=3, sticky="ew", pady=(12, 0)) - ttk.Button( - controls, - text="Run Tool", - command=self.run, - style="Accent.TButton", - ).pack(side=tk.LEFT) + controls.grid(row=7, column=0, columnspan=4, sticky="ew", pady=(12, 0)) + self.run_button = ttk.Button( + controls, text="Run Tool", command=self.run, style="Accent.TButton" + ) + self.run_button.pack(side=tk.LEFT) ttk.Button(controls, text="Cancel", command=self.cancel).pack( side=tk.LEFT, padx=(8, 0) ) @@ -177,24 +175,14 @@ def _build(self) -> None: highlightcolor=PALETTE.accent, ) self.output_text.grid(row=0, column=0, sticky="nsew") - scrollbar = ttk.Scrollbar( - output_panel, - orient=tk.VERTICAL, - command=self.output_text.yview, - ) + scrollbar = ttk.Scrollbar(output_panel, orient=tk.VERTICAL, command=self.output_text.yview) scrollbar.grid(row=0, column=1, sticky="ns") self.output_text.configure(yscrollcommand=scrollbar.set) - - self.status_var = tk.StringVar( - value=( - "Bundled XeXTool is ready. Choose an XEX file." - if self.bundled_xextool is not None - else "Choose a trusted executable and an input file." - ) - ) + self.status_var = tk.StringVar(value="Ready") ttk.Label(body, textvariable=self.status_var, style="Subheader.TLabel").grid( row=2, column=0, sticky="ew", pady=(8, 0) ) + self._load_tool(first) def _path_row( self, @@ -212,89 +200,170 @@ def _path_row( row=row, column=2, pady=4 ) - def _tool_type_changed(self, _event: tk.Event[Any]) -> None: - custom = self.tool_type_var.get() == "Custom CLI tool" - tool_config = self._read_config().get("external_tools", {}) - if not isinstance(tool_config, dict): - tool_config = {} - path_key = "custom_tool_path" if custom else "xextool_path" - if custom: - path = str(tool_config.get(path_key, "")) + def _selected_tool(self) -> ToolDefinition: + return self.tools_by_name[self.tool_type_var.get()] + + def _selected_operation(self) -> ToolOperation: + tool = self._selected_tool() + return next(item for item in tool.operations if item.label == self.operation_var.get()) + + def _tool_changed(self, _event: tk.Event[Any] | None = None) -> None: + self._load_tool(self._selected_tool()) + + def _load_tool(self, tool: ToolDefinition) -> None: + path = self.catalog.discover(tool.id) + self.executable_var.set(str(path or "")) + self.credit_var.set(f"{tool.name} by {tool.author}") + self.source_button.configure(state=tk.NORMAL if tool.homepage else tk.DISABLED) + labels = tuple(operation.label for operation in tool.operations) + self.operation_box.configure(values=labels) + self.operation_var.set(labels[0]) + self.input_var.set("") + self.output_var.set("") + self._operation_changed() + if path: + digest = self.catalog.checksum(path) + self.status_var.set(f"Detected {path.name} | SHA-256 {digest[:16]}...") + elif not tool.supports_current_platform(): + self.status_var.set(f"{tool.name} is not native to this operating system") else: - path = self._saved_or_bundled_path(tool_config) - self.executable_var.set(path) - self.operation_var.set("Custom arguments" if custom else "Extended information") - self.arguments_var.set("" if custom else XEXTOOL_PRESETS["Extended information"]) - self.operation_box.configure(state=tk.DISABLED if custom else "readonly") + self.status_var.set(f"Choose your {tool.name} executable") + self.run_button.configure( + state=tk.NORMAL if tool.supports_current_platform() else tk.DISABLED + ) - def _operation_changed(self, _event: tk.Event[Any]) -> None: - operation = self.operation_var.get() - self.arguments_var.set(XEXTOOL_PRESETS.get(operation, "")) - self.arguments_entry.focus_set() + def _open_source(self) -> None: + homepage = self._selected_tool().homepage + if homepage: + webbrowser.open(homepage) + + def _operation_changed(self, _event: tk.Event[Any] | None = None) -> None: + operation = self._selected_operation() + self.arguments_var.set(format_command(operation.arguments)) + self.risk_var.set("MODIFIES INPUT" if operation.destructive else "READ/CREATE") + self.run_button.configure(text="Launch Tool" if operation.detached else "Run Tool") + self.arguments_entry.configure( + state=tk.NORMAL if operation.id == "custom" else tk.DISABLED + ) + self._set_path_state( + self.input_label, + self.input_entry, + self.input_button, + operation.input_kind, + "Input", + ) + self._set_path_state( + self.output_label, + self.output_entry, + self.output_button, + operation.output_kind, + "Output", + ) - def _choose_executable(self) -> None: - selected = filedialog.askopenfilename( - parent=self.root, - title="Choose command-line tool", + @staticmethod + def _set_path_state( + label: ttk.Label, + entry: ttk.Entry, + button: ttk.Button, + kind: str, + prefix: str, + ) -> None: + label.configure( + text=( + f"{prefix} {kind}" + if kind not in {"none", "optional"} + else f"{prefix} path" + ) ) + state = tk.DISABLED if kind == "none" else tk.NORMAL + entry.configure(state=state) + button.configure(state=state) + + def _choose_executable(self) -> None: + selected = filedialog.askopenfilename(parent=self.root, title="Choose tool executable") if selected: - self.executable_var.set(selected) - self._save_tool_path() + path = self.catalog.save_path(self._selected_tool().id, selected) + self.executable_var.set(str(path)) + self.status_var.set(f"Saved {path.name} | SHA-256 {self.catalog.checksum(path)[:16]}...") + + def _detect_executable(self) -> None: + path = self.catalog.discover(self._selected_tool().id) + if path: + self.executable_var.set(str(path)) + self.status_var.set(f"Detected {path}") + else: + messagebox.showinfo("Tool not detected", "Choose the executable manually.", parent=self.root) def _choose_input(self) -> None: - selected = filedialog.askopenfilename( - parent=self.root, - title="Choose tool input", - filetypes=( - ("Xbox executable", "*.xex"), - ("All files", "*.*"), - ), - ) + kind = self._selected_operation().input_kind + if kind == "directory": + selected = filedialog.askdirectory(parent=self.root, title="Choose input folder") + elif kind == "any" and messagebox.askyesno( + "Choose input", "Select a folder instead of a file?", parent=self.root + ): + selected = filedialog.askdirectory(parent=self.root, title="Choose game folder") + else: + selected = filedialog.askopenfilename(parent=self.root, title="Choose input file") if selected: self.input_var.set(selected) def _choose_output(self) -> None: - selected = filedialog.asksaveasfilename( - parent=self.root, - title="Choose optional output path", - ) + if self._selected_operation().output_kind == "directory": + selected = filedialog.askdirectory(parent=self.root, title="Choose output folder") + else: + selected = filedialog.asksaveasfilename(parent=self.root, title="Choose output file") if selected: self.output_var.set(selected) def run(self) -> None: if self.running: - messagebox.showinfo( - "External Tools", - "Another tool is still running.", - parent=self.root, - ) + messagebox.showinfo("Tool Center", "Another tool is still running.", parent=self.root) + return + tool = self._selected_tool() + operation = self._selected_operation() + if operation.destructive and not messagebox.askyesno( + "Confirm modifying operation", + "This operation can modify the selected input. Continue?", + parent=self.root, + ): return try: - arguments = split_arguments(self.arguments_var.get()) + arguments = ( + split_arguments(self.arguments_var.get()) + if self.arguments_var.get().strip() + else [] + ) command = self.runner.build_command( self.executable_var.get(), arguments, input_path=self.input_var.get(), output_path=self.output_var.get(), + input_kind=operation.input_kind, + output_kind=operation.output_kind, ) except (ExternalToolError, ValueError) as exc: messagebox.showerror("Cannot run tool", str(exc), parent=self.root) return - - self._save_tool_path() + self.catalog.save_path(tool.id, self.executable_var.get()) self.running = True - self.status_var.set("External tool is running...") + self.status_var.set(f"{tool.name} is starting...") self._append(f"$ {format_command(command)}\n\n") def worker() -> None: try: - result = self.runner.run( - self.executable_var.get(), - arguments, - input_path=self.input_var.get(), - output_path=self.output_var.get(), - timeout=self.timeout_var.get(), - ) + if operation.detached: + result: ToolLaunch | ToolResult = self.runner.launch_detached( + self.executable_var.get(), arguments, + input_path=self.input_var.get(), output_path=self.output_var.get(), + input_kind=operation.input_kind, output_kind=operation.output_kind, + ) + else: + result = self.runner.run( + self.executable_var.get(), arguments, + input_path=self.input_var.get(), output_path=self.output_var.get(), + timeout=self.timeout_var.get(), input_kind=operation.input_kind, + output_kind=operation.output_kind, + ) self.events.put(("completed", result)) except Exception as exc: self.events.put(("failed", str(exc))) @@ -324,58 +393,20 @@ def _poll(self) -> None: if self.running: self.root.after(100, self._poll) - def _show_result(self, result: ToolResult) -> None: + def _show_result(self, result: ToolResult | ToolLaunch) -> None: + if isinstance(result, ToolLaunch): + self.status_var.set(f"Tool launched with process ID {result.pid}") + self._append(f"[Launched process {result.pid}]\n") + return if result.stdout: self._append(result.stdout.rstrip() + "\n") if result.stderr: self._append("\nSTDERR:\n" + result.stderr.rstrip() + "\n") state = "cancelled" if result.cancelled else f"exit code {result.returncode}" - self.status_var.set( - f"Tool finished with {state} in {result.duration_seconds:.2f} seconds" - ) - self._append( - f"\n[Finished: {state}; {result.duration_seconds:.2f} seconds]\n" - ) + self.status_var.set(f"Tool finished with {state} in {result.duration_seconds:.2f} seconds") + self._append(f"\n[Finished: {state}; {result.duration_seconds:.2f} seconds]\n") def _append(self, value: str) -> None: if self.output_text.winfo_exists(): self.output_text.insert(tk.END, value) self.output_text.see(tk.END) - - def _read_config(self) -> dict[str, Any]: - if not self.config_path.exists(): - return {} - try: - return json.loads(self.config_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - return {} - - def _saved_or_bundled_path(self, tool_config: dict[str, Any]) -> str: - saved = Path(str(tool_config.get("xextool_path", ""))).expanduser() - if saved.is_file(): - return str(saved) - return str(self.bundled_xextool or "") - - def _save_tool_path(self) -> None: - if not self.executable_var.get().strip(): - return - config = self._read_config() - tools = config.setdefault("external_tools", {}) - if not isinstance(tools, dict): - tools = {} - config["external_tools"] = tools - path_key = ( - "custom_tool_path" - if self.tool_type_var.get() == "Custom CLI tool" - else "xextool_path" - ) - if ( - path_key == "xextool_path" - and self.bundled_xextool is not None - and Path(self.executable_var.get()) == self.bundled_xextool - ): - tools.pop(path_key, None) - else: - tools[path_key] = self.executable_var.get() - self.config_path.parent.mkdir(parents=True, exist_ok=True) - self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") diff --git a/i18n.py b/i18n.py index 18926f9..9052ed8 100644 --- a/i18n.py +++ b/i18n.py @@ -75,7 +75,7 @@ 'nav_downloads': 'DOWNLOADS', 'nav_backups': 'BACKUP MANAGER', 'nav_profiles': 'PROFILES & SAVES', - 'nav_tools': 'EXTERNAL TOOLS', + 'nav_tools': 'TOOL CENTER', 'nav_collections': 'COLLECTIONS', 'nav_knowledge': 'KNOWLEDGE', 'nav_community': 'COMMUNITY HUB', diff --git a/main.py b/main.py index 517a15e..ec3c474 100644 --- a/main.py +++ b/main.py @@ -1052,6 +1052,24 @@ def main(): type=str, help='Output directory for --convert-iso' ) + parser.add_argument('--list-tools', action='store_true', + help='List supported community tool integrations') + parser.add_argument('--tool-id', type=str, + help='Run a Tool Center integration by ID') + parser.add_argument('--tool-operation', type=str, + help='Operation ID used with --tool-id') + parser.add_argument('--tool-executable', type=str, + help='Executable override used with --tool-id') + parser.add_argument('--tool-input', type=str, + help='Input file or folder used with --tool-id') + parser.add_argument('--tool-output', type=str, + help='Output file or folder used with --tool-id') + parser.add_argument('--tool-arg', action='append', default=[], + help='Override Tool Center argument; may be repeated') + parser.add_argument('--tool-timeout', type=int, default=3600, + help='Tool Center timeout in seconds') + parser.add_argument('--tool-allow-modify', action='store_true', + help='Confirm a Tool Center operation that modifies its input') parser.add_argument('--analyze-collection', type=str, help='Analyze a local collection root') parser.add_argument('--aurora-db', type=str, help='Analyze an Aurora database read-only') parser.add_argument('--collection-manifest', type=str, help='Write a preservation manifest') @@ -1121,6 +1139,70 @@ def main(): logger.info("Configuration saved") # Initialize scraper + if args.list_tools or args.tool_id: + try: + from external_tools import ExternalToolRunner, format_command + from tool_catalog import ToolCatalog, operation_for + + catalog = ToolCatalog(CONFIG_PATH) + if args.list_tools: + for tool in catalog.definitions(): + state = "ready" if catalog.discover(tool.id) else "not configured" + operations = ", ".join(item.id for item in tool.operations) + print(f"{tool.id:20} {state:14} {operations}") + sys.exit(0) + tool = catalog.get(args.tool_id) + if not args.tool_operation: + parser.error("--tool-operation is required with --tool-id") + operation = operation_for(tool, args.tool_operation) + if (operation.destructive or args.tool_arg) and not args.tool_allow_modify: + parser.error( + "this operation or argument override may modify content; pass " + "--tool-allow-modify after making a backup" + ) + executable = ( + Path(args.tool_executable).expanduser().resolve() + if args.tool_executable + else catalog.discover(tool.id) + ) + if executable is None: + raise FileNotFoundError( + f"{tool.name} is not configured; choose it in Tool Center" + ) + if args.tool_executable: + catalog.save_path(tool.id, executable) + arguments = tuple(args.tool_arg) if args.tool_arg else operation.arguments + runner = ExternalToolRunner() + if operation.detached: + result = runner.launch_detached( + executable, + arguments, + input_path=args.tool_input, + output_path=args.tool_output, + input_kind=operation.input_kind, + output_kind=operation.output_kind, + ) + print(f"Launched PID {result.pid}: {format_command(result.command)}") + else: + result = runner.run( + executable, + arguments, + input_path=args.tool_input, + output_path=args.tool_output, + timeout=max(1, args.tool_timeout), + input_kind=operation.input_kind, + output_kind=operation.output_kind, + ) + if result.stdout: + print(result.stdout, end="" if result.stdout.endswith("\n") else "\n") + if result.stderr: + print(result.stderr, file=sys.stderr) + sys.exit(result.returncode) + sys.exit(0) + except Exception as e: + logger.error("Tool Center operation failed: %s", e) + sys.exit(1) + if args.sync_title_catalog: try: from title_catalog import XboxUnityTitleCatalog diff --git a/modern_gui.py b/modern_gui.py index 323fec7..ac988da 100644 --- a/modern_gui.py +++ b/modern_gui.py @@ -411,7 +411,7 @@ def _build_menubar(self) -> None: view_menu.add_command(label="Community Hub", command=self.show_community_hub) tools_menu = tk.Menu(self.root, tearoff=False, **menu_options) tools_menu.add_command(label="Backup Manager", command=self.show_backups) - tools_menu.add_command(label="External Tools", command=self.show_external_tools) + tools_menu.add_command(label="Tool Center", command=self.show_external_tools) tools_menu.add_command(label="Archive Health", command=self.show_health) tools_menu.add_command(label="Settings", command=self.show_settings) help_menu = tk.Menu(self.root, tearoff=False, **menu_options) diff --git a/tests.py b/tests.py index 6ac8ca9..7fb78ec 100644 --- a/tests.py +++ b/tests.py @@ -34,6 +34,7 @@ split_arguments, ) from external_tools_gui import bundled_xextool_path +from tool_catalog import ToolCatalog, operation_for from knowledge_service import KnowledgeService from knowledge_sources import ( CachedHttpClient, @@ -720,6 +721,102 @@ def test_runner_rejects_missing_executable_and_input(self): input_path=Path(self.temp_dir) / "missing.xex", ) + def test_runner_supports_directory_input_and_output(self): + runner = ExternalToolRunner() + source = Path(self.temp_dir) / "source" + output = Path(self.temp_dir) / "output" + source.mkdir() + output.mkdir() + + command = runner.build_command( + sys.executable, + ["{input}", "{output}"], + input_path=source, + output_path=output, + input_kind="directory", + output_kind="directory", + ) + + self.assertEqual(command[1:], (str(source.resolve()), str(output.resolve()))) + + def test_unused_paths_are_ignored_for_launch_only_operations(self): + runner = ExternalToolRunner() + + command = runner.build_command( + sys.executable, + (), + input_path=Path(self.temp_dir) / "stale-missing-input", + output_path=Path(self.temp_dir) / "stale-missing-output", + input_kind="none", + output_kind="none", + ) + + self.assertEqual(command, (str(Path(sys.executable).resolve()),)) + + def test_catalog_contains_requested_tools_and_excludes_omissions(self): + catalog = ToolCatalog(Path(self.temp_dir) / "config.json") + tool_ids = {tool.id for tool in catalog.definitions()} + + self.assertTrue( + { + "xextool", + "extract-xiso", + "xenia", + "xenia-canary", + "velocity", + "iso2god", + "god2iso", + "xbox-image-browser", + "le-fluffie", + "custom", + }.issubset(tool_ids) + ) + self.assertNotIn("fatxplorer", tool_ids) + self.assertNotIn("j-runner", tool_ids) + + def test_extract_xiso_operations_are_explicit_and_guarded(self): + tool = ToolCatalog(Path(self.temp_dir) / "config.json").get("extract-xiso") + extract = operation_for(tool, "extract") + rewrite = operation_for(tool, "rewrite") + xextool = ToolCatalog(Path(self.temp_dir) / "config.json").get("xextool") + custom = operation_for(xextool, "custom") + + self.assertEqual(extract.arguments, ("-x", "{input}", "-d", "{output}")) + self.assertEqual((extract.input_kind, extract.output_kind), ("file", "directory")) + self.assertTrue(rewrite.destructive) + self.assertTrue(custom.destructive) + + def test_catalog_persists_and_hashes_user_selected_executable(self): + config_path = Path(self.temp_dir) / "config.json" + executable = Path(self.temp_dir) / "extract-xiso.exe" + executable.write_bytes(b"test executable") + catalog = ToolCatalog(config_path) + + saved = catalog.save_path("extract-xiso", executable) + + self.assertEqual(saved, executable.resolve()) + self.assertEqual(catalog.configured_path("extract-xiso"), executable.resolve()) + self.assertEqual( + catalog.checksum(executable), + hashlib.sha256(b"test executable").hexdigest().upper(), + ) + + @patch("external_tools.subprocess.Popen") + def test_detached_launch_uses_argument_vector(self, popen): + popen.return_value.pid = 360 + runner = ExternalToolRunner() + + launched = runner.launch_detached( + sys.executable, + ("{input}",), + input_path=self.input_path, + input_kind="file", + ) + + self.assertEqual(launched.pid, 360) + self.assertEqual(launched.command[1], str(self.input_path.resolve())) + self.assertFalse(popen.call_args.kwargs["shell"]) + def test_command_preview_quotes_paths(self): preview = format_command( ("tool.exe", "folder with spaces/default.xex"), diff --git a/tool_catalog.py b/tool_catalog.py new file mode 100644 index 0000000..4ea2fde --- /dev/null +++ b/tool_catalog.py @@ -0,0 +1,311 @@ +"""Declarative catalog and conservative discovery for community Xbox tools.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from app_paths import CONFIG_PATH, executable_root, resource_path + + +@dataclass(frozen=True) +class ToolOperation: + """One supported, reviewable action exposed by a tool.""" + + id: str + label: str + arguments: tuple[str, ...] = () + input_kind: str = "none" + output_kind: str = "none" + detached: bool = False + destructive: bool = False + + +@dataclass(frozen=True) +class ToolDefinition: + """Metadata and discovery hints for a community utility.""" + + id: str + name: str + author: str + homepage: str + platforms: tuple[str, ...] + executable_names: tuple[str, ...] + operations: tuple[ToolOperation, ...] + bundled_path: tuple[str, ...] = () + + def supports_current_platform(self) -> bool: + return platform_key() in self.platforms or "all" in self.platforms + + +TOOLS: tuple[ToolDefinition, ...] = ( + ToolDefinition( + "xextool", + "XeXTool", + "xorloser", + "https://github.com/XboxChef/XexToolGUI", + ("windows",), + ("xextool.exe",), + ( + ToolOperation( + "extended-info", "Extended information", ("-l", "{input}"), "file" + ), + ToolOperation("basic-info", "Basic information", ("{input}",), "file"), + ToolOperation( + "custom", "Custom arguments", (), "optional", destructive=True + ), + ), + ("assets", "tools", "xextool", "xextool.exe"), + ), + ToolDefinition( + "extract-xiso", + "extract-xiso", + "XboxDev", + "https://github.com/XboxDev/extract-xiso", + ("windows", "linux", "macos"), + ("extract-xiso.exe", "extract-xiso"), + ( + ToolOperation("list", "List image contents", ("-l", "{input}"), "file"), + ToolOperation( + "extract", + "Extract image", + ("-x", "{input}", "-d", "{output}"), + "file", + "directory", + ), + ToolOperation( + "create", + "Create image", + ("-c", "{input}", "{output}"), + "directory", + "file", + ), + ToolOperation( + "rewrite", + "Rewrite image", + ("-r", "{input}"), + "file", + destructive=True, + ), + ), + ), + ToolDefinition( + "xenia", + "Xenia", + "Xenia Project", + "https://github.com/xenia-project/xenia", + ("windows",), + ("xenia.exe",), + ( + ToolOperation( + "launch-game", "Launch game", ("{input}",), "any", detached=True + ), + ToolOperation("open", "Open emulator", detached=True), + ), + ), + ToolDefinition( + "xenia-canary", + "Xenia Canary", + "Xenia Canary Project", + "https://github.com/xenia-canary/xenia-canary", + ("windows",), + ("xenia_canary.exe", "xenia-canary.exe"), + ( + ToolOperation( + "launch-game", "Launch game", ("{input}",), "any", detached=True + ), + ToolOperation("open", "Open emulator", detached=True), + ), + ), + ToolDefinition( + "velocity", + "Velocity", + "Velocity contributors", + "https://github.com/Gualdimar/Velocity", + ("windows",), + ("Velocity.exe",), + (ToolOperation("open", "Open Velocity", detached=True),), + ), + ToolDefinition( + "iso2god", + "Iso2God", + "Iso2God contributors", + "https://github.com/r4dius/Iso2God", + ("windows",), + ("Iso2God.exe",), + (ToolOperation("open", "Open Iso2God", detached=True),), + ), + ToolDefinition( + "god2iso", + "God2ISO", + "Community utility", + "", + ("windows",), + ("God2Iso.exe", "God2ISO.exe"), + (ToolOperation("open", "Open God2ISO", detached=True),), + ), + ToolDefinition( + "xbox-image-browser", + "Xbox Image Browser", + "Community utility", + "", + ("windows",), + ("Xbox Image Browser.exe", "XboxImageBrowser.exe"), + (ToolOperation("open", "Open Xbox Image Browser", detached=True),), + ), + ToolDefinition( + "le-fluffie", + "Le Fluffie", + "Dalavin (DJ SkunkieButt)", + "", + ("windows",), + ("Le Fluffie.exe", "LeFluffie.exe"), + (ToolOperation("open", "Open Le Fluffie", detached=True),), + ), + ToolDefinition( + "custom", + "Custom CLI tool", + "User supplied", + "", + ("all",), + (), + ( + ToolOperation( + "custom", + "Custom arguments", + (), + "optional", + "optional", + destructive=True, + ), + ), + ), +) + + +def platform_key() -> str: + if os.name == "nt": + return "windows" + if sys.platform == "darwin": + return "macos" + return "linux" + + +class ToolCatalog: + """Resolve built-in definitions and user-approved executable paths.""" + + def __init__(self, config_path: Path | str = CONFIG_PATH) -> None: + self.config_path = Path(config_path) + self._definitions = {tool.id: tool for tool in TOOLS} + + def definitions(self, *, supported_only: bool = False) -> tuple[ToolDefinition, ...]: + values = TOOLS + if supported_only: + values = tuple(tool for tool in values if tool.supports_current_platform()) + return values + + def get(self, tool_id: str) -> ToolDefinition: + try: + return self._definitions[tool_id] + except KeyError as exc: + raise KeyError(f"Unknown external tool: {tool_id}") from exc + + def configured_path(self, tool_id: str) -> Path | None: + config = self._read_config() + tools = config.get("external_tools", {}) + if not isinstance(tools, dict): + return None + paths = tools.get("paths", {}) + value = paths.get(tool_id, "") if isinstance(paths, dict) else "" + if not value and tool_id == "xextool": + value = tools.get("xextool_path", "") + if not value and tool_id == "custom": + value = tools.get("custom_tool_path", "") + path = Path(str(value)).expanduser() + return path.resolve() if path.is_file() else None + + def discover(self, tool_id: str) -> Path | None: + tool = self.get(tool_id) + if not tool.supports_current_platform(): + return None + configured = self.configured_path(tool_id) + if configured: + return configured + if tool.bundled_path: + bundled = resource_path(*tool.bundled_path) + if bundled.is_file(): + return bundled.resolve() + for name in tool.executable_names: + located = shutil.which(name) + if located and Path(located).is_file(): + return Path(located).resolve() + for candidate in self._conventional_candidates(tool): + if candidate.is_file(): + return candidate.resolve() + return None + + def save_path(self, tool_id: str, path: Path | str) -> Path: + executable = Path(path).expanduser().resolve() + if not executable.is_file(): + raise FileNotFoundError(executable) + config = self._read_config() + tools = config.setdefault("external_tools", {}) + if not isinstance(tools, dict): + tools = {} + config["external_tools"] = tools + paths = tools.setdefault("paths", {}) + if not isinstance(paths, dict): + paths = {} + tools["paths"] = paths + paths[tool_id] = str(executable) + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self.config_path.write_text(json.dumps(config, indent=2), encoding="utf-8") + return executable + + def checksum(self, path: Path | str) -> str: + digest = hashlib.sha256() + with Path(path).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest().upper() + + def _read_config(self) -> dict[str, object]: + if not self.config_path.exists(): + return {} + try: + value = json.loads(self.config_path.read_text(encoding="utf-8")) + return value if isinstance(value, dict) else {} + except (OSError, ValueError): + return {} + + @staticmethod + def _conventional_candidates(tool: ToolDefinition) -> Iterable[Path]: + root = executable_root() + home = Path.home() + locations = [ + root, + root / "tools" / tool.id, + home / "Documents" / tool.name, + home / "Downloads" / tool.name, + ] + if os.name == "nt": + for variable in ("ProgramFiles", "ProgramFiles(x86)"): + value = os.environ.get(variable) + if value: + locations.append(Path(value) / tool.name) + for location in locations: + for name in tool.executable_names: + yield location / name + + +def operation_for(tool: ToolDefinition, operation_id: str) -> ToolOperation: + try: + return next(item for item in tool.operations if item.id == operation_id) + except StopIteration as exc: + raise KeyError(f"{tool.name} does not support operation {operation_id}") from exc