Apple Silicon: bundle FreeType/GnuTLS, default Wine sync off, live install progress#4
Conversation
The ellipsis in "fetching $name…" glued the multibyte char onto the variable name under some locales, so bash looked up an undefined var and `set -u` aborted with 'name…: unbound variable'. Brace + ASCII '...'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Apple Silicon users with only ARM Homebrew (or none) got blank text in Battle.net Setup and TLS failures, because the bundled x86_64 Wine dlopen()s libfreetype.6.dylib / libgnutls.30.dylib by leaf name and the only fallback was an Intel Homebrew at /usr/local/lib (issue MichaelLod#2). fetch-wine-libs.py pulls the x86_64 macOS bottles straight from Homebrew's GHCR registry (no Homebrew needed; the 'sonoma' bottles brew fetch used to serve are gone), BFS-resolves the dependency closure from the two libs Wine actually dlopen()s, rewrites install ids + Homebrew deps to @rpath, and stages 11 dylibs. build.sh copies them into the Wine runtime's lib/external where DYLD_FALLBACK_LIBRARY_PATH already points. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WINEMSYNC=1/WINEESYNC=1 were hardcoded and the Settings 'Synchronisation' picker was never read. On macOS 26 / Apple Silicon under Rosetta, esync/msync spin-wait pegs a CPU core: it throttles the Battle.net installer download to ~4 KB/s (vs ~8 MB/s with sync off — a ~2000x hit) and freezes D4 gameplay after a few minutes (issue MichaelLod#2 comments). WineProcess.environment now reads the syncStyle UserDefaults key (installer + launcher), defaulting to none. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
During install the UI only showed a spinner + 'look for the installer window' until it flipped to Launch at the very end, so a healthy (or throttled) install looked dead. BottleManager now polls the Agent log (update_bytes_current/total, playable_progress) ~every 1.5s while running the installer and publishes a determinate progress figure; ContentView shows a bar + 'NN% · X GB / Y GB · Z MB/s'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@BastianOrth2 is attempting to deploy a commit to the michaellod's projects Team on Vercel. A member of the Team first needs to authorize it. |
📝 WalkthroughWalkthroughThis PR adds an automated Python script ( ChangesWine x86_64 dylib staging pipeline
App sync style and install progress improvements
Sequence Diagram(s)sequenceDiagram
actor build_sh as build.sh
participant fetch_wine_libs as fetch-wine-libs.py
participant GHCR as GHCR Registry
participant WineBundle as Wine/lib/external
build_sh->>fetch_wine_libs: python3 fetch-wine-libs.py (if dylibs missing)
loop per formula (freetype, gnutls, ...)
fetch_wine_libs->>GHCR: GET anonymous token
GHCR-->>fetch_wine_libs: bearer token
fetch_wine_libs->>GHCR: GET x86_64 bottle manifest
GHCR-->>fetch_wine_libs: blob digest
fetch_wine_libs->>GHCR: GET bottle layer blob
GHCR-->>fetch_wine_libs: tar.gz archive
fetch_wine_libs->>fetch_wine_libs: extract → scan lib/ → map leaf→realpath
end
fetch_wine_libs->>fetch_wine_libs: BFS closure from SEEDS via otool -L
fetch_wine_libs->>fetch_wine_libs: copy + chmod each required dylib
fetch_wine_libs->>fetch_wine_libs: install_name_tool rewrite `@rpath`
build_sh->>WineBundle: cp Prereqs/wine-libs/*.dylib
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related issues
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@build.sh`:
- Around line 133-143: The validation at line 133 only checks for the presence
of libfreetype.6.dylib, but a partial or stale wine-libs directory could still
pass this check while missing other required libraries. Enhance the condition
that guards the mkdir and cp operations to validate all minimum required seed
libraries (at minimum both libfreetype.6.dylib and libgnutls-related dylibs, as
implied by the warning message mentioning both FreeType and GnuTLS) are present
in WINELIBS_SRC before proceeding with the copy. Replace the single file
existence check with validation logic that confirms all required dylib files
exist to prevent bundling a broken runtime with missing dependencies.
In `@Prereqs/fetch-wine-libs.py`:
- Around line 139-143: The fallback extraction in the except TypeError block for
Python versions prior to 3.12 is vulnerable to path traversal attacks because it
lacks the protection provided by the filter parameter. In the except clause
where t.extractall(extract_root) is called without the filter, add path
validation to prevent malicious archive members from writing outside the
extract_root directory by checking that extracted member paths do not contain
path traversal sequences like ".." or absolute paths that would escape the
target directory.
- Around line 160-166: The subprocess.run() calls for otool and
install_name_tool commands lack error handling, allowing failures to silently
produce incorrect results. Add check=True parameter to all subprocess.run()
calls that execute otool (such as the call with ["otool", "-L", dylib] argument)
and install_name_tool commands throughout the script to ensure the script exits
with an error if these critical commands fail, preventing broken dylibs from
being shipped silently.
- Line 83: The urllib.request.urlopen calls in this script lack timeout
parameters, which can cause indefinite hangs during transient network issues.
Define a timeout constant (such as GHCR_TIMEOUT set to a reasonable value like
30 seconds) at the module level, then add this timeout parameter to both urlopen
calls: the one on line 83 in the return statement and the one on line 90, by
passing the timeout argument to each urlopen invocation.
- Around line 228-232: The architecture detection logic currently uses
"??ARCH??" as a fallback tag when the architecture is not x86_64, allowing the
script to continue. Replace this silent fallback behavior with a hard failure by
raising an exception or calling sys.exit() when the architecture check in the
ternary operator finds anything other than x86_64. This ensures that unexpected
architectures cause an immediate failure rather than deferring the issue to
runtime, making it consistent with the script's explicit requirement to process
only x86_64 dylibs.
In `@Sources/D4Mac/BottleManager.swift`:
- Around line 403-479: The synchronous file I/O and regex operations performed
by newestAgentLog(), tailString(), and lastCapture() are executing on the main
actor within pollInstallProgress() and can block UI updates. Move the expensive
filesystem and regex work off the main actor by running currentAgentStats() on a
background task, then only hop back to the main actor when assigning the result
to the installProgress property. Keep the helper methods themselves unchanged
but ensure they execute on a background context.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: d6c0d5c4-4e48-4fdd-aa79-3c3d5e1ba54d
📒 Files selected for processing (10)
.gitignorePrereqs/fetch-wine-libs.pyPrereqs/fetch.shREADME.mdSources/D4Mac/BNetLauncher.swiftSources/D4Mac/BottleManager.swiftSources/D4Mac/ContentView.swiftSources/D4Mac/Settings.swiftTHIRD_PARTY_LICENSES.mdbuild.sh
| if [ ! -f "$WINELIBS_SRC/libfreetype.6.dylib" ]; then | ||
| echo "==> fetching x86_64 Wine support libs (one-time, ~8 MB)" | ||
| /usr/bin/python3 "$SCRIPT_DIR/Prereqs/fetch-wine-libs.py" | ||
| fi | ||
| if [ -f "$WINELIBS_SRC/libfreetype.6.dylib" ]; then | ||
| mkdir -p "$WINELIBS_DST" | ||
| cp -p "$WINELIBS_SRC"/*.dylib "$WINELIBS_DST/" | ||
| echo "bundled wine libs ($(ls "$WINELIBS_SRC"/*.dylib | wc -l | tr -d ' ') x86_64 dylibs)" | ||
| else | ||
| echo "warning: $WINELIBS_SRC missing — FreeType/GnuTLS won't be bundled" | ||
| fi |
There was a problem hiding this comment.
Validate the full required seed set before skipping fetch.
Line 133 only checks libfreetype.6.dylib; a partial/stale Prereqs/wine-libs can still pass this gate and produce a broken runtime bundle. Validate both seed libs (at minimum) before copy.
Suggested patch
-WINELIBS_SRC="$SCRIPT_DIR/Prereqs/wine-libs"
+WINELIBS_SRC="$SCRIPT_DIR/Prereqs/wine-libs"
WINELIBS_DST="$APP/Contents/SharedSupport/Wine/lib/external"
-if [ ! -f "$WINELIBS_SRC/libfreetype.6.dylib" ]; then
+required_winelibs=("libfreetype.6.dylib" "libgnutls.30.dylib")
+missing_winelib=0
+for lib in "${required_winelibs[@]}"; do
+ [ -f "$WINELIBS_SRC/$lib" ] || missing_winelib=1
+done
+if [ "$missing_winelib" -eq 1 ]; then
echo "==> fetching x86_64 Wine support libs (one-time, ~8 MB)"
/usr/bin/python3 "$SCRIPT_DIR/Prereqs/fetch-wine-libs.py"
fi
-if [ -f "$WINELIBS_SRC/libfreetype.6.dylib" ]; then
+if [ -f "$WINELIBS_SRC/libfreetype.6.dylib" ] && [ -f "$WINELIBS_SRC/libgnutls.30.dylib" ]; then🧰 Tools
🪛 Shellcheck (0.11.0)
[info] 140-140: Use find instead of ls to better handle non-alphanumeric filenames.
(SC2012)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@build.sh` around lines 133 - 143, The validation at line 133 only checks for
the presence of libfreetype.6.dylib, but a partial or stale wine-libs directory
could still pass this check while missing other required libraries. Enhance the
condition that guards the mkdir and cp operations to validate all minimum
required seed libraries (at minimum both libfreetype.6.dylib and
libgnutls-related dylibs, as implied by the warning message mentioning both
FreeType and GnuTLS) are present in WINELIBS_SRC before proceeding with the
copy. Replace the single file existence check with validation logic that
confirms all required dylib files exist to prevent bundling a broken runtime
with missing dependencies.
| f"https://ghcr.io/token?service=ghcr.io" | ||
| f"&scope=repository:homebrew/core/{formula}:pull" | ||
| ) | ||
| return json.load(urllib.request.urlopen(url))["token"] |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n Prereqs/fetch-wine-libs.py | sed -n '75,95p'Repository: MichaelLod/D4Mac
Length of output: 778
🏁 Script executed:
grep -n "urlopen" Prereqs/fetch-wine-libs.pyRepository: MichaelLod/D4Mac
Length of output: 164
Add explicit HTTP timeouts for GHCR calls.
Lines 83 and 90 call urlopen without a timeout parameter, allowing transient GHCR/network stalls to hang the build indefinitely. Add a timeout constant and apply it to both calls:
Suggested patch
+HTTP_TIMEOUT_SEC = 30
+
def _token(formula):
url = (
f"https://ghcr.io/token?service=ghcr.io"
f"&scope=repository:homebrew/core/{formula}:pull"
)
- return json.load(urllib.request.urlopen(url))["token"]
+ return json.load(urllib.request.urlopen(url, timeout=HTTP_TIMEOUT_SEC))["token"]
def _get(url, tok, accept):
req = urllib.request.Request(
url, headers={"Authorization": f"Bearer {tok}", "Accept": accept}
)
- return urllib.request.urlopen(req)
+ return urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SEC)🧰 Tools
🪛 Ruff (0.15.17)
[error] 83-83: Audit URL open for permitted schemes. Allowing use of file: or custom schemes is often unexpected.
(S310)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Prereqs/fetch-wine-libs.py` at line 83, The urllib.request.urlopen calls in
this script lack timeout parameters, which can cause indefinite hangs during
transient network issues. Define a timeout constant (such as GHCR_TIMEOUT set to
a reasonable value like 30 seconds) at the module level, then add this timeout
parameter to both urlopen calls: the one on line 83 in the return statement and
the one on line 90, by passing the timeout argument to each urlopen invocation.
| with tarfile.open(tgz) as t: | ||
| try: | ||
| t.extractall(extract_root, filter="data") # py3.12+ | ||
| except TypeError: | ||
| t.extractall(extract_root) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -t f "fetch-wine-libs.py"Repository: MichaelLod/D4Mac
Length of output: 87
🏁 Script executed:
# Once file is found, check the code context around lines 139-143
rg -n "extractall" Prereqs/fetch-wine-libs.py -B 5 -A 5Repository: MichaelLod/D4Mac
Length of output: 634
🌐 Web query:
Python tarfile filter parameter "data" security protection CVE path traversal
💡 Result:
The tarfile module in Python was historically vulnerable to path traversal attacks (CVE-2007-4559), where malicious tar archives could use .. sequences to overwrite files outside the intended destination directory [1][2]. To address this, Python 3.12 introduced extraction filters via the filter parameter in extract() and extractall() [3][4]. The 'data' filter is the recommended security protection for untrusted archives [5][4]. It mitigates path traversal by refusing to extract links or files that target locations outside the destination directory and by normalizing paths [6][4]. However, the security of these filters has been subject to multiple bypass vulnerabilities disclosed in 2025, specifically: - CVE-2025-4517 (Critical): Allows arbitrary filesystem writes outside the extraction directory when using filter='data' [3][7]. - CVE-2025-4330 (High): Allows the extraction filter to be ignored, potentially allowing symlink targets outside the destination [3]. - CVE-2025-4138 (High): Allows creating arbitrary symlinks outside the destination directory [3]. - CVE-2024-12718 (Moderate): Allows modification of file metadata (such as timestamps) or permissions outside the extraction directory [3][8]. As of June 2026, it is crucial to use a patched version of Python that includes fixes for these bypasses [3]. Users should also be aware that starting in Python 3.14, filter='data' became the default behavior [3][4]. Despite these protections, the tarfile module is not fully suited for extracting untrusted archives without prior inspection or additional security layers (such as containerization), as the built-in filters do not mitigate all potential risks, including denial-of-service attacks [4][9]. For code safety, it is recommended to check for filter availability using hasattr(tarfile, 'data_filter') before applying it [4][9]. If a system cannot be upgraded, one should avoid extracting untrusted tarballs entirely, or perform manual validation to reject any member paths containing .. segments before extraction [3].
Citations:
- 1: https://nvd.nist.gov/vuln/detail/cve-2007-4559
- 2: https://www.trellix.com/blogs/research/tarfile-exploiting-the-world/
- 3: https://openwall.com/lists/oss-security/2025/06/24/1
- 4: https://docs.python.org/3/library/tarfile.html
- 5: https://runebook.dev/en/docs/python/library/tarfile/extraction-filters
- 6: https://github.com/python/cpython/blob/main/Doc/library/tarfile.rst
- 7: https://osv.dev/vulnerability/CVE-2025-4517
- 8: https://www.upwind.io/feed/cve-2024-12718-path-escape-via-pythons-tarfile-extraction-filters
- 9: https://docs.python.domainunion.de/3/library/tarfile.html
🌐 Web query:
Python 3.12 tarfile.extractall() filter="data" vulnerability older versions
💡 Result:
In Python, the tarfile.extractall() method is subject to multiple security considerations, which have evolved through different versions and vulnerability disclosures. 1. Historical Context (CVE-2007-4559): For many years, the tarfile module was inherently vulnerable to directory traversal attacks [1][2]. Maliciously crafted tar archives containing filenames with ".." sequences could cause the module to overwrite arbitrary files outside of the intended destination directory [1][3]. This was not a single bug but a design limitation that persisted for over a decade [2]. 2. Introduction of Extraction Filters: To address this, PEP 706 introduced extraction filters in Python 3.12, allowing developers to pass a filter argument to extractall() and extract() [4][5]. The filter="data" option was designed to block common dangerous features, such as absolute paths and symlinks that escape the destination directory [4][5]. 3. Vulnerabilities in filter="data": Despite its intended purpose, the filter="data" mechanism itself has been found vulnerable to bypasses [6][7]. Specifically, vulnerabilities such as CVE-2025-4517 and others discovered in 2025 revealed that flaws in os.path.realpath() (which the filter relies on) can allow attackers to bypass path validation when paths exceed PATH_MAX [8][7]. These vulnerabilities affect Python 3.12 and later, as these are the only versions where the filter feature exists [6][9]. 4. Security Implications for Older Versions: - Versions prior to 3.12 do not support the filter argument at all [6][4]. Consequently, they remain vulnerable to the original directory traversal (CVE-2007-4559) and do not have the mitigation of a built-in filter [6][3]. - Users on older, unsupported versions are encouraged to either upgrade to a patched version of Python or implement manual, robust path validation before calling extractall() [8][7]. 5. Recommendations: - Always keep your Python environment updated. Patches for the filter bypass vulnerabilities were released in versions such as 3.12.11, 3.13.4, and 3.14.0b1 [8][7]. - Treat any untrusted tar archive as potentially malicious. Even with the data filter enabled, tarfile is not suitable for extracting untrusted archives without additional, external security inspection [5].
Citations:
- 1: https://nvd.nist.gov/vuln/detail/cve-2007-4559
- 2: https://www.trellix.com/blogs/research/tarfile-exploiting-the-world/
- 3: https://ubuntu.com/security/CVE-2007-4559
- 4: https://peps.python.org/pep-0706/
- 5: https://docs.python.org/3/library/tarfile.html
- 6: https://www.openwall.com/lists/oss-security/2025/06/24/1
- 7: GHSA-hgqp-3mmf-7h8f
- 8: https://github.com/0xDTC/CVE-2025-4517-tarfile-PATH_MAX-bypass
- 9: https://osv.dev/vulnerability/CVE-2024-12718
Harden the Python <3.12 extraction fallback.
Line 143 falls back to raw extractall() without the filter="data" protection. Python versions prior to 3.12 lack extraction filters and remain vulnerable to path traversal attacks (CVE-2007-4559)—malicious archive metadata can write paths outside extract_root.
Suggested patch
+from pathlib import Path
+import os
+
+def _safe_extractall(tf: tarfile.TarFile, dest: str) -> None:
+ base = Path(dest).resolve()
+ for m in tf.getmembers():
+ target = (base / m.name).resolve()
+ if not str(target).startswith(str(base) + os.sep) and target != base:
+ raise SystemExit(f"error: unsafe tar member path: {m.name}")
+ tf.extractall(dest)
+
def fetch_and_extract(formula, cache, extract_root):
with tarfile.open(tgz) as t:
try:
t.extractall(extract_root, filter="data") # py3.12+
except TypeError:
- t.extractall(extract_root)
+ _safe_extractall(t, extract_root)🧰 Tools
🪛 Ruff (0.15.17)
[error] 143-143: Uses of tarfile.extractall()
(S202)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Prereqs/fetch-wine-libs.py` around lines 139 - 143, The fallback extraction
in the except TypeError block for Python versions prior to 3.12 is vulnerable to
path traversal attacks because it lacks the protection provided by the filter
parameter. In the except clause where t.extractall(extract_root) is called
without the filter, add path validation to prevent malicious archive members
from writing outside the extract_root directory by checking that extracted
member paths do not contain path traversal sequences like ".." or absolute paths
that would escape the target directory.
Source: Linters/SAST tools
| txt = subprocess.run(["otool", "-L", dylib], capture_output=True, text=True).stdout | ||
| result = [] | ||
| for line in txt.splitlines()[1:]: | ||
| path = line.strip().split(" ")[0] | ||
| if path.startswith(HOMEBREW_PREFIXES): | ||
| result.append(os.path.basename(path)) | ||
| return result |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n Prereqs/fetch-wine-libs.py | head -210Repository: MichaelLod/D4Mac
Length of output: 9453
Add check=True to subprocess.run() calls for otool and install_name_tool.
Lines 160, 190–191, 193, and 198–201 execute critical commands without error handling. If otool or install_name_tool fails, the script silently continues: otool failures return empty output causing missing dependencies to be skipped, and install_name_tool failures leave dylibs partially modified. Both scenarios result in a broken dylib chain shipped silently.
Suggested patch
def deps(dylib):
"""Non-system dependency leaf names of a Mach-O dylib."""
- txt = subprocess.run(["otool", "-L", dylib], capture_output=True, text=True).stdout
+ txt = subprocess.run(
+ ["otool", "-L", dylib], capture_output=True, text=True, check=True
+ ).stdout
result = []
for line in txt.splitlines()[1:]:
path = line.strip().split(" ")[0]
if path.startswith(HOMEBREW_PREFIXES):
result.append(os.path.basename(path))
return result
def rewrite(dylib):
"""Set id and Homebrew deps to `@rpath/`<leaf> so dyld resolves from
lib/external. Leaves /usr/lib and /System references alone."""
leaf = os.path.basename(dylib)
subprocess.run(
- ["install_name_tool", "-id", f"`@rpath/`{leaf}", dylib], capture_output=True
+ ["install_name_tool", "-id", f"`@rpath/`{leaf}", dylib],
+ capture_output=True,
+ check=True,
)
- txt = subprocess.run(["otool", "-L", dylib], capture_output=True, text=True).stdout
+ txt = subprocess.run(
+ ["otool", "-L", dylib], capture_output=True, text=True, check=True
+ ).stdout
for line in txt.splitlines()[1:]:
path = line.strip().split(" ")[0]
if path.startswith(HOMEBREW_PREFIXES):
base = os.path.basename(path)
subprocess.run(
["install_name_tool", "-change", path, f"`@rpath/`{base}", dylib],
capture_output=True,
+ check=True,
)🧰 Tools
🪛 Ruff (0.15.17)
[error] 160-160: subprocess call: check for execution of untrusted input
(S603)
[error] 160-160: Starting a process with a partial executable path
(S607)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Prereqs/fetch-wine-libs.py` around lines 160 - 166, The subprocess.run()
calls for otool and install_name_tool commands lack error handling, allowing
failures to silently produce incorrect results. Add check=True parameter to all
subprocess.run() calls that execute otool (such as the call with ["otool", "-L",
dylib] argument) and install_name_tool commands throughout the script to ensure
the script exits with an error if these critical commands fail, preventing
broken dylibs from being shipped silently.
Source: Linters/SAST tools
| arch = subprocess.run( | ||
| ["file", "-b", dst], capture_output=True, text=True | ||
| ).stdout | ||
| tag = "x86_64" if "x86_64" in arch else "??ARCH??" | ||
| print(f" {leaf:24} [{tag}]") |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cd Prereqs && head -250 fetch-wine-libs.py | tail -30Repository: MichaelLod/D4Mac
Length of output: 1037
🏁 Script executed:
find . -name "fetch-wine-libs.py" -type fRepository: MichaelLod/D4Mac
Length of output: 89
🏁 Script executed:
wc -l Prereqs/fetch-wine-libs.pyRepository: MichaelLod/D4Mac
Length of output: 91
🏁 Script executed:
head -50 Prereqs/fetch-wine-libs.pyRepository: MichaelLod/D4Mac
Length of output: 2299
🏁 Script executed:
grep -n "SEEDS\|x86_64\|arch" Prereqs/fetch-wine-libs.py | head -20Repository: MichaelLod/D4Mac
Length of output: 1259
Make architecture mismatch a hard failure.
Line 231 currently downgrades non-x86_64 to ??ARCH?? and continues. Given that the entire script's purpose is to stage x86_64 dylibs (with hard errors elsewhere for missing x86_64 bottles), silently continuing with an unexpected architecture is inconsistent and could defer failure to runtime.
Suggested patch
arch = subprocess.run(
- ["file", "-b", dst], capture_output=True, text=True
+ ["file", "-b", dst], capture_output=True, text=True, check=True
).stdout
- tag = "x86_64" if "x86_64" in arch else "??ARCH??"
+ if "x86_64" not in arch:
+ raise SystemExit(f"error: non-x86_64 dylib staged: {leaf} ({arch.strip()})")
+ tag = "x86_64"
print(f" {leaf:24} [{tag}]")🧰 Tools
🪛 Ruff (0.15.17)
[error] 228-228: subprocess call: check for execution of untrusted input
(S603)
[error] 229-229: Starting a process with a partial executable path
(S607)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Prereqs/fetch-wine-libs.py` around lines 228 - 232, The architecture
detection logic currently uses "??ARCH??" as a fallback tag when the
architecture is not x86_64, allowing the script to continue. Replace this silent
fallback behavior with a hard failure by raising an exception or calling
sys.exit() when the architecture check in the ternary operator finds anything
other than x86_64. This ensures that unexpected architectures cause an immediate
failure rather than deferring the issue to runtime, making it consistent with
the script's explicit requirement to process only x86_64 dylibs.
| private func pollInstallProgress() async { | ||
| var lastBytes: Int64 = 0 | ||
| var lastTime = Date() | ||
| var primed = false | ||
| while !Task.isCancelled { | ||
| if let s = currentAgentStats() { | ||
| let now = Date() | ||
| let dt = now.timeIntervalSince(lastTime) | ||
| var speed = 0.0 | ||
| // Skip the first sample (no baseline) and any counter reset | ||
| // (the byte counter restarts between agent-update and client | ||
| // phases — a negative delta isn't a real rate). | ||
| if primed, dt > 0, s.done >= lastBytes { | ||
| speed = Double(s.done - lastBytes) / dt | ||
| } | ||
| installProgress = InstallProgress( | ||
| fraction: s.fraction, bytesDone: s.done, | ||
| bytesTotal: s.total, bytesPerSec: speed) | ||
| lastBytes = s.done | ||
| lastTime = now | ||
| primed = true | ||
| } | ||
| try? await Task.sleep(nanoseconds: 1_500_000_000) | ||
| } | ||
| } | ||
|
|
||
| /// Parse the newest Agent log's most recent progress blob. | ||
| private func currentAgentStats() -> (done: Int64, total: Int64, fraction: Double)? { | ||
| guard let logURL = newestAgentLog(), let tail = tailString(logURL) else { return nil } | ||
| let doneStr = lastCapture(#""update_bytes_current":\s*\[\s*([0-9]+)"#, tail) | ||
| let totalStr = lastCapture(#""update_bytes_total":\s*\[\s*([0-9]+)"#, tail) | ||
| let fracStr = lastCapture(#""playable_progress":\s*([0-9.]+)"#, tail) | ||
| if doneStr == nil && totalStr == nil && fracStr == nil { return nil } | ||
| let done = Int64(doneStr ?? "") ?? 0 | ||
| let total = Int64(totalStr ?? "") ?? 0 | ||
| let frac = Double(fracStr ?? "") ?? (total > 0 ? Double(done) / Double(total) : 0) | ||
| return (done, total, min(max(frac, 0), 1)) | ||
| } | ||
|
|
||
| /// Newest `Agent-*.log` under the bottle's Battle.net Agent dir, by mtime. | ||
| private func newestAgentLog() -> URL? { | ||
| let agentDir = bottleRoot.appendingPathComponent( | ||
| "drive_c/ProgramData/Battle.net/Agent", isDirectory: true) | ||
| let fm = FileManager.default | ||
| guard let en = fm.enumerator( | ||
| at: agentDir, includingPropertiesForKeys: [.contentModificationDateKey]) else { return nil } | ||
| var best: (url: URL, date: Date)? | ||
| for case let u as URL in en { | ||
| let name = u.lastPathComponent | ||
| guard name.hasPrefix("Agent-"), name.hasSuffix(".log") else { continue } | ||
| let m = (try? u.resourceValues(forKeys: [.contentModificationDateKey]))? | ||
| .contentModificationDate ?? .distantPast | ||
| if best == nil || m > best!.date { best = (u, m) } | ||
| } | ||
| return best?.url | ||
| } | ||
|
|
||
| /// Last `maxBytes` of a file as UTF-8 (the log only grows; the tail holds | ||
| /// the freshest progress blob). | ||
| private func tailString(_ url: URL, maxBytes: UInt64 = 65536) -> String? { | ||
| guard let fh = try? FileHandle(forReadingFrom: url) else { return nil } | ||
| defer { try? fh.close() } | ||
| let size = (try? fh.seekToEnd()) ?? 0 | ||
| try? fh.seek(toOffset: size > maxBytes ? size - maxBytes : 0) | ||
| let data = (try? fh.readToEnd()) ?? Data() | ||
| return String(data: data, encoding: .utf8) | ||
| } | ||
|
|
||
| /// First capture group of the LAST regex match (the most recent value). | ||
| private func lastCapture(_ pattern: String, _ s: String) -> String? { | ||
| guard let re = try? NSRegularExpression( | ||
| pattern: pattern, options: [.dotMatchesLineSeparators]) else { return nil } | ||
| let matches = re.matches(in: s, range: NSRange(s.startIndex..., in: s)) | ||
| guard let last = matches.last, last.numberOfRanges > 1, | ||
| let r = Range(last.range(at: 1), in: s) else { return nil } | ||
| return String(s[r]) | ||
| } |
There was a problem hiding this comment.
Move installer log polling/parsing off the main actor.
Line 403 onward runs synchronous filesystem and regex work under @MainActor every ~1.5s. That can block UI updates during installs. Run newestAgentLog/tailString/lastCapture on a background task (or a nonisolated helper) and only hop to MainActor when assigning installProgress.
Suggested direction
- private func pollInstallProgress() async {
+ nonisolated private func readAgentStats(bottleRoot: URL) -> (done: Int64, total: Int64, fraction: Double)? {
+ // do newestAgentLog/tail/regex here
+ }
+
+ private func pollInstallProgress() async {
var lastBytes: Int64 = 0
var lastTime = Date()
var primed = false
while !Task.isCancelled {
- if let s = currentAgentStats() {
+ if let s = await Task.detached(priority: .utility) {
+ readAgentStats(bottleRoot: self.bottleRoot)
+ }.value {
let now = Date()
let dt = now.timeIntervalSince(lastTime)
var speed = 0.0
if primed, dt > 0, s.done >= lastBytes {
speed = Double(s.done - lastBytes) / dt
}
- installProgress = InstallProgress(...)
+ installProgress = InstallProgress(...)
lastBytes = s.done
lastTime = now
primed = true
}
try? await Task.sleep(nanoseconds: 1_500_000_000)
}
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Sources/D4Mac/BottleManager.swift` around lines 403 - 479, The synchronous
file I/O and regex operations performed by newestAgentLog(), tailString(), and
lastCapture() are executing on the main actor within pollInstallProgress() and
can block UI updates. Move the expensive filesystem and regex work off the main
actor by running currentAgentStats() on a background task, then only hop back to
the main actor when assigning the result to the installProgress property. Keep
the helper methods themselves unchanged but ensure they execute on a background
context.
Fixes the Apple Silicon install/login breakage reported in #2, plus two related problems found while reproducing it.
Background
On Apple Silicon Macs with only ARM Homebrew (or none), the bundled x86_64 Wine
dlopen()slibfreetype.6.dylibandlibgnutls.30.dylibby leaf name and finds nothing — the onlyDYLD_FALLBACK_LIBRARY_PATHfallback was an Intel Homebrew at/usr/local/lib. Result: blank text in Battle.net Setup and Schannel/TLS failures during install (#2).Changes
1. Bundle the x86_64 FreeType/GnuTLS chain —
feat(wine)Prereqs/fetch-wine-libs.pyfetches the x86_64 macOS bottles straight from Homebrew's GHCR registry — no Homebrew needed on the build machine, and it still works even though thesonomabottlesbrew fetch --bottle-tag=sonomaused to serve have since been dropped upstream. It BFS-resolves the dependency closure from the two libraries Wine actuallydlopen()s, rewrites install ids + Homebrew dep paths to@rpath/…, and stages 11 dylibs.build.shcopies them intoContents/SharedSupport/Wine/lib/external, whereDYLD_FALLBACK_LIBRARY_PATHalready points. Licences documented inTHIRD_PARTY_LICENSES.md; fetched-on-demand (gitignored), matching the existing fonts / VC-redist pattern.Verified locally: all 11 are x86_64, no residual Homebrew paths remain, and
libfreetype.6.dylib+libgnutls.30.dylibdlopen()cleanly (whole transitive chain) under Rosetta out oflib/external.2. Default the Wine sync primitive to "none" + wire the Settings toggle —
fix(wine)WINEMSYNC=1/WINEESYNC=1were hardcoded, and the Settings → "Synchronisation" picker (syncStyle) was never read anywhere. On macOS 26 / Apple Silicon under Rosetta, esync/msync spin-wait pegs a CPU core.New finding beyond #2: this doesn't only freeze D4 gameplay (as the #2 comments describe) — it also throttles the Battle.net installer download from multiple MB/s to a crawl. Measured on an M-series Mac mid-repro: native link 5.6 MB/s, BNet download ~4 KB/s with
Battle.net-Setup.exepegging a full core; relaunching the exact same install withWINEMSYNC=0 WINEESYNC=0jumped it to ~8 MB/s (≈2000×) and it finished in seconds.WineProcess.environmentnow reads thesyncStyleUserDefaults key (covering both the installer and the launcher) and defaults tonone. The picker now defaults to / recommendsNonewith an explanatory caption.3. Live install progress —
feat(ui)During install the UI only showed a spinner + "look for the installer window" until it flipped to Launch at the very end, so a healthy (or throttled) install looked dead — which is exactly what made the throttle above so confusing.
BottleManagernow polls the Blizzard Agent log (update_bytes_current/total,playable_progress) ~every 1.5 s while the installer runs and publishes a determinate figure;ContentViewshows a bar + "NN% · X GB / Y GB · Z MB/s". A visible speed readout would also have surfaced the 4 KB/s throttle immediately.4.
fetch.shunbound-variable fix —fix(prereqs)The
…in"fetching $name…"glued the multibyte char onto the variable name under some locales, soset -uaborted withname…: unbound variable. Braced + ASCII....Verification
swift buildis green for all changed code. The pre-existing#Previewmacros (Settings.swift,BeerTip.swift) require the full Xcode toolchain's macro plugin and fail under bare CommandLineTools — unrelated to this PR, and they were temporarily stubbed only to confirm the changed code compiles, then restored unchanged.Workaround until a build ships
Apple Silicon users can already avoid the throttle/freeze by setting Settings → Synchronisation → None, or by launching Battle.net externally with
WINEMSYNC=0 WINEESYNC=0plus the existing--in-process-gpu --use-gl=swiftshaderCEF flags.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Chores