diff --git a/README.md b/README.md index ef0a906bf..21353b208 100644 --- a/README.md +++ b/README.md @@ -84,3 +84,36 @@ Retro Rewind was made by ZPL. More information about Retro Rewind can be found o Huge parts from the mii renderer were inspired and ported from [ariankordi's branch](https://github.com/ariankordi/FFL-Testing) of abood's [FFL-Testing](https://github.com/aboood40091/FFL-Testing) project. You can find their website [here](https://mii-unsecure.ariankordi.net/). Some of the icons used in Wheel Wizard are from [Game Icons](https://game-icons.net/about.html). Specifically, the [car wheel icon](https://game-icons.net/1x1/delapouite/car-wheel.html) and the [flat tire icon](https://game-icons.net/1x1/delapouite/flat-tire.html), both created by Delapouite. Also thanks Chadderz' for the specials icons in ["Chadderz' Terrible Mario Kart Font"](https://wiki.tockdom.com/wiki/CTMKF) + +### Native WiiCompiled on Apple Silicon + +On macOS 14 or later, the Apple Silicon build supports **Settings → Other → WiiCompiled (beta)**. +Choose a clean **PAL RMCP01** Mario Kart Wii disc image in Settings, then use the Home page to install +and play Retro Rewind natively. The installer verifies the disc, translates the game on your Mac, +and builds ARM64 apps using Metal. Dolphin is optional; its existing Retro Rewind files and NAND +settings can still be used. Intel Macs continue to use Dolphin. + +The Mac integration supports install/update, product checks and repair before launch, progress and +cancellation, online/offline payload choices, video settings, and portable uninstallation. Game apps, +source/tooling, build cache, and settings live under WheelWizard's `Recomp` directory. Asset-only +Retro Rewind updates are read live; changed `Code.pul` or mod directory topology triggers compilation. +The setup holds an installation lock while a game it launched is running. Directly opening a game +app bypasses that lock, so close standalone game instances before installing or repairing. + +To build from adjacent `WheelWizard` and `Wiicompiled` checkouts: + +```sh +./build-native-mac.sh +``` + +This requires .NET 10, Xcode Command Line Tools, and the native redistributable tools from +`WiiCompiled Setup.app` in Applications. Override `WIICOMPILED_REPO` or `WIICOMPILED_TOOLS_ROOT` +if they are elsewhere. The result is `release/WheelWizard.app`, with a game-code-free setup bundled +so first installation works before a fork release is published. For an existing setup asset, use +`RECOMP_SETUP=/absolute/path/WiiCompiled-Setup-macos-arm64.run ./build-mac.sh`. + +Mac updates select `WiiCompiled-Setup-macos-arm64.run` from `DarthMDev/Wiicompiled` releases. +Windows retains its existing repository and `.exe` contract. The local bundle is used when it is at +least as new as the available Mac release. A release package contains source and redistributable tools +only; your disc image, translated game, and Retro Rewind data are never included. Local builds are +ad-hoc signed; distributing them publicly still requires the normal signing/notarization process. diff --git a/WheelWizard.Test/Features/Recomp/RecompMacTests.cs b/WheelWizard.Test/Features/Recomp/RecompMacTests.cs new file mode 100644 index 000000000..9680f72c9 --- /dev/null +++ b/WheelWizard.Test/Features/Recomp/RecompMacTests.cs @@ -0,0 +1,116 @@ +using System.IO.Abstractions; +using Microsoft.Extensions.Logging.Abstractions; +using WheelWizard.GitHub.Domain; +using WheelWizard.Recomp; +using Xunit; + +namespace WheelWizard.Test.Features.Recomp; + +public class RecompMacTests +{ + [Fact] + public void MacReleaseSelectionDoesNotOfferWindowsInstaller() + { + var windows = new GithubRelease + { + TagName = "v9.0.0", + Assets = [new GithubAsset { Name = "WiiCompiled-Setup.exe", BrowserDownloadUrl = "https://example.com/windows" }], + }; + var mac = new GithubRelease + { + TagName = "v1.0.0", + Assets = [new GithubAsset { Name = "WiiCompiled-Setup-macos-arm64.run", BrowserDownloadUrl = "https://example.com/mac" }], + }; + Assert.Equal("v1.0.0", RecompReleaseResolver.FindLatest([windows, mac], "WiiCompiled-Setup-macos-arm64.run")?.TagName); + Assert.Null(RecompReleaseResolver.FindLatest([windows], "WiiCompiled-Setup-macos-arm64.run")); + } + + [Fact] + public async Task MacRunnerPassesLiteralPathsToSetupWithoutShellExpansion() + { + if (!OperatingSystem.IsMacOS()) + return; + var directory = Path.Combine(Path.GetTempPath(), "wheelwizard argv " + Guid.NewGuid()); + Directory.CreateDirectory(directory); + var script = Path.Combine(directory, "a \"quoted\" setup.run"); + try + { + await File.WriteAllTextAsync(script, "#!/bin/bash\nprintf '%s\\n' \"$@\"\n"); + var game = "/Users/Zoë/a \"quoted\" \\ game $(touch NEVER).rvz"; + var install = "/Users/Zoë/Application Support/Install"; + var arguments = RecompSetupCommandBuilder.BuildSilentInstallArguments( + new() + { + GameFilePath = game, + InstallFolderPath = install, + Portable = true, + } + ); + var output = new List(); + var runner = new RecompProcessRunner(NullLogger.Instance); + var result = await runner.RunAsync(script, arguments, null, output.Add); + Assert.True(result.IsSuccess); + Assert.Equal(0, result.Value); + Assert.Equal(game, output[2]); + Assert.Equal(install, output[4]); + Assert.Contains("--portable", output); + } + finally + { + Directory.Delete(directory, true); + } + } + + [Fact] + public void MacOffersMetal() + { + if (!OperatingSystem.IsMacOS()) + return; + Assert.Equal(new[] { "metal" }, RecompVideoConfig.OfferedGraphicsApis); + Assert.Equal("Metal", RecompVideoConfig.DescribeGraphicsApi("metal")); + } + + [Fact] + public async Task MacCancellationAllowsTheHelperToCleanUp() + { + if (!OperatingSystem.IsMacOS()) + return; + var directory = Path.Combine(Path.GetTempPath(), "wheelwizard-cancel-" + Guid.NewGuid()); + Directory.CreateDirectory(directory); + var script = Path.Combine(directory, "setup.run"); + string? signal = null; + var output = new List(); + using var cancellation = new CancellationTokenSource(); + try + { + await File.WriteAllTextAsync( + script, + "#!/bin/bash\nprintf '%s\\n' \"$MKWCOMPILED_CANCEL_FILE\"\nwhile [[ ! -f \"$MKWCOMPILED_CANCEL_FILE\" ]]; do sleep 0.05; done\necho cleaned-up\nexit 1\n" + ); + var runner = new RecompProcessRunner(NullLogger.Instance); + var result = await runner.RunAsync( + script, + "", + null, + line => + { + output.Add(line); + if (signal is null) + { + signal = line; + cancellation.Cancel(); + } + }, + cancellation.Token + ); + Assert.True(result.IsSuccess); + Assert.Equal(1, result.Value); + Assert.Contains("cleaned-up", output); + Assert.False(File.Exists(signal)); + } + finally + { + Directory.Delete(directory, true); + } + } +} diff --git a/WheelWizard/Features/Recomp/RecompExtensions.cs b/WheelWizard/Features/Recomp/RecompExtensions.cs index 23c7002c0..3aec6f012 100644 --- a/WheelWizard/Features/Recomp/RecompExtensions.cs +++ b/WheelWizard/Features/Recomp/RecompExtensions.cs @@ -7,12 +7,12 @@ public static class RecompExtensions { /// /// Registers the Mario Kart Wii recomp frontend. - /// The recomp only ships for Windows, so on every other platform nothing is registered at all; - /// ISettingsManager.IsRecompModeActive() is false there, so nothing ever resolves these. + /// Register only on Windows and supported Apple Silicon Macs. + /// ISettingsManager.IsRecompModeActive() is false elsewhere, so nothing ever resolves these. /// public static IServiceCollection AddRecomp(this IServiceCollection services) { - if (!OperatingSystem.IsWindows()) + if (!RecompPlatform.IsSupported) return services; services diff --git a/WheelWizard/Features/Recomp/RecompInstallService.cs b/WheelWizard/Features/Recomp/RecompInstallService.cs index 111cff493..6a4a21379 100644 --- a/WheelWizard/Features/Recomp/RecompInstallService.cs +++ b/WheelWizard/Features/Recomp/RecompInstallService.cs @@ -352,7 +352,7 @@ CancellationToken cancellationToken ) { // No platform guard here on purpose: AddRecomp() is the single gate, so this service only ever - // exists on Windows in the first place. + // exists only on supported platforms. if (!IsGameFileConfigured()) return Fail(t("message_warning.not_find_game.extra")); @@ -515,12 +515,22 @@ public async Task UninstallAsync(CancellationToken cancellation private OperationResult UninstallCore() { _launchReconciled = false; + if (IsInstallationBusy()) + return Fail(InstallationBusyMessage); // Only the recomp's own directories are removed. The shared Retro Rewind installation lives // outside them and survives on purpose: it belongs to WheelWizard's Dolphin frontend just as much. return TryCatch( () => { + // Mac publication can leave these owned siblings after a cancelled build. + if (OperatingSystem.IsMacOS()) + { + DeleteFolderIfPresent(environment.InstallFolderPath + ".staging"); + DeleteFolderIfPresent(environment.InstallFolderPath + ".previous"); + if (fileSystem.File.Exists(environment.InstallFolderPath + ".config-backup")) + fileSystem.File.Delete(environment.InstallFolderPath + ".config-backup"); + } DeleteFolderIfPresent(environment.InstallFolderPath); DeleteFolderIfPresent(environment.UserDataFolderPath); DeleteFolderIfPresent(environment.CacheFolderPath); @@ -545,6 +555,12 @@ private async Task> EnsureSetupDownloadedAsync( CancellationToken cancellationToken ) { + if (OperatingSystem.IsMacOS() && release.SetupDownloadUrl == new Uri(RecompPlatform.BundledSetupPath).AbsoluteUri) + { + return await SetupMatchesVersionAsync(RecompPlatform.BundledSetupPath, release.TagName, cancellationToken) + ? Ok(RecompPlatform.BundledSetupPath) + : Fail("The bundled Mac setup has an unexpected version."); + } var cachedSetupPath = fileSystem.Path.Combine(environment.CacheFolderPath, BuildCachedSetupFileName(release.TagName)); if (IsUsableFile(cachedSetupPath) && await SetupMatchesVersionAsync(cachedSetupPath, release.TagName, cancellationToken)) { @@ -590,7 +606,12 @@ private void PruneCachedSetupsExcept(string keepFilePath) { if (!fileSystem.Directory.Exists(environment.CacheFolderPath)) return; - foreach (var candidate in fileSystem.Directory.EnumerateFiles(environment.CacheFolderPath, "WiiCompiled-Setup-*.exe")) + foreach ( + var candidate in fileSystem.Directory.EnumerateFiles( + environment.CacheFolderPath, + OperatingSystem.IsMacOS() ? "WiiCompiled-Setup-*.run" : "WiiCompiled-Setup-*.exe" + ) + ) { if (string.Equals(candidate, keepFilePath, StringComparison.OrdinalIgnoreCase)) continue; @@ -879,18 +900,35 @@ private OperationResult FinishSetupRun( { cancellationToken.ThrowIfCancellationRequested(); + // A locally built WheelWizard may ship a setup before its fork publishes a release. + RecompRelease? bundled = null; + if (OperatingSystem.IsMacOS() && fileSystem.File.Exists(RecompPlatform.BundledSetupPath)) + { + await processRunner.RunAsync( + RecompPlatform.BundledSetupPath, + "--version", + null, + line => + { + if (RecompVersion.TryParse(line, out var version)) + bundled = new("v" + version, version, new Uri(RecompPlatform.BundledSetupPath).AbsoluteUri); + }, + cancellationToken + ); + } var releasesResult = await gitHubService.GetReleasesAsync( - RecompReleaseResolver.RepositoryOwner, + RecompPlatform.RepositoryOwner, RecompReleaseResolver.RepositoryName, count: 100 ); if (releasesResult.IsFailure) { logger.LogWarning("Could not retrieve the recomp releases: {Message}", releasesResult.Error.Message); - return null; + return bundled; } - return RecompReleaseResolver.FindLatest(releasesResult.Value); + var remote = RecompReleaseResolver.FindLatest(releasesResult.Value, RecompPlatform.ReleaseAssetName); + return bundled is not null && (remote is null || bundled.Version.ComparePrecedenceTo(remote.Version) >= 0) ? bundled : remote; } private RecompInstallState? ReadInstalledState() @@ -968,7 +1006,7 @@ private bool IsUsableFile(string filePath) private static string BuildCachedSetupFileName(string tagName) { var sanitized = new string(tagName.Select(character => char.IsLetterOrDigit(character) ? character : '-').ToArray()); - return $"WiiCompiled-Setup-{sanitized}.exe"; + return $"WiiCompiled-Setup-{sanitized}{(OperatingSystem.IsMacOS() ? ".run" : ".exe")}"; } private static void Report(IProgress? progress, string message, int percent) => diff --git a/WheelWizard/Features/Recomp/RecompLauncher.cs b/WheelWizard/Features/Recomp/RecompLauncher.cs index 45b1cf7b5..7d067da7a 100644 --- a/WheelWizard/Features/Recomp/RecompLauncher.cs +++ b/WheelWizard/Features/Recomp/RecompLauncher.cs @@ -62,6 +62,11 @@ public async Task Launch() if (progressWindow.WasCancellationRequested || cancellationTokenSource.IsCancellationRequested) return Fail("WiiCompiled launch preparation was cancelled."); + // Apply the current save-data preference even after an external install or a relink. + var nandResult = dolphinData.ApplyNandToRecompConfig(); + if (nandResult.IsFailure) + return nandResult; + // Reconciliation is the only cancellable progress phase. The launch call is // awaited through game exit. progressWindow.SetCancellationTokenSource(null); diff --git a/WheelWizard/Features/Recomp/RecompPlatform.cs b/WheelWizard/Features/Recomp/RecompPlatform.cs new file mode 100644 index 000000000..cddcc328a --- /dev/null +++ b/WheelWizard/Features/Recomp/RecompPlatform.cs @@ -0,0 +1,22 @@ +using System.Runtime.InteropServices; + +namespace WheelWizard.Recomp; + +public static class RecompPlatform +{ + public static bool IsSupported => + OperatingSystem.IsWindows() + || (OperatingSystem.IsMacOSVersionAtLeast(14) && RuntimeInformation.ProcessArchitecture == Architecture.Arm64); + public static string SetupFileName => OperatingSystem.IsMacOS() ? "WiiCompiled-Setup.run" : "WiiCompiled-Setup.exe"; + public static string ReleaseAssetName => OperatingSystem.IsMacOS() ? "WiiCompiled-Setup-macos-arm64.run" : "WiiCompiled-Setup.exe"; + public static string RepositoryOwner => OperatingSystem.IsMacOS() ? "DarthMDev" : RecompReleaseResolver.RepositoryOwner; + public static string BundledSetupPath => + Path.GetFullPath( + Path.Combine( + Path.GetDirectoryName(Environment.ProcessPath) ?? AppContext.BaseDirectory, + "..", + "Resources", + "WiiCompiled-Setup-macos-arm64.run" + ) + ); +} diff --git a/WheelWizard/Features/Recomp/RecompProcessRunner.cs b/WheelWizard/Features/Recomp/RecompProcessRunner.cs index 513c22573..b4031850f 100644 --- a/WheelWizard/Features/Recomp/RecompProcessRunner.cs +++ b/WheelWizard/Features/Recomp/RecompProcessRunner.cs @@ -37,6 +37,10 @@ public async Task> RunAsync( CancellationToken cancellationToken = default ) { + var cancellationFile = + OperatingSystem.IsMacOS() && cancellationToken.CanBeCanceled + ? Path.Combine(Path.GetTempPath(), "mkwc-cancel-" + Guid.NewGuid().ToString("N")) + : null; try { cancellationToken.ThrowIfCancellationRequested(); @@ -51,6 +55,9 @@ public async Task> RunAsync( if (cancellationEvent is not null) startInfo.Environment[CancellationEventEnvironmentVariable] = cancellationEventName; + if (cancellationFile is not null) + startInfo.Environment["MKWCOMPILED_CANCEL_FILE"] = cancellationFile; + using var process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; process.OutputDataReceived += (_, eventArgs) => @@ -79,9 +86,10 @@ public async Task> RunAsync( } catch (OperationCanceledException) { - var exited = cancellationEvent is not null - ? await CancelAndWaitForExitAsync(process, cancellationEvent) - : await KillAndWaitForExitAsync(process); + var exited = + cancellationEvent is not null || cancellationFile is not null + ? await CancelAndWaitForExitAsync(process, cancellationEvent, cancellationFile) + : await KillAndWaitForExitAsync(process); if (!exited) throw; @@ -102,13 +110,30 @@ public async Task> RunAsync( logger.LogError(exception, "Failed to run '{FileName}'", fileName); return Fail(exception); } + finally + { + if (cancellationFile is not null) + { + try + { + File.Delete(cancellationFile); + } + catch (IOException exception) + { + logger.LogDebug(exception, "Could not remove cancellation signal"); + } + } + } } private static ProcessStartInfo CreateStartInfo(string fileName, string arguments, string? workingDirectory) => new() { - FileName = fileName, - Arguments = arguments, + FileName = OperatingSystem.IsMacOS() && fileName.EndsWith(".run", StringComparison.Ordinal) ? "/bin/bash" : fileName, + Arguments = + OperatingSystem.IsMacOS() && fileName.EndsWith(".run", StringComparison.Ordinal) + ? RecompSetupCommandBuilder.Quote(fileName) + " " + arguments + : arguments, WorkingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? string.Empty : workingDirectory, UseShellExecute = false, // The recomp setup is CLI-only. Wheel Wizard supplies the UI, including during launch, @@ -118,11 +143,13 @@ private static ProcessStartInfo CreateStartInfo(string fileName, string argument RedirectStandardError = true, }; - private async Task CancelAndWaitForExitAsync(Process process, EventWaitHandle cancellationEvent) + private async Task CancelAndWaitForExitAsync(Process process, EventWaitHandle? cancellationEvent, string? cancellationFile) { try { - cancellationEvent.Set(); + cancellationEvent?.Set(); + if (cancellationFile is not null) + await File.WriteAllTextAsync(cancellationFile, "cancel"); } catch (Exception exception) { diff --git a/WheelWizard/Features/Recomp/RecompReleaseResolver.cs b/WheelWizard/Features/Recomp/RecompReleaseResolver.cs index cde21dc56..7b49505b8 100644 --- a/WheelWizard/Features/Recomp/RecompReleaseResolver.cs +++ b/WheelWizard/Features/Recomp/RecompReleaseResolver.cs @@ -23,7 +23,7 @@ public static class RecompReleaseResolver /// /// Returns the newest usable release, or when the listing contains none. /// - public static RecompRelease? FindLatest(IEnumerable? releases) + public static RecompRelease? FindLatest(IEnumerable? releases, string? assetName = null) { if (releases is null) return null; @@ -38,7 +38,7 @@ public static class RecompReleaseResolver continue; var asset = release.Assets.FirstOrDefault(candidate => - string.Equals(candidate.Name, RecompSetupCommandBuilder.SetupFileName, StringComparison.OrdinalIgnoreCase) + string.Equals(candidate.Name, assetName ?? RecompSetupCommandBuilder.SetupFileName, StringComparison.OrdinalIgnoreCase) ); if (asset is null || string.IsNullOrWhiteSpace(asset.BrowserDownloadUrl)) continue; diff --git a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs index c85238188..e7b9daf66 100644 --- a/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs +++ b/WheelWizard/Features/Recomp/RecompSetupCommandBuilder.cs @@ -115,18 +115,13 @@ public static string BuildCheckProductsArguments(string installFolderPath, strin private static string RetroWfcPayloadArgument(RecompRetroWfcPayloadMode mode) => mode == RecompRetroWfcPayloadMode.Skip ? "--skip-retro-wfc-payload" : "--download-retro-wfc-payload"; - // The recomp is Windows only, so quoting is always Windows quoting (never the POSIX form EnvHelper would pick). - private static string Quote(string path) + // ProcessStartInfo uses double-quoted arguments on every platform; these are not shell commands. + internal static string Quote(string path) { - var value = path.Trim(); - - // A trailing backslash would escape the closing quote for CommandLineToArgvW, so drop redundant - // separators and double the one that has to stay (a drive root such as "D:\"). - while (value.Length > 1 && value[^1] == '\\' && !value.EndsWith(@":\", StringComparison.Ordinal)) - value = value[..^1]; - if (value.EndsWith('\\')) - value += '\\'; - - return $"\"{value}\""; + // Match ProcessStartInfo's argv parser: backslashes are literal except immediately + // before quotes or the closing delimiter. No shell ever interprets this string. + var value = System.Text.RegularExpressions.Regex.Replace(path, @"(\\*)""", "$1$1\\\""); + value = System.Text.RegularExpressions.Regex.Replace(value, @"(\\+)$", "$1$1"); + return "\"" + value + "\""; } } diff --git a/WheelWizard/Features/Recomp/RecompVideoConfig.cs b/WheelWizard/Features/Recomp/RecompVideoConfig.cs index aadfa3c3c..278bd47c8 100644 --- a/WheelWizard/Features/Recomp/RecompVideoConfig.cs +++ b/WheelWizard/Features/Recomp/RecompVideoConfig.cs @@ -14,7 +14,7 @@ public static class RecompVideoConfig /// (auto, d3d11, opengl, ...), but only these two actually run the game reliably, so nothing else /// is ever offered. A value outside this list stays untouched until the user picks one of these. /// - public static IReadOnlyList OfferedGraphicsApis { get; } = ["d3d12", "vulkan"]; + public static IReadOnlyList OfferedGraphicsApis { get; } = OperatingSystem.IsMacOS() ? ["metal"] : ["d3d12", "vulkan"]; /// The label for a graphics API value, for example DirectX 12 for d3d12. public static string DescribeGraphicsApi(string api) => @@ -22,6 +22,7 @@ public static string DescribeGraphicsApi(string api) => { "d3d12" => "DirectX 12", "vulkan" => "Vulkan", + "metal" => "Metal", _ => api, }; diff --git a/WheelWizard/Features/Settings/ISettingsServices.cs b/WheelWizard/Features/Settings/ISettingsServices.cs index a07bcc95a..8df033fcb 100644 --- a/WheelWizard/Features/Settings/ISettingsServices.cs +++ b/WheelWizard/Features/Settings/ISettingsServices.cs @@ -78,7 +78,7 @@ public interface ISettingsManager : ISettingsProperties /// /// Whether WiiCompiled is the active frontend instead of Dolphin/Retro Rewind. This is the single - /// definition of that mode: it carries the Windows-only guard, so a stale EnableRecomp + /// definition of that mode: it carries the platform support guard, so a stale EnableRecomp /// flag can never activate recomp behavior on a platform the recomp does not run on. /// bool IsRecompModeActive(); diff --git a/WheelWizard/Features/Settings/SettingsManager.cs b/WheelWizard/Features/Settings/SettingsManager.cs index ae911eb96..99f235820 100644 --- a/WheelWizard/Features/Settings/SettingsManager.cs +++ b/WheelWizard/Features/Settings/SettingsManager.cs @@ -286,7 +286,7 @@ public OperationResult ValidateCorePathSettings() private OperationResult ValidateDolphinPathSettings() => ValidatePathSettings(requireDolphin: true); - public bool IsRecompModeActive() => OperatingSystem.IsWindows() && Get(ENABLE_RECOMP); + public bool IsRecompModeActive() => WheelWizard.Recomp.RecompPlatform.IsSupported && Get(ENABLE_RECOMP); private OperationResult ValidatePathSettings(bool requireDolphin) { diff --git a/WheelWizard/Services/Launcher/LauncherProvider.cs b/WheelWizard/Services/Launcher/LauncherProvider.cs index 3c264b793..b23b7dc4f 100644 --- a/WheelWizard/Services/Launcher/LauncherProvider.cs +++ b/WheelWizard/Services/Launcher/LauncherProvider.cs @@ -4,7 +4,7 @@ namespace WheelWizard.Services.Launcher; /// -/// Resolves the launcher the Home page should drive. The recomp is a Windows-only beta that, when +/// Resolves the launcher the Home page should drive. The recomp is a native beta that, when /// opted into, replaces the Dolphin/Retro Rewind frontend entirely; which launcher that decision /// selects lives here, so no view has to re-derive it. /// diff --git a/WheelWizard/Services/PathManager.cs b/WheelWizard/Services/PathManager.cs index f6b75ed3d..aa88aac28 100644 --- a/WheelWizard/Services/PathManager.cs +++ b/WheelWizard/Services/PathManager.cs @@ -97,7 +97,7 @@ public static bool IsUsingCustomWheelWizardAppdataPath public static string RecompCacheFolderPath => Path.Combine(RecompFolderPath, "Cache"); public static string RecompInstallStateFilePath => Path.Combine(RecompInstallFolderPath, RecompInstallStateFileName); - public static string RecompSetupFilePath => Path.Combine(RecompInstallFolderPath, "WiiCompiled-Setup.exe"); + public static string RecompSetupFilePath => Path.Combine(RecompInstallFolderPath, WheelWizard.Recomp.RecompPlatform.SetupFileName); /// The backend-owned runtime user state (Config.toml, private NAND, caches) inside the portable root. public static string RecompUserDataFolderPath => Path.Combine(RecompFolderPath, "UserData"); diff --git a/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs b/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs index 4c136e19b..b9a3d2982 100644 --- a/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs +++ b/WheelWizard/Views/Pages/Settings/OtherSettings.axaml.cs @@ -48,8 +48,8 @@ private void ForceLoadSettings() { // Always loads - // The recomp only ships for Windows, so on every other platform the whole section stays hidden. - var recompSupported = OperatingSystem.IsWindows(); + // Only show the recomp section on platforms with a native setup backend. + var recompSupported = WheelWizard.Recomp.RecompPlatform.IsSupported; RecompSectionLabel.IsVisible = recompSupported; RecompBorder.IsVisible = recompSupported; if (recompSupported) diff --git a/build-native-mac.sh b/build-native-mac.sh new file mode 100755 index 000000000..98ab505d6 --- /dev/null +++ b/build-native-mac.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Build WheelWizard with the sibling Apple Silicon WiiCompiled backend bundled. +set -euo pipefail +root=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +recomp_repo="${WIICOMPILED_REPO:-$root/../Wiicompiled}" +tools_root="${WIICOMPILED_TOOLS_ROOT:-/Applications/WiiCompiled Setup.app/Contents/Resources/tools}" +[[ $(uname -m) == arm64 ]] || { echo 'Use a native Apple Silicon terminal.' >&2; exit 1; } +[[ -d "$tools_root" ]] || { echo 'Install WiiCompiled Setup.pkg first, or set WIICOMPILED_TOOLS_ROOT to its native tools directory.' >&2; exit 1; } +setup="$root/release/WiiCompiled-Setup-macos-arm64.run" +"$recomp_repo/Launcher/macos/build-wheelwizard-setup.command" --tools-root "$tools_root" --output "$setup" +BUILD_ARCH=arm64 RECOMP_SETUP="$setup" "$root/build-mac.sh" diff --git a/macos/release-macos.sh b/macos/release-macos.sh index 3d4865cdf..68614a99e 100755 --- a/macos/release-macos.sh +++ b/macos/release-macos.sh @@ -9,6 +9,7 @@ # BUILD_ARCH - "arm64" or "x64" (default: auto-detect) # SKIP_BUILD - Set to "true" to skip dotnet build # OUTPUT_DIR - Output directory (default: ./release) +# RECOMP_SETUP - Optional game-code-free WiiCompiled-Setup-macos-arm64.run to bundle # # No codesigning, no notarization, no DMG creation. # DMG is created by the GitHub Actions workflow using create-dmg action. @@ -107,6 +108,14 @@ if [ -f "$MAC_DIRS/Contents/Resources/WheelWizard.icns" ]; then cp "$MAC_DIRS/Contents/Resources/WheelWizard.icns" "$APP_BUNDLE/Contents/Resources/WheelWizard.icns" fi +# Bundle the native installer so local builds are usable before a GitHub release exists. +if [ -n "${RECOMP_SETUP:-}" ]; then + [ "$BUILD_ARCH" = arm64 ] || { echo "RECOMP_SETUP requires BUILD_ARCH=arm64" >&2; exit 1; } + [ -s "$RECOMP_SETUP" ] || { echo "RECOMP_SETUP does not exist: $RECOMP_SETUP" >&2; exit 1; } + mkdir -p "$APP_BUNDLE/Contents/Resources" + cp "$RECOMP_SETUP" "$APP_BUNDLE/Contents/Resources/WiiCompiled-Setup-macos-arm64.run" +fi + # Set the bundle version from the release tag (e.g. "v2.5.1" -> "2.5.1"), # falling back to the version declared in the project file. if [ -n "${GITHUB_REF_NAME:-}" ]; then @@ -122,6 +131,9 @@ else /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUNDLE_VERSION" "$APP_BUNDLE/Contents/Info.plist" fi +chmod +x "$APP_BUNDLE/Contents/MacOS/WheelWizard" +codesign --force --deep --sign - "$APP_BUNDLE" +codesign --verify --deep --strict "$APP_BUNDLE" echo "[INFO] .app bundle created successfully" # =============================================================================