diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a156710a5..bc7234e0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -46,3 +46,18 @@ jobs: - name: Test run: dotnet test translator/Translator.sln -c Release --no-build --verbosity normal + + macos-setup: + name: macOS arm64 setup package + runs-on: macos-14 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Build and smoke-test setup archive + run: bash Launcher/package-macos-setup.sh --output-dir Launcher/dist diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 2022fbedb..cafdd613e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -1,9 +1,7 @@ name: Package installers -# Builds the per-platform installer/setup tool (WiiCompiled-Setup.exe / -# WiiCompiled-Setup-x86_64.AppImage) via Launcher/Build-Installer.ps1 and -# Launcher/build-appimage.sh respectively - the same scripts a maintainer runs by hand today to -# produce a GitHub Release asset. This does NOT build the actual translated game executable: +# Builds the per-platform installer/setup tools (WiiCompiled-Setup.exe, +# WiiCompiled-Setup-.AppImage, and WiiCompiled-Setup-macos-arm64.zip). This does NOT build the actual translated game executable: # that step requires the end user's own Mario Kart Wii dump (Assets/main.dol, Assets/StaticR.rel), # which is proprietary and not present in this repository or in CI. on: @@ -90,6 +88,28 @@ jobs: if-no-files-found: error archive: false + macos-setup: + name: macOS setup (arm64) + runs-on: macos-14 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: actions/setup-dotnet@v6 + with: + dotnet-version: '8.0.x' + + - name: Build and smoke-test setup archive + run: bash Launcher/package-macos-setup.sh --output-dir Launcher/dist + + - uses: actions/upload-artifact@v7 + with: + name: WiiCompiled-Setup-macos-arm64 + path: Launcher/dist/WiiCompiled-Setup-macos-arm64.zip + if-no-files-found: error + archive: false + # Publishes the packaged installers as a GitHub Release whenever a v* tag is pushed. Wheel Wizard # discovers updates from these releases, so the contract it relies on is enforced here: a full # (non-prerelease) release whose tag is v, carrying an asset named exactly @@ -97,7 +117,7 @@ jobs: release: name: Publish GitHub Release if: startsWith(github.ref, 'refs/tags/v') - needs: [linux-appimage, windows-installer, recompilation] + needs: [linux-appimage, windows-installer, macos-setup, recompilation] runs-on: ubuntu-latest permissions: contents: write @@ -141,7 +161,7 @@ jobs: set -euo pipefail ls -lR artifacts assets=() - for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage; do + for name in WiiCompiled-Setup.exe WiiCompiled-Setup-x86_64.AppImage WiiCompiled-Setup-aarch64.AppImage WiiCompiled-Setup-macos-arm64.zip; do found="$(find artifacts -type f -name "$name" | head -n 1)" [ -n "$found" ] && [ -s "$found" ] || { echo "::error::missing release asset $name"; exit 1; } assets+=("$found") diff --git a/Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs b/Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs index f58769565..b0ae14d18 100644 --- a/Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs +++ b/Launcher/WiiCompiled.Setup.Common/NodToolProvider.cs @@ -8,9 +8,8 @@ namespace WiiCompiled.Setup.Common; /// caller can supply one directly; otherwise this downloads the matching prebuilt release binary /// from encounter/nod and caches it at Launcher/artifacts/nodtool[.exe]. /// -/// Shared by: WiiCompiled.Setup.Linux/DiscTool.cs (falls back to this at end-user install time on -/// a plain git checkout), and WiiCompiled.Setup.Common.Cli (invoked once at packaging time by both -/// build-appimage.sh and Build-Installer.ps1 to acquire the copy each bundles). +/// Shared by the Linux/macOS setup hosts (which fall back to this at end-user install time on a +/// plain git checkout), and WiiCompiled.Setup.Common.Cli (invoked at packaging time). /// public static class NodToolProvider { @@ -56,6 +55,14 @@ private static string AssetName() var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for Windows {other}"), }; } + if (OperatingSystem.IsMacOS()) + { + return RuntimeInformation.OSArchitecture switch + { + Architecture.Arm64 => "nodtool-macos-arm64", + var other => throw new PlatformNotSupportedException($"No prebuilt nodtool release for macOS {other}"), + }; + } return RuntimeInformation.OSArchitecture switch { Architecture.X64 => "nodtool-linux-x86_64", diff --git a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs index 284310a12..463966bda 100644 --- a/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs +++ b/Launcher/WiiCompiled.Setup.Linux/BuildRunner.cs @@ -3,7 +3,7 @@ namespace WiiCompiled.Setup.Linux; /// -/// Invokes Launcher/local-build.sh and turns its stdout into progress reports. Replaces +/// Invokes the native platform build script and turns its stdout into progress reports. Replaces /// LocalBuildService.cs's hardcoded Windows PowerShell 5.1 invocation - there is no PowerShell /// dependency here at all, just bash. /// @@ -17,8 +17,13 @@ public static async Task RunAsync( IInstallReporter reporter, CancellationToken cancellationToken) { - var script = Path.Combine(workspace, "Launcher", "local-build.sh"); - if (!File.Exists(script)) throw new FileNotFoundException("local-build.sh is missing", script); + var macOS = OperatingSystem.IsMacOS(); + if (macOS && sysroot is not null) + throw new ArgumentException("--sysroot is not supported by the macOS build."); + + var script = Path.Combine(workspace, "Launcher", + macOS ? "local-build-macos.command" : "local-build.sh"); + if (!File.Exists(script)) throw new FileNotFoundException($"{Path.GetFileName(script)} is missing", script); var startInfo = new ProcessStartInfo("bash") { @@ -36,8 +41,6 @@ public static async Task RunAsync( } if (!string.IsNullOrEmpty(retroDir)) { - // Still forwarded to local-build.sh under its own internal name - - // --retro-rewind-package-dir - matching LocalBuild.ps1's own -RetroRewindPackageDirectory. startInfo.ArgumentList.Add("--retro-rewind-package-dir"); startInfo.ArgumentList.Add(retroDir); } if (!string.IsNullOrEmpty(retroWfcOfflineDir)) @@ -50,37 +53,51 @@ public static async Task RunAsync( { startInfo.ArgumentList.Add("--translator-bin"); startInfo.ArgumentList.Add(translatorBin); } - // Forwarded by AppRun so the AppImage's bundled clang/lld (see prepare-portable-clang.sh) - // is used instead of local-build.sh's own default of whatever clang is on $PATH. - if (!string.IsNullOrEmpty(ccBin)) - { - startInfo.ArgumentList.Add("--cc"); startInfo.ArgumentList.Add(ccBin); - } - if (!string.IsNullOrEmpty(cxxBin)) - { - startInfo.ArgumentList.Add("--cxx"); startInfo.ArgumentList.Add(cxxBin); - } - if (!string.IsNullOrEmpty(fuseLd)) - { - startInfo.ArgumentList.Add("--fuse-ld"); startInfo.ArgumentList.Add(fuseLd); - } - if (!string.IsNullOrEmpty(cmakeBin)) - { - startInfo.ArgumentList.Add("--cmake"); startInfo.ArgumentList.Add(cmakeBin); - } - if (!string.IsNullOrEmpty(ninjaBin)) - { - startInfo.ArgumentList.Add("--ninja"); startInfo.ArgumentList.Add(ninjaBin); - } - // Forwarded by AppRun so the AppImage's bundled precompiled aurora/third-party package (see - // Prepare-NativePrebuilt.sh) is used instead of local-build.sh compiling aurora-main itself. - if (!string.IsNullOrEmpty(nativePrebuiltDir)) + if (macOS) { - startInfo.ArgumentList.Add("--native-prebuilt-dir"); startInfo.ArgumentList.Add(nativePrebuiltDir); + if (!string.IsNullOrEmpty(cmakeBin)) + { + startInfo.ArgumentList.Add("--cmake"); startInfo.ArgumentList.Add(cmakeBin); + } + if (!string.IsNullOrEmpty(ninjaBin)) + { + startInfo.ArgumentList.Add("--ninja"); startInfo.ArgumentList.Add(ninjaBin); + } } - if (!string.IsNullOrEmpty(sysroot)) + else { - startInfo.ArgumentList.Add("--sysroot"); startInfo.ArgumentList.Add(sysroot); + // Forwarded by AppRun so the AppImage's bundled clang/lld (see prepare-portable-tools.sh) + // is used instead of local-build.sh's own default of whatever clang is on $PATH. + if (!string.IsNullOrEmpty(ccBin)) + { + startInfo.ArgumentList.Add("--cc"); startInfo.ArgumentList.Add(ccBin); + } + if (!string.IsNullOrEmpty(cxxBin)) + { + startInfo.ArgumentList.Add("--cxx"); startInfo.ArgumentList.Add(cxxBin); + } + if (!string.IsNullOrEmpty(fuseLd)) + { + startInfo.ArgumentList.Add("--fuse-ld"); startInfo.ArgumentList.Add(fuseLd); + } + if (!string.IsNullOrEmpty(cmakeBin)) + { + startInfo.ArgumentList.Add("--cmake"); startInfo.ArgumentList.Add(cmakeBin); + } + if (!string.IsNullOrEmpty(ninjaBin)) + { + startInfo.ArgumentList.Add("--ninja"); startInfo.ArgumentList.Add(ninjaBin); + } + // Forwarded by AppRun so the AppImage's bundled precompiled aurora/third-party package + // is used instead of local-build.sh compiling aurora-main itself. + if (!string.IsNullOrEmpty(nativePrebuiltDir)) + { + startInfo.ArgumentList.Add("--native-prebuilt-dir"); startInfo.ArgumentList.Add(nativePrebuiltDir); + } + if (!string.IsNullOrEmpty(sysroot)) + { + startInfo.ArgumentList.Add("--sysroot"); startInfo.ArgumentList.Add(sysroot); + } } using var process = new Process { StartInfo = startInfo }; @@ -105,7 +122,7 @@ public static async Task RunAsync( if (process.ExitCode != 0) { - throw new InvalidOperationException($"local-build.sh failed (exit {process.ExitCode}). See diagnostics above."); + throw new InvalidOperationException($"{Path.GetFileName(script)} failed (exit {process.ExitCode}). See diagnostics above."); } } diff --git a/Launcher/WiiCompiled.Setup.Linux/Models.cs b/Launcher/WiiCompiled.Setup.Linux/Models.cs index 387fa9a17..189eec39a 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Models.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Models.cs @@ -19,10 +19,13 @@ internal sealed class ProductInstallRecord public string DolSha256 { get; set; } = ""; public string RelSha256 { get; set; } = ""; public string BuiltUtc { get; set; } = ""; + public string? RetroRewindDirectory { get; set; } + public bool UsesRetroWfcPayload { get; set; } + public string? CodePulSha256 { get; set; } } /// -/// The whole flat state document this tool keeps at ~/.local/share/WiiCompiled/install-state.json. +/// The whole flat per-user state document this tool keeps. /// Deliberately not a fingerprint tree: local-build.sh already does its own incremental-rebuild /// caching, so this only needs to remember where things were installed and what they were built /// against, not decide when to rebuild. diff --git a/Launcher/WiiCompiled.Setup.Linux/Program.cs b/Launcher/WiiCompiled.Setup.Linux/Program.cs index 9595398a5..b150e5229 100644 --- a/Launcher/WiiCompiled.Setup.Linux/Program.cs +++ b/Launcher/WiiCompiled.Setup.Linux/Program.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using System.Runtime.Versioning; using WiiCompiled.Setup.Common; namespace WiiCompiled.Setup.Linux; @@ -38,16 +39,26 @@ private static async Task RunAsync(string[] args, CancellationTokenSource c switch (command) { case "install": + case "silent": await InstallAsync(flags, reporter, cts.Token); break; + case "repair-products": + await RepairProductsAsync(flags, reporter, cts.Token); + break; case "uninstall": + case "silent-uninstall": Uninstall(); break; case "launch-base": + case "launch_base": return Launch("base", flags); case "launch-retro": + case "launch_retro": return Launch("retro-rewind", flags); case "check-products": + case "check_products": + if (OperatingSystem.IsMacOS()) + return CheckMacProducts(flags, reporter); CheckProducts(); break; default: @@ -74,6 +85,7 @@ private static async Task RunAsync(string[] args, CancellationTokenSource c private static async Task InstallAsync(Dictionary flags, IInstallReporter reporter, CancellationToken token) { + EnsureSupportedHost(); var retroDir = flags.GetValueOrDefault("retro-dir"); var installsRetro = !string.IsNullOrEmpty(retroDir); var downloadPayload = flags.ContainsKey("download-retro-wfc-payload"); @@ -94,7 +106,7 @@ private static async Task InstallAsync(Dictionary flags, IInsta // check further down is a simpler backstop, not the primary validation anymore. if (installsRetro) retroDir = RetroRewindSource.ResolveRetroRewind6(retroDir!); - var workspace = flags.GetValueOrDefault("workspace") ?? WorkspaceLocator.FindFrom(AppContext.BaseDirectory); + var workspace = flags.GetValueOrDefault("workspace") ?? FindWorkspace(); var manifest = ProjectManifest.Load(Path.Combine(workspace, "projects", "mkwii", "recomp.yml")); var assetsDir = Path.Combine(workspace, "Assets"); @@ -123,6 +135,15 @@ await DiscTool.ValidateAndExtractAsync(isoPath, manifest, assetsDir, workspace, var profiles = installsRetro ? new[] { "base", "retro-rewind" } : new[] { "base" }; var baseInstallDir = installsRetro ? DefaultInstallDir("base") : null; var installDir = flags.GetValueOrDefault("install-dir") ?? DefaultInstallDir(installsRetro ? "retro-rewind" : "base"); + var translatorBin = flags.GetValueOrDefault("translator-bin"); + var cmakeBin = flags.GetValueOrDefault("cmake"); + var ninjaBin = flags.GetValueOrDefault("ninja"); + if (OperatingSystem.IsMacOS()) + { + translatorBin ??= BundledTool("Translator.Cli"); + if (BundledTool("nodtool") is { } nodtool) + flags.TryAdd("disc-tool-bin", nodtool); + } string? retroWfcOfflineDir = null; if (downloadPayload) @@ -158,12 +179,12 @@ await BuildRunner.RunAsync( retroWfcOfflineDir, skipPayload, flags.ContainsKey("force-clean-build"), - flags.GetValueOrDefault("translator-bin"), + translatorBin, flags.GetValueOrDefault("cc"), flags.GetValueOrDefault("cxx"), flags.GetValueOrDefault("fuse-ld"), - flags.GetValueOrDefault("cmake"), - flags.GetValueOrDefault("ninja"), + cmakeBin, + ninjaBin, flags.GetValueOrDefault("native-prebuilt-dir"), sysroot, reporter, token); @@ -175,7 +196,10 @@ await BuildRunner.RunAsync( foreach (var p in profiles) { var dir = p == "base" ? (baseInstallDir ?? installDir) : installDir; - var exeName = p == "base" ? "WiiCompiled" : "RetroRewind"; + var exeName = OperatingSystem.IsMacOS() + ? Path.Combine(p == "base" ? "WiiCompiled.app" : "RetroRewind.app", "Contents", "MacOS", + p == "base" ? "WiiCompiled" : "RetroRewind") + : p == "base" ? "WiiCompiled" : "RetroRewind"; var displayName = p == "base" ? "WiiCompiled (base game)" : "WiiCompiled (Retro Rewind)"; state.Products.RemoveAll(r => r.Profile == p); state.Products.Add(new ProductInstallRecord @@ -186,8 +210,14 @@ await BuildRunner.RunAsync( DolSha256 = dolSha, RelSha256 = relSha, BuiltUtc = DateTime.UtcNow.ToString("O"), + RetroRewindDirectory = p == "retro-rewind" ? retroDir : null, + UsesRetroWfcPayload = p == "retro-rewind" && downloadPayload, + CodePulSha256 = p == "retro-rewind" && retroDir is not null + ? Sha256IfExists(Path.Combine(retroDir, "Binaries", "Code.pul")) + : null, }); - DesktopEntry.Create(p, displayName, Path.Combine(dir, exeName)); + if (!OperatingSystem.IsMacOS()) + DesktopEntry.Create(p, displayName, Path.Combine(dir, exeName)); } JsonState.Write(StatePath, state); @@ -196,7 +226,7 @@ await BuildRunner.RunAsync( // that isn't main.dol/StaticR.rel. Linux has no --portable flag, so this is always the // per-user Config.toml (RuntimeConfiguration.ResolveConfigPath's Windows-only portable-root // lookup has nothing to find here either way). - var configPath = RuntimeConfiguration.ApplicationDataConfigPath; + var configPath = OperatingSystem.IsMacOS() ? MacConfigPath : RuntimeConfiguration.ApplicationDataConfigPath; var dataDir = Path.Combine(assetsDir, "DATA"); if (Directory.Exists(dataDir)) { @@ -210,18 +240,56 @@ await BuildRunner.RunAsync( reporter.Progress(InstallStages.Shortcuts, "Install complete", 99); } + private static async Task RepairProductsAsync( + Dictionary flags, IInstallReporter reporter, CancellationToken token) + { + var state = JsonState.TryRead(StatePath); + if (state is null || state.Products.Count == 0) + throw new InvalidOperationException("Nothing is installed to repair. Run 'install' first."); + + var retroRecord = state.Products.FirstOrDefault(record => record.Profile == "retro-rewind"); + var retro = flags.GetValueOrDefault("retro-dir") ?? + (retroRecord is null + ? null + : retroRecord.RetroRewindDirectory ?? + (OperatingSystem.IsMacOS() ? RuntimeConfiguration.GetRetroRewindRoot(MacConfigPath) : null)); + if (retroRecord is not null && string.IsNullOrWhiteSpace(retro)) + throw new InvalidOperationException( + "The installed Retro Rewind source directory is not recorded. Supply --retro-dir to repair it."); + if (retro is not null) + { + flags["retro-dir"] = retro; + if (!flags.ContainsKey("download-retro-wfc-payload") && + !flags.ContainsKey("skip-retro-wfc-payload")) + flags[retroRecord?.UsesRetroWfcPayload == true + ? "download-retro-wfc-payload" + : "skip-retro-wfc-payload"] = null; + } + flags["install-dir"] = flags.GetValueOrDefault("install-dir") ?? + state.Products.FirstOrDefault(record => record.Profile == "retro-rewind")?.InstallDirectory ?? + state.Products[0].InstallDirectory; + await InstallAsync(flags, reporter, token); + } + private static void Uninstall() { - // Matches Windows: UninstallService.cs removes the whole install directory unconditionally - - // there is no partial-product uninstall on either platform. + // Remove the installed products but keep the build workspace and user-owned disc data. var state = JsonState.TryRead(StatePath) ?? new InstallState(); foreach (var record in state.Products.ToList()) { - if (Directory.Exists(record.InstallDirectory)) + if (OperatingSystem.IsMacOS()) + { + var appBundle = Path.Combine(record.InstallDirectory, + record.Profile == "base" ? "WiiCompiled.app" : "RetroRewind.app"); + if (appBundle is not null && Directory.Exists(appBundle)) + Directory.Delete(appBundle, recursive: true); + } + else { - Directory.Delete(record.InstallDirectory, recursive: true); + if (Directory.Exists(record.InstallDirectory)) + Directory.Delete(record.InstallDirectory, recursive: true); + DesktopEntry.Remove(record.Profile); } - DesktopEntry.Remove(record.Profile); state.Products.Remove(record); Console.WriteLine($"Removed {record.Profile} from {record.InstallDirectory}"); } @@ -236,14 +304,14 @@ private static int Launch(string profile, Dictionary flags) { var installHint = profile == "retro-rewind" ? "install --retro-dir {--download-retro-wfc-payload | --skip-retro-wfc-payload}" - : $"install --profile {profile}"; + : "install --game "; Console.Error.WriteLine($"{profile} is not installed. Run '{installHint}' first."); return 1; } var exePath = Path.Combine(record.InstallDirectory, record.ExecutableName); if (!File.Exists(exePath)) { - Console.Error.WriteLine($"Installed executable is missing: {exePath}. Run 'install --profile {profile}' again."); + Console.Error.WriteLine($"Installed executable is missing: {exePath}. Run 'repair-products' to rebuild it."); return 1; } var startInfo = new System.Diagnostics.ProcessStartInfo(exePath) @@ -277,6 +345,77 @@ private static void CheckProducts() } } + private static int CheckMacProducts(Dictionary flags, IInstallReporter reporter) + { + var state = JsonState.TryRead(StatePath) ?? new InstallState(); + var assetsDirectory = string.IsNullOrWhiteSpace(state.Workspace) + ? "" + : Path.Combine(state.Workspace, "Assets"); + var currentDol = string.IsNullOrEmpty(assetsDirectory) + ? null + : Sha256IfExists(Path.Combine(assetsDirectory, "main.dol")); + var currentRel = string.IsNullOrEmpty(assetsDirectory) + ? null + : Sha256IfExists(Path.Combine(assetsDirectory, "StaticR.rel")); + var baseState = GetMacProductState(state, "base", currentDol, currentRel); + var retroState = GetMacProductState(state, "retro-rewind", currentDol, currentRel); + var rebuildRequired = NeedsMacRepair(baseState.Status) || NeedsMacRepair(retroState.Status); + var installDirectory = Path.GetFullPath(flags.GetValueOrDefault("install-dir") ?? + DefaultInstallDir("base")); + + Console.Out.WriteLine(System.Text.Json.JsonSerializer.Serialize(new + { + type = "products", + setupVersion = ProductInfo.Version, + installDir = installDirectory, + rebuildRequired, + @base = baseState, + retroRewind = retroState, + }, new System.Text.Json.JsonSerializerOptions + { + PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase, + })); + Console.Out.Flush(); + (reporter as NdjsonInstallReporter)?.Success(installDirectory); + return rebuildRequired ? 2 : 0; + } + + private static MacProductStatus GetMacProductState( + InstallState state, string profile, string? currentDol, string? currentRel) + { + var record = state.Products.FirstOrDefault(product => product.Profile == profile); + if (record is null) + return new MacProductStatus("absent", profile == "base" + ? "WiiCompiled is not installed here." + : "Retro Rewind is not installed."); + + var executable = Path.Combine(record.InstallDirectory, record.ExecutableName); + if (!File.Exists(executable)) + return new MacProductStatus("broken", $"Installed executable is missing: {executable}"); + if (currentDol is null || currentRel is null) + return new MacProductStatus("inputs-missing", "The workspace game inputs are missing."); + if (currentDol != record.DolSha256 || currentRel != record.RelSha256) + return new MacProductStatus("compile-inputs-changed", + "The local game inputs changed since this product was built."); + if (profile == "retro-rewind") + { + var codePulPath = record.RetroRewindDirectory is null + ? null + : Path.Combine(record.RetroRewindDirectory, "Binaries", "Code.pul"); + var codePulSha = codePulPath is null ? null : Sha256IfExists(codePulPath); + if (codePulSha is null) + return new MacProductStatus("inputs-missing", "The recorded Retro Rewind Code.pul is missing."); + if (record.CodePulSha256 is null || codePulSha != record.CodePulSha256) + return new MacProductStatus("code-pul-changed", + "Retro Rewind Code.pul changed since this product was built."); + } + return new MacProductStatus("current", ""); + } + + private static bool NeedsMacRepair(string status) => status is not "current" and not "absent"; + + private sealed record MacProductStatus(string Status, string Detail); + private static string Sha256Of(string path) { using var stream = File.OpenRead(path); @@ -286,12 +425,144 @@ private static string Sha256Of(string path) private static string? Sha256IfExists(string path) => File.Exists(path) ? Sha256Of(path) : null; private static string StatePath => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "install-state.json"); + OperatingSystem.IsMacOS() ? MacApplicationDataDirectory : + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + OperatingSystem.IsMacOS() ? "install-state.json" : Path.Combine("WiiCompiled", "install-state.json")); private static string DefaultInstallDir(string profile) => Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "Install", + OperatingSystem.IsMacOS() ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Applications", "WiiCompiled") : + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WiiCompiled", "Install"), profile == "base" ? "Base" : "RetroRewind"); + private static string MacApplicationDataDirectory => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "Application Support", "WiiCompiled"); + private static string MacConfigPath => Path.Combine(MacApplicationDataDirectory, "Config.toml"); + + private static string? BundledTool(string relativePath) + { + var path = Path.Combine(AppContext.BaseDirectory, "tools", relativePath); + return File.Exists(path) ? path : null; + } + + private static void EnsureSupportedHost() + { + if (!OperatingSystem.IsMacOS()) return; + if (System.Runtime.InteropServices.RuntimeInformation.OSArchitecture != + System.Runtime.InteropServices.Architecture.Arm64) + throw new PlatformNotSupportedException("The macOS setup requires Apple Silicon (arm64)."); + if (!OperatingSystem.IsMacOSVersionAtLeast(14)) + throw new PlatformNotSupportedException("The macOS setup requires macOS 14 (Sonoma) or later."); + } + + private static string FindWorkspace() + { + var marker = OperatingSystem.IsMacOS() ? "local-build-macos.command" : "local-build.sh"; + var current = new DirectoryInfo(AppContext.BaseDirectory); + for (var level = 0; level <= 8 && current is not null; level++, current = current.Parent) + { + var packagedWorkspace = Path.Combine(current.FullName, "workspace"); + if (File.Exists(Path.Combine(packagedWorkspace, "Launcher", marker))) + return OperatingSystem.IsMacOS() + ? PrepareMacWorkspace(packagedWorkspace) + : packagedWorkspace; + if (File.Exists(Path.Combine(current.FullName, "Launcher", marker))) + return current.FullName; + } + throw new InvalidOperationException($"Could not find Launcher/{marker}. Pass --workspace explicitly."); + } + + [SupportedOSPlatform("macos")] + private static string PrepareMacWorkspace(string packagedWorkspace) + { + var versionFile = Path.Combine(packagedWorkspace, ".setup-source-version"); + if (!File.Exists(versionFile)) + throw new InvalidDataException("The packaged macOS setup is missing its source version marker."); + + var persistentWorkspace = Path.Combine(MacApplicationDataDirectory, "BuildWorkspace"); + var persistentVersionFile = Path.Combine(persistentWorkspace, ".setup-source-version"); + var version = File.ReadAllText(versionFile).Trim(); + if (File.Exists(persistentVersionFile) && + string.Equals(File.ReadAllText(persistentVersionFile).Trim(), version, StringComparison.Ordinal) && + File.Exists(Path.Combine(persistentWorkspace, "Launcher", "local-build-macos.command"))) + return persistentWorkspace; + + Directory.CreateDirectory(persistentWorkspace); + foreach (var directory in new[] { "projects", "runtime", "aurora-main" }) + ReplacePackagedDirectory(Path.Combine(packagedWorkspace, directory), + Path.Combine(persistentWorkspace, directory)); + + var sourceLauncher = Path.Combine(packagedWorkspace, "Launcher"); + var targetLauncher = Path.Combine(persistentWorkspace, "Launcher"); + Directory.CreateDirectory(Path.Combine(targetLauncher, "macos")); + foreach (var script in new[] + { + "local-build-macos.command", + Path.Combine("macos", "extract-disc.command"), + Path.Combine("macos", "publish-app.command"), + }) + { + var source = Path.Combine(sourceLauncher, script); + var target = Path.Combine(targetLauncher, script); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(source, target, overwrite: true); + File.SetUnixFileMode(target, UnixFileMode.UserRead | UnixFileMode.UserWrite | + UnixFileMode.UserExecute | UnixFileMode.GroupRead | + UnixFileMode.GroupExecute | UnixFileMode.OtherRead | + UnixFileMode.OtherExecute); + } + + foreach (var name in new[] { "LICENSE", "THIRD-PARTY-NOTICES.md" }) + File.Copy(Path.Combine(packagedWorkspace, name), + Path.Combine(persistentWorkspace, name), overwrite: true); + + File.WriteAllText(persistentVersionFile, version); + return persistentWorkspace; + } + + [SupportedOSPlatform("macos")] + private static void ReplacePackagedDirectory(string source, string destination) + { + if (!Directory.Exists(source)) + throw new DirectoryNotFoundException($"The packaged setup source directory is missing: {source}"); + if (Directory.Exists(destination)) + Directory.Delete(destination, recursive: true); + CopyPackagedDirectory(source, destination); + } + + [SupportedOSPlatform("macos")] + private static void CopyPackagedDirectory(string source, string destination) + { + var sourceInfo = new DirectoryInfo(source); + if (sourceInfo.LinkTarget is { } linkTarget) + { + Directory.CreateSymbolicLink(destination, linkTarget); + return; + } + + Directory.CreateDirectory(destination); + foreach (var entry in sourceInfo.EnumerateFileSystemInfos()) + { + var target = Path.Combine(destination, entry.Name); + if (entry is DirectoryInfo directory) + { + if (directory.LinkTarget is { } directoryTarget) + Directory.CreateSymbolicLink(target, directoryTarget); + else + CopyPackagedDirectory(directory.FullName, target); + } + else if (entry is FileInfo file) + { + if (file.LinkTarget is { } fileTarget) + File.CreateSymbolicLink(target, fileTarget); + else + { + File.Copy(file.FullName, target); + File.SetUnixFileMode(target, File.GetUnixFileMode(file.FullName)); + } + } + } + } + /// /// A single pass that finds both the command word and every --flag[=value] pair, regardless /// of order - a --flag may appear before or after the command (see the AppRun caller note in @@ -302,12 +573,31 @@ private static (string? Command, Dictionary Flags) ParseArgs(st { string? command = null; var flags = new Dictionary(); + static string? CommandForOption(string option) => option switch + { + "--install" or "--silent" => "install", + "--uninstall" or "--silent-uninstall" => "uninstall", + "--launch-base" or "--launch_base" => "launch-base", + "--launch-retro" or "--launch_retro" => "launch-retro", + "--check-products" or "--check_products" => "check-products", + "--repair-products" => "repair-products", + _ => null, + }; + for (var i = 0; i < args.Length; i++) { var arg = args[i]; if (arg.StartsWith("--", StringComparison.Ordinal)) { var name = arg[2..]; + var optionCommand = CommandForOption(arg); + if (optionCommand is not null) + { + if (command is not null) + throw new ArgumentException($"Only one command may be specified (already selected '{command}')."); + command = optionCommand; + continue; + } if (i + 1 < args.Length && !args[i + 1].StartsWith("--", StringComparison.Ordinal)) { flags[name] = args[++i]; @@ -339,6 +629,7 @@ install [--game ISO_PATH] [--install-dir DIR] [--retro-dir DIR launch-base launch-retro check-products + repair-products --version """); } diff --git a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj index 2737c4c73..7f08cc5d5 100644 --- a/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj +++ b/Launcher/WiiCompiled.Setup.Linux/WiiCompiled.Setup.Linux.csproj @@ -8,7 +8,7 @@ WiiCompiled.Setup.Linux patchzy WiiCompiled - Command-line installer and launcher for WiiCompiled on Linux + Command-line installer and launcher for WiiCompiled on Linux and macOS embedded en true diff --git a/Launcher/package-macos-setup.sh b/Launcher/package-macos-setup.sh new file mode 100644 index 000000000..44f1ef193 --- /dev/null +++ b/Launcher/package-macos-setup.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash +# Build the distributable macOS arm64 setup CLI and a clean source workspace. +set -euo pipefail + +fail() { printf 'package-macos-setup.sh: error: %s\n' "$*" >&2; exit 1; } +usage() { + cat <<'EOF' +Usage: package-macos-setup.sh [--output-dir DIR] + +Creates WiiCompiled-Setup-macos-arm64.zip. Requires an Apple Silicon Mac and +.NET 8. The archive contains no game dump, translated game, or Retro Rewind data. +EOF +} + +script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd "$script_dir/.." && pwd) +output_dir="$repo_root/Launcher/dist" +while (($#)); do + case "$1" in + --output-dir) output_dir=${2:-}; shift 2 ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown argument: $1" ;; + esac +done + +[[ $(uname -s) == Darwin ]] || fail 'packaging must run on macOS' +[[ $(uname -m) == arm64 ]] || fail 'packaging requires an Apple Silicon arm64 runner' +command -v dotnet >/dev/null || fail '.NET 8 SDK is required' +command -v git >/dev/null || fail 'git is required' +command -v ditto >/dev/null || fail 'ditto is required' + +version=$(sed -n 's/.*\([^<]*\)<\/Version>.*/\1/p' \ + "$repo_root/Launcher/Directory.Build.props" | head -n1) +[[ "$version" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]] || fail 'could not read a valid setup version' +git -C "$repo_root" diff --quiet HEAD -- projects runtime aurora-main \ + Launcher/local-build-macos.command Launcher/macos/extract-disc.command Launcher/macos/publish-app.command || + fail 'tracked packaged sources have changes not present in HEAD; commit them before packaging' +source_revision=$(git -C "$repo_root" rev-parse HEAD) + +stage=$(mktemp -d "${TMPDIR:-/tmp}/wiicompiled-setup-macos.XXXXXX") +trap 'rm -rf "$stage"' EXIT +package="$stage/WiiCompiled-Setup-macos-arm64" +mkdir -p "$package/tools" "$package/workspace" + +echo "Publishing WiiCompiled Setup $version for osx-arm64..." +dotnet publish "$repo_root/Launcher/WiiCompiled.Setup.Linux" -c Release -r osx-arm64 \ + --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true \ + -o "$stage/setup" +cp "$stage/setup/WiiCompiled.Setup.Linux" "$package/WiiCompiled-Setup-macos-arm64" +chmod 755 "$package/WiiCompiled-Setup-macos-arm64" + +echo "Publishing the self-contained translator..." +dotnet publish "$repo_root/translator/src/Translator.Cli" -c Release -r osx-arm64 \ + --self-contained true -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true \ + -o "$stage/translator" +cp "$stage/translator/Translator.Cli" "$package/tools/Translator.Cli" +chmod 755 "$package/tools/Translator.Cli" + +echo "Resolving the pinned nodtool release..." +nodtool=$(dotnet run --project "$repo_root/Launcher/WiiCompiled.Setup.Common.Cli" -c Release \ + --verbosity quiet -- --workspace "$repo_root" | tail -n1) +[[ -f "$nodtool" ]] || fail "nodtool resolver did not produce a binary: $nodtool" +cp "$nodtool" "$package/tools/nodtool" +chmod 755 "$package/tools/nodtool" + +echo "Staging tracked, game-code-free build sources..." +git -C "$repo_root" archive --format=tar HEAD projects/mkwii runtime aurora-main | + tar -xf - -C "$package/workspace" +git -C "$repo_root" archive --format=tar HEAD \ + Launcher/local-build-macos.command \ + Launcher/macos/extract-disc.command \ + Launcher/macos/publish-app.command | + tar -xf - -C "$package/workspace" +printf '%s+%s\n' "$version" "$source_revision" > "$package/workspace/.setup-source-version" +chmod 755 "$package/workspace/Launcher/local-build-macos.command" \ + "$package/workspace/Launcher/macos/extract-disc.command" \ + "$package/workspace/Launcher/macos/publish-app.command" +cp "$repo_root/LICENSE" "$package/LICENSE" +cp "$repo_root/THIRD-PARTY-NOTICES.md" "$package/THIRD-PARTY-NOTICES.md" +cat > "$package/README.txt" </dev/null || + fail 'packaged setup --version smoke test failed' +mkdir -p "$stage/home" +check_output=$(HOME="$stage/home" "$package/WiiCompiled-Setup-macos-arm64" --check-products --progress-json) +grep -Fq '"type":"products"' <<< "$check_output" && + grep -Fq '"type":"result","success":true' <<< "$check_output" || + fail 'packaged check-products smoke test failed' +if HOME="$stage/home" "$package/WiiCompiled-Setup-macos-arm64" --silent --progress-json \ + > "$stage/install-smoke.log" 2>&1; then + fail 'install-without-disc smoke test unexpectedly succeeded' +fi +grep -Fq '"type":"result","success":false' "$stage/install-smoke.log" && + grep -Fq 'No --game ISO was given' "$stage/install-smoke.log" || + fail 'packaged install CLI contract smoke test failed' +[[ -x "$package/tools/Translator.Cli" && -x "$package/tools/nodtool" ]] || + fail 'a bundled command-line tool is not executable' +if find "$package" \( -type d -name Assets -o -type f \( -name main.dol -o -name StaticR.rel -o -name '*.iso' -o -name '*.wbfs' \) \) -print -quit | + grep -q .; then + fail 'staging unexpectedly contains proprietary game data' +fi + +mkdir -p "$output_dir" +output_dir=$(cd "$output_dir" && pwd) +output="$output_dir/WiiCompiled-Setup-macos-arm64.zip" +rm -f "$output" +ditto -c -k --norsrc --noqtn --keepParent "$package" "$output" +[[ -s "$output" ]] || fail 'archive was not created' +echo "Created unsigned macOS arm64 setup: $output" diff --git a/README.md b/README.md index aceca104f..5989f12c5 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Known limitations of the Wii Remote path: - CPU: Intel Core i5-8400 / AMD Ryzen 5 2600 (4c/6c, ~3.5GHz+) or higher - About 20 GB of free disk space during installation (Final game size ~5 GB) - macOS 14 (Sonoma) or later on Apple Silicon -- On macOS, Apple Xcode Command Line Tools (Setup opens Apple's installer when they are missing) +- On macOS, Apple Xcode Command Line Tools, CMake, and Ninja - A clean, unmodified **PAL `RMCP01`** disc image of Mario Kart Wii, dumped by you. ISO, GCM, GCZ, CISO, WBFS, WIA and RVZ are accepted. @@ -118,6 +118,11 @@ image under Settings, turn on **WiiCompiled (beta)**, and hit install from the H Wheel Wizard downloads the setup tool from this repo and walks you through install, updates and launching. The backend itself is deliberately command-line only, Wheel Wizard is a wrapper around it. +For macOS 14 or later on Apple Silicon, a tagged release that includes the macOS setup package +publishes `WiiCompiled-Setup-macos-arm64.zip` on this repository's +[Releases](https://github.com/patchzyy/Wiicompiled/releases) page. Extract it and follow the +included `README.txt` to run the setup CLI. The release setup is not signed or notarized; see the +[macOS build guide](docs/building-macos.md) for quarantine and prerequisite instructions. > [!CAUTION] > Only take builds from this repository's diff --git a/docs/building-macos.md b/docs/building-macos.md index f1084c809..462fea924 100644 --- a/docs/building-macos.md +++ b/docs/building-macos.md @@ -2,6 +2,36 @@ This guide covers building **WiiCompiled** (base game) and **Retro Rewind** from source on macOS for Apple Silicon (`arm64`). Follow these instructions to compile the native executables directly. +## Downloadable setup + +For a packaged setup CLI rather than building the tools from source, download +`WiiCompiled-Setup-macos-arm64.zip` from a [WiiCompiled tagged release](https://github.com/patchzyy/Wiicompiled/releases) +that includes the macOS setup package. +The archive includes the self-contained setup and translator executables, the pinned `nodtool` +binary, and only the tracked source files needed for the local native build. It contains no game +dump, translated game executable, or Retro Rewind files. The setup compiles the game on your Mac +from your own clean PAL `RMCP01` disc. + +The setup requires macOS 14 or later on Apple Silicon, Xcode Command Line Tools, CMake, and Ninja. +Install the build tools with `brew install cmake ninja`. It does not require the .NET SDK; that is +only needed when building the setup or translator from source. + +The release archive is currently unsigned and not notarized because release signing credentials +are not configured. If macOS adds a quarantine attribute to the downloaded executable, verify that +the archive came from the official release page and then remove quarantine from the setup binary +before running it: + +```bash +xattr -dr com.apple.quarantine WiiCompiled-Setup-macos-arm64 +cd WiiCompiled-Setup-macos-arm64 +./WiiCompiled-Setup-macos-arm64 --version +./WiiCompiled-Setup-macos-arm64 --silent --game /path/to/RMCP01.iso +``` + +The setup CLI also supports `--check-products`, `--repair-products`, `--launch-base`, and +`--uninstall`. Its default products are written under `~/Applications/WiiCompiled/`; use +`--help` for the full command syntax. + > [!NOTE] > If you only want to build the base game (**WiiCompiled**), look for sections marked **`(Skip if only building WiiCompiled)`** to bypass Retro Rewind and online payload steps.