Skip to content

Commit 2f8a1aa

Browse files
committed
rpc update
1 parent 0e1908b commit 2f8a1aa

12 files changed

Lines changed: 405 additions & 15 deletions

File tree

0 Bytes
Binary file not shown.
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import 'dart:convert';
2+
import 'dart:ffi' as ffi;
3+
import 'dart:io';
4+
import 'dart:typed_data';
5+
6+
import 'package:ffi/ffi.dart';
7+
import 'package:win32/win32.dart';
8+
9+
class LauncherDiscordActivity {
10+
const LauncherDiscordActivity({
11+
required this.details,
12+
required this.state,
13+
required this.startTimestampSeconds,
14+
this.largeImageKey,
15+
this.largeImageText,
16+
this.smallImageKey,
17+
this.smallImageText,
18+
this.buttons = const <LauncherDiscordButton>[],
19+
});
20+
21+
final String details;
22+
final String state;
23+
final int startTimestampSeconds;
24+
final String? largeImageKey;
25+
final String? largeImageText;
26+
final String? smallImageKey;
27+
final String? smallImageText;
28+
final List<LauncherDiscordButton> buttons;
29+
30+
String get signature => '$details|$state';
31+
32+
Map<String, dynamic> toJson() {
33+
final activity = <String, dynamic>{
34+
'details': details,
35+
'timestamps': <String, dynamic>{'start': startTimestampSeconds},
36+
};
37+
if (_hasText(state)) {
38+
activity['state'] = state;
39+
}
40+
41+
final assets = <String, String>{};
42+
if (_hasText(largeImageKey)) {
43+
assets['large_image'] = largeImageKey!.trim();
44+
}
45+
if (_hasText(largeImageText)) {
46+
assets['large_text'] = largeImageText!.trim();
47+
}
48+
if (_hasText(smallImageKey)) {
49+
assets['small_image'] = smallImageKey!.trim();
50+
}
51+
if (_hasText(smallImageText)) {
52+
assets['small_text'] = smallImageText!.trim();
53+
}
54+
if (assets.isNotEmpty) {
55+
activity['assets'] = assets;
56+
}
57+
58+
final resolvedButtons = buttons
59+
.where((button) => _hasText(button.label) && _hasText(button.url))
60+
.take(2)
61+
.map((button) => button.toJson())
62+
.toList(growable: false);
63+
if (resolvedButtons.isNotEmpty) {
64+
activity['buttons'] = resolvedButtons;
65+
}
66+
67+
return activity;
68+
}
69+
}
70+
71+
class LauncherDiscordButton {
72+
const LauncherDiscordButton({required this.label, required this.url});
73+
74+
final String label;
75+
final String url;
76+
77+
Map<String, String> toJson() {
78+
return <String, String>{'label': label.trim(), 'url': url.trim()};
79+
}
80+
}
81+
82+
class LauncherDiscordRpcClient {
83+
LauncherDiscordRpcClient({
84+
required this.applicationId,
85+
int? sessionStartTimestampSeconds,
86+
}) : sessionStartTimestampSeconds =
87+
sessionStartTimestampSeconds ??
88+
DateTime.now().millisecondsSinceEpoch ~/ 1000;
89+
90+
final String applicationId;
91+
final int sessionStartTimestampSeconds;
92+
93+
int _pipeHandle = INVALID_HANDLE_VALUE;
94+
String _connectedApplicationId = '';
95+
int _nonceCounter = 0;
96+
97+
bool setActivity(LauncherDiscordActivity activity) {
98+
if (!Platform.isWindows || !_hasText(applicationId)) return false;
99+
if (!_ensureConnected()) return false;
100+
101+
final payload = jsonEncode(<String, dynamic>{
102+
'cmd': 'SET_ACTIVITY',
103+
'args': <String, dynamic>{'pid': pid, 'activity': activity.toJson()},
104+
'nonce': _nextNonce(),
105+
});
106+
107+
if (!_writeFrame(1, payload)) {
108+
_close();
109+
return false;
110+
}
111+
_drainIncomingMessages();
112+
return true;
113+
}
114+
115+
bool clearActivity() {
116+
if (!Platform.isWindows || !_hasText(applicationId)) return false;
117+
if (!_ensureConnected()) return false;
118+
119+
final payload = jsonEncode(<String, dynamic>{
120+
'cmd': 'SET_ACTIVITY',
121+
'args': <String, dynamic>{'pid': pid, 'activity': null},
122+
'nonce': _nextNonce(),
123+
});
124+
125+
if (!_writeFrame(1, payload)) {
126+
_close();
127+
return false;
128+
}
129+
_drainIncomingMessages();
130+
return true;
131+
}
132+
133+
void dispose() {
134+
_close();
135+
}
136+
137+
bool _ensureConnected() {
138+
if (_pipeHandle != INVALID_HANDLE_VALUE &&
139+
_connectedApplicationId == applicationId) {
140+
return true;
141+
}
142+
143+
_close();
144+
if (!_connect()) return false;
145+
146+
_connectedApplicationId = applicationId;
147+
if (_sendHandshake()) return true;
148+
149+
_close();
150+
return false;
151+
}
152+
153+
bool _connect() {
154+
for (var index = 0; index <= 9; index++) {
155+
final path = '\\\\.\\pipe\\discord-ipc-$index'.toNativeUtf16();
156+
try {
157+
final handle = CreateFile(
158+
path,
159+
GENERIC_READ | GENERIC_WRITE,
160+
0,
161+
ffi.nullptr,
162+
OPEN_EXISTING,
163+
0,
164+
NULL,
165+
);
166+
if (handle != INVALID_HANDLE_VALUE) {
167+
_pipeHandle = handle;
168+
return true;
169+
}
170+
} finally {
171+
calloc.free(path);
172+
}
173+
}
174+
175+
return false;
176+
}
177+
178+
bool _sendHandshake() {
179+
final payload = jsonEncode(<String, dynamic>{
180+
'v': 1,
181+
'client_id': applicationId,
182+
});
183+
if (!_writeFrame(0, payload)) return false;
184+
_drainIncomingMessages();
185+
return true;
186+
}
187+
188+
bool _writeFrame(int opcode, String payload) {
189+
if (_pipeHandle == INVALID_HANDLE_VALUE) return false;
190+
191+
final payloadBytes = Uint8List.fromList(utf8.encode(payload));
192+
final header = ByteData(8)
193+
..setInt32(0, opcode, Endian.little)
194+
..setInt32(4, payloadBytes.length, Endian.little);
195+
196+
if (!_writeAll(header.buffer.asUint8List())) return false;
197+
if (payloadBytes.isEmpty) return true;
198+
return _writeAll(payloadBytes);
199+
}
200+
201+
bool _writeAll(Uint8List bytes) {
202+
if (_pipeHandle == INVALID_HANDLE_VALUE) return false;
203+
if (bytes.isEmpty) return true;
204+
205+
final buffer = calloc<ffi.Uint8>(bytes.length);
206+
final bytesWritten = calloc<ffi.Uint32>();
207+
try {
208+
buffer.asTypedList(bytes.length).setAll(0, bytes);
209+
final ok = WriteFile(
210+
_pipeHandle,
211+
buffer,
212+
bytes.length,
213+
bytesWritten,
214+
ffi.nullptr.cast(),
215+
);
216+
return ok != 0 && bytesWritten.value == bytes.length;
217+
} finally {
218+
calloc.free(bytesWritten);
219+
calloc.free(buffer);
220+
}
221+
}
222+
223+
void _drainIncomingMessages() {
224+
if (_pipeHandle == INVALID_HANDLE_VALUE) return;
225+
226+
final available = calloc<ffi.Uint32>();
227+
final read = calloc<ffi.Uint32>();
228+
final buffer = calloc<ffi.Uint8>(4096);
229+
try {
230+
while (PeekNamedPipe(
231+
_pipeHandle,
232+
ffi.nullptr,
233+
0,
234+
ffi.nullptr.cast(),
235+
available,
236+
ffi.nullptr.cast(),
237+
) !=
238+
0 &&
239+
available.value > 0) {
240+
final chunk = available.value > 4096 ? 4096 : available.value;
241+
final ok = ReadFile(
242+
_pipeHandle,
243+
buffer,
244+
chunk,
245+
read,
246+
ffi.nullptr.cast(),
247+
);
248+
if (ok == 0 || read.value == 0) {
249+
break;
250+
}
251+
}
252+
} finally {
253+
calloc.free(buffer);
254+
calloc.free(read);
255+
calloc.free(available);
256+
}
257+
}
258+
259+
void _close() {
260+
if (_pipeHandle != INVALID_HANDLE_VALUE) {
261+
CloseHandle(_pipeHandle);
262+
_pipeHandle = INVALID_HANDLE_VALUE;
263+
}
264+
_connectedApplicationId = '';
265+
}
266+
267+
String _nextNonce() {
268+
_nonceCounter += 1;
269+
return 'atlas-launcher-$_nonceCounter';
270+
}
271+
}
272+
273+
bool _hasText(String? value) {
274+
return value != null && value.trim().isNotEmpty;
275+
}

0 commit comments

Comments
 (0)