Skip to content

Commit 9a28465

Browse files
author
Bartłomiej Dach
authored
Implement WebSocket server skeleton for external integrations (ppy#37335)
- Supersedes / closes ppy#18129. Reasons I didn't use that PR are hopefully obvious upon comparing diffs but I can elaborate if they are not. - Single metric included for demonstration purposes. - Do not want to talk about further schema design at this time. - Specify `OSU_WEBSOCKET_SERVER=1` envvar to enable. - Can test consumption with [this five minute html job](https://github.com/user-attachments/files/26839923/index.html) (works even as a standalone file opened in browser, no CORS bs!) - There's a lot of inline comments, go read them. There are many WTFs because the .NET frozen websocket API is weird and stanky and reeks of the year 2007. The inline comments attempt to explain.
1 parent a7ac628 commit 9a28465

9 files changed

Lines changed: 899 additions & 1 deletion

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using Newtonsoft.Json;
5+
6+
namespace osu.Desktop.IPC.Messages
7+
{
8+
public class HitCountMessage : OsuWebSocketMessage
9+
{
10+
[JsonProperty("new_hits")]
11+
public long NewHits { get; init; }
12+
}
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using Newtonsoft.Json;
5+
using osu.Framework.Extensions.TypeExtensions;
6+
7+
namespace osu.Desktop.IPC.Messages
8+
{
9+
public abstract class OsuWebSocketMessage
10+
{
11+
[JsonProperty("type")]
12+
public string Type { get; }
13+
14+
protected OsuWebSocketMessage()
15+
{
16+
Type = GetType().ReadableName();
17+
}
18+
}
19+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.Linq;
6+
using System.Threading;
7+
using osu.Desktop.IPC.Messages;
8+
using osu.Framework.Allocation;
9+
using osu.Framework.Bindables;
10+
using osu.Framework.Extensions;
11+
using osu.Framework.Graphics;
12+
using osu.Framework.Logging;
13+
using osu.Game.Configuration;
14+
using osu.Game.IPC;
15+
using osu.Game.Online.Multiplayer;
16+
using osu.Game.Rulesets.Scoring;
17+
using osu.Game.Scoring;
18+
using JsonConvert = Newtonsoft.Json.JsonConvert;
19+
20+
namespace osu.Desktop.IPC
21+
{
22+
public partial class OsuWebSocketProvider : Component
23+
{
24+
private WebSocketServer? server;
25+
private readonly Bindable<ScoreInfo> lastLocalScore = new Bindable<ScoreInfo>();
26+
27+
[BackgroundDependencyLoader]
28+
private void load(SessionStatics sessionStatics)
29+
{
30+
server = new WebSocketServer(49727);
31+
server.StartAsync().FireAndForget(onError: ex => Logger.Error(ex, "Failed to start websocket"));
32+
33+
sessionStatics.BindWith(Static.LastLocalUserScore, lastLocalScore);
34+
}
35+
36+
protected override void LoadComplete()
37+
{
38+
base.LoadComplete();
39+
40+
lastLocalScore.BindValueChanged(val =>
41+
{
42+
if (val.NewValue == null)
43+
return;
44+
45+
if (server?.IsRunning != true)
46+
return;
47+
48+
var msg = new HitCountMessage { NewHits = val.NewValue.Statistics.Where(kv => kv.Key.IsBasic() && kv.Key.IsHit()).Sum(kv => kv.Value) };
49+
broadcast(msg);
50+
});
51+
}
52+
53+
private void broadcast(OsuWebSocketMessage message)
54+
{
55+
if (server?.IsRunning != true)
56+
return;
57+
58+
string messageString = JsonConvert.SerializeObject(message);
59+
server.BroadcastAsync(messageString).FireAndForget();
60+
}
61+
62+
protected override void Dispose(bool isDisposing)
63+
{
64+
base.Dispose(isDisposing);
65+
66+
if (server?.IsRunning == true)
67+
{
68+
var cts = new CancellationTokenSource();
69+
cts.CancelAfter(TimeSpan.FromSeconds(10));
70+
server.StopAsync(cts.Token).WaitSafely();
71+
server = null;
72+
}
73+
}
74+
}
75+
}

osu.Desktop/OsuGameDesktop.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using System.Reflection;
77
using System.Runtime.Versioning;
88
using Microsoft.Win32;
9+
using osu.Desktop.IPC;
910
using osu.Desktop.Performance;
1011
using osu.Desktop.Security;
1112
using osu.Framework.Platform;
@@ -35,6 +36,8 @@ internal partial class OsuGameDesktop : OsuGame
3536

3637
public bool IsFirstRun { get; init; }
3738

39+
public bool EnableWebSocketServer { get; init; }
40+
3841
public OsuGameDesktop(string[]? args = null)
3942
: base(args)
4043
{
@@ -148,6 +151,9 @@ protected override void LoadComplete()
148151

149152
osuSchemeLinkIPCChannel = new OsuSchemeLinkIPCChannel(Host, this);
150153
archiveImportIPCChannel = new ArchiveImportIPCChannel(Host, this);
154+
155+
if (EnableWebSocketServer)
156+
Add(new OsuWebSocketProvider());
151157
}
152158

153159
public override void SetHost(GameHost host)

osu.Desktop/Program.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,8 @@ public static void Main(string[] args)
140140
{
141141
host.Run(new OsuGameDesktop(args)
142142
{
143-
IsFirstRun = isFirstRun
143+
IsFirstRun = isFirstRun,
144+
EnableWebSocketServer = Environment.GetEnvironmentVariable("OSU_WEBSOCKET_SERVER") == "1",
144145
});
145146
}
146147
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using System.Net.WebSockets;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using osu.Game.IPC;
9+
10+
namespace osu.Game.Tests.IPC
11+
{
12+
public sealed class WebSocketClient : IDisposable
13+
{
14+
public event Action<string>? MessageReceived;
15+
public event Action? Closed;
16+
17+
private readonly int port;
18+
private WebSocketChannel? channel;
19+
20+
public WebSocketClient(int port)
21+
{
22+
this.port = port;
23+
}
24+
25+
public async Task Start(CancellationToken cancellationToken = default)
26+
{
27+
var webSocket = new ClientWebSocket();
28+
await webSocket.ConnectAsync(new Uri($@"ws://localhost:{port}/"), cancellationToken);
29+
channel = new WebSocketChannel(webSocket);
30+
channel.MessageReceived += msg => MessageReceived?.Invoke(msg);
31+
channel.ClosedPrematurely += () => Closed?.Invoke();
32+
channel.Start(cancellationToken);
33+
}
34+
35+
public async Task SendAsync(string message)
36+
{
37+
if (channel == null)
38+
throw new InvalidOperationException($@"Must {nameof(Start)} first.");
39+
40+
await channel.SendAsync(message);
41+
}
42+
43+
public async Task StopAsync(CancellationToken stoppingToken = default)
44+
{
45+
try
46+
{
47+
if (channel != null)
48+
await channel.StopAsync(stoppingToken).ConfigureAwait(false);
49+
}
50+
catch (OperationCanceledException)
51+
{
52+
// has to be caught manually because outer task isn't accepting `stoppingToken`.
53+
}
54+
}
55+
56+
public void Dispose()
57+
{
58+
channel?.Dispose();
59+
}
60+
}
61+
}

0 commit comments

Comments
 (0)