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);