diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 67860188f..b38eadf26 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -21,6 +21,37 @@ jobs: run: uv run pytest -q -rs env: DECOMP_TRAY: "0" + macos: + strategy: + fail-fast: false + matrix: + runner: [macos-15, macos-15-intel] + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.3.0 + - run: uv sync --locked + - run: uv run ruff check src tests projects + - name: Demo linker and object tools + run: | + brew install llvm lld + echo "$(brew --prefix llvm)/bin" >> "$GITHUB_PATH" + echo "$(brew --prefix lld)/bin" >> "$GITHUB_PATH" + - name: Check Intel compiler runtime on Apple Silicon + run: | + if [ "$(uname -m)" = arm64 ]; then + /usr/bin/arch -x86_64 /usr/bin/true + fi + - name: Free pinned N64 compiler and decompilation tools + run: >- + uv run python -c "from decompnet import toolchains; [toolchains.install(t) for t in + ['gcc-2.7.2-kmc', 'binutils-mips', 'm2c', 'decomp-permuter', 'objdiff-cli']]" + - run: uv run python projects/zlib-n64/prepare.py + - name: Native WebKit and portable regressions, including N64 demo matching and rebuild + run: uv run pytest -q -rs + env: + DECOMP_TEST_COCOA: "1" + DECOMP_TRAY: "0" check: runs-on: ubuntu-24.04 steps: diff --git a/AGENTS.md b/AGENTS.md index 716d3b08c..7c0ca6a98 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ How to work in this repository. Read README.md for what decompdecomp does from a decompdecomp: AI agents decompile games the user owns into C that compiles, with the game's original compiler, to the exact same bytes. Python 3.12+, managed with uv. A continuation of decomp.wtf (Sam Wolfs, i12bp8/decomp.wtf, -archived at 0.10.6): `origin` is n-delic/decompdecomp, `upstream` the archived original. +archived at 0.10.6). This fork is RetroReversing/decompdecomp; its parent is n-delic/decompdecomp. Naming: the project is **decompdecomp** in everything users read. Internal names stay as decomp.wtf left them, so existing installs and projects keep working: the `decomp` command, the `decompnet` package and data folder, @@ -106,7 +106,7 @@ from the vtables constructors store, MIPS: the `get_class` tool and the class li `compilermap.py` (which compiler built each part of a MIPS game; the judge's survey is `engine.survey_compilers`; `app/rangecheck.py` tests a suggested range on its neighbours before it applies), `app/tools.py` (the MCP tools helpers get and their prompt; MCP is only the app's private wiring to the CLI helpers it starts), `app/window.py` -+ `app/gtkwindow.py` + `app/tray.py` (the window: GTK and WebKitGTK from the system, run by the system's python3; the ++ `app/gtkwindow.py` + `app/macwindow.py` + `app/tray.py` (the window: GTK and WebKitGTK from the system, run by the system's python3; the page answers only it, by the secret in its user agent; and the tray icon; tests set `DECOMP_WINDOW=0` and `DECOMP_TRAY=0`), `app/edition.py` (release or development copy), `app/saves.py` (a ROM project's work as plain files, and a project restored from them), `app/readable.py` (polishing matched code), `app/knowledge.py` (what agents learn, diff --git a/README.md b/README.md index 61cb95f09..88b10c32d 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@

AGPL-3.0 license Linux + macOS Windows Discord Agents: Claude Code, Codex, opencode, Antigravity, any API model @@ -28,16 +29,22 @@ under a tenth of a second (1.4 s before), and agents restart by themselves after stand-in for a model that hands in the demo's own C; the app compiles and judges every function for real.

+## RetroReversing fork + +This fork of [NDelic's decompdecomp](https://github.com/n-delic/decompdecomp) adds a native macOS app. +The original decomp.wtf and decompdecomp credits and licenses are preserved. Report fork issues to +[RetroReversing/decompdecomp](https://github.com/RetroReversing/decompdecomp/issues). + ## Install -decompdecomp runs on **Linux and Windows 10/11**, on a 64-bit Intel or AMD PC. On Linux, install it with one command. The installer also gets +decompdecomp runs on **Linux and Windows 10/11** on x86-64, and **macOS 12+** on Intel or Apple Silicon. On Linux, install it with one command. The installer also gets the free compilers and keeps decompdecomp up to date: ``` -curl -fsSL https://decomp.ndelic.com/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/RetroReversing/decompdecomp/macos-support/site/public/install.sh | bash -s -- --source branch:macos-support ``` -Or install a package from the [releases](https://github.com/n-delic/decompdecomp/releases): +Upstream Linux packages remain available from the [releases](https://github.com/n-delic/decompdecomp/releases): ``` sudo apt install ./decompdecomp_*_amd64.deb # Debian, Ubuntu, Pop!_OS, Mint @@ -66,13 +73,57 @@ brings back a window closed while helpers work. Free tools use Windows builds wh > [!IMPORTANT] > **PS1, PS2 and most N64 games need WSL 2 on Windows.** Their period compilers (GCC for PlayStation, EE-GCC and -> CodeWarrior for PS2, KMC GCC for N64) exist only for Linux, so decompdecomp runs them inside WSL 2. Without it, +> CodeWarrior for PS2, KMC GCC for N64) use their Linux builds through WSL 2 on Windows. Without it, > every compile of such a game fails. Set it up before you import one: run `wsl --install` in an administrator > PowerShell, restart if asked, then open Ubuntu once to finish its setup. > > GameCube, Wii, DS and Windows PC games, and N64 games built with IDO, don't need WSL: their compilers run on > Windows itself. -macOS isn't supported in this version. + +### macOS (Intel and Apple Silicon) + +Use the installer above, or clone this fork and install the checkout: + +```sh +git clone --branch macos-support https://github.com/RetroReversing/decompdecomp.git +cd decompdecomp +bash site/public/install.sh +``` + +The installer uses uv and Python 3.12, installs the Cocoa/WebKit bridge, gets the free tools, and creates +`~/Applications/decompdecomp.app`. Open it from Finder or run `decomp`. Projects stay in `~/Decomp`; +toolchains live in `~/Library/Application Support/decompnet/toolchains`. Intel Macs also need Xcode Command Line +Tools (`xcode-select --install`) to build the pinned modern MIPS binutils on their first N64 export; upstream only +publishes those tools for Apple Silicon. Finder launches preserve the PATH +from installation so your installed Node and helper CLIs remain available; rerun `decomp shortcut` after +changing their locations. This is a source install; there is no signed DMG or macOS release bundle yet. +Installs from `macos-support` track that branch in this fork when checking for updates. + +**Apple Silicon:** the app, WebKit window and modern tools run natively. The pinned N64 KMC-compatible +GCC 2.7.2 and binutils 2.6 archives contain Intel programs, so install Apple's Rosetta runtime before setup: + +```sh +softwareupdate --install-rosetta +``` + +Read and accept Apple's license if you agree, then rerun `decomp setup`. No proprietary game compiler is +installed automatically. IDO still needs your explicit license acceptance in Compilers & tools. + +| Mac capability | Availability | +| --- | --- | +| N64 ROM import, compile, match and export | KMC GCC via Rosetta on Apple Silicon; directly on Intel | +| Native window, file/folder pickers and export downloads | Cocoa and system WKWebView, no GTK or X11 | +| m2c, decomp-permuter and objdiff | Pinned Mac/Python builds | +| Other consoles and public decomp projects | Only where their pinned compiler/tool builds support this Mac | +| Linux-only PS1/PS2 period compilers | Unavailable on macOS; use a supported Linux host | +| Keeping helpers working after closing the window | Run `decomp` again to reopen; no Mac menu bar tray icon yet | + +For development: `uv sync`, `uv run decomp` (separate `~/Decomp-dev` state), and `uv run pytest -q`. +On a Mac desktop, `DECOMP_TEST_COCOA=1 uv run pytest -q tests/test_macos.py` also exercises a hidden +WKWebView against a fresh app, including authentication, external links and close confirmation. +The homemade N64 demo tests need `brew install llvm lld`, with `$(brew --prefix llvm)/bin` and +`$(brew --prefix lld)/bin` on PATH, plus the free tools from `decomp setup`. + ## Start diff --git a/ROADMAP.md b/ROADMAP.md index 0e7eed1ff..794cf9682 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -201,6 +201,11 @@ Where to start on a gap: Andreas, 129 in Ridge Racer V. - **Consoles as packages**: one folder per console (`consoles//`), a template and a conformance test. PS1 and GBA are public decomps only now; the git history keeps their code. +- **macOS app** (RetroReversing fork): a native Cocoa/WKWebView window with the private window user agent, + native open/save panels, downloads, Edit/Reload/Quit menus and the page's close confirmation. A uv source + installer creates a Finder app in `~/Applications`. Intel and Apple Silicon CI exercise portable regressions + and the hidden native window. KMC GCC/binutils require Rosetta on Apple Silicon; unavailable Linux-only + compilers remain unavailable. Mac workers use per-thread QoS so they don't lower the whole app's priority. - **Linux packages**: a `.deb`, an `.rpm` and an AppImage with their own Python, built for each release. - **Windows native app**: a WebView2 window, Start menu and desktop shortcuts, and a native tray icon. A custom native caption follows the app's theme, with Windows resizing, snapping and drag-to-restore. Scrollbars @@ -358,4 +363,4 @@ An image with % matched, functions and platform, for READMEs and posts. No game - **Polish in the drafts.** Methods of known classes are drafted as `Class::method` in the brief; the polishing pass doesn't use them yet. - A Flatpak on the GNOME runtime. -- macOS. +- macOS signed/notarized distribution and a menu bar tray icon. diff --git a/pyproject.toml b/pyproject.toml index c33f49174..a00eeb93c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,12 +22,14 @@ dependencies = [ "ninja>=1.11", # builds GameCube/Wii (dtk) and other ninja-based decomp projects "zstandard>=0.23; sys_platform == 'linux'", # the .zst runtime old compilers need, without the zstd command "pywebview>=6.2.1; sys_platform == 'win32'", # the tested WinForms/WebView2 bridge and native shortcut support + "pyobjc-framework-Cocoa>=11.0; sys_platform == 'darwin'", # native macOS window, dialogs, and Finder launcher + "pyobjc-framework-WebKit>=11.0; sys_platform == 'darwin'", # WKWebView with the private window user agent "tzdata>=2025.2; sys_platform == 'win32'", # Windows has no IANA timezone database for helpers' reset times ] [project.urls] -Homepage = "https://github.com/n-delic/decompdecomp" -Source = "https://github.com/n-delic/decompdecomp" +Homepage = "https://github.com/RetroReversing/decompdecomp" +Source = "https://github.com/RetroReversing/decompdecomp" [project.scripts] decomp = "decompnet.app.cli:main" diff --git a/site/public/install.sh b/site/public/install.sh index 6c4ca69ab..6b4db2faf 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash -# decompdecomp installer for Linux. Windows and macOS aren't supported in this version. +# decompdecomp installer for Linux and macOS (Intel and Apple Silicon). # -# curl -fsSL https://decomp.ndelic.com/install.sh | bash +# curl -fsSL https://raw.githubusercontent.com/RetroReversing/decompdecomp/macos-support/site/public/install.sh | bash -s -- --source branch:macos-support # # decompdecomp installs on this computer, with uv: # 1. installs uv, Astral's Python tool manager, if you don't have it (asks first) @@ -15,14 +15,14 @@ # # Options: # --no-start install only -# --source where decompdecomp comes from: "release" (default), "branch:" (a branch on GitHub, for +# --source where decompdecomp comes from: "release" (a tagged release), "branch:main" (default), "branch:" (a branch on GitHub, for # testing; its updates follow that branch) or a folder with decompdecomp's source. Run from a # git checkout, the checkout is used (and updates follow its branch; main's are releases). # The same through the environment: DECOMP_SOURCE=..., DECOMP_BRANCH=. set -euo pipefail VERSION="1.0.6-alpha.3" -REPO="n-delic/decompdecomp" +REPO="RetroReversing/decompdecomp" START=1 SOURCE="${DECOMP_SOURCE:-}" [ -z "$SOURCE" ] && [ -n "${DECOMP_BRANCH:-}" ] && SOURCE="branch:${DECOMP_BRANCH}" @@ -57,16 +57,17 @@ if [ -n "${BASH_SOURCE[0]:-}" ] && [ -f "${BASH_SOURCE[0]}" ]; then CHECKOUT="$(cd "$SCRIPT_DIR/../.." && pwd)" fi fi -[ -n "$SOURCE" ] || SOURCE="${CHECKOUT:-release}" +[ -n "$SOURCE" ] || SOURCE="${CHECKOUT:-branch:main}" bold "decompdecomp setup" OS="$(uname -s)" case "$OS" in - Linux) ;; - *) fail "decompdecomp runs on Linux, on x86-64 PCs (the games' compilers are x86 programs). Windows and macOS aren't supported in this version; a Linux virtual machine works." ;; + Linux|Darwin) ;; + *) fail "This installer supports Linux x86-64 and macOS Intel/Apple Silicon. Use the Windows installer on Windows." ;; esac case "$(uname -m)" in x86_64|amd64) ;; + arm64) [ "$OS" = Darwin ] || fail "ARM is supported on macOS only" ;; *) fail "decompdecomp needs a 64-bit Intel or AMD PC for now: the games' compilers are x86 programs, and this computer's processor is $(uname -m)" ;; esac command -v curl >/dev/null || fail "curl is required" @@ -103,8 +104,12 @@ resolve_branch() { # the branch's newest commit, installed from GitHub's archiv fi } -# ------------------------------------------------------------------------------------------- on this Linux -for d in "$HOME/.local/bin" "$HOME/.cargo/bin"; do [ -x "$d/uv" ] && export PATH="$d:$PATH"; done +# ------------------------------------------------------------------------------------------- on this host +if ! command -v uv >/dev/null; then + for d in "$HOME/.local/bin" "$HOME/.cargo/bin" /opt/homebrew/bin /usr/local/bin; do + [ -x "$d/uv" ] && export PATH="$d:$PATH" + done +fi if ! command -v uv >/dev/null; then ask "decompdecomp is installed with uv (Astral's Python tool manager). Install uv now?" || fail "uv is needed: https://docs.astral.sh/uv/" curl -LsSf https://astral.sh/uv/install.sh | sh @@ -133,7 +138,7 @@ uv tool update-shell >/dev/null 2>&1 || true "$BIN/decomp" install-info --channel "$CHANNEL" --branch "$BRANCH" --commit "$COMMIT" --repo "$REPO" >/dev/null # the window: GTK and WebKitGTK 4.1 with their Python bindings, from the system (no browser involved) -if ! /usr/bin/python3 -c 'import gi; gi.require_version("Gtk", "3.0"); gi.require_version("WebKit2", "4.1")' 2>/dev/null; then +if [ "$OS" = Linux ] && ! /usr/bin/python3 -c 'import gi; gi.require_version("Gtk", "3.0"); gi.require_version("WebKit2", "4.1")' 2>/dev/null; then . /etc/os-release 2>/dev/null || true case " ${ID:-} ${ID_LIKE:-} " in *" debian "*|*" ubuntu "*) WINDOW_PACKAGES="sudo apt install -y python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1" ;; @@ -156,7 +161,7 @@ step "downloading the compilers and tools" "$BIN/decomp" stop >/dev/null 2>&1 || true # one decompdecomp at a time: an older .deb or .rpm of it would be a second one in the apps menu and on PATH -if [ -d /opt/decompdecomp ]; then +if [ "$OS" = Linux ] && [ -d /opt/decompdecomp ]; then if command -v dpkg >/dev/null && dpkg -s decompdecomp >/dev/null 2>&1; then REMOVE="sudo apt remove -y decompdecomp" elif command -v rpm >/dev/null && rpm -q decompdecomp >/dev/null 2>&1; then REMOVE="sudo dnf remove -y decompdecomp" else REMOVE=""; fi @@ -170,7 +175,7 @@ fi bold "Done." echo " Start it any time from your apps menu, or with: decomp" if [ "$START" = 1 ]; then - if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then + if [ "$OS" = Darwin ] || [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then nohup "$BIN/decomp" >/dev/null 2>&1 & echo " Opening decompdecomp…" else diff --git a/src/decompnet/app/cli.py b/src/decompnet/app/cli.py index 5533f128e..b1fa2e3bc 100644 --- a/src/decompnet/app/cli.py +++ b/src/decompnet/app/cli.py @@ -316,7 +316,7 @@ def main(argv: list[str] | None = None) -> int: ii.add_argument("--channel", choices=["release", "branch"], default="release") ii.add_argument("--branch", default="") ii.add_argument("--commit", default="") - ii.add_argument("--repo", default="n-delic/decompdecomp") + ii.add_argument("--repo", default="RetroReversing/decompdecomp") args = ap.parse_args(argv) __import__("decompnet").utf8_output() return {"setup": setup, "stop": stop, "shortcut": shortcut, "update": update_cmd, diff --git a/src/decompnet/app/launcher.py b/src/decompnet/app/launcher.py index 724413e19..fcc2d68e4 100644 --- a/src/decompnet/app/launcher.py +++ b/src/decompnet/app/launcher.py @@ -9,6 +9,8 @@ import base64 import os +import plistlib +import shlex import shutil import subprocess import sys @@ -57,6 +59,23 @@ def install_launcher() -> str: from its checkout.""" if edition.DEV: return "a development copy gets no launcher: run it with `uv run decomp` in the checkout" + if sys.platform == "darwin": + bundle = Path.home() / "Applications" / "decompdecomp.app" / "Contents" + executable = bundle / "MacOS" / "decompdecomp" + resources = bundle / "Resources" + executable.parent.mkdir(parents=True, exist_ok=True) + resources.mkdir(parents=True, exist_ok=True) + # Finder doesn't read the user's shell profile. Preserve this interpreter and discover uv explicitly. + executable.write_text("#!/bin/sh\nexport PATH=" + shlex.quote(os.environ.get("PATH", "/usr/bin:/bin")) + + "\nexec " + shlex.quote(sys.executable) + + " -m decompnet.app.cli \"$@\"\n", encoding="utf-8") + executable.chmod(0o755) + with (bundle / "Info.plist").open("wb") as stream: + plistlib.dump({"CFBundleName": "decompdecomp", "CFBundleDisplayName": "decompdecomp", + "CFBundleIdentifier": "com.retroreversing.decompdecomp", "CFBundlePackageType": "APPL", + "CFBundleExecutable": "decompdecomp", "LSMinimumSystemVersion": "12.0", + "NSHighResolutionCapable": True}, stream) + return f"added decompdecomp to Finder ({bundle.parent})" if sys.platform == "win32": desktop = _windows_folder(0x10) # CSIDL_DESKTOPDIRECTORY programs = _windows_folder(0x02) # CSIDL_PROGRAMS: the current user's Start menu diff --git a/src/decompnet/app/macwindow.py b/src/decompnet/app/macwindow.py new file mode 100644 index 000000000..493e02104 --- /dev/null +++ b/src/decompnet/app/macwindow.py @@ -0,0 +1,232 @@ +"""Native macOS WKWebView, launched separately by window.py with the app's Python. + +Cocoa/WebKit are macOS-only dependencies. Import them inside create_window() so navigation policy can also be tested on Linux. +The per-launch secret travels only in the private web view's user agent, never in an external browser's URL. +""" + +import argparse +import os +import sys +import threading +import webbrowser +from pathlib import Path +from urllib.parse import urlsplit + +TOKEN_AGENT = "decompdecomp-window/" +ASK_PAGE = 'typeof decompClose === "function" ? String(decompClose()) : "close"' + + +def navigation_kind(origin: str, url: str) -> str: + """Same-origin pages stay private; only HTTP(S) links may leave for the user's browser.""" + target, source = urlsplit(url), urlsplit(origin) + if target.scheme not in ("http", "https"): + return "reject" + if (target.scheme, target.netloc) == (source.scheme, source.netloc): + return "internal" + return "external" + + +def export_url(origin: str, url: str) -> bool: + source = urlsplit(origin) + return navigation_kind(origin, url) == "internal" or url.startswith(f"blob:{source.scheme}://{source.netloc}/") + + +def create_window(args): + import objc + from AppKit import ( + NSAlert, + NSAlertFirstButtonReturn, + NSApplication, + NSEvent, + NSEventTypeApplicationDefined, + NSImage, + NSMenu, + NSMenuItem, + NSModalResponseOK, + NSOpenPanel, + NSSavePanel, + NSTerminateCancel, + NSTextField, + NSWindow, + ) + from Foundation import NSMakeRect, NSObject, NSProcessInfo + from WebKit import WKWebView, WKWebViewConfiguration + + app = NSApplication.sharedApplication() + app.setActivationPolicy_(0) # regular Dock app, including when invoked from a terminal + NSProcessInfo.processInfo().setProcessName_(args.title) + if args.icon: + icon = NSImage.alloc().initWithContentsOfFile_(args.icon) + if icon: + app.setApplicationIconImage_(icon) + + class MacWindowDelegate(NSObject): + @objc.typedSelector(objc._C_NSBOOL + b"@:@") + def windowShouldClose_(self, _window): + if not self.asking: + self.asking = True + def answered(answer, error): + self.asking = False + if error or answer == "close": + self.close() + self.view.evaluateJavaScript_completionHandler_(ASK_PAGE, answered) + return False + + @objc.typedSelector(b"Q@:@") + def applicationShouldTerminate_(self, _application): + self.windowShouldClose_(self.window) + return NSTerminateCancel + + @objc.typedSelector(objc._C_NSBOOL + b"@:@") + def applicationShouldTerminateAfterLastWindowClosed_(self, _application): + return True + + def close(self): + self.window.orderOut_(None) + app.stop_(None) + # stop: alone leaves nextEventMatchingMask waiting when close came from a timer or JS callback. + wake = NSEvent.otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2_( + NSEventTypeApplicationDefined, (0, 0), 0, 0, 0, None, 0, 0, 0) + app.postEvent_atStart_(wake, True) + + def present(self): + self.window.makeKeyAndOrderFront_(None) + app.activateIgnoringOtherApps_(True) + + def reload_(self, _sender): + self.view.reload() + + def quit_(self, _sender): + self.windowShouldClose_(self.window) + + def webView_decidePolicyForNavigationAction_decisionHandler_(self, _view, action, decide): + url = str(action.request().URL().absoluteString()) + kind = navigation_kind(args.url, url) + if kind == "external": + webbrowser.open(url) + # Downloads, including same-origin blob URLs, are exports rather than pages with our secret. + if action.shouldPerformDownload() and export_url(args.url, url): + decide(2) # WKNavigationActionPolicyDownload + elif kind == "internal" and action.targetFrame() is None: + decide(0) + self.view.loadRequest_(action.request()) + else: + decide(1 if kind == "internal" else 0) + + def webView_decidePolicyForNavigationResponse_decisionHandler_(self, _view, response, decide): + headers = response.response().allHeaderFields() if hasattr(response.response(), "allHeaderFields") else {} + attachment = any(str(k).lower() == "content-disposition" and "attachment" in str(v).lower() + for k, v in headers.items()) + decide(2 if attachment or not response.canShowMIMEType() else 1) + + def webView_navigationAction_didBecomeDownload_(self, _view, _action, download): + download.setDelegate_(self) + + def webView_navigationResponse_didBecomeDownload_(self, _view, _response, download): + download.setDelegate_(self) + + def download_decideDestinationUsingResponse_suggestedFilename_completionHandler_(self, _download, _response, name, done): + panel = NSSavePanel.savePanel() + panel.setNameFieldStringValue_(Path(str(name)).name) + panel.setCanCreateDirectories_(True) + done(panel.URL() if panel.runModal() == NSModalResponseOK else None) + + def webView_runOpenPanelWithParameters_initiatedByFrame_completionHandler_(self, _view, parameters, _frame, done): + panel = NSOpenPanel.openPanel() + panel.setAllowsMultipleSelection_(parameters.allowsMultipleSelection()) + panel.setCanChooseDirectories_(parameters.allowsDirectories()) + panel.setCanChooseFiles_(True) + done(panel.URLs() if panel.runModal() == NSModalResponseOK else None) + + def webView_runJavaScriptAlertPanelWithMessage_initiatedByFrame_completionHandler_(self, _view, message, _frame, done): + alert = NSAlert.alloc().init() + alert.setMessageText_(message) + alert.runModal() + done() + + def webView_runJavaScriptConfirmPanelWithMessage_initiatedByFrame_completionHandler_(self, _view, message, _frame, done): + alert = NSAlert.alloc().init() + alert.setMessageText_(message) + alert.addButtonWithTitle_("Continue") + alert.addButtonWithTitle_("Cancel") + done(alert.runModal() == NSAlertFirstButtonReturn) # NSAlertFirstButtonReturn = 1000 + + def webView_runJavaScriptTextInputPanelWithPrompt_defaultText_initiatedByFrame_completionHandler_(self, _view, prompt, text, _frame, done): + alert = NSAlert.alloc().init() + alert.setMessageText_(prompt) + alert.addButtonWithTitle_("OK") + alert.addButtonWithTitle_("Cancel") + field = NSTextField.alloc().initWithFrame_(NSMakeRect(0, 0, 360, 24)) + field.setStringValue_(text or "") + alert.setAccessoryView_(field) + done(field.stringValue() if alert.runModal() == NSAlertFirstButtonReturn else None) + + delegate = MacWindowDelegate.alloc().init() + delegate.asking = False + config = WKWebViewConfiguration.alloc().init() + delegate.view = WKWebView.alloc().initWithFrame_configuration_(NSMakeRect(0, 0, 1440, 920), config) + delegate.view.setCustomUserAgent_("Mozilla/5.0 (Macintosh) AppleWebKit/605.1.15 " + + TOKEN_AGENT + os.environ.get("DECOMP_WINDOW_TOKEN", "")) + delegate.view.setNavigationDelegate_(delegate) + delegate.view.setUIDelegate_(delegate) + if args.dev and hasattr(delegate.view, "setInspectable_"): + delegate.view.setInspectable_(True) + delegate.window = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_(NSMakeRect(0, 0, 1440, 920), 15, 2, False) + delegate.window.setTitle_(args.title) + delegate.window.setReleasedWhenClosed_(False) + delegate.window.setContentView_(delegate.view) + delegate.window.setDelegate_(delegate) + delegate.window.center() + app.setDelegate_(delegate) + + menu = NSMenu.alloc().init() + for title, entries in [(args.title, [("Quit " + args.title, "quit:", "q")]), + ("Edit", [("Undo", "undo:", "z"), ("Cut", "cut:", "x"), ("Copy", "copy:", "c"), + ("Paste", "paste:", "v"), ("Select All", "selectAll:", "a")]), + ("View", [("Reload", "reload:", "r")])]: + parent = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(title, None, "") + submenu = NSMenu.alloc().initWithTitle_(title) + for label, action, key in entries: + item = NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(label, action, key) + if action in ("quit:", "reload:"): + item.setTarget_(delegate) + submenu.addItem_(item) + parent.setSubmenu_(submenu) + menu.addItem_(parent) + app.setMainMenu_(menu) + + return app, delegate + + +def run(args) -> int: + from Foundation import NSURL, NSOperationQueue, NSURLRequest + + app, delegate = create_window(args) + + def commands(): + for line in sys.stdin: + if line.strip() == "present": + NSOperationQueue.mainQueue().addOperationWithBlock_(delegate.present) + elif line.strip() == "close": + break + NSOperationQueue.mainQueue().addOperationWithBlock_(delegate.close) + + threading.Thread(target=commands, daemon=True, name="window-commands").start() + delegate.view.loadRequest_(NSURLRequest.requestWithURL_(NSURL.URLWithString_(args.url))) + delegate.present() + app.run() + return 0 + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--url", required=True) + parser.add_argument("--title", default="decompdecomp") + parser.add_argument("--window-class", default="decompdecomp") + parser.add_argument("--icon", default="") + parser.add_argument("--dev", action="store_true") + return run(parser.parse_args()) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/decompnet/app/picker.py b/src/decompnet/app/picker.py index e6707b51d..d4e32a47a 100644 --- a/src/decompnet/app/picker.py +++ b/src/decompnet/app/picker.py @@ -21,7 +21,7 @@ def choose(kind: str, title: str) -> str: if kind not in KINDS: raise ValueError("pick a folder or a file") - python = sys.executable if window.IS_WINDOWS else window.SYSTEM_PYTHON + python = sys.executable if window.IS_WINDOWS or window.IS_MACOS else window.SYSTEM_PYTHON try: res = subprocess.run([python, "-I", str(Path(__file__)), kind, title[:200]], capture_output=True, text=True, timeout=PICK_SECONDS, @@ -48,6 +48,21 @@ def main(kind: str, title: str) -> None: root.destroy() return + if sys.platform == "darwin": + from AppKit import NSApplication, NSModalResponseOK, NSOpenPanel + + app = NSApplication.sharedApplication() + app.setActivationPolicy_(0) + app.activateIgnoringOtherApps_(True) + panel = NSOpenPanel.openPanel() + panel.setTitle_(title) + panel.setCanChooseDirectories_(kind == "folder") + panel.setCanChooseFiles_(kind == "file") + panel.setAllowsMultipleSelection_(False) + if panel.runModal() == NSModalResponseOK: + print(panel.URL().path()) + return + import gi gi.require_version("Gtk", "3.0") diff --git a/src/decompnet/app/updates.py b/src/decompnet/app/updates.py index 9d9ad12e8..687861c51 100644 --- a/src/decompnet/app/updates.py +++ b/src/decompnet/app/updates.py @@ -28,7 +28,7 @@ from .. import __version__, toolchains from . import edition, window -REPO = "n-delic/decompdecomp" +REPO = "RetroReversing/decompdecomp" RECHECK = {"release": 3 * 3600, "branch": 15 * 60} # GitHub allows 60 unauthenticated API calls an hour diff --git a/src/decompnet/app/window.py b/src/decompnet/app/window.py index 3d79f89b8..87c86d8b5 100644 --- a/src/decompnet/app/window.py +++ b/src/decompnet/app/window.py @@ -31,6 +31,8 @@ CHECK_SECONDS = 20 CLOSE_SECONDS = 5 # a window told to close that's still there after this long is ended IS_WINDOWS = sys.platform == "win32" +IS_MACOS = sys.platform == "darwin" +MAC_WINDOW_PROGRAM = Path(__file__).with_name("macwindow.py") NVIDIA_MODULE = Path("/sys/module/nvidia") # there while NVIDIA's own driver is loaded # what the window needs from the system, per family of distributions (/etc/os-release ID and ID_LIKE) @@ -82,6 +84,10 @@ def missing() -> str | None: "Install WebView2 from https://developer.microsoft.com/microsoft-edge/webview2/ " "and any Windows updates, then start decompdecomp again.") return None + if IS_MACOS: + if any(importlib.util.find_spec(name) is None for name in ("AppKit", "WebKit")): + return "decompdecomp's macOS window needs PyObjC Cocoa and WebKit. Reinstall the app's dependencies." + return None if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): # over ssh, or a text console return ("decompdecomp opens its own window, and there's no desktop here to show it on. " "Start decompdecomp from a terminal in your desktop session (or from the apps menu).") @@ -106,6 +112,14 @@ def tell_desktop(title: str, text: str) -> None: ctypes.windll.user32.MessageBoxW(None, text, title, 0x10) # MB_ICONERROR: shortcuts have no console return + if IS_MACOS: + from AppKit import NSAlert + + alert = NSAlert.alloc().init() + alert.setMessageText_(title) + alert.setInformativeText_(text) + alert.runModal() + return gdbus = shutil.which("gdbus") if not gdbus: return @@ -146,11 +160,12 @@ def open(self) -> bool: icon = self.internal / "window-icon.svg" icon.write_text(edition.icon_svg(), encoding="utf-8") win_icon = Path(__file__).with_name("static") / ("favicon-dev.ico" if edition.DEV else "favicon.ico") - if IS_WINDOWS: + if IS_WINDOWS or IS_MACOS: url = self.url if os.environ.get("DECOMP_THEME") in ("light", "dark"): url += ("&" if "?" in url else "?") + "system-theme=" + os.environ["DECOMP_THEME"] - argv = [sys.executable, "-I", str(WIN_WINDOW_PROGRAM), "--url", url, "--title", edition.NAME, + program = WIN_WINDOW_PROGRAM if IS_WINDOWS else MAC_WINDOW_PROGRAM + argv = [sys.executable, "-I", str(program), "--url", url, "--title", edition.NAME, "--window-class", edition.WINDOW_CLASS, "--icon", str(win_icon)] + (["--dev"] if edition.DEV else []) else: url = self.url diff --git a/src/decompnet/app/workers.py b/src/decompnet/app/workers.py index 610dc948e..168002a00 100644 --- a/src/decompnet/app/workers.py +++ b/src/decompnet/app/workers.py @@ -9,6 +9,7 @@ from __future__ import annotations import os +import sys import threading from concurrent.futures import ThreadPoolExecutor @@ -33,6 +34,14 @@ def _lower_priority() -> None: kernel.GetCurrentThread.restype = ctypes.c_void_p kernel.SetThreadPriority.argtypes = (ctypes.c_void_p, ctypes.c_int) kernel.SetThreadPriority(kernel.GetCurrentThread(), -1) # THREAD_PRIORITY_BELOW_NORMAL + elif sys.platform == "darwin": + import ctypes + + # Darwin's nice value belongs to the process, so os.nice would slow the GUI too. + # Set this worker's QoS instead. Utility threads still let the interactive window run first. + system = ctypes.CDLL(None) + system.pthread_set_qos_class_self_np.argtypes = (ctypes.c_uint, ctypes.c_int) + system.pthread_set_qos_class_self_np(0x11, 0) # QOS_CLASS_UTILITY else: os.nice(NICE) diff --git a/src/decompnet/hostenv.py b/src/decompnet/hostenv.py index cd1924b49..3ddcb13b7 100644 --- a/src/decompnet/hostenv.py +++ b/src/decompnet/hostenv.py @@ -50,7 +50,10 @@ def find_uv() -> str | None: if found: return found name = "uv.exe" if sys.platform == "win32" else "uv" - for folder in (Path.home() / ".local" / "bin", Path.home() / ".cargo" / "bin"): + folders = [Path.home() / ".local" / "bin", Path.home() / ".cargo" / "bin"] + if sys.platform == "darwin": + folders += [Path("/opt/homebrew/bin"), Path("/usr/local/bin")] + for folder in folders: if (folder / name).is_file(): return str(folder / name) return None @@ -111,7 +114,7 @@ def stop_process_tree(proc: subprocess.Popen) -> None: def open_folder(path: Path) -> str | None: """Show a folder in the user's file manager. Returns why it couldn't, or None.""" try: - opener = "explorer.exe" if sys.platform == "win32" else "xdg-open" + opener = {"win32": "explorer.exe", "darwin": "open"}.get(sys.platform, "xdg-open") subprocess.Popen([opener, str(path)], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) except OSError: diff --git a/src/decompnet/toolchains.py b/src/decompnet/toolchains.py index 40457191b..8bd2629de 100644 --- a/src/decompnet/toolchains.py +++ b/src/decompnet/toolchains.py @@ -26,7 +26,7 @@ from decompnet.hostenv import NO_WINDOW, find_uv -from . import wsl +from . import limits, wsl MANIFEST = ".decompnet-toolchain.json" LINUX = "linux-x86_64" @@ -93,10 +93,11 @@ def current_platform() -> str: def unsupported_cpu() -> str | None: - """Why this computer can't run the games' compilers, or None: they're x86 programs, which an ARM computer - (a Raspberry Pi, an ARM laptop or server) doesn't run.""" - machine = current_platform().split("-", 1)[1] - if machine == "x86_64": + """Supported hosts; Apple Silicon uses native builds or Intel Mac tools through Rosetta. + Each tool still checks that its own build is available before installation.""" + host = current_platform() + machine = host.split("-", 1)[1] + if machine == "x86_64" or host == "macos-arm64": return None return (f"decompdecomp needs a 64-bit Intel or AMD PC for now (the games' compilers are x86 programs; this " f"computer's processor is {machine})") @@ -144,10 +145,30 @@ def installed(tid: str, linux: bool | None = None) -> dict | None: return json.loads(m.read_text(encoding="utf-8")) if m.exists() else None +@functools.cache +def _intel_mac_runtime_ready() -> bool: + try: + return subprocess.run(["/usr/bin/arch", "-x86_64", "/usr/bin/true"], capture_output=True, timeout=10).returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def runtime_error(tid: str) -> str | None: + """Intel-only Mac compiler archives need Apple's translation runtime on Apple Silicon.""" + if current_platform() != "macos-arm64" or not any(p.get("rosetta") for p in get(tid).parts_for()): + return None + if _intel_mac_runtime_ready(): + return None + return (f"{tid} uses Intel macOS binaries and needs Rosetta 2 on Apple Silicon. " + "Run `softwareupdate --install-rosetta` in Terminal, accept Apple's license if you agree, then try again.") + + def require(tid: str, linux: bool | None = None) -> Path: """Directory of an installed toolchain, or a clear error telling the operator how to install it.""" if installed(tid, linux) is None: raise ToolchainError(f"toolchain {tid!r} is not installed: install it in decompdecomp, under Compilers & tools") + if why := runtime_error(tid): + raise ToolchainError(why) return path(tid, linux) @@ -242,6 +263,9 @@ def install(tid: str, *, accept_license: bool = False, force: bool = False, log= that's how it runs on this computer).""" tc = get(tid) linux = tc.via_wsl if linux is None else linux + _intel_mac_runtime_ready.cache_clear() # Rosetta may have been installed since the last setup attempt + if why := runtime_error(tid): + raise ToolchainError(why) if tc.proprietary and not accept_license and (force or installed(tid, linux) is None): raise ToolchainError( f"{tid} ({tc.name}) is proprietary software. Running it on your coordinator is your own licensing " @@ -289,7 +313,9 @@ def install(tid: str, *, accept_license: bool = False, force: bool = False, log= raise ToolchainError(f"checksum mismatch for {part['url']}: expected {part['sha256']}, got {digest}") if not part.get("sha256"): log(f" no pinned checksum in the registry; pinning on first use: sha256 {digest}") - if part.get("binary"): # a plain executable, not an archive + if part.get("build") == "mips-binutils": + _build_mips_binutils(archive, dest, part.get("subdir"), log) + elif part.get("binary"): # a plain executable, not an archive shutil.copyfile(archive, dest / part["binary"]) os.chmod(dest / part["binary"], 0o755) else: @@ -316,6 +342,47 @@ def install(tid: str, *, accept_license: bool = False, force: bool = False, log= return dest +def _build_mips_binutils(archive: Path, dest: Path, subdir: str | None, log) -> None: + """The v0.10 upstream has no Intel Mac archive. Build that same pinned source, never a tool from PATH. + Only the host build tools (Xcode's C compiler and make) come from the system; installed MIPS tools stay pinned. + """ + if not shutil.which("make") or not shutil.which("clang"): + raise ToolchainError("Building MIPS binutils on Intel macOS needs Xcode Command Line Tools: run `xcode-select --install`, then try again.") + with tempfile.TemporaryDirectory(prefix="decomp-binutils-") as tmp: + source = Path(tmp) / "source" + build = Path(tmp) / "build" + source.mkdir() + build.mkdir() + (build / "gas" / "doc").mkdir(parents=True) # git sources omit the generated documentation directory + _extract(archive, source, subdir) + # The git archive includes GDB, unlike a standalone binutils release. Its top-level configure probes + # GMP/MPFR whenever gdb/ exists, even with --disable-gdb. We build only the assembler/linker/object tools. + shutil.rmtree(source / "gdb", ignore_errors=True) + shutil.rmtree(source / "gdbserver", ignore_errors=True) + commands = [ + [str(source / "configure"), "--target=mips", "--disable-gprofng", "--disable-gdb", "--disable-werror", + "--disable-nls", "--disable-sim", "--without-zstd", "--with-system-zlib"], + ["make", "-j" + str(min(4, os.cpu_count() or 1)), "all-binutils", "all-gas", "all-ld", "MAKEINFO=true"], + ] + for argv in commands: + log(" building pinned MIPS binutils: " + Path(argv[0]).name + " (first install only)") + try: + res = subprocess.run(limits.capped(argv), cwd=build, capture_output=True, text=True, + errors="replace", timeout=900, env=dict(os.environ, CC="/usr/bin/clang", MAKEINFO="true")) + except (OSError, subprocess.TimeoutExpired) as error: + raise ToolchainError(f"Couldn't build the pinned MIPS tools: {error}. Install Xcode Command Line Tools and try again.") from error + if res.returncode: + raise ToolchainError("Couldn't build the pinned MIPS tools. Install Xcode Command Line Tools and try again.\n" + + (res.stdout + res.stderr)[-4000:]) + outputs = {"as": "gas/as-new", "ld": "ld/ld-new", "objcopy": "binutils/objcopy", + "objdump": "binutils/objdump", "nm": "binutils/nm-new", "ar": "binutils/ar", + "ranlib": "binutils/ranlib", "strip": "binutils/strip-new", "readelf": "binutils/readelf"} + for name, binary in outputs.items(): + target = dest / ("mips-ps2-decompals-" + name) + shutil.copy2(build / binary, target) + target.chmod(0o755) + + def venv_bin(venv: Path, name: str) -> Path: """Path of an executable inside a virtual environment.""" return venv / "Scripts" / f"{name}.exe" if sys.platform == "win32" else venv / "bin" / name diff --git a/src/decompnet/toolchains.toml b/src/decompnet/toolchains.toml index b531143a3..674d5b33f 100644 --- a/src/decompnet/toolchains.toml +++ b/src/decompnet/toolchains.toml @@ -10,7 +10,7 @@ # # Each part lists its host platforms. Windows uses native tools where available, and Linux-only compilers # through WSL 2 (wsl.py). A part for wsl-x86_64 replaces the Linux archive there (a .deb Windows can unpack). -# The macos-* parts stay for a later macOS version. +# macOS uses its pinned builds directly; Intel-only parts need Rosetta on Apple Silicon. [runtime-i386] name = "32-bit x86 C runtime (glibc 2.44)" @@ -51,10 +51,12 @@ platforms = ["linux-x86_64"] url = "https://github.com/decompals/mips-gcc-2.7.2/releases/download/v0.1/gcc-2.7.2-mac.tar.gz" sha256 = "5923ea59b97d39d65d684a3ef2f25cd1cbcaf2fd0b5cf6a7f1a4804500a770e0" platforms = ["macos-x86_64", "macos-arm64"] +rosetta = true [["gcc-2.7.2-kmc".parts]] url = "https://github.com/decompals/mips-binutils-2.6/releases/download/v0.3/binutils-2.6-mac.tar.gz" sha256 = "ab21ad5908fb7776a4b0c596cc82f216876e3b5d108c7c7c1f33543b3f3fce4c" platforms = ["macos-x86_64", "macos-arm64"] +rosetta = true ["gcc-2.6.3-psx"] name = "GCC 2.6.3 for PlayStation + maspsx (ASPSX 2.34)" @@ -491,6 +493,14 @@ platforms = ["linux-x86_64"] url = "https://github.com/decompals/binutils-mips-ps2-decompals/releases/download/v0.10/binutils-mips-ps2-decompals-macos-arm64.tar.gz" sha256 = "948cc736e01b2c135168caabfb616f6ed8efe89f1dad1ed8ef1ca74acb1b742c" platforms = ["macos-arm64"] +# Upstream v0.10 publishes Apple Silicon only. Intel Macs build the exact same source with Xcode's tools. +[[binutils-mips.parts]] +url = "https://github.com/decompals/binutils-mips-ps2-decompals/archive/1cb36733961d0ffa2d169ef16032066d94096ac0.tar.gz" +sha256 = "83817a54570e6b62b87f9a7be31114965750894b547629e187f380079a96f988" +file = "binutils-mips-source-v0.10.tar.gz" +subdir = "binutils-mips-ps2-decompals-1cb36733961d0ffa2d169ef16032066d94096ac0" +platforms = ["macos-x86_64"] +build = "mips-binutils" [[binutils-mips.parts]] url = "https://github.com/decompals/binutils-mips-ps2-decompals/releases/download/v0.10/binutils-mips-ps2-decompals-windows-x86-64.zip" sha256 = "2bb5904ee174e04f1600cc70ffdb3dc36b0efa4d1892ea446be588ba8dc6198c" diff --git a/tests/native_macwindow.py b/tests/native_macwindow.py new file mode 100644 index 000000000..c04b8f018 --- /dev/null +++ b/tests/native_macwindow.py @@ -0,0 +1,158 @@ +"""Opt-in hidden WKWebView test against a fresh app; never discovers or runs the owner's agents.""" + +import os +import socket +import tempfile +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import AppKit +import httpx +import uvicorn +from Foundation import NSURL, NSTimer, NSURLRequest +from starlette.responses import Response + +from decompnet.app import collab, macwindow +from decompnet.app.cli import server_config +from decompnet.app.engine import RomNetwork +from decompnet.app.server import build + +home = Path(tempfile.mkdtemp(prefix="native-mac-window-", dir=os.environ["DECOMP_HOME"])) +internal = home / ".decomp" +internal.mkdir() +os.environ.update(DECOMP_HOME=str(home), DECOMP_WINDOW_TOKEN="native-mac-test-secret", + DECOMP_DETECT_AGENTS="off", DECOMP_UPDATES="off", DECOMP_TRAY="0") +collab.local_addresses = lambda: [] +listener = socket.socket() +listener.bind(("127.0.0.1", 0)) +listener.listen(128) +port = listener.getsockname()[1] +net = RomNetwork(internal / "decomp.db", home) +mcp, manager, security, _ = build(net, internal, port, "native-mac-test-secret") +@mcp.custom_route("/native-export", methods=["GET"]) +async def export_fixture(request): + if "decompdecomp-window/native-mac-test-secret" not in request.headers.get("user-agent", ""): + return Response(status_code=401) + return Response(b"native export bytes", media_type="application/zip", + headers={"Content-Disposition": 'attachment; filename="export.zip"'}) + + +served = mcp.streamable_http_app(transport_security=security, host="127.0.0.1") +headers = [] + + +async def capture(scope, receive, send): + if scope["type"] == "http": + headers.append(dict(scope["headers"]).get(b"user-agent", b"").decode()) + await served(scope, receive, send) + + +server = uvicorn.Server(server_config(capture)) +thread = threading.Thread(target=lambda: server.run(sockets=[listener]), daemon=True) +thread.start() +url = f"http://127.0.0.1:{port}/" +assert httpx.get(url).status_code == 401, "the app answered a browser without its window's secret" +downloaded = home / "export.zip" + + +class SavePanel: + @classmethod + def savePanel(cls): + return cls() + + def setNameFieldStringValue_(self, name): + assert name == "export.zip" + + def setCanCreateDirectories_(self, value): + pass + + def runModal(self): + return AppKit.NSModalResponseOK + + def URL(self): + return NSURL.fileURLWithPath_(str(downloaded)) + + +# Exercise the real WebKit download delegate while choosing a scratch destination without a dialog. +AppKit.NSSavePanel = SavePanel +app, delegate = macwindow.create_window(SimpleNamespace(url=url, title="decompdecomp Mac test", icon="", dev=False)) +external = [] +macwindow.webbrowser.open = lambda url: external.append(url) +state = {"phase": 0, "busy": False} +failures = [] +deadline = time.monotonic() + 25 + + +def evaluate(script, callback): + state["busy"] = True + def done(value, error): + state["busy"] = False + if error: + failures.append(str(error)) + delegate.close() + else: + callback(value) + delegate.view.evaluateJavaScript_completionHandler_(script, done) + + +def tick(_timer): + try: + assert time.monotonic() < deadline, "native app load/close timed out" + if state["busy"]: + return + phase = state["phase"] + if phase == 0: + def loaded(value): + if value: + state["phase"] = 10 + evaluate('Boolean(typeof S !== "undefined" && S.st && typeof decompClose === "function")', loaded) + elif phase == 10: + state["phase"] = 11 + evaluate('var a=document.createElement("a");a.href="/native-export";a.download="export.zip";a.click();true', + lambda _: None) + elif phase == 11: + if not downloaded.exists(): + return + assert downloaded.read_bytes() == b"native export bytes" + state["phase"] = 1 + elif phase == 1: + state["phase"] = 2 + evaluate('location.href="https://example.invalid/external"; true', lambda _: None) + elif phase == 2: + if not external: + return + assert external == ["https://example.invalid/external"] + assert "native-mac-test-secret" not in external[0] + state["phase"] = 3 + evaluate('window.decompClose=()=>"wait"; true', lambda _: None) + elif phase == 3: + state["phase"] = 4 + delegate.windowShouldClose_(delegate.window) + elif phase == 4: + if delegate.asking: + return + # Reaching this tick proves "wait" left the message loop running; the external link kept our page. + state["phase"] = 5 + evaluate('window.decompClose=()=>"close"; document.title', + lambda title: failures.append("lost app page") if "decompdecomp" not in title else None) + elif phase == 5: + state["phase"] = 6 + delegate.windowShouldClose_(delegate.window) + except Exception as error: + failures.append(str(error)) + delegate.close() + + +timer = NSTimer.scheduledTimerWithTimeInterval_repeats_block_(0.1, True, tick) +delegate.view.loadRequest_(NSURLRequest.requestWithURL_(NSURL.URLWithString_(url))) +# A hidden window exercises the same delegates and WKWebView without showing a test window on the desktop. +app.run() +timer.invalidate() +server.should_exit = True +thread.join(timeout=5) +assert not failures, failures +assert state["phase"] == 6, f"close bypassed the page's decision: {state}" +assert sum("decompdecomp-window/native-mac-test-secret" in h for h in headers) >= 5, headers +print("native macOS window passed: real app, private requests, external navigation, export download, wait/close") diff --git a/tests/test_app_server.py b/tests/test_app_server.py index c3374d379..40cb96eca 100644 --- a/tests/test_app_server.py +++ b/tests/test_app_server.py @@ -647,6 +647,7 @@ def test_paths_are_picked_in_the_systems_own_chooser(app, tmp_path, monkeypatch) from decompnet.app import picker, window monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", False) monkeypatch.setattr(window, "SYSTEM_PYTHON", str(tmp_path / "python3")) monkeypatch.setattr(picker.subprocess, "run", lambda argv, **kwargs: subprocess.CompletedProcess(argv, 0 if argv[3] == "folder" else 1, diff --git a/tests/test_consoles.py b/tests/test_consoles.py index 7c5b52802..bc5c7b003 100644 --- a/tests/test_consoles.py +++ b/tests/test_consoles.py @@ -122,7 +122,8 @@ def _missing(console) -> list[str]: def there(tool: str) -> bool: try: - return toolchains.installed(tool) is not None or not toolchains.get(tool).supported + toolchains.get(tool) + return toolchains.installed(tool) is not None except toolchains.ToolchainError: # a program on PATH, not a toolchain return shutil.which(tool) is not None return [t for t in console.demo_tools if not there(t)] + ([] if ZLIB.exists() else ["zlib (projects/zlib-n64/prepare.py)"]) diff --git a/tests/test_macos.py b/tests/test_macos.py new file mode 100644 index 000000000..c22546776 --- /dev/null +++ b/tests/test_macos.py @@ -0,0 +1,208 @@ +"""Mac integration points, with isolated launchers and no dialogs or real agents in the regular test run.""" + +import functools +import os +import plistlib +import shlex +import shutil +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from decompnet import hostenv, toolchains +from decompnet.app import launcher, macwindow, picker, window + + +@pytest.mark.parametrize("url,kind", [ + ("http://127.0.0.1:6474/api/export?file=1", "internal"), + ("http://127.0.0.1:64740/", "external"), + ("http://127.0.0.1:6474.evil.example/", "external"), + ("https://example.com/", "external"), ("https://127.0.0.1:6474/", "external"), + ("javascript:alert(1)", "reject"), ("file:///etc/passwd", "reject"), ("data:text/html,hello", "reject"), +]) +def test_native_navigation_keeps_the_private_origin(url, kind): + assert macwindow.navigation_kind("http://127.0.0.1:6474/", url) == kind + + +def test_only_local_exports_can_download(): + origin = "http://127.0.0.1:6474/" + assert macwindow.export_url(origin, origin + "api/export") + assert macwindow.export_url(origin, "blob:" + origin + "uuid") + assert not macwindow.export_url(origin, "blob:http://127.0.0.1:64740/uuid") + assert not macwindow.export_url(origin, "https://other.example/export") + + +@pytest.mark.parametrize("cpu", ["x86_64", "arm64"]) +def test_mac_hosts_use_pinned_tools(cpu, monkeypatch): + monkeypatch.setattr(toolchains, "current_platform", lambda: "macos-" + cpu) + monkeypatch.setattr(toolchains.wsl, "wanted", lambda: False) + assert toolchains.unsupported_cpu() is None + assert toolchains.get("gcc-2.7.2-kmc").supported + assert not toolchains.get("ee-gcc-2.96").supported + assert all(p["sha256"] and p.get("rosetta") for p in toolchains.get("gcc-2.7.2-kmc").parts_for()) + + +def test_missing_rosetta_is_an_actionable_error_before_download(monkeypatch, tmp_path): + monkeypatch.setattr(toolchains, "current_platform", lambda: "macos-arm64") + monkeypatch.setenv("DECOMPNET_TOOLCHAINS", str(tmp_path)) + monkeypatch.setattr(toolchains.subprocess, "run", lambda *a, **kw: SimpleNamespace(returncode=1)) + with pytest.raises(toolchains.ToolchainError, match="softwareupdate --install-rosetta"): + toolchains.install("gcc-2.7.2-kmc") + assert not list(tmp_path.iterdir()) + monkeypatch.setattr(toolchains.subprocess, "run", lambda *a, **kw: SimpleNamespace(returncode=0)) + toolchains._intel_mac_runtime_ready.cache_clear() + assert toolchains.runtime_error("gcc-2.7.2-kmc") is None + toolchains._intel_mac_runtime_ready.cache_clear() + + +def test_finder_launcher_preserves_interpreter_and_helper_path(monkeypatch, tmp_path): + home = tmp_path / "Owner's Mac" + monkeypatch.setattr(launcher.Path, "home", lambda: home) + monkeypatch.setattr(launcher.sys, "platform", "darwin") + monkeypatch.setattr(launcher.sys, "executable", str(home / "Python env/bin/python")) + monkeypatch.setattr(launcher.edition, "DEV", False) + monkeypatch.setenv("PATH", str(home / "node bin") + ":/usr/bin:/bin") + launcher.install_launcher() + bundle = home / "Applications/decompdecomp.app/Contents" + info = plistlib.loads((bundle / "Info.plist").read_bytes()) + assert info["CFBundleIdentifier"] == "com.retroreversing.decompdecomp" + script = bundle / "MacOS" / info["CFBundleExecutable"] + if os.name != "nt": + assert script.stat().st_mode & 0o111 + lines = script.read_text().splitlines() + assert shlex.split(lines[1]) == ["export", "PATH=" + os.environ["PATH"]] + assert shlex.split(lines[2]) == ["exec", sys.executable, "-m", "decompnet.app.cli", "$@"] + + +def test_mac_chooser_uses_the_apps_python_and_cancellation_is_empty(monkeypatch): + monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", True) + calls = [] + def run(argv, **kwargs): + calls.append(argv) + return SimpleNamespace(returncode=0, stdout="/Users/owner/Decomp save\n") + monkeypatch.setattr(picker.subprocess, "run", run) + assert picker.choose("folder", "Open a save") == "/Users/owner/Decomp save" + assert calls[0][:2] == [sys.executable, "-I"] + monkeypatch.setattr(picker.subprocess, "run", lambda *a, **kw: SimpleNamespace(returncode=0, stdout="")) + assert picker.choose("file", "Open") == "" + + +def test_mac_opens_folders_in_finder(monkeypatch, tmp_path): + monkeypatch.setattr(hostenv.sys, "platform", "darwin") + calls = [] + monkeypatch.setattr(hostenv.subprocess, "Popen", lambda argv, **kwargs: calls.append(argv)) + assert hostenv.open_folder(tmp_path) is None + assert calls == [["open", str(tmp_path)]] + + +def test_mac_window_needs_no_x11_display(monkeypatch): + monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", True) + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + monkeypatch.setattr(window.importlib.util, "find_spec", lambda name: object()) + window.missing.cache_clear() + try: + assert window.missing() is None + monkeypatch.setattr(window.importlib.util, "find_spec", lambda name: None) + window.missing.cache_clear() + assert "PyObjC" in window.missing() + finally: + window.missing.cache_clear() + + +def test_mac_window_uses_its_python_and_private_environment(monkeypatch, tmp_path): + calls = [] + monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", True) + monkeypatch.setattr(window, "missing", functools.cache(lambda: None)) + monkeypatch.setenv("DECOMP_WINDOW", "1") + monkeypatch.setattr(window.threading, "Thread", lambda **kw: SimpleNamespace(start=lambda: None)) + def popen(argv, **kwargs): + calls.append((argv, kwargs)) + return SimpleNamespace(poll=lambda: None) + monkeypatch.setattr(window.subprocess, "Popen", popen) + assert window.Window(tmp_path, "http://127.0.0.1:6474/", "private-token", lambda: None).open() + argv, kwargs = calls[0] + assert argv[:3] == [sys.executable, "-I", str(window.MAC_WINDOW_PROGRAM)] + assert "private-token" not in " ".join(argv) + assert kwargs["env"]["DECOMP_WINDOW_TOKEN"] == "private-token" + + +@pytest.mark.skipif(os.name == "nt", reason="the installer is a POSIX shell script") +def test_mac_installer_skips_linux_packages_and_tracks_this_fork(tmp_path): + bindir = tmp_path / "test bin" + bindir.mkdir() + calls = tmp_path / "calls" + scripts = { + "uname": 'case "$1" in -s) echo Darwin;; -m) echo arm64;; esac', + "uv": 'case "$*" in --version) echo "uv 0.13.5";; "tool dir --bin") echo ' + shlex.quote(str(bindir)) + ';; esac', + "curl": 'echo \'{"sha":"' + "a" * 40 + '"}\'', + "decomp": 'printf "%s\\n" "$*" >> ' + shlex.quote(str(calls)), + } + for name, text in scripts.items(): + path = bindir / name + path.write_text("#!/bin/sh\n" + text + "\n") + path.chmod(0o755) + script = Path(__file__).resolve().parents[1] / "site/public/install.sh" + env = dict(os.environ, PATH=str(bindir) + ":/usr/bin:/bin") + result = subprocess.run(["bash", str(script), "--no-start", "--source", "branch:macos-support"], + capture_output=True, text=True, env=env, timeout=20) + assert result.returncode == 0, result.stdout + result.stderr + recorded = calls.read_text() + assert "--repo RetroReversing/decompdecomp" in recorded and "--branch macos-support" in recorded + assert "setup" in recorded and "shortcut" in recorded + assert "GTK" not in result.stdout and "sudo" not in result.stdout + + +@pytest.mark.skipif(sys.platform != "darwin" or os.environ.get("DECOMP_TEST_COCOA") != "1", + reason="set DECOMP_TEST_COCOA=1 on a Mac desktop for the native WebKit smoke test") +def test_native_webkit_loads_the_app_and_obeys_close_confirmation(): + result = subprocess.run([sys.executable, str(Path(__file__).with_name("native_macwindow.py"))], + capture_output=True, text=True, timeout=45) + assert result.returncode == 0, result.stdout + result.stderr + assert "native macOS window passed" in result.stdout + + +@pytest.mark.parametrize("kind,accepted", [("folder", True), ("file", True), ("file", False)]) +def test_native_picker_selects_files_or_folders_and_honors_cancel(monkeypatch, capsys, kind, accepted): + settings = {} + panel = SimpleNamespace( + setTitle_=lambda value: settings.update(title=value), + setCanChooseDirectories_=lambda value: settings.update(directories=value), + setCanChooseFiles_=lambda value: settings.update(files=value), + setAllowsMultipleSelection_=lambda value: settings.update(multiple=value), + runModal=lambda: 1 if accepted else 0, + URL=lambda: SimpleNamespace(path=lambda: "/Users/owner/My save")) + app = SimpleNamespace(setActivationPolicy_=lambda _: None, activateIgnoringOtherApps_=lambda _: None) + monkeypatch.setitem(sys.modules, "AppKit", SimpleNamespace( + NSApplication=SimpleNamespace(sharedApplication=lambda: app), NSModalResponseOK=1, + NSOpenPanel=SimpleNamespace(openPanel=lambda: panel))) + monkeypatch.setattr(picker.sys, "platform", "darwin") + picker.main(kind, "Open a save") + assert capsys.readouterr().out == ("/Users/owner/My save\n" if accepted else "") + assert settings == {"title": "Open a save", "directories": kind == "folder", "files": kind == "file", "multiple": False} + + +def test_finder_can_find_homebrew_uv_without_a_shell_profile(monkeypatch): + monkeypatch.setattr(hostenv.sys, "platform", "darwin") + monkeypatch.setattr(hostenv.shutil, "which", lambda _: None) + monkeypatch.setattr(hostenv.Path, "is_file", lambda path: path.as_posix() == "/opt/homebrew/bin/uv") + assert Path(hostenv.find_uv()).as_posix() == "/opt/homebrew/bin/uv" + + +def test_demo_prerequisites_distinguish_path_tools_from_unavailable_compilers(monkeypatch, tmp_path): + from . import test_consoles + + monkeypatch.setattr(test_consoles, "ZLIB", tmp_path) + monkeypatch.setattr(toolchains, "installed", lambda tid: {} if tid == "gcc-2.7.2-kmc" else None) + monkeypatch.setattr(shutil, "which", + lambda name: "/tools/" + name) + demo = SimpleNamespace(demo_tools=("gcc-2.7.2-kmc", "ld.lld", "llvm-objcopy")) + assert test_consoles._missing(demo) == [] + demo.demo_tools = ("ee-gcc-2.96",) + assert test_consoles._missing(demo) == ["ee-gcc-2.96"] diff --git a/tests/test_network.py b/tests/test_network.py index df252098c..11c1b3226 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -12,7 +12,7 @@ DEMO = Path(__file__).resolve().parent.parent / "projects" / "demo" # projects/demo is a toy x86-64 game built with the computer's own gcc into ELF objects: Linux only -HOST_GCC = sys.platform != "win32" and shutil.which("gcc") is not None +HOST_GCC = sys.platform == "linux" and shutil.which("gcc") is not None needs_host_gcc = pytest.mark.skipif(not HOST_GCC, reason="projects/demo needs a Linux gcc (x86-64 ELF)") diff --git a/tests/test_permute.py b/tests/test_permute.py index e8100e331..778f90a71 100644 --- a/tests/test_permute.py +++ b/tests/test_permute.py @@ -176,6 +176,16 @@ def priority(): return kernel.GetThreadPriority(kernel.GetCurrentThread()) return os.getpriority(os.PRIO_PROCESS, threading.get_native_id()) + if sys.platform == "darwin": + import ctypes + + system = ctypes.CDLL(None) + system.qos_class_self.restype = ctypes.c_uint + app_qos = system.qos_class_self() + app_nice = os.getpriority(os.PRIO_PROCESS, 0) + assert workers.pool().submit(system.qos_class_self).result() == 0x11 + assert system.qos_class_self() == app_qos and os.getpriority(os.PRIO_PROCESS, 0) == app_nice + return app = priority() if os.name == "nt": assert workers.pool().submit(priority).result() == -1 and priority() == app diff --git a/tests/test_ps2_program.py b/tests/test_ps2_program.py index 61bb35bca..b4bc3c71a 100644 --- a/tests/test_ps2_program.py +++ b/tests/test_ps2_program.py @@ -144,6 +144,7 @@ def test_install_all_brings_every_compiler_then_tests_them(monkeypatch): from decompnet.app.engine import RomNetwork ps2 = consoles.get("ps2") + monkeypatch.setattr(toolchains, "current_platform", lambda: "linux-x86_64") have = {"ee-gcc-2.96"} monkeypatch.setattr(toolchains, "installed", lambda tid, linux=None: {} if tid in have else None) monkeypatch.setattr(toolchains, "install", lambda tid, accept_license=False, log=None: have.add(tid)) diff --git a/tests/test_toolchains.py b/tests/test_toolchains.py index 84857a4dc..bee1f6647 100644 --- a/tests/test_toolchains.py +++ b/tests/test_toolchains.py @@ -170,3 +170,53 @@ def test_a_ghidra_that_didnt_run_says_why(tmp_path, monkeypatch): monkeypatch.setattr(ghidra, "available", lambda: True) assert ghidra.decompile_object(program, tmp_path / "cache") == {} # a draft is optional: none, nothing cached assert not (tmp_path / "cache").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="the native Mac build needs POSIX executable permissions") +def test_intel_mac_linker_is_built_from_pinned_source(monkeypatch, tmp_path): + """No Intel release asset exists: the pinned source yields executable MIPS tools, with no GDB dependency.""" + from types import SimpleNamespace + + dest = tmp_path / "tools" + dest.mkdir() + calls = [] + outputs = ["gas/as-new", "ld/ld-new", "binutils/objcopy", "binutils/objdump", "binutils/nm-new", + "binutils/ar", "binutils/ranlib", "binutils/strip-new", "binutils/readelf"] + def extract(archive, source, subdir): + (source / "configure").touch() + (source / "gdb").mkdir() + (source / "gdbserver").mkdir() + def run(argv, **kwargs): + calls.append((argv, kwargs)) + if argv[0].endswith("configure"): + source = toolchains.Path(argv[0]).parent + assert not (source / "gdb").exists() and not (source / "gdbserver").exists() + else: + for output in outputs: + file = kwargs["cwd"] / output + file.parent.mkdir(exist_ok=True) + file.write_bytes(b"native host executable") + return SimpleNamespace(returncode=0, stdout="", stderr="") + monkeypatch.setattr(toolchains.shutil, "which", lambda name: "/usr/bin/" + name) + monkeypatch.setattr(toolchains, "_extract", extract) + monkeypatch.setattr(toolchains.limits, "capped", lambda argv: argv) + monkeypatch.setattr(toolchains.subprocess, "run", run) + toolchains._build_mips_binutils(tmp_path / "pinned.tar.gz", dest, "source", lambda _: None) + assert {p.name for p in dest.iterdir()} >= {"mips-ps2-decompals-ld", "mips-ps2-decompals-objcopy", "mips-ps2-decompals-as"} + assert all(p.stat().st_mode & 0o111 for p in dest.iterdir()) + assert len(calls) == 2 and all(kwargs["timeout"] == 900 for _, kwargs in calls) + assert calls[0][1]["env"]["CC"] == "/usr/bin/clang" + part = toolchains.get("binutils-mips").parts_for("macos-x86_64")[0] + assert part["build"] == "mips-binutils" and len(part["sha256"]) == 64 + + +def test_source_build_failure_reports_the_build_output(monkeypatch, tmp_path): + from types import SimpleNamespace + + monkeypatch.setattr(toolchains.shutil, "which", lambda name: "/usr/bin/" + name) + monkeypatch.setattr(toolchains, "_extract", lambda *args: None) + monkeypatch.setattr(toolchains.limits, "capped", lambda argv: argv) + monkeypatch.setattr(toolchains.subprocess, "run", lambda *args, **kw: SimpleNamespace( + returncode=1, stdout="", stderr="C compiler cannot create executables")) + with pytest.raises(toolchains.ToolchainError, match="C compiler cannot create executables"): + toolchains._build_mips_binutils(tmp_path / "source.tar.gz", tmp_path, None, lambda _: None) diff --git a/tests/test_updates.py b/tests/test_updates.py index f52e3ddc5..35384d57d 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -19,6 +19,7 @@ def data_home(tmp_path, monkeypatch): monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "data")) + monkeypatch.setattr(updates, "info_file", lambda: tmp_path / "data" / "install.json") return tmp_path / "data" @@ -34,12 +35,12 @@ def test_install_info_round_trip(): def test_release_update(monkeypatch): newer = ".".join(str(x + (1 if i == 2 else 0)) for i, x in enumerate(updates.version_key(__version__)[:3])) def get(url, timeout=15): - assert url == "https://raw.githubusercontent.com/n-delic/decompdecomp/main/site/public/install.sh" + assert url == "https://raw.githubusercontent.com/RetroReversing/decompdecomp/main/site/public/install.sh" return f'#!/bin/sh\nVERSION="{newer}"\n'.encode() monkeypatch.setattr(updates, "_get", get) found = updates.check() assert found["available"] and found["latest"]["version"] == newer and found["error"] is None - assert found["spec"] == f"decompnet @ https://github.com/n-delic/decompdecomp/archive/refs/tags/v{newer}.tar.gz" + assert found["spec"] == f"decompnet @ https://github.com/RetroReversing/decompdecomp/archive/refs/tags/v{newer}.tar.gz" monkeypatch.setattr(updates, "_get", lambda url, timeout=15: f'VERSION="{__version__}"\n'.encode()) assert not updates.check()["available"] @@ -56,7 +57,7 @@ def test_branch_update(monkeypatch): monkeypatch.setattr(updates, "_branch_version", lambda repo, sha: "0.10.0") found = updates.check() assert found["available"] and found["latest"]["commit"] == head and found["notes"] == ["Newest", "Older one"] - assert found["spec"] == f"decompnet @ https://github.com/n-delic/decompdecomp/archive/{head}.tar.gz" + assert found["spec"] == f"decompnet @ https://github.com/RetroReversing/decompdecomp/archive/{head}.tar.gz" # installed from a commit newer than the branch (pushed elsewhere): not an "update" answers[f"compare/{have}...{head}"] = {"status": "behind", "commits": []} @@ -98,7 +99,7 @@ def test_install_runs_uv_then_the_new_setup(tmp_path, monkeypatch): monkeypatch.setattr(updates.edition, "DEV", False) # (the tests run from a checkout: a development copy) updates.record("branch", "cross-platform", "1" * 40) found = {"current": updates.installed(), "available": True, "latest": {"version": "0.10.0", "commit": "2" * 40}, - "spec": "decompnet @ https://github.com/n-delic/decompdecomp/archive/" + "2" * 40 + ".tar.gz"} + "spec": "decompnet @ https://github.com/RetroReversing/decompdecomp/archive/" + "2" * 40 + ".tar.gz"} said = [] updates.install(found, log=said.append) ran = calls.read_text().splitlines() @@ -292,7 +293,7 @@ def test_settings_switch_between_releases_and_a_branch(monkeypatch): monkeypatch.setattr(updates, "_branch_version", lambda repo, sha: "1.0.6a0") found = updates.switch_to("branch", "polish-everywhere") assert found["available"] and found["current"]["branch"] == "polish-everywhere" - assert found["spec"] == f"decompnet @ https://github.com/n-delic/decompdecomp/archive/{head}.tar.gz" + assert found["spec"] == f"decompnet @ https://github.com/RetroReversing/decompdecomp/archive/{head}.tar.gz" with pytest.raises(ValueError, match="no branch gone-branch on GitHub"): updates.switch_to("branch", "gone-branch") with pytest.raises(ValueError, match="isn't a branch name"): diff --git a/tests/test_window.py b/tests/test_window.py index 3ea124970..a65b92aa0 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -26,6 +26,7 @@ def fake_window(tmp_path, monkeypatch): monkeypatch.setattr(window, "WINDOW_PROGRAM", program) monkeypatch.setattr(window, "SYSTEM_PYTHON", sys.executable) monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", False) monkeypatch.setattr(window, "missing", functools.cache(lambda: None)) # (open() asks it to look again) monkeypatch.delenv("DECOMP_WINDOW", raising=False) log = tmp_path / "said.txt" @@ -79,6 +80,7 @@ def test_without_webkitgtk_it_says_what_to_install(monkeypatch): """No browser to fall back to: the user is told the packages, with their distribution's command.""" window.missing.cache_clear() monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", False) monkeypatch.setenv("DISPLAY", ":0") monkeypatch.setattr(window, "SYSTEM_PYTHON", "/bin/false") monkeypatch.setattr(window, "_distro_family", lambda: "fedora") @@ -95,6 +97,7 @@ def test_with_no_desktop_it_says_so_instead_of_vanishing(monkeypatch): """Started over ssh, the app exited without a word: the window had nowhere to open.""" window.missing.cache_clear() monkeypatch.setattr(window, "IS_WINDOWS", False) + monkeypatch.setattr(window, "IS_MACOS", False) monkeypatch.delenv("DISPLAY", raising=False) monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) try: diff --git a/uv.lock b/uv.lock index e3e3af71e..e4eec481c 100644 --- a/uv.lock +++ b/uv.lock @@ -299,6 +299,8 @@ dependencies = [ { name = "mcp" }, { name = "ninja" }, { name = "pyelftools" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'" }, { name = "pywebview", marker = "sys_platform == 'win32'" }, { name = "rabbitizer" }, { name = "spimdisasm" }, @@ -321,6 +323,8 @@ requires-dist = [ { name = "mcp", specifier = ">=2.3.0" }, { name = "ninja", specifier = ">=1.11" }, { name = "pyelftools", specifier = ">=0.33" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'", specifier = ">=11.0" }, + { name = "pyobjc-framework-webkit", marker = "sys_platform == 'darwin'", specifier = ">=11.0" }, { name = "pywebview", marker = "sys_platform == 'win32'", specifier = ">=6.2.1" }, { name = "rabbitizer", specifier = ">=1.16.2" }, { name = "spimdisasm", specifier = ">=1.42.4" }, @@ -782,6 +786,58 @@ version = "0.1.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/1a/3ae773a0d4cc0b787d1b7307786c666de0729df2c4159ec964e8ba45d06d/pylibyaml-0.1.0.tar.gz", hash = "sha256:3b58dea061903c04689e35fab63ec149f7cf5e82f0808bd3425fb3ab3950623e", size = 4743, upload-time = "2020-09-21T08:33:32.121Z" } +[[package]] +name = "pyobjc-core" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/78/abc4ce5920305780aeb36b4067a86253378b36e29ba96673a3deb02eb03a/pyobjc_core-12.2.2.tar.gz", hash = "sha256:3906452339cd06a3bb07df103c2511d4cb0f7a22d8771c0b802eba15d9a642b6", size = 1067701, upload-time = "2026-08-11T19:43:39.059Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/b2/bbf7f049880ab40d110e66f25122342a1f6c98d6fe3c59bb98985503c660/pyobjc_core-12.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:122e6ad302a2abf5d4d4adb0156db751600ddf2768441696cba17b31323085e7", size = 6427637, upload-time = "2026-08-11T14:51:36.038Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ed/a8bf040caf3704023d74086b7fb96cf4ed2e844e24bd94e5248ba214b700/pyobjc_core-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:950bd2d9c74634398c4e3d24ef2f213d4e23d705083697464fa67afedc53c1ad", size = 6427113, upload-time = "2026-08-11T15:04:39.424Z" }, + { url = "https://files.pythonhosted.org/packages/e7/5a/760f8b9e116edd43c57e33844dc17619158fbdd311250d4209910192d72d/pyobjc_core-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:3772b406edb3ff78171530a17cda1c4a7817f87b87ded0d8715b3fa664df16db", size = 6666817, upload-time = "2026-08-11T19:30:17.01Z" }, + { url = "https://files.pythonhosted.org/packages/13/37/486d38a173b0b8dce973a3e13c74cf402ed1b8621586b5963bc9efd49a48/pyobjc_core-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2062e8ad30a310441cd022544a897553408bebeaa7820d5edba3c96fd7fd693b", size = 6421184, upload-time = "2026-08-11T19:30:21.081Z" }, + { url = "https://files.pythonhosted.org/packages/04/f1/d138fd9b9a66ea8db56a8138b77d3413b85da3defe13363a19f364f85529/pyobjc_core-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:2c7ef3d2f865b4b3ebb14ec3556f7a3e8abb6d130c67275cd9daa08dbd6e4e4e", size = 6671824, upload-time = "2026-08-11T19:30:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/d5/85/577e2265cccf59daf48c460f0a8deeaf7dbe2991227a8859ab1eeab4945e/pyobjc_core-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:89acc6bc13aaa6e3f52b0ce652ede7e201edb6bf062741b246b0c5a44582f25f", size = 6477694, upload-time = "2026-08-11T19:30:28.821Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/bd9f830c64c6f334530831e75c01bfe0a770a3fbb00fddc70329223118b3/pyobjc_core-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59a77038ebe0ab1240f61c341e7fb67b8674f2b4cd41bc71a6472511a12b50f7", size = 6712233, upload-time = "2026-08-11T19:30:33.032Z" }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/76/49c6da2c6a831020b4854ba20079d5a1030474bffc776b7b73c2eeff8c15/pyobjc_framework_cocoa-12.2.2.tar.gz", hash = "sha256:c96c0ef69a71afbbb0e6a7d594b455c5fe47d62e0db376ee7a2b4b828c16ace9", size = 3132831, upload-time = "2026-08-11T19:44:02.288Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/2f/b67e73d8bc367e03fe7861cd9c49fff9dcfa6db83bc0630c0adcfb25b7fa/pyobjc_framework_cocoa-12.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e106f395531e67694376b0f1184612cbeea3ec8b9bf56b55ef41d026171d2a2d", size = 388117, upload-time = "2026-08-11T19:32:43.161Z" }, + { url = "https://files.pythonhosted.org/packages/db/e1/5d9b04ebb60042b9cb49adc2d33115e2f2c2e4ff7d548017bfaff8b7f536/pyobjc_framework_cocoa-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:600b1723184ca094931330e79355274949965460e23de38628d601b5a967baf9", size = 388181, upload-time = "2026-08-11T19:32:44.537Z" }, + { url = "https://files.pythonhosted.org/packages/b4/25/2a343357d5fe09bbe9c0e294dc03450866a0d6c1792fad36b6bcc00174c0/pyobjc_framework_cocoa-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:875f2aad73963faa81a6b36ae674fd494a4658d6d999e1075e0e2aca3d2391df", size = 392275, upload-time = "2026-08-11T19:32:45.631Z" }, + { url = "https://files.pythonhosted.org/packages/1f/1a/b99521999b9f54b89aad928ddff0faad507abfe33bc46599454bfa48a4b2/pyobjc_framework_cocoa-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:889d7bbd4ba2d4941078bfbbfb882138e51dbead27df006abfe0f2e0d49b5b2e", size = 388368, upload-time = "2026-08-11T19:32:46.781Z" }, + { url = "https://files.pythonhosted.org/packages/6d/26/0c697dbc73dcc76bc0f68ea5aeed25bf7b05217df5102659e878501b2d5f/pyobjc_framework_cocoa-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:de69c5933750f3a4599ed962eccd92b6a71914c7e4318dacc7895738a8ae60d7", size = 392404, upload-time = "2026-08-11T19:32:47.918Z" }, + { url = "https://files.pythonhosted.org/packages/df/82/502f740fd8f4e9ef741c9d40ba67467ab2c8196f2c09dcba12936d28a4fd/pyobjc_framework_cocoa-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0e8ace0d44a00d281281a723d17fcd05eea7544a38a6a512e1fd018ddb7aece2", size = 388585, upload-time = "2026-08-11T19:32:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/07ce3c0ab8d1e9e1bed74fea1bf1cce73527a365a7a23c755051d3be9865/pyobjc_framework_cocoa-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8fe5b2e79c9530f667b4e58a87a3a15ea62f86a5d19eec405517ecbd4f454868", size = 392693, upload-time = "2026-08-11T19:32:50.283Z" }, +] + +[[package]] +name = "pyobjc-framework-webkit" +version = "12.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/1f/766e338197f7051c25f23cb0d350caa88234b31c3a759127f2cbb67f3376/pyobjc_framework_webkit-12.2.2.tar.gz", hash = "sha256:e5588df2a73b377b59a994cc2a78b467e4341f4e4d28b52e8671e21a2811d3c1", size = 333834, upload-time = "2026-08-11T19:45:42.708Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/76/344b197a89878e8b103727984f2815bc38393515be5bf63aa7b847c414e0/pyobjc_framework_webkit-12.2.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ef37692f0280151bf8164a718e334a9b3cb0abfc5455be14172280de7e277505", size = 50371, upload-time = "2026-08-11T19:43:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/036541aaf4795c0022b6b1dda9a4e099e14b137012065a799a0b174947e5/pyobjc_framework_webkit-12.2.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:206f88451e1c3e152c72c16c2af2664646cd752a3007c9cda8029a9e3f0ec9a4", size = 50395, upload-time = "2026-08-11T19:43:32.434Z" }, + { url = "https://files.pythonhosted.org/packages/46/f9/8525d01afc8e7898ef395e55891d932111099a3770b2e5f0eb901a57f3ca/pyobjc_framework_webkit-12.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:36bea435489daa20ead1390d6bf0b98659beac0fc5a281e502acc5da334ec9eb", size = 50865, upload-time = "2026-08-11T19:43:33.263Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/1f815581ca8884d1cc1a577f98ed0f43454bb3aa12c4aa7562d33c18c353/pyobjc_framework_webkit-12.2.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:83a5c250125ecaaf680a405cf435a038e4b168d0305ba7ad14398d810a49cf87", size = 50502, upload-time = "2026-08-11T19:43:34.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/fb/3d48fb3deb0a57d25c118684dd13f702f856cadfcbd553b9a1ff5c8ba5fc/pyobjc_framework_webkit-12.2.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:147f0f18c0ddd70a1d05f88cf42a89b423b191957db6faf508cbbdb33092864c", size = 50971, upload-time = "2026-08-11T19:43:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/56/79/5dfb4cb4cc3bf1f534a154fd6d9737a53288224e5eeca14cb0a4b18e5665/pyobjc_framework_webkit-12.2.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0be3ed885100dd38c6b927c9a25a63f22fd624bc4c6bb3d3af14fab5dcdcfc97", size = 50501, upload-time = "2026-08-11T19:43:35.835Z" }, + { url = "https://files.pythonhosted.org/packages/03/5b/e1e2f3f2a2d9194467898494cf102eaa96db1a395278a961861254ba0c48/pyobjc_framework_webkit-12.2.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:88c896d89764675af1ef85c6bda8740e50451e31bcd1d1d2ef59323acc9916ed", size = 50963, upload-time = "2026-08-11T19:43:37.152Z" }, +] + [[package]] name = "pypng" version = "0.20220715.0" @@ -835,6 +891,9 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "bottle" }, { name = "proxy-tools" }, + { name = "pyobjc-core", marker = "python_version < '0'" }, + { name = "pyobjc-framework-cocoa", marker = "python_version < '0'" }, + { name = "pyobjc-framework-webkit", marker = "python_version < '0'" }, { name = "pythonnet" }, { name = "typing-extensions" }, ]