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..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" },
]