diff --git a/.gitattributes b/.gitattributes
index 8ad74f78..8c7e7cd4 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,3 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf
+*.bat text eol=crlf
diff --git a/.github/release-extras/opengl3-compatibility.bat b/.github/release-extras/opengl3-compatibility.bat
new file mode 100644
index 00000000..20c2fbc1
--- /dev/null
+++ b/.github/release-extras/opengl3-compatibility.bat
@@ -0,0 +1,2 @@
+@echo off
+"%~dp0Rhythia.exe" --rendering-driver opengl3 %*
diff --git a/.github/release-extras/opengl3-compatibility.sh b/.github/release-extras/opengl3-compatibility.sh
new file mode 100755
index 00000000..b55cdb2f
--- /dev/null
+++ b/.github/release-extras/opengl3-compatibility.sh
@@ -0,0 +1,4 @@
+#!/bin/sh
+printf '\033c\033]0;%s\a' Rhythia
+base_path="$(dirname "$(realpath "$0")")"
+exec "$base_path/Rhythia.x86_64" --rendering-driver opengl3 "$@"
diff --git a/.github/scripts/install-godot.sh b/.github/scripts/install-godot.sh
new file mode 100755
index 00000000..ba28728f
--- /dev/null
+++ b/.github/scripts/install-godot.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+# Installs the Godot .NET editor and export templates matching .github/godot_version.txt
+# Exports GODOT (path to the editor binary) to $GITHUB_ENV when running in Actions
+set -euo pipefail
+
+VERSION=$(tr -d '[:space:]' < .github/godot_version.txt)
+TAG="${VERSION}-stable"
+BASE_URL="https://github.com/godotengine/godot/releases/download/${TAG}"
+CACHE_DIR="${GODOT_CACHE:-$HOME/.cache/godot-downloads}"
+
+case "$(uname -s)" in
+ Linux)
+ EDITOR_ZIP="Godot_v${TAG}_mono_linux_x86_64.zip"
+ TEMPLATE_DIR="$HOME/.local/share/godot/export_templates/${VERSION}.stable.mono"
+ GODOT="$CACHE_DIR/Godot_v${TAG}_mono_linux_x86_64/Godot_v${TAG}_mono_linux.x86_64"
+ ;;
+ Darwin)
+ EDITOR_ZIP="Godot_v${TAG}_mono_macos.universal.zip"
+ TEMPLATE_DIR="$HOME/Library/Application Support/Godot/export_templates/${VERSION}.stable.mono"
+ GODOT="$CACHE_DIR/Godot_mono.app/Contents/MacOS/Godot"
+ ;;
+ *)
+ echo "Unsupported OS: $(uname -s)" >&2
+ exit 1
+ ;;
+esac
+
+TEMPLATES_TPZ="Godot_v${TAG}_mono_export_templates.tpz"
+
+mkdir -p "$CACHE_DIR"
+
+for file in "$EDITOR_ZIP" "$TEMPLATES_TPZ"; do
+ if [ ! -f "$CACHE_DIR/$file" ]; then
+ echo "Downloading $file"
+ curl -fsSL --retry 3 -o "$CACHE_DIR/$file" "$BASE_URL/$file"
+ fi
+done
+
+if [ ! -x "$GODOT" ]; then
+ unzip -q -o "$CACHE_DIR/$EDITOR_ZIP" -d "$CACHE_DIR"
+fi
+
+if [ ! -f "$TEMPLATE_DIR/version.txt" ]; then
+ rm -rf "$CACHE_DIR/templates"
+ unzip -q -o "$CACHE_DIR/$TEMPLATES_TPZ" -d "$CACHE_DIR"
+ mkdir -p "$TEMPLATE_DIR"
+ cp -R "$CACHE_DIR/templates/." "$TEMPLATE_DIR/"
+ rm -rf "$CACHE_DIR/templates"
+fi
+
+"$GODOT" --headless --version
+
+if [ -n "${GITHUB_ENV:-}" ]; then
+ echo "GODOT=$GODOT" >> "$GITHUB_ENV"
+else
+ echo "GODOT=$GODOT"
+fi
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..bcdf61b3
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,100 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ export:
+ name: Export ${{ matrix.preset }}
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - preset: Windows Desktop
+ asset: Windows.zip
+ runner: ubuntu-latest
+ extras: opengl3-compatibility.bat
+ binary: Rhythia.exe
+ export: Rhythia.zip
+ - preset: Linux
+ asset: Linux.zip
+ runner: ubuntu-latest
+ extras: opengl3-compatibility.sh
+ binary: Rhythia.x86_64
+ export: Rhythia.x86_64.zip
+ # macOS must export on a Mac: the preset signs with Xcode codesign,
+ # Godot's built-in signer produces apps that are killed at launch
+ - preset: macOS
+ asset: macOS.zip
+ runner: macos-latest
+ extras: ""
+ binary: Rhythia.app/Contents/MacOS/Rhythia
+ export: Rhythia.zip
+ runs-on: ${{ matrix.runner }}
+ env:
+ GODOT_CACHE: ${{ github.workspace }}/../godot-cache
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # Checkout v4
+
+ - name: Setup
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # Dotnet 4.0.2
+ with:
+ dotnet-version: 10.0.x
+
+ - name: Cache Godot
+ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # Cache v4
+ with:
+ path: ${{ env.GODOT_CACHE }}
+ key: godot-${{ runner.os }}-${{ hashFiles('.github/godot_version.txt') }}
+
+ - name: Install Godot
+ run: .github/scripts/install-godot.sh
+
+ - name: Restore dependencies
+ run: dotnet restore --locked-mode
+
+ - name: Build
+ run: dotnet build --no-restore
+
+ - name: Import project
+ run: '"$GODOT" --headless --path . --import'
+
+ # Godot names the executable after the export file, so export under the binary's name and rename
+ - name: Export
+ run: |
+ mkdir -p build/export
+ "$GODOT" --headless --path . --export-release "${{ matrix.preset }}" "build/export/${{ matrix.export }}"
+ mv "build/export/${{ matrix.export }}" "build/${{ matrix.asset }}"
+
+ - name: Add launch scripts
+ if: matrix.extras != ''
+ working-directory: .github/release-extras
+ run: zip -X "../../build/${{ matrix.asset }}" ${{ matrix.extras }}
+
+ - name: Verify archive layout
+ run: unzip -Z1 "build/${{ matrix.asset }}" | grep -x "${{ matrix.binary }}" > /dev/null
+
+ - name: Verify macOS app launches
+ if: matrix.preset == 'macOS'
+ run: |
+ mkdir -p build/verify
+ ditto -x -k build/macOS.zip build/verify
+ codesign --verify --deep --strict build/verify/Rhythia.app
+ build/verify/Rhythia.app/Contents/MacOS/Rhythia --headless --version
+
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # Upload Artifact v4
+ with:
+ name: ${{ matrix.asset }}
+ path: build/${{ matrix.asset }}
+ if-no-files-found: error
+
+ - name: Upload to release
+ if: github.event_name == 'release'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release upload "${{ github.event.release.tag_name }}" "build/${{ matrix.asset }}" --clobber
diff --git a/.gitignore b/.gitignore
index 6696ada6..610009a8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,7 +15,6 @@ Properties/
# Godot 4+ specific ignores
.godot/
.import/
-export_presets.cfg
# Imported translations (automatically generated from CSV files)
*.translation
@@ -30,3 +29,6 @@ mono_crash.*.json
# Other
nuget.config
*.lscache
+
+# Local export output
+build/
diff --git a/README.md b/README.md
index 0b2274a8..db9f79d6 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,8 @@
- [Windows](#windows)
- [Linux](#linux)
- [Linux Troubleshooting](#linux-troubleshooting)
+ - [macOS](#macos)
+ - [macOS Troubleshooting](#macos-troubleshooting)
- [User Folder](#user-folder)
- [Getting Maps](#getting-maps)
- [Development](#development)
@@ -64,6 +66,7 @@ You can view all releases on the [Rewrite releases page](https://github.com/Rhyt
|---|---|
| **Windows** | [Download](https://github.com/Rhythia/Client/releases/latest/download/windows.zip) |
| **Linux** | [Download](https://github.com/Rhythia/Client/releases/latest/download/linux.zip) |
+| **macOS** | [Download](https://github.com/Rhythia/Client/releases/latest/download/macOS.zip) |
You can view the Rewrite source code [here](https://github.com/Rhythia/Client).
@@ -138,6 +141,41 @@ sudo chmod +x Rhythia.x86_64
Still having issues? Visit the **#support** channel in our [Discord](https://discord.gg/rhythia).
+### macOS
+
+> [!NOTE]
+> Requires macOS 11 (Big Sur) or later on Intel, or macOS 13 (Ventura) or later on Apple Silicon.
+
+1. Download `macOS.zip` for your chosen client above.
+2. Double-click the downloaded file to extract `Rhythia.app`.
+3. Move `Rhythia.app` to your **Applications** folder.
+4. Open `Rhythia.app`. The first launch will be blocked, see below.
+
+#### Allowing Rhythia to Open
+
+Rhythia is not signed with an Apple Developer ID, so macOS blocks it the first time you open it. You only need to do this once; in-game updates keep working afterwards.
+
+**System Settings:**
+
+1. Try to open `Rhythia.app` and close the warning that appears.
+2. Open **System Settings** → **Privacy & Security**.
+3. Scroll down to the message about Rhythia being blocked and click **Open Anyway**.
+4. Confirm with your password, then open Rhythia again.
+
+**Terminal:**
+
+```bash
+xattr -dr com.apple.quarantine /Applications/Rhythia.app
+```
+
+### macOS Troubleshooting
+
+If macOS says the app **is damaged and can't be opened**, run the Terminal command above; it removes the download flag macOS adds to files from the internet.
+
+If the game won't open at all, check that you are using a release newer than 0.2.0; the 0.2.0 `macOS.zip` and earlier builds were signed incorrectly and are killed by macOS on launch.
+
+Still having issues? Visit the **#support** channel in our [Discord](https://discord.gg/rhythia).
+
---
## User Folder
@@ -148,6 +186,7 @@ Your maps, replays, skins, and settings are stored separately from the game inst
|---|---|
| **Windows** | `%appdata%\Rhythia` |
| **Linux** | `~/.local/share/Rhythia` |
+| **macOS** | `~/Library/Application Support/Rhythia` |
You can also access the user folder from within the game via the **User Folder** button on the top bar.
@@ -210,7 +249,7 @@ We ❤️ developers! Contributions are welcome — whether it's bug fixes, feat
| Software | Notes |
|---|---|
| [Godot 4.7 Stable (.NET)](https://godotengine.org/download) | The C# / .NET build is required |
-| [Git](https://git-scm.com/downloads) | Windows: Git for Windows · Linux: `git` package |
+| [Git](https://git-scm.com/downloads) | Windows: Git for Windows · Linux: `git` package · macOS: `xcode-select --install` or `brew install git` |
| [GitHub Account](https://github.com/signup) | Needed to fork and open pull requests |
**Optional:** [Visual Studio Code](https://code.visualstudio.com/) with the [godot-tools](https://marketplace.visualstudio.com/items?itemName=geequlim.godot-tools) extension.
@@ -250,6 +289,17 @@ dotnet tool restore
3. Browse to your cloned repository folder and select `project.godot`.
4. Click **Open**.
+#### 4. Exporting a Build (Optional)
+
+Release builds are exported automatically by the [Release workflow](.github/workflows/release.yml) when a release is published. To export locally, install the matching [export templates](https://godotengine.org/download) and use **Project** → **Export**, or:
+
+```bash
+godot --headless --export-release "macOS" build/macOS/Rhythia.zip
+```
+
+> [!NOTE]
+> The macOS preset signs with Apple's `codesign`, so macOS builds must be exported on a Mac.
+
### Submitting Your Changes
1. **Format** the project:
diff --git a/export_presets.cfg b/export_presets.cfg
index d69582c3..d571c3f6 100644
--- a/export_presets.cfg
+++ b/export_presets.cfg
@@ -136,7 +136,7 @@ custom_features=""
export_filter="all_resources"
include_filter="user/*"
exclude_filter=""
-export_path=""
+export_path="build/macOS/Rhythia.zip"
patches=PackedStringArray()
patch_delta_encoding=false
patch_delta_compression_level_zstd=19
@@ -160,11 +160,11 @@ debug/export_console_wrapper=1
application/liquid_glass_icon=""
application/icon=""
application/icon_interpolation=4
-application/bundle_identifier=""
+application/bundle_identifier="wiki.rhythia.net"
application/signature=""
application/app_category="Games"
-application/short_version=""
-application/version=""
+application/short_version="0.2.0"
+application/version="0.2.0"
application/copyright=""
application/copyright_localized={}
application/min_macos_version_x86_64="11.00"
@@ -179,7 +179,7 @@ xcode/sdk_build="22C55"
xcode/sdk_name="macosx13.1"
xcode/xcode_version="1420"
xcode/xcode_build="14C18"
-codesign/codesign=1
+codesign/codesign=3
codesign/installer_identity=""
codesign/apple_team_id=""
codesign/identity=""
diff --git a/scripts/Constants.cs b/scripts/Constants.cs
index 4c961086..1be1f94d 100644
--- a/scripts/Constants.cs
+++ b/scripts/Constants.cs
@@ -6,8 +6,6 @@ public partial class Constants : Node
{
public static readonly ulong STARTED = Time.GetTicksUsec();
- public static readonly string ROOT_FOLDER = Directory.GetCurrentDirectory();
-
public static readonly string DEFAULT_USER_FOLDER = OS.GetUserDataDir();
public static readonly string USER_FOLDER_POINTER = $"{DEFAULT_USER_FOLDER}/user_folder_path.txt";
diff --git a/scripts/SettingsManager.cs b/scripts/SettingsManager.cs
index dc99e357..6f09bb60 100644
--- a/scripts/SettingsManager.cs
+++ b/scripts/SettingsManager.cs
@@ -269,11 +269,10 @@ private static void restartGame()
OS.CreateProcess("powershell.exe", ["-Command", $"Wait-Process -Id {pid}; Start-Process '{executablePath}'"]);
break;
case "macOS":
- string executablePathMac = Path.Combine(executablePath, "../..");
- OS.CreateProcess("/bin/sh", [$"while kill -0 {pid} 2>/dev/null; do sleep 0.1; done; open {executablePathMac}"]);
+ OS.CreateProcess("/bin/sh", ["-c", $"{Platform.WaitForExitScript}; open {Platform.ShellQuote(Platform.MacAppBundlePath)}"]);
break;
case "Linux":
- OS.CreateProcess("/bin/sh", [$"while kill -0 {pid} 2>/dev/null; do sleep 0.1; done; exec {executablePath}"]);
+ OS.CreateProcess("/bin/sh", ["-c", $"{Platform.WaitForExitScript}; exec {Platform.ShellQuote(executablePath)}"]);
break;
}
diff --git a/scripts/ui/SettingsMenu.cs b/scripts/ui/SettingsMenu.cs
index ddc91b4e..4e18e552 100644
--- a/scripts/ui/SettingsMenu.cs
+++ b/scripts/ui/SettingsMenu.cs
@@ -225,7 +225,7 @@ public override void _Input(InputEvent @event)
switch (eventKey.Keycode)
{
case Key.O:
- if (eventKey.CtrlPressed)
+ if (eventKey.IsCommandOrControlPressed())
{
ShowMenu(!Shown);
GetViewport().SetInputAsHandled();
diff --git a/scripts/ui/menu/play/SearchPanel.cs b/scripts/ui/menu/play/SearchPanel.cs
index 56949793..25493204 100644
--- a/scripts/ui/menu/play/SearchPanel.cs
+++ b/scripts/ui/menu/play/SearchPanel.cs
@@ -23,7 +23,7 @@ public override void _Ready()
public override void _Input(InputEvent @event)
{
- if (@event is InputEventKey eventKey && eventKey.Pressed && !eventKey.CtrlPressed && !eventKey.AltPressed)
+ if (@event is InputEventKey eventKey && eventKey.Pressed && !eventKey.CtrlPressed && !eventKey.MetaPressed && !eventKey.AltPressed)
{
Control focusOwner = GetViewport().GuiGetFocusOwner();
diff --git a/scripts/util/Platform.cs b/scripts/util/Platform.cs
new file mode 100644
index 00000000..74254b97
--- /dev/null
+++ b/scripts/util/Platform.cs
@@ -0,0 +1,20 @@
+using System.IO;
+using Godot;
+
+public static class Platform
+{
+ ///
+ /// Path to the running .app bundle on macOS, the executable lives at Rhythia.app/Contents/MacOS/Rhythia
+ ///
+ public static string MacAppBundlePath => Path.GetFullPath(Path.Combine(OS.GetExecutablePath(), "../../.."));
+
+ ///
+ /// Wraps a value in single quotes so it is passed to /bin/sh as one literal argument
+ ///
+ public static string ShellQuote(string value) => $"'{value.Replace("'", "'\\''")}'";
+
+ ///
+ /// Shell snippet that blocks until the current process has exited
+ ///
+ public static string WaitForExitScript => $"while kill -0 {OS.GetProcessId()} 2>/dev/null; do sleep 0.1; done";
+}
diff --git a/scripts/util/Platform.cs.uid b/scripts/util/Platform.cs.uid
new file mode 100644
index 00000000..8124b9d7
--- /dev/null
+++ b/scripts/util/Platform.cs.uid
@@ -0,0 +1 @@
+uid://d0b88m1ycctvg
diff --git a/scripts/util/Releases.cs b/scripts/util/Releases.cs
index aa0bfce0..df223327 100644
--- a/scripts/util/Releases.cs
+++ b/scripts/util/Releases.cs
@@ -50,6 +50,36 @@ public static async Task InstallUpdateAsync(UpdatumDownloadedAsset asset)
return false;
}
+ if (OS.GetName() == "macOS")
+ {
+ return installMacUpdate(asset);
+ }
+
return await MANAGER.InstallUpdateAsync(asset);
}
+
+ // Updatum finds the .app through AppContext.BaseDirectory, which Godot points at Contents/Resources,
+ // so it would sync the update inside the running bundle instead of replacing it
+ private static bool installMacUpdate(UpdatumDownloadedAsset asset)
+ {
+ if (asset == null || !asset.FileExists)
+ {
+ return false;
+ }
+
+ string app = Platform.ShellQuote(Platform.MacAppBundlePath);
+ string zip = Platform.ShellQuote(asset.FilePath);
+
+ string script =
+ $"{Platform.WaitForExitScript}; "
+ + $"tmp=$(mktemp -d) && ditto -x -k {zip} \"$tmp\" "
+ + "&& new=$(find \"$tmp\" -maxdepth 1 -name '*.app' | head -n 1) && [ -n \"$new\" ] "
+ + $"&& mv {app} \"$tmp/old.app\" && {{ ditto \"$new\" {app} || mv \"$tmp/old.app\" {app}; }}; "
+ + $"xattr -dr com.apple.quarantine {app}; rm -rf \"$tmp\"; open {app}";
+
+ OS.CreateProcess("/bin/sh", ["-c", script]);
+ ((SceneTree)Engine.GetMainLoop()).Quit();
+
+ return true;
+ }
}