-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathDiscordPipeClient.cs
269 lines (238 loc) · 10.5 KB
/
DiscordPipeClient.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.IO.Pipes;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Shizou.AnimePresence;
public class DiscordPipeClient : IDisposable
{
private static readonly int ProcessId = Process.GetCurrentProcess().Id;
private readonly string _discordClientId;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private NamedPipeClientStream? _pipeClientStream;
private bool _isReady;
private readonly bool _allowRestricted;
public DiscordPipeClient(string discordClientId, bool allowRestricted)
{
_discordClientId = discordClientId;
_allowRestricted = allowRestricted;
}
public async Task ReadLoop(CancellationToken cancelToken)
{
while (!cancelToken.IsCancellationRequested)
{
try
{
if (_pipeClientStream is null)
{
await Connect(cancelToken);
continue;
}
var opCode = (Opcode)await ReadUInt32Async(_pipeClientStream, cancelToken);
var length = await ReadUInt32Async(_pipeClientStream, cancelToken);
var payload = new byte[length];
await _pipeClientStream.ReadExactlyAsync(payload, 0, Convert.ToInt32(length), cancelToken);
cancelToken.ThrowIfCancellationRequested();
switch (opCode)
{
case Opcode.Close:
var close = JsonSerializer.Deserialize(payload, CloseContext.Default.Close)!;
throw new InvalidOperationException($"Discord closed the connection with error {close.code}: {close.message}");
case Opcode.Frame:
var message = JsonSerializer.Deserialize(payload, MessageContext.Default.Message);
if (message?.evt == Event.ERROR.ToString())
throw new InvalidOperationException($"Discord returned error: {message.data}");
if (message?.evt == Event.READY.ToString())
_isReady = true;
break;
case Opcode.Ping:
var buff = new byte[length + 8];
BitConverter.GetBytes(Convert.ToUInt32(Opcode.Pong)).CopyTo(buff, 0);
BitConverter.GetBytes(length).CopyTo(buff, 4);
payload.CopyTo(buff, 8);
await _writeLock.WaitAsync(cancelToken);
await _pipeClientStream.WriteAsync(buff, cancelToken);
await _pipeClientStream.FlushAsync(cancelToken);
_writeLock.Release();
cancelToken.ThrowIfCancellationRequested();
break;
case Opcode.Pong:
break;
default:
throw new InvalidOperationException($"Discord sent unexpected payload: {opCode}: {Encoding.UTF8.GetString(payload)}");
}
}
catch (EndOfStreamException)
{
_isReady = false;
_pipeClientStream = null;
}
}
}
public async Task SetPresenceAsync(RichPresence? presence, CancellationToken cancelToken)
{
if (!_isReady)
return;
var cmd = new PresenceCommand(ProcessId, presence);
var frame = new Message(Command.SET_ACTIVITY.ToString(), null, Guid.NewGuid().ToString(), null, cmd);
await WriteFrameAsync(frame, cancelToken);
}
public void Dispose()
{
_pipeClientStream?.Dispose();
}
private async Task Connect(CancellationToken cancelToken)
{
while (_pipeClientStream?.IsConnected is not true)
{
for (var i = 0; i < 10; ++i)
{
_pipeClientStream = new NamedPipeClientStream(".", GetPipeName(i), PipeDirection.InOut, PipeOptions.Asynchronous);
try
{
await _pipeClientStream.ConnectAsync(TimeSpan.FromMilliseconds(200), cancelToken);
cancelToken.ThrowIfCancellationRequested();
}
catch (TimeoutException)
{
}
if (_pipeClientStream.IsConnected)
break;
}
if (_pipeClientStream?.IsConnected is true)
break;
await Task.Delay(TimeSpan.FromSeconds(10), cancelToken);
cancelToken.ThrowIfCancellationRequested();
}
await WriteFrameAsync(new HandShake(_discordClientId), cancelToken);
cancelToken.ThrowIfCancellationRequested();
static string GetTemporaryDirectory()
{
var temp = Environment.GetEnvironmentVariable("XDG_RUNTIME_DIR");
temp ??= Environment.GetEnvironmentVariable("TMPDIR");
temp ??= Environment.GetEnvironmentVariable("TMP");
temp ??= Environment.GetEnvironmentVariable("TEMP");
temp ??= "/tmp";
return temp;
}
static string GetPipeName(int pipe)
{
var pipeName = $"discord-ipc-{pipe}";
return Environment.OSVersion.Platform is PlatformID.Unix ? Path.Combine(GetTemporaryDirectory(), pipeName) : pipeName;
}
}
private async Task<uint> ReadUInt32Async(Stream stream, CancellationToken cancelToken)
{
var buff = new byte[4];
await stream.ReadExactlyAsync(buff, cancelToken);
cancelToken.ThrowIfCancellationRequested();
return BitConverter.ToUInt32(buff);
}
private async Task WriteFrameAsync<T>(T payload, CancellationToken cancelToken)
{
if (_pipeClientStream is null)
throw new InvalidOperationException("Pipe client can't be null");
Opcode opcode;
JsonTypeInfo jsonTypeInfo;
switch (payload)
{
case Message:
opcode = Opcode.Frame;
jsonTypeInfo = MessageContext.Default.Message;
break;
case Close:
opcode = Opcode.Close;
jsonTypeInfo = CloseContext.Default.Close;
break;
case HandShake:
opcode = Opcode.Handshake;
jsonTypeInfo = HandShakeContext.Default.HandShake;
break;
default:
throw new ArgumentOutOfRangeException(nameof(payload), payload, null);
}
var opCodeBytes = BitConverter.GetBytes(Convert.ToUInt32(opcode));
// var payloadString = JsonSerializer.Serialize(payload, jsonTypeInfo);
var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, jsonTypeInfo);
var lengthBytes = BitConverter.GetBytes(Convert.ToUInt32(payloadBytes.Length));
var buff = new byte[opCodeBytes.Length + lengthBytes.Length + payloadBytes.Length];
opCodeBytes.CopyTo(buff, 0);
lengthBytes.CopyTo(buff, opCodeBytes.Length);
payloadBytes.CopyTo(buff, opCodeBytes.Length + lengthBytes.Length);
await _writeLock.WaitAsync(cancelToken);
await _pipeClientStream.WriteAsync(buff, cancelToken);
await _pipeClientStream.FlushAsync(cancelToken);
_writeLock.Release();
cancelToken.ThrowIfCancellationRequested();
}
private static string SmartStringTrim(string str, int length)
{
if (str.Length <= length)
return str;
return str[..str[..(length + 1)].LastIndexOf(' ')] + "...";
}
public RichPresence? CreateNewPresence(QueryInfo queryInfo, bool paused, double playbackTime, double timeLeft)
{
if (paused || !_allowRestricted && queryInfo.Restricted)
return null;
return new RichPresence
{
details = SmartStringTrim(queryInfo.AnimeName, 64),
state = $"{queryInfo.EpisodeType} {queryInfo.EpisodeNumber}" +
(queryInfo.EpisodeType != "Episode" || queryInfo.EpisodeCount is null
? string.Empty
: $" of {queryInfo.EpisodeCount}"),
timestamps = TimeStamps.FromPlaybackPosition(playbackTime, timeLeft),
assets = new Assets
{
large_image = string.IsNullOrWhiteSpace(queryInfo.PosterUrl) ? "mpv" : queryInfo.PosterUrl,
large_text = string.IsNullOrWhiteSpace(queryInfo.EpisodeName) ? "mpv" : SmartStringTrim(queryInfo.EpisodeName, 64),
},
buttons = string.IsNullOrWhiteSpace(queryInfo.AnimeUrl) ? [] : [new Button { label = "View Anime", url = queryInfo.AnimeUrl }]
};
}
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Global")]
public record HandShake(string client_id, int v = 1);
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(HandShake))]
internal partial class HandShakeContext : JsonSerializerContext;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public record Message(string cmd, string? evt, string? nonce, object? data, object? args);
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Message))]
[JsonSerializable(typeof(PresenceCommand))]
internal partial class MessageContext : JsonSerializerContext;
[SuppressMessage("ReSharper", "InconsistentNaming")]
public record Close(int code, string message);
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Close))]
internal partial class CloseContext : JsonSerializerContext;
[JsonConverter(typeof(JsonStringEnumConverter<Command>))]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Command
{
DISPATCH,
SET_ACTIVITY
}
[JsonConverter(typeof(JsonStringEnumConverter<Event>))]
[SuppressMessage("ReSharper", "InconsistentNaming")]
public enum Event
{
READY,
ERROR
}
public enum Opcode
{
Handshake = 0,
Frame = 1,
Close = 2,
Ping = 3,
Pong = 4
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
[SuppressMessage("ReSharper", "NotAccessedPositionalProperty.Global")]
public record PresenceCommand(int pid, RichPresence? activity);