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/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/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/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;
}
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"