From f8f3ab74e4079ee424368976ec6250e6d81a63ea Mon Sep 17 00:00:00 2001 From: Paulo Henrique Garcia Date: Mon, 29 Jun 2026 20:25:51 +0200 Subject: [PATCH 1/2] Add native arm64 macOS build + .app packaging - Add osx-arm64 to RuntimeIdentifiers. - ppy.SDL2-CS ships no arm64-macOS SDL2 native, which crashes the app on launch (DllNotFoundException: SDL2). build-app.sh now downloads the official SDL2 release, sha256-verifies it, thins it to arm64, and bundles it; an MSBuild guard fails the publish with a clear message if the native is absent. - build-app.sh publishes self-contained, assembles a signed .app bundle with a generated .icns icon, and ad-hoc signs it. - Ignore build output (dist/), the downloaded SDL2 native, and .serena/. --- .gitignore | 9 ++++ GithubLauncher.csproj | 20 +++++++- build-app.sh | 106 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100755 build-app.sh diff --git a/.gitignore b/.gitignore index 4fd589e..8b3ee77 100644 --- a/.gitignore +++ b/.gitignore @@ -409,3 +409,12 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml flatpak.bat + +# macOS build output (regenerated by build-app.sh) +dist/ + +# Serena tool working directory +.serena/ + +# Downloaded SDL2 native (fetched + verified by build-app.sh, not vendored) +native/osx-arm64/*.dylib diff --git a/GithubLauncher.csproj b/GithubLauncher.csproj index d20861a..d6fc003 100644 --- a/GithubLauncher.csproj +++ b/GithubLauncher.csproj @@ -11,7 +11,7 @@ AnyCPU;x64 enable enable - win-x64;linux-x64;linux-arm64;osx-x64 + win-x64;linux-x64;linux-arm64;osx-x64;osx-arm64 @@ -42,6 +42,24 @@ + + + + libSDL2.dylib + PreserveNewest + PreserveNewest + + + + + + + diff --git a/build-app.sh b/build-app.sh new file mode 100755 index 0000000..4c0e24e --- /dev/null +++ b/build-app.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# +# Build a native arm64 macOS .app bundle for Github Launcher. +# Self-contained: bundles the .NET runtime and the official SDL2 (arm64). +# No Homebrew or other external runtime dependencies. +# +# ppy.SDL2-CS ships no arm64-macOS native, so this script downloads the official +# SDL2 release, verifies its sha256, and thins it to arm64 before publishing. +# +set -euo pipefail + +cd "$(dirname "$0")" + +RID="osx-arm64" +CONFIG="Release" +TFM="net9.0" +APP_NAME="Github Launcher" +APP="dist/${APP_NAME}.app" +PUBLISH="bin/${CONFIG}/${TFM}/${RID}/publish" + +# Official SDL2 release (universal, self-contained: links only system frameworks). +SDL2_VERSION="2.32.10" +SDL2_DMG_URL="https://github.com/libsdl-org/SDL/releases/download/release-${SDL2_VERSION}/SDL2-${SDL2_VERSION}.dmg" +SDL2_DMG_SHA256="4a7ac31640d70214e848f994be8a12849c0f97918a7e6c2e27a40036166d1a7f" +SDL2_NATIVE="native/osx-arm64/libSDL2.dylib" + +echo ">> Ensuring submodule is present..." +git submodule update --init --recursive + +if [ -f "$SDL2_NATIVE" ]; then + echo ">> SDL2 native already present ($SDL2_NATIVE)" +else + echo ">> Fetching official SDL2 ${SDL2_VERSION}..." + TMP="$(mktemp -d)" + curl -fsSL -o "$TMP/sdl2.dmg" "$SDL2_DMG_URL" + echo "${SDL2_DMG_SHA256} ${TMP}/sdl2.dmg" | shasum -a 256 -c - + MNT="$(hdiutil attach "$TMP/sdl2.dmg" -nobrowse -readonly | grep -o '/Volumes/.*' | head -1)" + mkdir -p "$(dirname "$SDL2_NATIVE")" + # Thin the universal framework binary to arm64 for this RID. + lipo "$MNT/SDL2.framework/Versions/A/SDL2" -thin arm64 -output "$SDL2_NATIVE" + hdiutil detach "$MNT" >/dev/null + rm -rf "$TMP" + echo ">> SDL2 native ready: $SDL2_NATIVE ($(file -b "$SDL2_NATIVE"))" +fi + +echo ">> Publishing ${RID} (self-contained)..." +rm -rf "bin/${CONFIG}/${TFM}/${RID}" +dotnet publish GithubLauncher.csproj -c "$CONFIG" -r "$RID" --self-contained true + +echo ">> Assembling ${APP}..." +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp -R "$PUBLISH/." "$APP/Contents/MacOS/" + +echo ">> Generating icon..." +ICONSET="$(mktemp -d)/AppIcon.iconset" +mkdir -p "$ICONSET" +sips -s format png icon.ico --out "$ICONSET/base.png" >/dev/null +for sz in 16 32 128 256 512; do + sips -z "$sz" "$sz" "$ICONSET/base.png" --out "$ICONSET/icon_${sz}x${sz}.png" >/dev/null + dbl=$((sz * 2)) + sips -z "$dbl" "$dbl" "$ICONSET/base.png" --out "$ICONSET/icon_${sz}x${sz}@2x.png" >/dev/null +done +rm "$ICONSET/base.png" +iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns" + +echo ">> Writing Info.plist..." +cat > "$APP/Contents/Info.plist" <<'PLIST' + + + + + CFBundleName + Github Launcher + CFBundleDisplayName + Github Launcher + CFBundleIdentifier + com.sirdiabo.githublauncher + CFBundleVersion + 1.0.0.0 + CFBundleShortVersionString + 1.0.0 + CFBundlePackageType + APPL + CFBundleExecutable + GithubLauncher + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.games + NSPrincipalClass + NSApplication + + +PLIST + +echo ">> Code signing (ad-hoc)..." +chmod +x "$APP/Contents/MacOS/GithubLauncher" +codesign --force --deep --sign - "$APP" +codesign --verify --strict "$APP" + +echo ">> Done: $APP" From f196fd217606e0379161dd8fc9e2c8ce57d83f08 Mon Sep 17 00:00:00 2001 From: Paulo Henrique Garcia Date: Mon, 29 Jun 2026 20:26:02 +0200 Subject: [PATCH 2/2] Store writable data outside the app directory on macOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher wrote settings, the app registry, caches, and installed games next to its executable (AppDomain.CurrentDomain.BaseDirectory). Inside a macOS .app that is Contents/MacOS/ — writing there corrupts the bundle's code signature ('a sealed resource is missing or invalid') and blocks installs into read-only locations like /Applications. Add AppPaths.DataDirectory, which resolves to ~/Library/Application Support/GithubLauncher on macOS and stays next to the executable on Windows/Linux (unchanged). Route all writable paths through it: settings.json, apps.json/games.json, Cache/, the Apps/ install folder, catalog caches, apps_export.json, and update_check.json. Self-update / executable replacement still uses the real app directory. --- App.axaml.cs | 5 ++++- MainWindow.axaml.cs | 14 +++++++------- Services/AppPaths.cs | 41 +++++++++++++++++++++++++++++++++++++++++ Services/AppSettings.cs | 2 +- Services/CLIHandler.cs | 2 +- Services/GameManager.cs | 12 ++++++------ 6 files changed, 60 insertions(+), 16 deletions(-) create mode 100644 Services/AppPaths.cs diff --git a/App.axaml.cs b/App.axaml.cs index 32f1deb..988a98d 100644 --- a/App.axaml.cs +++ b/App.axaml.cs @@ -209,7 +209,10 @@ public async Task CheckForAppUpdatesManually() private async Task CheckForUpdatesAndApplyAsync(bool isManualCheck = false) { string currentAppDirectory = AppDomain.CurrentDomain.BaseDirectory; - string updateCheckFilePath = Path.Combine(currentAppDirectory, UpdateCheckFileName); + // Update-check state is writable runtime data: keep it out of the (signed, + // immutable) app bundle on macOS. The executable-replacement logic below + // still uses currentAppDirectory. + string updateCheckFilePath = Path.Combine(AppPaths.DataDirectory, UpdateCheckFileName); UpdateCheckInfo updateCheckInfo = await LoadUpdateCheckInfo(updateCheckFilePath); string currentVersionString = updateCheckInfo.CurrentVersion; diff --git a/MainWindow.axaml.cs b/MainWindow.axaml.cs index 243e94d..afbab0d 100644 --- a/MainWindow.axaml.cs +++ b/MainWindow.axaml.cs @@ -919,7 +919,7 @@ private void LoadCurrentVersion() try { string currentAppDirectory = AppDomain.CurrentDomain.BaseDirectory; - string updateCheckFilePath = Path.Combine(currentAppDirectory, "update_check.json"); + string updateCheckFilePath = Path.Combine(AppPaths.DataDirectory, "update_check.json"); if (File.Exists(updateCheckFilePath)) { @@ -3565,7 +3565,7 @@ private async void ExportGames_Click(object sender, RoutedEventArgs e) }; var options = new JsonSerializerOptions { WriteIndented = true }; - var exportPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "apps_export.json"); + var exportPath = Path.Combine(AppPaths.DataDirectory, "apps_export.json"); await File.WriteAllTextAsync(exportPath, JsonSerializer.Serialize(exportData, options)); await ShowMessageBoxAsync($"Apps exported to {exportPath}", "Export Complete"); @@ -3609,8 +3609,8 @@ private async void ValidateGames_Click(object sender, RoutedEventArgs e) } private async Task> LoadGamesFromJsonAsync() { - var appsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "apps.json"); - var legacyPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "games.json"); + var appsPath = Path.Combine(AppPaths.DataDirectory, "apps.json"); + var legacyPath = Path.Combine(AppPaths.DataDirectory, "games.json"); var sourcePath = File.Exists(appsPath) ? appsPath : legacyPath; if (!File.Exists(sourcePath)) return []; @@ -3687,7 +3687,7 @@ private static object SerializeGame(GameInfo game) } private async Task SaveGamesToJsonAsync(List appsToSave) { - var appsPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "apps.json"); + var appsPath = Path.Combine(AppPaths.DataDirectory, "apps.json"); var data = new { apps = appsToSave.Select(SerializeGame).ToList() @@ -4256,10 +4256,10 @@ private void RefreshAppCatalog_Click(object sender, RoutedEventArgs e) // App Catalog private static readonly string AppCatalogCachePath = Path.Combine( - AppDomain.CurrentDomain.BaseDirectory, "app_catalog_cache.json"); + AppPaths.DataDirectory, "app_catalog_cache.json"); private static readonly string AppCatalogVersionPath = Path.Combine( - AppDomain.CurrentDomain.BaseDirectory, "app_catalog_version.txt"); + AppPaths.DataDirectory, "app_catalog_version.txt"); private async Task LoadAppCatalogAsync(bool forceRefresh) { diff --git a/Services/AppPaths.cs b/Services/AppPaths.cs new file mode 100644 index 0000000..3a62a57 --- /dev/null +++ b/Services/AppPaths.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; + +namespace GithubLauncher +{ + /// + /// Resolves where the launcher stores writable data. + /// + /// On macOS the launcher ships as a signed .app bundle whose contents + /// must stay immutable — writing next to the executable (inside + /// Contents/MacOS) invalidates the code signature and prevents the app + /// from living in a read-only location such as /Applications. So on + /// macOS user data lives in ~/Library/Application Support/GithubLauncher. + /// + /// On Windows/Linux behavior is unchanged: data stays next to the executable + /// (portable app layout). + /// + public static class AppPaths + { + /// Executable location. Use only for self-update; never for user data on macOS. + public static string AppDirectory => AppContext.BaseDirectory; + + private static readonly Lazy _dataDirectory = new(() => + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + string appSupport = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Library", "Application Support", "GithubLauncher"); + Directory.CreateDirectory(appSupport); + return appSupport; + } + + return AppContext.BaseDirectory; + }); + + /// Writable data root (settings, app registry, caches, installed apps). Created on first access. + public static string DataDirectory => _dataDirectory.Value; + } +} diff --git a/Services/AppSettings.cs b/Services/AppSettings.cs index 7f2fc23..a394f12 100644 --- a/Services/AppSettings.cs +++ b/Services/AppSettings.cs @@ -36,7 +36,7 @@ public class AppSettings public string AppListRepository { get; set; } = "SirDiabo/GHLAppList"; public string AppListCachedVersion { get; set; } = string.Empty; private static readonly string SettingsPath = Path.Combine( - AppDomain.CurrentDomain.BaseDirectory, + AppPaths.DataDirectory, "settings.json" ); diff --git a/Services/CLIHandler.cs b/Services/CLIHandler.cs index e2bddb1..699826e 100644 --- a/Services/CLIHandler.cs +++ b/Services/CLIHandler.cs @@ -164,7 +164,7 @@ private void LoadVersion() try { string currentAppDirectory = AppDomain.CurrentDomain.BaseDirectory; - string updateCheckFilePath = Path.Combine(currentAppDirectory, "update_check.json"); + string updateCheckFilePath = Path.Combine(AppPaths.DataDirectory, "update_check.json"); if (File.Exists(updateCheckFilePath)) { diff --git a/Services/GameManager.cs b/Services/GameManager.cs index 3301aa0..5b2abea 100644 --- a/Services/GameManager.cs +++ b/Services/GameManager.cs @@ -58,11 +58,11 @@ public GameManager() _appsFolder = !string.IsNullOrEmpty(_settings?.AppsPath) ? _settings.AppsPath - : Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Profile.DefaultInstallFolderName); + : Path.Combine(AppPaths.DataDirectory, Profile.DefaultInstallFolderName); - _cacheFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Cache"); - _appsConfigPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "apps.json"); - _legacyGamesConfigPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "games.json"); + _cacheFolder = Path.Combine(AppPaths.DataDirectory, "Cache"); + _appsConfigPath = Path.Combine(AppPaths.DataDirectory, "apps.json"); + _legacyGamesConfigPath = Path.Combine(AppPaths.DataDirectory, "games.json"); try { @@ -419,7 +419,7 @@ public async Task UpdateGamesFolderAsync(string newPath) } else { - targetPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Profile.DefaultInstallFolderName); + targetPath = Path.Combine(AppPaths.DataDirectory, Profile.DefaultInstallFolderName); Directory.CreateDirectory(targetPath); } @@ -435,7 +435,7 @@ public async Task UpdateGamesFolderAsync(string newPath) catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error updating apps folder: {ex.Message}"); - _appsFolder = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, Profile.DefaultInstallFolderName); + _appsFolder = Path.Combine(AppPaths.DataDirectory, Profile.DefaultInstallFolderName); Directory.CreateDirectory(_appsFolder); throw; }