Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,48 @@ jobs:
exit 1
fi

# Architecture sanity check: a previous build packaged the Linux-glibc
# libbass.so from ppy.osu.Framework.NativeLibs into lib/arm64-v8a/, which
# passed the name check above but failed at runtime with
# System.DllNotFoundException: bass because Android's bionic linker cannot
# resolve glibc-only symbols. Both Linux and Android builds report
# identically as "ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV)"
# via file(1), so we rely on the presence of GLIBC_ versioned symbols
# (which exist only in glibc-linked binaries) to distinguish them.
echo ""
echo "All required native libraries present ✓"
echo "Verifying native library architectures (must be Android arm64, not Linux glibc)..."
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
BAD=0
for LIB in libbass.so libbass_fx.so libbassmix.so; do
unzip -p "$APK" "lib/arm64-v8a/$LIB" > "$TMPDIR/$LIB"
FILE_INFO=$(file "$TMPDIR/$LIB")
echo " $LIB: $FILE_INFO"
# Must be a 64-bit aarch64 ELF shared object.
if ! echo "$FILE_INFO" | grep -qE "ELF 64-bit.*aarch64|ELF 64-bit.*ARM aarch64"; then
echo "::error::$LIB is not a 64-bit aarch64 ELF — runtime DllNotFoundException will occur."
BAD=1
continue
fi
# Reliable Linux-vs-Android distinguisher: GLIBC_ versioned symbols
# (e.g. memcpy@@GLIBC_2.17) appear only in glibc-linked Linux binaries.
# Android's bionic libc uses no symbol versioning.
if strings "$TMPDIR/$LIB" | grep -q "^GLIBC_"; then
echo "::error::$LIB references GLIBC_ versioned symbols — this is the Linux ELF from ppy.osu.Framework.NativeLibs runtimes/linux-arm64/native/, not the Android ELF from ppy.osu.Framework.Android. Android's bionic linker cannot load it; the app will crash at startup with System.DllNotFoundException: bass."
BAD=1
continue
fi
echo " ✅ $LIB is a valid Android arm64 ELF"
done

if [ "$BAD" -ne 0 ]; then
echo ""
echo "::error::One or more native libraries in the APK are NOT valid Android arm64 binaries. This usually means desktop runtime .so files (e.g. from ppy.osu.Framework.NativeLibs runtimes/linux-arm64/native/) leaked into lib/arm64-v8a/ during packaging. See osu.Android.props FixRuntimePackAssetTypes target."
exit 1
fi

echo ""
echo "All required native libraries present and valid ✓"

- name: Upload APK artifact
uses: actions/upload-artifact@v7
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,15 @@ Settings → Graphics → Renderer now exposes the full set of fork-added option

### 🛡️ Stability improvements

This fork includes several crash fixes on top of upstream:
This fork includes several hardening fixes on top of upstream:

- **Sentry crash fix** — the app no longer crashes on startup when the error reporting service can't initialise (e.g. with a placeholder DSN)
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them instead of crashing
- **Sentry-safe init** — the app gracefully handles a missing/placeholder Sentry DSN instead of failing on startup
- **Graceful native library loading** — if the Oboe or Vulkan native libraries are missing, the app continues without them
- **JNI surface safety** — proper lifecycle management with atomic swaps and timeouts to prevent race conditions between Android surface creation and destruction
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming to prevent `TypeLoadException` crashes in release builds
- **Trimmer-safe builds** — critical reflection-heavy assemblies are protected from .NET IL trimming so release builds behave the same as debug
- **Architecture-correct native libraries (v144+)** — `osu.Android.props` strips desktop runtime `.so` files (`runtimes/{linux,osx,ios,maccatalyst,win,…}-*/native/`) from the Android publish set and only marks Android-RID assets as `AssetType=native`, so the proper Android arm64 BASS libraries from `ppy.osu.Framework.Android`'s AAR (`jni/arm64-v8a/`) always win over the desktop `.so` files transitively pulled in by `ppy.osu.Framework.NativeLibs`. The release workflow scans every shipped `libbass*.so` for `GLIBC_*` versioned symbols (only present in glibc-linked Linux ELFs) and fails the build if any are found, so an architecture mismatch can never reach a release.
- **IPC / WebSocket hardening (v144+)** — desktop external-integrations server (env-var gated, `localhost`-only) tightened on top of upstream: `WebSocketChannel` now uses a strict `UTF8Encoding(throwOnInvalidBytes: true)` decoder so malformed payloads are rejected with `InvalidPayloadData` instead of being silently replaced with `U+FFFD`, and the message-size guard accepts payloads of exactly `max_message_size` bytes (was off-by-one); `WebSocketServer.Dispose()` now cancels the request loop and waits briefly for it to exit before tearing down the cancellation/reset-event handles to avoid an `ObjectDisposedException` race on shutdown; `OsuWebSocketProvider.Dispose()` swaps the server reference under a local, properly disposes the bounded `CancellationTokenSource` via `using`, and always disposes the `WebSocketServer` in a `finally` so listener handles can't leak across screen transitions.
- **Ranked-play song-preview playback (v144+)** — restored the `Enabled`/`CardHovered` → `PreviewTrack.Start()/Stop()` wiring on `RankedPlayCard.SongPreviewContainer` that was lost in upstream's "playback rewrite" merge. The bind now happens in the `LoadComponentAsync` continuation (so previews never race the track's async load), with both bindables driving a single `updatePlaybackState()` callback.

