From bf5e06eec062c7fd423c61a2744b403be6db0230 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:10:15 -0400 Subject: [PATCH 1/2] feat: add integrated external tools workspace --- CHANGELOG.md | 3 + EXTERNAL_TOOLS.md | 54 +++++++ README.md | 14 ++ external_tools.py | 171 +++++++++++++++++++++ external_tools_gui.py | 341 ++++++++++++++++++++++++++++++++++++++++++ modern_gui.py | 54 ++++++- tests.py | 70 ++++++++- 7 files changed, 705 insertions(+), 2 deletions(-) create mode 100644 EXTERNAL_TOOLS.md create mode 100644 external_tools.py create mode 100644 external_tools_gui.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d2fb68d..d8b3071 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ Notable changes to UnityScraper are documented here. The project follows ### Added +- External Tools workspace with XeXTool inspection presets, custom CLI + arguments, command previews, captured output, cancellation, and timeouts. +- Project creator attribution and a community message from TrapEmAll in About. - Offline XboxUnity title catalog with background refresh, sync history, TitleID/name autocomplete, and a manual/CLI refresh path. - Additive schema migration 5 for cached XboxUnity titles and catalog sync diff --git a/EXTERNAL_TOOLS.md b/EXTERNAL_TOOLS.md new file mode 100644 index 0000000..e0985f3 --- /dev/null +++ b/EXTERNAL_TOOLS.md @@ -0,0 +1,54 @@ +# External Tools + +UnityScraper can run trusted Xbox command-line utilities from the desktop +interface. Open **External Tools** from the sidebar. + +## XeXTool + +XeXTool is not distributed with UnityScraper. Its redistribution terms are not +clear enough for this project to package the executable. Obtain it lawfully, +review it with your usual security tools, and select your local copy using +**Browse**. + +The XeXTool preset provides: + +- **Extended information** using `-l "{input}"` +- **Basic information** using `"{input}"` +- **Custom arguments** for advanced users + +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. + +## Other CLI Tools + +Choose **Custom CLI tool**, select an executable, and enter its argument +template. Two placeholders are supported: + +```text +{input} selected input file +{output} selected output path +``` + +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. + +The configured XeXTool path is stored in the normal application configuration. +Tool binaries are never copied into UnityScraper's data folder. + +## Safety + +- 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. + +## Platform Notes + +The external tool must be executable on the current operating system. +Windows `.exe` files do not run natively on Linux. UnityScraper does not +automatically install or invoke Wine. Linux users can select native tools or a +trusted wrapper executable they configured themselves. diff --git a/README.md b/README.md index 62bca1c..4494936 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ service exposes. UnityScraper does not silently substitute HTTPS URLs. - Captures read-only console inventories and compares PC and console content. - Runs a user-selected external ISO converter without bundling converter code. +### External Tools + +- Runs user-supplied Xbox command-line utilities inside the desktop interface. +- Includes a XeXTool workspace for basic or extended XEX information. +- Supports custom executables and argument templates for other community tools. +- Shows the exact command, captures standard output and errors, and supports + cancellation and timeouts. +- Executes argument vectors directly without using a command shell. + +Third-party binaries such as XeXTool are not bundled. Select an executable you +obtained lawfully and trust. See [EXTERNAL_TOOLS.md](EXTERNAL_TOOLS.md) for +setup, placeholders, platform notes, and safety guidance. + ### Collection Intelligence and Preservation - Discovers mounted console, USB, and archive storage. @@ -132,6 +145,7 @@ Linux source setup: | Add Games | Search cached game names, select TitleIDs, or import lists | | Downloads | Review and manage download activity | | Backup Manager | Scan, install, verify, export, convert, and transfer owned content | +| External Tools | Run XeXTool and other user-supplied command-line utilities | | Collections | Identify storage, compare Title Updates, verify preservation data, and preview repairs | | Knowledge | Search sources, facts, citations, imports, and conflicts | | Archive Health | Find missing or inconsistent downloaded files | diff --git a/external_tools.py b/external_tools.py new file mode 100644 index 0000000..3680e45 --- /dev/null +++ b/external_tools.py @@ -0,0 +1,171 @@ +"""Safe process runner for user-supplied Xbox command-line tools.""" + +from __future__ import annotations + +import os +import shlex +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +class ExternalToolError(RuntimeError): + """Raised when an external tool cannot be configured or launched.""" + + +@dataclass(frozen=True) +class ToolResult: + """Captured result from one external tool invocation.""" + + command: tuple[str, ...] + returncode: int + stdout: str + stderr: str + duration_seconds: float + cancelled: bool + + +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 + arguments = shlex.split(value, posix=not use_windows_rules) + if use_windows_rules: + return [ + argument[1:-1] + if len(argument) >= 2 and argument[0] == argument[-1] == '"' + else argument + for argument in arguments + ] + return arguments + + +def format_command(command: Iterable[str], *, windows: bool | None = None) -> str: + """Format an argument vector for display only.""" + values = list(command) + use_windows_rules = os.name == "nt" if windows is None else windows + return subprocess.list2cmdline(values) if use_windows_rules else shlex.join(values) + + +class ExternalToolRunner: + """Run one selected executable at a time without shell interpretation.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._process: subprocess.Popen[str] | None = None + self._cancel_requested = False + + def build_command( + self, + executable: str | Path, + argument_template: Iterable[str], + *, + input_path: str | Path | None = None, + output_path: str | Path | None = None, + ) -> 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) + arguments: list[str] = [] + for value in argument_template: + if "{input}" in value and source is None: + raise ExternalToolError("This command requires an input file") + if "{output}" in value and output is None: + raise ExternalToolError("This command requires an output path") + arguments.append( + value.replace("{input}", str(source) if source else "") + .replace("{output}", str(output) if output else "") + ) + return (str(tool), *arguments) + + def run( + self, + executable: str | Path, + argument_template: Iterable[str], + *, + input_path: str | Path | None = None, + output_path: str | Path | None = None, + timeout: float = 300, + ) -> ToolResult: + command = self.build_command( + executable, + argument_template, + input_path=input_path, + output_path=output_path, + ) + source = self._resolve_input(input_path) + 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() + + with self._lock: + if self._process is not None: + raise ExternalToolError("Another external tool is already running") + self._cancel_requested = False + try: + self._process = subprocess.Popen( + command, + cwd=working_directory, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + shell=False, + creationflags=creation_flags, + ) + except OSError as exc: + raise ExternalToolError(f"Could not start external tool: {exc}") from exc + process = self._process + + try: + stdout, stderr = process.communicate(timeout=max(1, timeout)) + except subprocess.TimeoutExpired as exc: + process.kill() + stdout, stderr = process.communicate() + raise ExternalToolError( + f"External tool exceeded the {timeout:g}-second timeout" + ) from exc + finally: + with self._lock: + cancelled = self._cancel_requested + self._process = None + + return ToolResult( + command, + process.returncode, + stdout, + stderr, + time.monotonic() - started, + cancelled, + ) + + def cancel(self) -> bool: + """Terminate the active process, returning whether one was running.""" + with self._lock: + if self._process is None: + return False + self._cancel_requested = True + self._process.terminate() + return True + + @staticmethod + def _resolve_input(value: str | Path | None) -> Path | None: + if value is None or not str(value).strip(): + return None + path = Path(value).expanduser().resolve() + if not path.is_file(): + raise ExternalToolError(f"Input file was not found: {path}") + return path + + @staticmethod + def _resolve_output(value: str | Path | None) -> Path | None: + if value is None or not str(value).strip(): + return None + path = Path(value).expanduser().resolve() + if 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 new file mode 100644 index 0000000..9b9e1c2 --- /dev/null +++ b/external_tools_gui.py @@ -0,0 +1,341 @@ +"""Dark desktop workspace for user-supplied Xbox command-line tools.""" + +from __future__ import annotations + +import json +import queue +import threading +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, ttk +from typing import Any, Callable + +from external_tools import ( + ExternalToolError, + ExternalToolRunner, + ToolResult, + format_command, + split_arguments, +) + + +XEXTOOL_PRESETS = { + "Extended information": '-l "{input}"', + "Basic information": '"{input}"', + "Custom arguments": "", +} + + +class ExternalToolsPage: + """Build and coordinate the external tools workspace.""" + + def __init__( + self, + root: tk.Tk, + parent: ttk.Frame, + page_header: Callable[[str, str], None], + config_path: Path, + ) -> None: + self.root = root + self.parent = parent + self.config_path = config_path + self.runner = ExternalToolRunner() + self.events: queue.Queue[tuple[str, Any]] = queue.Queue() + self.running = False + + page_header( + "External Tools", + "Run trusted Xbox utilities from one workspace and keep their output with your library.", + ) + self._build() + + def _build(self) -> None: + body = ttk.Frame(self.parent) + body.grid(row=1, column=0, sticky="nsew") + body.columnconfigure(0, weight=1) + body.rowconfigure(1, weight=1) + + setup = ttk.LabelFrame(body, text="Tool Setup", padding=14) + 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.tool_type_var = tk.StringVar(value="XeXTool") + self.executable_var = tk.StringVar( + value=str(tool_config.get("xextool_path", "")) + ) + self.operation_var = tk.StringVar(value="Extended information") + self.arguments_var = tk.StringVar( + value=XEXTOOL_PRESETS["Extended information"] + ) + self.input_var = tk.StringVar() + self.output_var = tk.StringVar() + self.timeout_var = tk.IntVar(value=300) + + ttk.Label(setup, text="Tool preset").grid(row=0, column=0, sticky=tk.W, pady=4) + tool_type = ttk.Combobox( + setup, + textvariable=self.tool_type_var, + values=("XeXTool", "Custom CLI tool"), + state="readonly", + width=24, + ) + tool_type.grid(row=0, column=1, sticky=tk.W, padx=(10, 0), pady=4) + tool_type.bind("<>", self._tool_type_changed) + + self._path_row( + setup, + 1, + "Executable", + self.executable_var, + self._choose_executable, + ) + + 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, + ) + self.operation_box.grid(row=2, column=1, sticky=tk.W, padx=(10, 0), pady=4) + self.operation_box.bind("<>", self._operation_changed) + + 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) + + 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 + ) + + 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, + ).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) + ttk.Button(controls, text="Cancel", command=self.cancel).pack( + side=tk.LEFT, padx=(8, 0) + ) + ttk.Button(controls, text="Clear Output", command=self.clear_output).pack( + side=tk.RIGHT + ) + + output_panel = ttk.LabelFrame(body, text="Command Output", padding=8) + output_panel.grid(row=1, column=0, sticky="nsew") + output_panel.columnconfigure(0, weight=1) + output_panel.rowconfigure(0, weight=1) + self.output_text = tk.Text( + output_panel, + wrap=tk.WORD, + background="#070b08", + foreground="#f2f5f2", + insertbackground="#72e000", + selectbackground="#315f12", + selectforeground="#f2f5f2", + relief=tk.FLAT, + highlightthickness=1, + highlightbackground="#26352a", + highlightcolor="#72e000", + ) + self.output_text.grid(row=0, column=0, sticky="nsew") + 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="Choose a trusted executable and an input file." + ) + ttk.Label(body, textvariable=self.status_var, style="Subheader.TLabel").grid( + row=2, column=0, sticky="ew", pady=(8, 0) + ) + + def _path_row( + self, + parent: ttk.LabelFrame, + row: int, + label: str, + variable: tk.StringVar, + callback: Callable[[], None], + ) -> None: + ttk.Label(parent, text=label).grid(row=row, column=0, sticky=tk.W, pady=4) + ttk.Entry(parent, textvariable=variable).grid( + row=row, column=1, sticky="ew", padx=(10, 8), pady=4 + ) + ttk.Button(parent, text="Browse", command=callback).grid( + 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" + self.executable_var.set(str(tool_config.get(path_key, ""))) + 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") + + 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 _choose_executable(self) -> None: + selected = filedialog.askopenfilename( + parent=self.root, + title="Choose command-line tool", + ) + if selected: + self.executable_var.set(selected) + self._save_tool_path() + + def _choose_input(self) -> None: + selected = filedialog.askopenfilename( + parent=self.root, + title="Choose tool input", + filetypes=( + ("Xbox executable", "*.xex"), + ("All files", "*.*"), + ), + ) + if selected: + self.input_var.set(selected) + + def _choose_output(self) -> None: + selected = filedialog.asksaveasfilename( + parent=self.root, + title="Choose optional output path", + ) + 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, + ) + return + try: + arguments = split_arguments(self.arguments_var.get()) + command = self.runner.build_command( + self.executable_var.get(), + arguments, + input_path=self.input_var.get(), + output_path=self.output_var.get(), + ) + except (ExternalToolError, ValueError) as exc: + messagebox.showerror("Cannot run tool", str(exc), parent=self.root) + return + + self._save_tool_path() + self.running = True + self.status_var.set("External tool is running...") + 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(), + ) + self.events.put(("completed", result)) + except Exception as exc: + self.events.put(("failed", str(exc))) + + threading.Thread(target=worker, name="external-tool", daemon=True).start() + self.root.after(100, self._poll) + + def cancel(self) -> None: + if self.runner.cancel(): + self.status_var.set("Stopping external tool...") + + def clear_output(self) -> None: + self.output_text.delete("1.0", tk.END) + + def _poll(self) -> None: + while True: + try: + event, value = self.events.get_nowait() + except queue.Empty: + break + self.running = False + if event == "completed": + self._show_result(value) + else: + self.status_var.set("External tool failed") + self._append(f"ERROR: {value}\n") + if self.running: + self.root.after(100, self._poll) + + def _show_result(self, result: ToolResult) -> None: + 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" + ) + + 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 _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" + ) + 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/modern_gui.py b/modern_gui.py index 834be92..4f8be25 100644 --- a/modern_gui.py +++ b/modern_gui.py @@ -40,6 +40,7 @@ from database import DatabaseManager from database_migrations import create_database_backup, restore_database_backup from diagnostics import create_diagnostics_bundle +from external_tools_gui import ExternalToolsPage from knowledge_service import KnowledgeService from knowledge_gui import KnowledgePage from library_service import GameSummary, LibraryService @@ -50,6 +51,15 @@ APP_VERSION = DISPLAY_VERSION +PROJECT_CREATOR = "TrapEmAll" +COMMUNITY_MESSAGE = ( + "UnityScraper is my way of giving back to the Xbox 360 community that has " + "kept this console alive through curiosity, preservation, homebrew, and " + "shared knowledge. Thank you to every developer, archivist, modder, tester, " + "and player who continues to contribute. I hope this project makes your " + "collection easier to care for and helps preserve a piece of gaming history " + "for years to come." +) BG = "#050806" PANEL = "#0a0f0c" @@ -436,6 +446,7 @@ def _build_shell(self) -> None: ("ADD GAMES", self.show_add_games), ("DOWNLOADS", self.show_downloads), ("BACKUP MANAGER", self.show_backups), + ("EXTERNAL TOOLS", self.show_external_tools), ("COLLECTIONS", self.show_collections), ("KNOWLEDGE", self.show_knowledge), ("ARCHIVE HEALTH", self.show_health), @@ -444,7 +455,7 @@ def _build_shell(self) -> None: ) for label, callback in pages: ttk.Button(nav, text=label, command=callback, style="Nav.TButton", width=22).pack( - fill=tk.X, pady=5 + fill=tk.X, pady=3 ) ttk.Label(nav, text="CONNECTED", style="AccentBrand.TLabel").pack( @@ -498,6 +509,15 @@ def show_backups(self) -> None: self._page_header, ) + def show_external_tools(self) -> None: + self._clear_content() + self.external_tools_page = ExternalToolsPage( + self.root, + self.content, + self._page_header, + CONFIG_PATH, + ) + def show_collections(self) -> None: self._clear_content() self.collection_page = CollectionPage( @@ -1167,6 +1187,11 @@ def show_about(self) -> None: text=f"Version {APP_VERSION}", style="CardTitle.TLabel", ).pack(anchor=tk.W) + ttk.Label( + panel, + text=f"Created and maintained by {PROJECT_CREATOR}", + style="CardTitle.TLabel", + ).pack(anchor=tk.W, pady=(6, 0)) ttk.Label( panel, text=( @@ -1178,6 +1203,26 @@ def show_about(self) -> None: justify=tk.LEFT, ).pack(anchor=tk.W, pady=(8, 18)) + ttk.Separator(panel).pack(fill=tk.X, pady=(0, 14)) + ttk.Label( + panel, + text=f"A message from {PROJECT_CREATOR}", + style="CardTitle.TLabel", + ).pack(anchor=tk.W) + message_panel = ttk.Frame(panel) + message_panel.pack(fill=tk.X, pady=(0, 14)) + ttk.Label( + message_panel, + text=COMMUNITY_MESSAGE, + wraplength=780, + justify=tk.LEFT, + ).pack(anchor=tk.W) + ttk.Label( + message_panel, + text=f"- {PROJECT_CREATOR}", + foreground=ACCENT, + ).pack(anchor=tk.W, pady=(10, 0)) + ttk.Button( panel, text="Export Diagnostics ZIP", @@ -1204,6 +1249,13 @@ def show_about(self) -> None: "https://github.com/TrapEmAll/UnityScraper#readme" ), ).pack(anchor=tk.W, pady=4) + ttk.Button( + panel, + text=f"View {PROJECT_CREATOR} on GitHub", + command=lambda: webbrowser.open( + f"https://github.com/{PROJECT_CREATOR}" + ), + ).pack(anchor=tk.W, pady=4) ttk.Button( panel, text="Open GitHub Project", diff --git a/tests.py b/tests.py index 59dab41..22bae72 100644 --- a/tests.py +++ b/tests.py @@ -5,8 +5,9 @@ import unittest import tempfile -import shutil +import shutil import json +import sys import time import zipfile from pathlib import Path @@ -24,6 +25,12 @@ ) from knowledge_base import EntityRecord, Fact, Identifier, KnowledgeRepository from dat_adapters import parse_dat +from external_tools import ( + ExternalToolError, + ExternalToolRunner, + format_command, + split_arguments, +) from knowledge_service import KnowledgeService from knowledge_sources import KnowledgeImportService, SourceInfo from library_service import LibraryService @@ -617,6 +624,66 @@ def test_non_http_xboxunity_base_url_is_rejected(self): XboxUnityTitleCatalog(self.db_path, base_url="https://xboxunity.net") +class TestExternalTools(unittest.TestCase): + """Test shell-free execution for user-supplied command-line tools.""" + + def setUp(self): + self.temp_dir = tempfile.mkdtemp() + self.input_path = Path(self.temp_dir) / "default.xex" + self.input_path.write_bytes(b"XEX2") + + def tearDown(self): + shutil.rmtree(self.temp_dir) + + def test_xextool_template_splits_into_an_argument_vector(self): + self.assertEqual( + split_arguments('-l "{input}"', windows=True), + ["-l", "{input}"], + ) + + def test_runner_substitutes_input_without_shell_interpretation(self): + runner = ExternalToolRunner() + marker = "value; echo this-is-data" + + result = runner.run( + sys.executable, + [ + "-c", + "import sys; print(sys.argv[1]); print(sys.argv[2])", + marker, + "{input}", + ], + input_path=self.input_path, + ) + + self.assertEqual(result.returncode, 0) + self.assertIn(marker, result.stdout) + self.assertIn(str(self.input_path.resolve()), result.stdout) + self.assertFalse(result.cancelled) + + def test_runner_rejects_missing_executable_and_input(self): + runner = ExternalToolRunner() + with self.assertRaises(ExternalToolError): + runner.build_command( + Path(self.temp_dir) / "missing.exe", + ["{input}"], + input_path=self.input_path, + ) + with self.assertRaises(ExternalToolError): + runner.build_command( + sys.executable, + ["{input}"], + input_path=Path(self.temp_dir) / "missing.xex", + ) + + def test_command_preview_quotes_paths(self): + preview = format_command( + ("tool.exe", "folder with spaces/default.xex"), + windows=True, + ) + self.assertEqual(preview, 'tool.exe "folder with spaces/default.xex"') + + class TestConsoleModsAdapters(unittest.TestCase): """Test ConsoleMods parsing helpers.""" @@ -1330,6 +1397,7 @@ def run_tests(): suite.addTests(loader.loadTestsFromTestCase(TestUnityScraper)) suite.addTests(loader.loadTestsFromTestCase(TestDatabaseManager)) suite.addTests(loader.loadTestsFromTestCase(TestXboxUnityTitleCatalog)) + suite.addTests(loader.loadTestsFromTestCase(TestExternalTools)) suite.addTests(loader.loadTestsFromTestCase(TestConsoleModsAdapters)) suite.addTests(loader.loadTestsFromTestCase(TestKnowledgeApplication)) suite.addTests(loader.loadTestsFromTestCase(TestDownloadProgress)) From e7425e8b89794e0fdd125b81ae523ed5c1ce3b89 Mon Sep 17 00:00:00 2001 From: Sthornberry9 <46094434+Sthornberry9@users.noreply.github.com> Date: Fri, 24 Jul 2026 01:15:24 -0400 Subject: [PATCH 2/2] fix: map tenth navigation shortcut to alt zero --- modern_gui.py | 16 +++++++++++++++- tests.py | 7 +++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/modern_gui.py b/modern_gui.py index 4f8be25..5027ec6 100644 --- a/modern_gui.py +++ b/modern_gui.py @@ -82,6 +82,15 @@ def _open_path(path: Path) -> None: messagebox.showerror("Unable to open", str(exc)) +def navigation_shortcut(index: int) -> str | None: + """Map the first ten navigation entries to Alt+1 through Alt+0.""" + if 1 <= index <= 9: + return str(index) + if index == 10: + return "0" + return None + + class ResponsiveBackgroundBanner(ttk.Frame): """Responsive, center-cropped image banner for application pages.""" @@ -470,7 +479,12 @@ def _build_shell(self) -> None: self.shell.bind("", self._resize_shell) self.root.after_idle(lambda: self._resize_shell(None)) for index, (_, callback) in enumerate(pages, start=1): - self.root.bind(f"", lambda _event, action=callback: action()) + shortcut = navigation_shortcut(index) + if shortcut: + self.root.bind( + f"", + lambda _event, action=callback: action(), + ) self.root.bind("", lambda _event: self.show_library()) self.show_library() diff --git a/tests.py b/tests.py index 22bae72..9934469 100644 --- a/tests.py +++ b/tests.py @@ -34,6 +34,7 @@ from knowledge_service import KnowledgeService from knowledge_sources import KnowledgeImportService, SourceInfo from library_service import LibraryService +from modern_gui import navigation_shortcut from title_catalog import XboxUnityTitleCatalog from wiki_adapters import extract_article_text, parse_sitemap from backup_manager import ( @@ -683,6 +684,12 @@ def test_command_preview_quotes_paths(self): ) self.assertEqual(preview, 'tool.exe "folder with spaces/default.xex"') + def test_tenth_navigation_page_uses_alt_zero(self): + self.assertEqual(navigation_shortcut(1), "1") + self.assertEqual(navigation_shortcut(9), "9") + self.assertEqual(navigation_shortcut(10), "0") + self.assertIsNone(navigation_shortcut(11)) + class TestConsoleModsAdapters(unittest.TestCase): """Test ConsoleMods parsing helpers."""