From 712009191fbebc01f0e6883dcceada8fdaec1d5d Mon Sep 17 00:00:00 2001
From: RetroGameDeveloper
<40120498+RetroGameDeveloper@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:29:10 +0100
Subject: [PATCH 1/2] decompdecomp 1.0.6-alpha.3: add native macOS support
Use Cocoa and WKWebView for a native Mac window with authenticated navigation, native dialogs, downloads and close confirmation. PyObjC provides the native framework bridge without exposing the private app in a browser.
Support Finder launchers, the shell installer, Apple Silicon host detection and per-thread worker QoS. Keep KMC toolchain archives checksum pinned, explain their Rosetta prerequisite, and build the pinned modern MIPS tools from source on Intel Macs where upstream has no binary release.
Cover the integration points with portable tests and a hidden WebKit test against the real app. Add Intel and Apple Silicon CI, document platform limits, and keep fork updates pointed at RetroReversing.
---
.github/workflows/check.yml | 31 +++++
AGENTS.md | 4 +-
README.md | 61 ++++++++-
ROADMAP.md | 7 +-
pyproject.toml | 6 +-
site/public/install.sh | 29 +++--
src/decompnet/app/cli.py | 2 +-
src/decompnet/app/launcher.py | 19 +++
src/decompnet/app/macwindow.py | 232 +++++++++++++++++++++++++++++++++
src/decompnet/app/picker.py | 17 ++-
src/decompnet/app/updates.py | 2 +-
src/decompnet/app/window.py | 19 ++-
src/decompnet/app/workers.py | 9 ++
src/decompnet/hostenv.py | 7 +-
src/decompnet/toolchains.py | 79 ++++++++++-
src/decompnet/toolchains.toml | 12 +-
tests/native_macwindow.py | 158 ++++++++++++++++++++++
tests/test_app_server.py | 1 +
tests/test_consoles.py | 3 +-
tests/test_macos.py | 207 +++++++++++++++++++++++++++++
tests/test_network.py | 2 +-
tests/test_permute.py | 10 ++
tests/test_ps2_program.py | 1 +
tests/test_toolchains.py | 50 +++++++
tests/test_updates.py | 11 +-
tests/test_window.py | 3 +
uv.lock | 59 +++++++++
27 files changed, 998 insertions(+), 43 deletions(-)
create mode 100644 src/decompnet/app/macwindow.py
create mode 100644 tests/native_macwindow.py
create mode 100644 tests/test_macos.py
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 @@
+
@@ -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..231513c83
--- /dev/null
+++ b/tests/test_macos.py
@@ -0,0 +1,207 @@
+"""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)
+ 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: str(path) == "/opt/homebrew/bin/uv")
+ assert hostenv.find_uv() == "/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" },
]
From d88affa29470ab7bdc9684e48139f62b1c879923 Mon Sep 17 00:00:00 2001
From: RetroGameDeveloper
<40120498+RetroGameDeveloper@users.noreply.github.com>
Date: Fri, 23 Oct 2026 15:32:47 +0100
Subject: [PATCH 2/2] decompdecomp 1.0.6-alpha.3: make Mac platform tests
portable on Windows
Disable WSL explicitly in Mac host simulations and compare Homebrew paths using portable separators. The Windows CI run exposed these three test-only assumptions; native Mac behavior is unchanged.
---
tests/test_macos.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/test_macos.py b/tests/test_macos.py
index 231513c83..c22546776 100644
--- a/tests/test_macos.py
+++ b/tests/test_macos.py
@@ -38,6 +38,7 @@ def test_only_local_exports_can_download():
@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
@@ -190,8 +191,8 @@ def test_native_picker_selects_files_or_folders_and_honors_cancel(monkeypatch, c
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: str(path) == "/opt/homebrew/bin/uv")
- assert hostenv.find_uv() == "/opt/homebrew/bin/uv"
+ 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):