---

Expand Down
37 changes: 35 additions & 2 deletions osu.Android.props
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,46 @@
Only .so files need the AssetType override — the previous broader filter
(all non-DLL, non-PDB) incorrectly reclassified signing metadata and config
files, which corrupted the APK signature (INSTALL_PARSE_FAILED_NO_CERTIFICATES).
Scoped to Release only to avoid interfering with Debug builds. -->
Scoped to Release only to avoid interfering with Debug builds.

IMPORTANT: only Android-runtime .so files may be marked as native and packaged
into the APK. ppy.osu.Framework transitively depends on ppy.osu.Framework.NativeLibs
which ships desktop-only natives under runtimes/linux-arm64/native/, runtimes/osx/native/,
runtimes/win-*/native/ etc. — including a bare libbass.so, libbass_fx.so, libbassmix.so
for Linux. If any of those are marked AssetType=native, the .NET Android SDK packs them
into lib/arm64-v8a/ of the APK, racing with (and replacing) the proper Android arm64
libbass*.so coming from ppy.osu.Framework.Android's AAR (jni/arm64-v8a/). The Linux ELF
is linked against glibc and cannot be loaded by Android's bionic dynamic linker, which
surfaces at startup as System.DllNotFoundException: bass from AudioManager..ctor →
ManagedBass.Bass.get_DeviceCount, immediately crashing the app. -->
<Target Name="FixRuntimePackAssetTypes" AfterTargets="ResolveRuntimePackAssets;ComputeFilesToPublish;ComputeResolvedFilesToPublishList"
Condition="'$(Configuration)' == 'Release'">
<ItemGroup>
<RuntimePackAsset Update="@(RuntimePackAsset)" Condition="'%(Extension)' == '.so'">
<!-- Strip desktop/iOS runtime .so files from the Android publish set entirely.
Match path components for any non-Android RID known to ship .so files.
Path normalisation handles both Windows (\) and Unix (/) separators. -->
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)"
Condition="'%(Extension)' == '.so'
AND ($([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/linux-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/linux/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/osx-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/osx/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/ios-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/ios/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/maccatalyst-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/win-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/win/'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/browser-'))
OR $([System.String]::Copy('%(Identity)').Replace('\','/').Contains('/runtimes/freebsd-')))" />
<!-- Only mark Android-RID runtime pack assets as native. RuntimePackAsset items carry
an explicit RuntimeIdentifier metadata, so this filter is exact. -->
<RuntimePackAsset Update="@(RuntimePackAsset)"
Condition="'%(Extension)' == '.so'
AND $([System.String]::Copy('%(RuntimeIdentifier)').StartsWith('android'))">
<AssetType>native</AssetType>
</RuntimePackAsset>
<!-- Mark the surviving (Android-only after the Remove above) publish .so files as native
so the Android SDK packs them into lib/<abi>/ rather than dropping them as data. -->
<ResolvedFileToPublish Update="@(ResolvedFileToPublish)" Condition="'%(Extension)' == '.so'">
<AssetType>native</AssetType>
</ResolvedFileToPublish>
Expand Down
13 changes: 13 additions & 0 deletions osu.Desktop/IPC/Messages/HitCountMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Newtonsoft.Json;

namespace osu.Desktop.IPC.Messages
{
public class HitCountMessage : OsuWebSocketMessage
{
[JsonProperty("new_hits")]
public long NewHits { get; init; }
}
}
19 changes: 19 additions & 0 deletions osu.Desktop/IPC/Messages/OsuWebSocketMessage.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using Newtonsoft.Json;
using osu.Framework.Extensions.TypeExtensions;

namespace osu.Desktop.IPC.Messages
{
public abstract class OsuWebSocketMessage
{
[JsonProperty("type")]
public string Type { get; }

protected OsuWebSocketMessage()
{
Type = GetType().ReadableName();
}
}
}
86 changes: 86 additions & 0 deletions osu.Desktop/IPC/OsuWebSocketProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Linq;
using System.Threading;
using osu.Desktop.IPC.Messages;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Configuration;
using osu.Game.IPC;
using osu.Game.Online.Multiplayer;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using JsonConvert = Newtonsoft.Json.JsonConvert;

namespace osu.Desktop.IPC
{
public partial class OsuWebSocketProvider : Component
{
private WebSocketServer? server;
private readonly Bindable<ScoreInfo> lastLocalScore = new Bindable<ScoreInfo>();

[BackgroundDependencyLoader]
private void load(SessionStatics sessionStatics)
{
server = new WebSocketServer(49727);
server.StartAsync().FireAndForget(onError: ex => Logger.Error(ex, "Failed to start websocket"));

sessionStatics.BindWith(Static.LastLocalUserScore, lastLocalScore);
}

protected override void LoadComplete()
{
base.LoadComplete();

lastLocalScore.BindValueChanged(val =>
{
if (val.NewValue == null)
return;

if (server?.IsRunning != true)
return;

var msg = new HitCountMessage { NewHits = val.NewValue.Statistics.Where(kv => kv.Key.IsBasic() && kv.Key.IsHit()).Sum(kv => kv.Value) };
broadcast(msg);
});
}

private void broadcast(OsuWebSocketMessage message)
{
if (server?.IsRunning != true)
return;

string messageString = JsonConvert.SerializeObject(message);
server.BroadcastAsync(messageString).FireAndForget();
}

protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

var localServer = server;
server = null;

if (localServer == null)
return;

try
{
if (localServer.IsRunning)
{
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
localServer.StopAsync(cts.Token).WaitSafely();
}
}
finally
{
localServer.Dispose();
}
}
}
}
6 changes: 6 additions & 0 deletions osu.Desktop/OsuGameDesktop.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Reflection;
using System.Runtime.Versioning;
using Microsoft.Win32;
using osu.Desktop.IPC;
using osu.Desktop.MacOS;
using osu.Desktop.Performance;
using osu.Desktop.Security;
Expand Down Expand Up @@ -35,6 +36,8 @@ internal partial class OsuGameDesktop : OsuGame

public bool IsFirstRun { get; init; }

public bool EnableWebSocketServer { get; init; }

public OsuGameDesktop(string[]? args = null)
: base(args)
{
Expand Down Expand Up @@ -148,6 +151,9 @@ protected override void LoadComplete()

osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this);

if (EnableWebSocketServer)
Add(new OsuWebSocketProvider());
}

public override void SetHost(GameHost host)
Expand Down
3 changes: 2 additions & 1 deletion osu.Desktop/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@ public static void Main(string[] args)
{
host.Run(new OsuGameDesktop(args)
{
IsFirstRun = isFirstRun
IsFirstRun = isFirstRun,
EnableWebSocketServer = Environment.GetEnvironmentVariable("OSU_WEBSOCKET_SERVER") == "1",
});
}
}
Expand Down
61 changes: 61 additions & 0 deletions osu.Game.Tests/IPC/WebSocketClient.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.IPC;

namespace osu.Game.Tests.IPC
{
public sealed class WebSocketClient : IDisposable
{
public event Action<string>? MessageReceived;
public event Action? Closed;

private readonly int port;
private WebSocketChannel? channel;

public WebSocketClient(int port)
{
this.port = port;
}

public async Task Start(CancellationToken cancellationToken = default)
{
var webSocket = new ClientWebSocket();
await webSocket.ConnectAsync(new Uri($@"ws://localhost:{port}/"), cancellationToken);
channel = new WebSocketChannel(webSocket);
channel.MessageReceived += msg => MessageReceived?.Invoke(msg);
channel.ClosedPrematurely += () => Closed?.Invoke();
channel.Start(cancellationToken);
}

public async Task SendAsync(string message)
{
if (channel == null)
throw new InvalidOperationException($@"Must {nameof(Start)} first.");

await channel.SendAsync(message);
}

public async Task StopAsync(CancellationToken stoppingToken = default)
{
try
{
if (channel != null)
await channel.StopAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// has to be caught manually because outer task isn't accepting `stoppingToken`.
}
}

public void Dispose()
{
channel?.Dispose();
}
}
}
Loading
Loading