diff --git a/.github/workflows/release-desktop-apps.yml b/.github/workflows/release-desktop-apps.yml
index 4941af752..34893a0f6 100644
--- a/.github/workflows/release-desktop-apps.yml
+++ b/.github/workflows/release-desktop-apps.yml
@@ -9,12 +9,11 @@ on:
# Allow manual triggering of the workflow for testing (artifacts will be uploaded to the workflow run, not a release))
workflow_dispatch:
-permissions:
- contents: write
-
jobs:
build-and-upload:
runs-on: ubuntu-latest
+ permissions:
+ contents: write
strategy:
fail-fast: false
@@ -84,3 +83,124 @@ jobs:
DotNet6502-Avalonia-${{ matrix.runtime }}.zip
DotNet6502-SadConsole-${{ matrix.runtime }}.zip
checksums-${{ matrix.runtime }}.sha256
+
+ update-package-managers:
+ if: github.event_name == 'release'
+ needs: build-and-upload
+ runs-on: ubuntu-latest
+ steps:
+ - name: Extract version from tag
+ id: version
+ run: |
+ TAG="${{ github.event.release.tag_name }}"
+ VERSION="${TAG#v}"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
+
+ - name: Download checksum files from release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ for runtime in osx-arm64 linux-x64 linux-arm64 win-x64 win-arm64; do
+ gh release download "${{ github.event.release.tag_name }}" \
+ --repo ${{ github.repository }} \
+ --pattern "checksums-${runtime}.sha256"
+ done
+
+ - name: Extract SHA256 hashes
+ id: hashes
+ run: |
+ extract_hash() {
+ grep "DotNet6502-Avalonia-${1}.zip" "checksums-${1}.sha256" | awk '{print $1}'
+ }
+ echo "osx_arm64=$(extract_hash osx-arm64)" >> "$GITHUB_OUTPUT"
+ echo "linux_x64=$(extract_hash linux-x64)" >> "$GITHUB_OUTPUT"
+ echo "linux_arm64=$(extract_hash linux-arm64)" >> "$GITHUB_OUTPUT"
+ echo "win_x64=$(extract_hash win-x64)" >> "$GITHUB_OUTPUT"
+ echo "win_arm64=$(extract_hash win-arm64)" >> "$GITHUB_OUTPUT"
+
+ - name: Update Homebrew tap
+ env:
+ GH_TOKEN: ${{ secrets.PACKAGE_MANAGER_TOKEN }}
+ VERSION: ${{ steps.version.outputs.version }}
+ OSX_ARM64_HASH: ${{ steps.hashes.outputs.osx_arm64 }}
+ LINUX_X64_HASH: ${{ steps.hashes.outputs.linux_x64 }}
+ LINUX_ARM64_HASH: ${{ steps.hashes.outputs.linux_arm64 }}
+ run: |
+ git clone "https://x-access-token:${GH_TOKEN}@github.com/highbyte/homebrew-dotnet-6502.git"
+ cd homebrew-dotnet-6502
+
+ # Update Cask (macOS)
+ sed -i "s/version \".*\"/version \"${VERSION}\"/" Casks/dotnet-6502.rb
+ sed -i "s/sha256 \".*\"/sha256 \"${OSX_ARM64_HASH}\"/" Casks/dotnet-6502.rb
+
+ # Update Formula (Linux) - use Python for multi-hash replacement
+ python3 <<'PYEOF'
+ import re, os
+
+ version = os.environ["VERSION"]
+ linux_x64_hash = os.environ["LINUX_X64_HASH"]
+ linux_arm64_hash = os.environ["LINUX_ARM64_HASH"]
+
+ with open("Formula/dotnet-6502.rb", "r") as f:
+ content = f.read()
+
+ content = re.sub(r'version ".*?"', f'version "{version}"', content)
+
+ # Replace hashes in order: first occurrence is x64, second is arm64
+ hashes = [linux_x64_hash, linux_arm64_hash]
+ idx = [0]
+ def replace_hash(m):
+ if idx[0] < len(hashes):
+ h = hashes[idx[0]]
+ idx[0] += 1
+ return f'sha256 "{h}"'
+ return m.group(0)
+ content = re.sub(r'sha256 ".*?"', replace_hash, content)
+
+ with open("Formula/dotnet-6502.rb", "w") as f:
+ f.write(content)
+ PYEOF
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add -A
+ git diff --cached --quiet || { git commit -m "Update to ${VERSION}" && git push; }
+
+ - name: Update Scoop bucket
+ env:
+ GH_TOKEN: ${{ secrets.PACKAGE_MANAGER_TOKEN }}
+ VERSION: ${{ steps.version.outputs.version }}
+ TAG: ${{ steps.version.outputs.tag }}
+ WIN_X64_HASH: ${{ steps.hashes.outputs.win_x64 }}
+ WIN_ARM64_HASH: ${{ steps.hashes.outputs.win_arm64 }}
+ run: |
+ git clone "https://x-access-token:${GH_TOKEN}@github.com/highbyte/scoop-dotnet-6502.git"
+ cd scoop-dotnet-6502
+
+ python3 <<'PYEOF'
+ import json, os
+
+ version = os.environ["VERSION"]
+ tag = os.environ["TAG"]
+ win_x64_hash = os.environ["WIN_X64_HASH"]
+ win_arm64_hash = os.environ["WIN_ARM64_HASH"]
+
+ with open("bucket/dotnet-6502.json", "r") as f:
+ manifest = json.load(f)
+
+ manifest["version"] = version
+ manifest["architecture"]["64bit"]["url"] = f"https://github.com/highbyte/dotnet-6502/releases/download/{tag}/DotNet6502-Avalonia-win-x64.zip"
+ manifest["architecture"]["64bit"]["hash"] = win_x64_hash
+ manifest["architecture"]["arm64"]["url"] = f"https://github.com/highbyte/dotnet-6502/releases/download/{tag}/DotNet6502-Avalonia-win-arm64.zip"
+ manifest["architecture"]["arm64"]["hash"] = win_arm64_hash
+
+ with open("bucket/dotnet-6502.json", "w") as f:
+ json.dump(manifest, f, indent=4)
+ f.write("\n")
+ PYEOF
+
+ git config user.name "github-actions[bot]"
+ git config user.email "github-actions[bot]@users.noreply.github.com"
+ git add -A
+ git diff --cached --quiet || { git commit -m "Update to ${VERSION}" && git push; }
diff --git a/doc/DESKTOP_APPS.md b/doc/DESKTOP_APPS.md
index 4466971f4..f43535254 100644
--- a/doc/DESKTOP_APPS.md
+++ b/doc/DESKTOP_APPS.md
@@ -12,7 +12,82 @@ The emulator has front-ends written with different technologies, and have somewh
| **SadConsole** | Cross-platform desktop console-style app using SadConsole library. See details [here](APPS_SADCONSOLE.md). |
| **SilkNetNative** | Cross-platform desktop app using Silk.NET + SkiaSharp + shaders for rendering. See details [here](APPS_SILKNET_NATIVE.md). |
-## Download
+## Install via Package Manager
+
+The **Avalonia** desktop app can be installed via package managers for a simpler experience.
+
+**Prerequisites:** Install [Homebrew](https://brew.sh/) (macOS/Linux) or [Scoop](https://scoop.sh/) (Windows) if you don't have them already.
+
+### macOS (Homebrew)
+
+```bash
+brew tap highbyte/dotnet-6502
+brew install --cask dotnet-6502
+```
+
+### Linux (Homebrew)
+
+```bash
+brew tap highbyte/dotnet-6502
+brew install --formula dotnet-6502
+```
+
+### Windows (Scoop)
+
+```powershell
+scoop bucket add dotnet-6502 https://github.com/highbyte/scoop-dotnet-6502
+scoop install dotnet-6502
+```
+
+### Launching
+
+After installing via a package manager, run the emulator from a terminal:
+
+```sh
+dotnet-6502
+```
+
+On macOS, the app is also installed to `/Applications` and can be launched from Launchpad, Spotlight, or Finder like any other Mac app.
+
+On Windows (Scoop), a Start Menu shortcut **DotNet6502 Emulator** is also created.
+
+### Updating
+
+```bash
+# macOS
+brew update && brew upgrade --cask dotnet-6502
+
+# Linux
+brew update && brew upgrade --formula dotnet-6502
+```
+
+```powershell
+# Windows
+scoop update
+scoop update dotnet-6502
+```
+
+### Uninstalling
+
+```bash
+# macOS
+brew uninstall --cask dotnet-6502
+brew untap highbyte/dotnet-6502
+
+# Linux
+brew uninstall --formula dotnet-6502
+brew untap highbyte/dotnet-6502
+```
+
+```powershell
+# Windows
+scoop uninstall dotnet-6502
+scoop bucket rm dotnet-6502
+```
+
+---
+
+## Install via manual download
Download the latest release for your platform from the [Releases](https://github.com/highbyte/dotnet-6502/releases) page under Assets.
@@ -24,22 +99,14 @@ Download the latest release for your platform from the [Releases](https://github
| Linux ARM64 | `DotNet6502-*-linux-arm64.zip` |
| macOS ARM64 (Apple Silicon) | `DotNet6502-*-osx-arm64.zip` |
----
-### Prerequisites, compatibility, and troubleshooting
-[Avalonia desktop app](APPS_AVALONIA_TROUBLESHOOT.md)
+### Launching the Application
-[Silk.NET desktop app](APPS_SILKNET_NATIVE_TROUBLESHOOT.md)
-
-[SadConsole desktop app](APPS_SADCONSOLE_TROUBLESHOOT.md)
-
-## Launching the Application
-
-### Windows
+#### Windows
1. Extract the `.zip` file to a folder
2. Double-click the `.exe` file to run
-#### SmartScreen Warning
+##### SmartScreen Warning
Since the application is not code-signed, Windows SmartScreen may show a warning:
@@ -53,7 +120,7 @@ This warning only appears the first time you run the application.
---
-### Linux
+#### Linux
1. Extract the `.zip` file:
```sh
@@ -70,7 +137,7 @@ No security warnings are typically shown on Linux.
---
-### macOS
+#### macOS
> **Note:** The macOS build is not notarized with Apple. It must be run from Terminal.
@@ -91,32 +158,41 @@ No security warnings are typically shown on Linux.
./Highbyte.DotNet6502.App.Avalonia.Desktop
```
-#### Why can't I double-click to run?
+##### Why can't I double-click to run?
macOS Gatekeeper blocks unsigned/non-notarized applications from running via Finder. Running from Terminal with the `xattr -cr .` command removes the quarantine flag and allows execution.
---
-## Verifying Download Integrity (Optional)
+### Verifying Download Integrity (Optional)
Each release includes SHA256 checksum files (`checksums-*.sha256`) to verify your download hasn't been corrupted or tampered with.
-### Windows (PowerShell)
+#### Windows (PowerShell)
```powershell
(Get-FileHash -Algorithm SHA256 DotNet6502-Avalonia-win-x64.zip).Hash.ToLower()
```
-### Linux
+#### Linux
```sh
sha256sum DotNet6502-Avalonia-linux-x64.zip
```
-### macOS
+#### macOS
```sh
shasum -a 256 DotNet6502-Avalonia-osx-arm64.zip
```
Compare the output with the corresponding entry in the `checksums-*.sha256` file.
+
+---
+
+## Prerequisites, compatibility, and troubleshooting
+[Avalonia desktop app](APPS_AVALONIA_TROUBLESHOOT.md)
+
+[Silk.NET desktop app](APPS_SILKNET_NATIVE_TROUBLESHOOT.md)
+
+[SadConsole desktop app](APPS_SADCONSOLE_TROUBLESHOOT.md)
diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/AppIcon.icns b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/AppIcon.icns
new file mode 100644
index 000000000..798d8a054
Binary files /dev/null and b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/AppIcon.icns differ
diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Highbyte.DotNet6502.App.Avalonia.Desktop.csproj b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Highbyte.DotNet6502.App.Avalonia.Desktop.csproj
index 490cdc4d9..ef048a779 100644
--- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Highbyte.DotNet6502.App.Avalonia.Desktop.csproj
+++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Highbyte.DotNet6502.App.Avalonia.Desktop.csproj
@@ -12,6 +12,7 @@
app.manifest
+ ..\Highbyte.DotNet6502.App.Avalonia.Core\Assets\favicon.ico
diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Info.plist b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Info.plist
new file mode 100644
index 000000000..b6432a036
--- /dev/null
+++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Info.plist
@@ -0,0 +1,28 @@
+
+
+
+
+ CFBundleName
+ DotNet6502
+ CFBundleDisplayName
+ DotNet6502 Emulator
+ CFBundleIdentifier
+ com.highbyte.dotnet6502
+ CFBundleVersion
+ 1.0.0
+ CFBundleShortVersionString
+ 1.0.0
+ CFBundlePackageType
+ APPL
+ CFBundleExecutable
+ Highbyte.DotNet6502.App.Avalonia.Desktop
+ CFBundleIconFile
+ AppIcon
+ LSMinimumSystemVersion
+ 12.0
+ NSHighResolutionCapable
+
+ NSSupportsAutomaticGraphicsSwitching
+
+
+
diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Program.cs b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Program.cs
index 94c886e66..79dbc36c3 100644
--- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Program.cs
+++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/Program.cs
@@ -148,8 +148,9 @@ public static int Main(string[] args)
// Get config file
// ----------
WriteBootstrapLog($"Creating configuration object.");
+ var appDir = AppContext.BaseDirectory;
var builder = new ConfigurationBuilder()
- .SetBasePath(Directory.GetCurrentDirectory())
+ .SetBasePath(appDir)
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.Development.json", optional: true);
diff --git a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh
index 158d843d6..96209072d 100755
--- a/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh
+++ b/src/apps/Avalonia/Highbyte.DotNet6502.App.Avalonia.Desktop/publish.sh
@@ -71,12 +71,44 @@ fi
# Run dotnet publish
dotnet publish "${PUBLISH_ARGS[@]}"
-if [[ $? -eq 0 ]]; then
- echo ""
- echo "✅ Published successfully to: $OUTPUT_DIR/$RUNTIME"
- ls -la "$OUTPUT_DIR/$RUNTIME"
-else
+if [[ $? -ne 0 ]]; then
echo ""
echo "❌ Publish failed"
exit 1
fi
+
+echo ""
+echo "✅ Published successfully to: $OUTPUT_DIR/$RUNTIME"
+
+# Create .app bundle for macOS
+if [[ "$RUNTIME" == osx-* ]]; then
+ APP_NAME="DotNet6502 Emulator.app"
+ APP_DIR="$OUTPUT_DIR/$RUNTIME/$APP_NAME"
+ MACOS_DIR="$APP_DIR/Contents/MacOS"
+ RESOURCES_DIR="$APP_DIR/Contents/Resources"
+
+ echo ""
+ echo "Creating macOS .app bundle: $APP_NAME"
+
+ # Create bundle structure
+ mkdir -p "$MACOS_DIR"
+ mkdir -p "$RESOURCES_DIR"
+
+ # Copy Info.plist
+ cp "$SCRIPT_DIR/Info.plist" "$APP_DIR/Contents/Info.plist"
+
+ # Copy icon
+ cp "$SCRIPT_DIR/AppIcon.icns" "$RESOURCES_DIR/AppIcon.icns"
+
+ # Move all published files into MacOS directory
+ # (exclude the .app bundle itself to avoid recursion)
+ for item in "$OUTPUT_DIR/$RUNTIME/"*; do
+ if [[ "$(basename "$item")" != "$APP_NAME" ]]; then
+ mv "$item" "$MACOS_DIR/"
+ fi
+ done
+
+ echo "✅ macOS .app bundle created: $APP_DIR"
+fi
+
+ls -la "$OUTPUT_DIR/$RUNTIME"
diff --git a/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs b/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs
index 3b7893e15..d856381d9 100644
--- a/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs
+++ b/src/apps/Highbyte.DotNet6502.App.SadConsole/Program.cs
@@ -31,8 +31,9 @@
// Get config file
// ----------
WriteBootstrapLog($"Creating configuration object.");
+var appDir = AppContext.BaseDirectory;
var builder = new ConfigurationBuilder()
- .SetBasePath(Directory.GetCurrentDirectory())
+ .SetBasePath(appDir)
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.Development.json", optional: true);
diff --git a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs
index 275048398..b9f9cb538 100644
--- a/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs
+++ b/src/apps/Highbyte.DotNet6502.App.SilkNetNative/Program.cs
@@ -49,8 +49,9 @@
// Get config file
// ----------
WriteBootstrapLog($"Creating configuration object.");
+var appDir = AppContext.BaseDirectory;
var builder = new ConfigurationBuilder()
- .SetBasePath(Directory.GetCurrentDirectory())
+ .SetBasePath(appDir)
.AddJsonFile("appsettings.json")
.AddJsonFile("appsettings.Development.json", optional: true);