ogi-addon 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/main.ts CHANGED
@@ -1,400 +1,85 @@
1
- import ws, { WebSocket } from 'ws';
1
+ import { EventResponseSocket, randomMessageId } from '@ogi-sdk/connect';
2
+ import type {
3
+ AddonClientToServerEventArgs,
4
+ AddonClientToServerEventName,
5
+ AddonClientToServerWebsocketMessage,
6
+ AddonNotificationMessage,
7
+ AddonProtocolEventListenerTypes,
8
+ AddonSDKLifecycleEventListenerTypes,
9
+ AddonServerToClientWebsocketMessage,
10
+ BasicLibraryInfo,
11
+ CatalogResponse,
12
+ LibraryInfo,
13
+ OGIAddonConfiguration,
14
+ OGIAddonSDKEventListener,
15
+ SearchResult,
16
+ SetupResponse,
17
+ StoreData,
18
+ AddonTaskRunEventArgs,
19
+ } from '@ogi-sdk/connect';
2
20
  import events from 'node:events';
3
21
  import { ConfigurationBuilder } from './config/ConfigurationBuilder';
4
- import type { ConfigurationFile } from './config/ConfigurationBuilder';
5
- import { Configuration } from './config/Configuration';
22
+ import { Configuration, DefiniteConfig } from './config/Configuration';
6
23
  import EventResponse from './EventResponse';
7
- import type { SearchResult } from './SearchEngine';
8
24
  import Fuse, { IFuseOptions } from 'fuse.js';
9
25
 
10
- /**
11
- * Exposed events that the programmer can use to listen to and emit events.
12
- */
13
- export type OGIAddonEvent =
14
- | 'connect'
15
- | 'disconnect'
16
- | 'configure'
17
- | 'authenticate'
18
- | 'search'
19
- | 'setup'
20
- | 'library-search'
21
- | 'game-details'
22
- | 'exit'
23
- | 'check-for-updates'
24
- | 'request-dl'
25
- | 'catalog'
26
- | 'launch-app';
27
-
28
- /**
29
- * The events that the client can send to the server and are handled by the server.
30
- */
31
- export type OGIAddonClientSentEvent =
32
- | 'response'
33
- | 'authenticate'
34
- | 'configure'
35
- | 'defer-update'
36
- | 'notification'
37
- | 'input-asked'
38
- | 'get-app-details'
39
- | 'search-app-name'
40
- | 'flag'
41
- | 'task-update';
42
-
43
- /**
44
- * The events that the server sends to the client
45
- * This is the events that the server can send to the client and are handled by the client.
46
- */
47
- export type OGIAddonServerSentEvent =
48
- | 'authenticate'
49
- | 'configure'
50
- | 'config-update'
51
- | 'launch-app'
52
- | 'search'
53
- | 'setup'
54
- | 'response'
55
- | 'library-search'
56
- | 'check-for-updates'
57
- | 'task-run'
58
- | 'game-details'
59
- | 'request-dl'
60
- | 'catalog';
61
26
  export { ConfigurationBuilder, Configuration, EventResponse };
62
- export type { SearchResult };
27
+ export { extraction };
63
28
  const defaultPort = 7654;
64
29
  import pjson from '../package.json';
65
- import { exec, spawn } from 'node:child_process';
66
- import fs from 'node:fs';
67
30
  import { z } from 'zod';
31
+ import { extraction } from './extraction';
68
32
  export const VERSION = pjson.version;
69
33
 
70
- export interface ClientSentEventTypes {
71
- response: any;
72
- authenticate: {
73
- name: string;
74
- id: string;
75
- description: string;
76
- version: string;
77
- author: string;
78
- };
79
- configure: ConfigurationFile;
80
- 'defer-update': {
81
- logs: string[];
82
- progress: number;
83
- };
84
- notification: Notification;
85
- 'input-asked': ConfigurationBuilder<
86
- Record<string, string | number | boolean>
87
- >;
88
- 'task-update': {
89
- id: string;
90
- progress: number;
91
- logs: string[];
92
- finished: boolean;
93
- failed: string | undefined;
94
- };
95
- 'get-app-details': {
96
- appID: number;
97
- storefront: string;
98
- };
99
- 'search-app-name': {
100
- query: string;
101
- storefront: string;
102
- };
103
- flag: {
104
- flag: string;
105
- value: string | string[];
106
- };
107
- }
108
-
109
- export type BasicLibraryInfo = {
110
- name: string;
111
- capsuleImage: string;
112
- appID: number;
113
- storefront: string;
114
- };
115
-
116
- export interface CatalogSection {
117
- name: string;
118
- description: string;
119
- listings: BasicLibraryInfo[];
120
- }
121
-
122
- export interface CatalogCarouselItem {
123
- name: string;
124
- description: string;
125
- carouselImage: string;
126
- fullBannerImage?: string;
127
- appID?: number;
128
- storefront?: string;
129
- capsuleImage?: string;
130
- }
131
-
132
- export interface CatalogWithCarousel {
133
- sections: Record<string, CatalogSection>;
134
- carousel?: Record<string, CatalogCarouselItem> | CatalogCarouselItem[];
135
- }
136
-
137
- export type CatalogResponse =
138
- | Record<string, CatalogSection>
139
- | CatalogWithCarousel;
140
-
141
- /**
142
- * UMU ID format: 'steam:${number}' or 'umu:${string | number}'
143
- * - steam:${number} → maps to umu-${number} for Steam games
144
- * - umu:${string | number} → maps to umu-${string | number} for non-Steam games
145
- */
146
- export type UmuId = `steam:${number}` | `umu:${string | number}`;
147
-
148
- export type SetupEventResponse = Omit<
34
+ export type {
35
+ AddonClientToServerEventArgs,
36
+ AddonClientToServerEventName,
37
+ AddonClientToServerWebsocketMessage,
38
+ AddonNotificationMessage,
39
+ AddonServerToClientEventName,
40
+ AddonServerToClientWebsocketMessage,
41
+ BasicLibraryInfo,
42
+ CatalogCarouselItem,
43
+ CatalogResponse,
44
+ CatalogSection,
45
+ CatalogWithCarousel,
46
+ ConfigurationFile,
47
+ ConfigurationOptionType,
48
+ ConfigurationOptionWire,
149
49
  LibraryInfo,
150
- | 'capsuleImage'
151
- | 'coverImage'
152
- | 'name'
153
- | 'appID'
154
- | 'storefront'
155
- | 'addonsource'
156
- | 'titleImage'
157
- > & {
158
- redistributables?: {
159
- name: string;
160
- path: string;
161
- }[];
162
- /**
163
- * UMU Proton integration configuration
164
- */
165
- umu?: {
166
- /**
167
- * UMU ID for the game. Format: 'steam:${number}' or 'umu:${string | number}'
168
- * - steam:${number} → maps to umu-${number} for Steam games
169
- * - umu:${string | number} → maps to umu-${string | number} for non-Steam games
170
- */
171
- umuId: UmuId;
172
- /**
173
- * Optional DLL overrides. Can be WINEDLLOVERRIDES-style (e.g. "dinput8=n,b") or bare DLL names.
174
- * Bare names get "=n,b" inferred; entries that already include "=..." are used as-is.
175
- */
176
- dllOverrides?: string[];
177
- /**
178
- * Optional Proton version to use (e.g., 'GE-Proton9-5', 'GE-Proton')
179
- * If not specified, uses latest UMU-Proton
180
- */
181
- protonVersion?: string;
182
- /**
183
- * Optional store identifier for protonfixes (e.g., 'gog', 'egs', 'none')
184
- */
185
- store?: string;
186
- /**
187
- * Cached Steam shortcut app ID after adding UMU game to Steam (avoids re-adding on each launch)
188
- */
189
- steamShortcutId?: number;
190
- };
191
- };
192
-
193
- export interface EventListenerTypes {
194
- /**
195
- * This event is emitted when the addon connects to the OGI Addon Server. Addon does not need to resolve anything.
196
- * @param event
197
- * @returns
198
- */
199
- connect: (event: EventResponse<void>) => void;
200
-
201
- /**
202
- * This event is emitted when the client requests for the addon to disconnect. Addon does not need to resolve this event, but we recommend `process.exit(0)` so the addon can exit gracefully instead of by force by the addon server.
203
- * @param reason
204
- * @returns
205
- */
206
- disconnect: (reason: string) => void;
207
- /**
208
- * This event is emitted when the client requests for the addon to configure itself. Addon should resolve the event with the internal configuration. (See ConfigurationBuilder)
209
- * @param config
210
- * @returns
211
- */
212
- configure: (config: ConfigurationBuilder) => ConfigurationBuilder;
213
- /**
214
- * This event is called when the client provides a response to any event. This should be treated as middleware.
215
- * @param response
216
- * @returns
217
- */
218
- response: (response: any) => void;
219
-
220
- /**
221
- * This event is called when the client requests for the addon to authenticate itself. You don't need to provide any info.
222
- * @param config
223
- * @returns
224
- */
225
- authenticate: (config: any) => void;
226
- /**
227
- * This event is emitted when the client requests for a torrent/direct download search to be performed. Addon is given the gameID (could be a steam appID or custom store appID), along with the storefront type. Addon should resolve the event with the search results. (See SearchResult)
228
- * @param query
229
- * @param event
230
- * @returns
231
- */
232
- search: (
233
- query: {
234
- storefront: string;
235
- appID: number;
236
- } & (
237
- | {
238
- for: 'game' | 'task' | 'all';
239
- }
240
- | {
241
- for: 'update';
242
- libraryInfo: LibraryInfo;
243
- }
244
- ),
245
- event: EventResponse<SearchResult[]>
246
- ) => void;
247
- /**
248
- * This event is emitted when the client requests for app setup to be performed. Addon should resolve the event with the metadata for the library entry. (See LibraryInfo)
249
- * @param data
250
- * @param event
251
- * @returns
252
- */
253
- setup: (
254
- data: {
255
- path: string;
256
- type: 'direct' | 'torrent' | 'magnet' | 'empty';
257
- name: string;
258
- usedRealDebrid: boolean;
259
- clearOldFilesBeforeUpdate?: boolean;
260
- multiPartFiles?: {
261
- name: string;
262
- downloadURL: string;
263
- }[];
264
- appID: number;
265
- storefront: string;
266
- manifest?: Record<string, unknown>;
267
- } & (
268
- | {
269
- for: 'game';
270
- }
271
- | {
272
- for: 'update';
273
- currentLibraryInfo: LibraryInfo;
274
- }
275
- ),
276
- event: EventResponse<SetupEventResponse>
277
- ) => void;
278
-
279
- /**
280
- * This event is emitted when the client requires for a search to be performed. Input is the search query.
281
- * @param query
282
- * @param event
283
- * @returns
284
- */
285
- 'library-search': (
286
- query: string,
287
- event: EventResponse<BasicLibraryInfo[]>
288
- ) => void;
289
-
290
- /**
291
- * This event is emitted when the client requests for a game details to be fetched. Addon should resolve the event with the game details. This is used to generate a store page for the game.
292
- * @param appID
293
- * @param event
294
- * @returns
295
- */
296
- 'game-details': (
297
- details: { appID: number; storefront: string },
298
- event: EventResponse<StoreData | undefined>
299
- ) => void;
300
-
301
- /**
302
- * This event is emitted when the client requests for the addon to exit. Use this to perform any cleanup tasks, ending with a `process.exit(0)`.
303
- * @returns
304
- */
305
- exit: () => void;
306
-
307
- /**
308
- * This event is emitted when the client requests for a download to be performed with the 'request' type. Addon should resolve the event with a SearchResult containing the actual download info.
309
- * @param appID
310
- * @param info
311
- * @param event
312
- * @returns
313
- */
314
- 'request-dl': (
315
- appID: number,
316
- info: SearchResult,
317
- event: EventResponse<SearchResult>
318
- ) => void;
319
-
320
- /**
321
- * This event is emitted when the client requests for a catalog to be fetched. Addon should resolve the event with the catalog.
322
- * @param event
323
- * @returns
324
- */
325
- catalog: (event: Omit<EventResponse<CatalogResponse>, 'askForInput'>) => void;
326
-
327
- /**
328
- * This event is emitted when the client requests for an addon to check for updates. Addon should resolve the event with the update information.
329
- * @param data
330
- * @param event
331
- * @returns
332
- */
333
- 'check-for-updates': (
334
- data: { appID: number; storefront: string; currentVersion: string },
335
- event: EventResponse<
336
- | {
337
- available: true;
338
- version: string;
339
- }
340
- | {
341
- available: false;
342
- }
343
- >
344
- ) => void;
345
-
346
- /**
347
- * This event is emitted when the client is going to launch an app. Addon should use this to perform any pre or post launch tasks.
348
- * @param data {LibraryInfo} The library information for the app to be launched.
349
- * @param launchType { 'pre' | 'post' } The type of launch task to perform.
350
- * @param event {EventResponse<void>} The event response from the server.
351
- */
352
- 'launch-app': (
353
- data: { libraryInfo: LibraryInfo; launchType: 'pre' | 'post' },
354
- event: EventResponse<void>
355
- ) => void;
356
- }
357
-
358
- export interface StoreData {
359
- name: string;
360
- publishers: string[];
361
- developers: string[];
362
- appID: number;
363
- releaseDate: string;
364
- capsuleImage: string;
365
- coverImage: string;
366
- basicDescription: string;
367
- description: string;
368
- headerImage: string;
369
- latestVersion: string;
370
- }
371
- export interface WebsocketMessageClient {
372
- event: OGIAddonClientSentEvent;
373
- id?: string;
374
- args: any;
375
- statusError?: string;
376
- }
377
- export interface WebsocketMessageServer {
378
- event: OGIAddonServerSentEvent;
379
- id?: string;
380
- args: any;
381
- statusError?: string;
382
- }
50
+ OGIAddonConfiguration,
51
+ OGIAddonSDKEventListener,
52
+ SearchResult,
53
+ SetupResponse,
54
+ SetupEventResponse,
55
+ StoreData,
56
+ UmuId,
57
+ SetupCommandData,
58
+ AddonProtocolEventListenerTypes,
59
+ AddonSDKLifecycleEventListenerTypes,
60
+ AddonServerHostEventListeners,
61
+ AddonServerHostEventName,
62
+ AddonServerLifecycleEvent,
63
+ } from '@ogi-sdk/connect';
64
+
65
+ /** @deprecated Use {@link AddonNotificationMessage}. */
66
+ export type Notification = AddonNotificationMessage;
383
67
 
384
68
  /**
385
- * The configuration for the addon. This is used to identify the addon and provide information about it.
386
- * Storefronts is an array of names of stores that the addon supports.
69
+ * Addon SDK listener signatures. Protocol commands come from `addonProtocol` in
70
+ * `@ogi-sdk/connect`; lifecycle and builder-specific hooks are merged below.
387
71
  */
388
- export interface OGIAddonConfiguration {
389
- name: string;
390
- id: string;
391
- description: string;
392
- version: string;
393
-
394
- author: string;
395
- repository: string;
396
- storefronts: string[];
397
- }
72
+ export type EventListenerTypes = AddonSDKLifecycleEventListenerTypes<
73
+ EventResponse<unknown>
74
+ > &
75
+ AddonProtocolEventListenerTypes<
76
+ EventResponse<unknown>,
77
+ 'authenticate' | 'configure' | 'catalog'
78
+ > & {
79
+ authenticate: (config: unknown) => void;
80
+ configure: (config: ConfigurationBuilder) => ConfigurationBuilder;
81
+ catalog: (event: Omit<EventResponse<CatalogResponse>, 'askForInput'>) => void;
82
+ };
398
83
 
399
84
  /**
400
85
  * The main class for the OGI Addon. This class is used to interact with the OGI Addon Server. The OGI Addon Server provides a `--addonSecret` to the addon so it can securely connect.
@@ -416,7 +101,7 @@ export default class OGIAddon {
416
101
  public addonWSListener: OGIAddonWSListener;
417
102
  public addonInfo: OGIAddonConfiguration;
418
103
  public config: Configuration = new Configuration({});
419
- private eventsAvailable: OGIAddonEvent[] = [];
104
+ private eventsAvailable: OGIAddonSDKEventListener[] = [];
420
105
  private registeredConnectEvent: boolean = false;
421
106
  private taskHandlers: Map<
422
107
  string,
@@ -438,10 +123,10 @@ export default class OGIAddon {
438
123
 
439
124
  /**
440
125
  * Register an event listener for the addon. (See EventListenerTypes)
441
- * @param event {OGIAddonEvent}
442
- * @param listener {EventListenerTypes[OGIAddonEvent]}
126
+ * @param event {OGIAddonSDKEventListener}
127
+ * @param listener {EventListenerTypes[OGIAddonSDKEventListener]}
443
128
  */
444
- public on<T extends OGIAddonEvent>(
129
+ public on<T extends OGIAddonSDKEventListener>(
445
130
  event: T,
446
131
  listener: EventListenerTypes[T]
447
132
  ) {
@@ -459,7 +144,7 @@ export default class OGIAddon {
459
144
  }
460
145
  }
461
146
 
462
- public emit<T extends OGIAddonEvent>(
147
+ public emit<T extends OGIAddonSDKEventListener>(
463
148
  event: T,
464
149
  ...args: Parameters<EventListenerTypes[T]>
465
150
  ) {
@@ -470,7 +155,7 @@ export default class OGIAddon {
470
155
  * Notify the client using a notification. Provide the type of notification, the message, and an ID.
471
156
  * @param notification {Notification}
472
157
  */
473
- public notify(notification: Notification) {
158
+ public notify(notification: AddonNotificationMessage) {
474
159
  this.addonWSListener.send('notification', [notification]);
475
160
  }
476
161
 
@@ -481,23 +166,23 @@ export default class OGIAddon {
481
166
  * @returns {Promise<StoreData>}
482
167
  */
483
168
  public async getAppDetails(appID: number, storefront: string) {
484
- const id = this.addonWSListener.send('get-app-details', {
485
- appID,
486
- storefront,
487
- });
488
- return await this.addonWSListener.waitForResponseFromServer<
489
- StoreData | undefined
490
- >(id);
169
+ return await this.addonWSListener.requestResponse<StoreData | undefined>(
170
+ 'get-app-details',
171
+ {
172
+ appID,
173
+ storefront,
174
+ }
175
+ );
491
176
  }
492
177
 
493
178
  public async searchGame(query: string, storefront: string) {
494
- const id = this.addonWSListener.send('search-app-name', {
495
- query,
496
- storefront,
497
- });
498
- return await this.addonWSListener.waitForResponseFromServer<
499
- BasicLibraryInfo[]
500
- >(id);
179
+ return await this.addonWSListener.requestResponse<BasicLibraryInfo[]>(
180
+ 'search-app-name',
181
+ {
182
+ query,
183
+ storefront,
184
+ }
185
+ );
501
186
  }
502
187
 
503
188
  /**
@@ -581,117 +266,10 @@ export default class OGIAddon {
581
266
  * Extract a file using 7-Zip on Windows, unzip on Linux/Mac.
582
267
  * @param path {string}
583
268
  * @param outputPath {string}
584
- * @param type {'unrar' | 'unzip'}
585
269
  * @returns {Promise<void>}
586
270
  */
587
- public async extractFile(
588
- path: string,
589
- outputPath: string,
590
- type: 'unrar' | 'unzip'
591
- ) {
592
- return new Promise<void>((resolve, reject) => {
593
- // Ensure outputPath exists
594
- if (!fs.existsSync(outputPath)) {
595
- fs.mkdirSync(outputPath, { recursive: true });
596
- }
597
-
598
- if (type === 'unzip') {
599
- // Prefer 7-Zip on Windows, unzip on Linux/Mac
600
- if (process.platform === 'win32') {
601
- // 7-Zip path (default install location)
602
- const s7ZipPath = '"C:\\Program Files\\7-Zip\\7z.exe"';
603
- exec(
604
- `${s7ZipPath} x "${path}" -o"${outputPath}"`,
605
- (err: any, stdout: any, stderr: any) => {
606
- if (err) {
607
- console.error(err);
608
- console.log(stderr);
609
- reject(new Error('Failed to extract ZIP file'));
610
- return;
611
- }
612
- console.log(stdout);
613
- console.log(stderr);
614
- resolve();
615
- }
616
- );
617
- } else {
618
- // Use unzip on Linux/Mac
619
- const unzipProcess = spawn(
620
- 'unzip',
621
- [
622
- '-o', // overwrite files without prompting
623
- path,
624
- '-d', // specify output directory
625
- outputPath,
626
- ],
627
- {
628
- env: {
629
- ...process.env,
630
- UNZIP_DISABLE_ZIPBOMB_DETECTION: 'TRUE',
631
- },
632
- }
633
- );
634
-
635
- unzipProcess.stdout.on('data', (data: Buffer) => {
636
- console.log(`[unzip stdout]: ${data}`);
637
- });
638
-
639
- unzipProcess.stderr.on('data', (data: Buffer) => {
640
- console.error(`[unzip stderr]: ${data}`);
641
- });
642
-
643
- unzipProcess.on('close', (code: number) => {
644
- if (code !== 0) {
645
- console.error(`unzip process exited with code ${code}`);
646
- reject(new Error('Failed to extract ZIP file'));
647
- return;
648
- }
649
- resolve();
650
- });
651
- }
652
- } else if (type === 'unrar') {
653
- if (process.platform === 'win32') {
654
- // 7-Zip path (default install location)
655
- const s7ZipPath = '"C:\\Program Files\\7-Zip\\7z.exe"';
656
- exec(
657
- `${s7ZipPath} x "${path}" -o"${outputPath}"`,
658
- (err: any, stdout: any, stderr: any) => {
659
- if (err) {
660
- console.error(err);
661
- console.log(stderr);
662
- reject(new Error('Failed to extract RAR file'));
663
- return;
664
- }
665
- console.log(stdout);
666
- console.log(stderr);
667
- resolve();
668
- }
669
- );
670
- } else {
671
- // Use unrar on Linux/Mac
672
- const unrarProcess = spawn('unrar', ['x', '-y', path, outputPath]);
673
-
674
- unrarProcess.stdout.on('data', (data: Buffer) => {
675
- console.log(`[unrar stdout]: ${data}`);
676
- });
677
-
678
- unrarProcess.stderr.on('data', (data: Buffer) => {
679
- console.error(`[unrar stderr]: ${data}`);
680
- });
681
-
682
- unrarProcess.on('close', (code: number) => {
683
- if (code !== 0) {
684
- console.error(`unrar process exited with code ${code}`);
685
- reject(new Error('Failed to extract RAR file'));
686
- return;
687
- }
688
- resolve();
689
- });
690
- }
691
- } else {
692
- reject(new Error('Unknown extraction type'));
693
- }
694
- });
271
+ public async extractFile(path: string, outputPath: string) {
272
+ return await extraction(path, outputPath);
695
273
  }
696
274
  }
697
275
 
@@ -879,7 +457,7 @@ export class SearchTool<T> {
879
457
  /**
880
458
  * Library Info is the metadata for a library entry after setting up a game.
881
459
  */
882
- export const ZodLibraryInfo = z.object({
460
+ export const ZodLibraryInfo: z.ZodType<LibraryInfo> = z.object({
883
461
  name: z.string(),
884
462
  version: z.string(),
885
463
  cwd: z.string(),
@@ -922,36 +500,52 @@ export const ZodLibraryInfo = z.object({
922
500
  )
923
501
  .optional(),
924
502
  });
925
- export type LibraryInfo = z.infer<typeof ZodLibraryInfo>;
926
- interface Notification {
927
- type: 'warning' | 'error' | 'info' | 'success';
928
- message: string;
929
- id: string;
930
- }
503
+
504
+ export type { AddonTaskRunEventArgs as TaskRunMessageArgs } from '@ogi-sdk/connect';
505
+
931
506
  class OGIAddonWSListener {
932
- private socket: WebSocket;
507
+ private socket: InstanceType<typeof globalThis.WebSocket>;
508
+ private transport: EventResponseSocket<
509
+ AddonServerToClientWebsocketMessage,
510
+ AddonClientToServerWebsocketMessage
511
+ >;
933
512
  public eventEmitter: events.EventEmitter;
934
513
  public addon: OGIAddon;
935
514
 
936
515
  constructor(ogiAddon: OGIAddon, eventEmitter: events.EventEmitter) {
937
- if (
938
- process.argv[process.argv.length - 1].split('=')[0] !== '--addonSecret'
939
- ) {
516
+ const secret = process.argv
517
+ .find((arg) => arg.startsWith('--addonSecret='))
518
+ ?.split('=')[1];
519
+ if (!secret) {
940
520
  throw new Error(
941
521
  'No secret provided. This usually happens because the addon was not started by the OGI Addon Server.'
942
522
  );
943
523
  }
524
+
525
+ // get the port from the arguments
526
+ let port = process.argv
527
+ .find((arg) => arg.startsWith('--addonPort='))
528
+ ?.split('=')[1];
529
+ if (!port) {
530
+ port = defaultPort.toString();
531
+ }
532
+
944
533
  this.addon = ogiAddon;
945
534
  this.eventEmitter = eventEmitter;
946
- this.socket = new ws('ws://localhost:' + defaultPort);
947
- this.socket.on('open', () => {
535
+ const WebSocketConstructor = globalThis.WebSocket;
536
+ if (!WebSocketConstructor) {
537
+ throw new Error('WebSocket is not available in this runtime');
538
+ }
539
+ this.socket = new WebSocketConstructor('ws://localhost:' + port);
540
+ this.transport = new EventResponseSocket(this.socket);
541
+ this.socket.addEventListener('open', () => {
948
542
  console.log('Connected to OGI Addon Server');
949
543
  console.log('OGI Addon Server Version:', VERSION);
950
544
 
951
545
  // Authenticate with OGI Addon Server
952
- this.send('authenticate', {
546
+ this.send('authenticate' as AddonClientToServerEventName, {
953
547
  ...this.addon.addonInfo,
954
- secret: process.argv[process.argv.length - 1].split('=')[1],
548
+ secret,
955
549
  ogiVersion: VERSION,
956
550
  });
957
551
 
@@ -962,59 +556,44 @@ class OGIAddonWSListener {
962
556
  this.addon.config = new Configuration(configBuilder.build(true));
963
557
 
964
558
  // wait for the config-update to be received then send connect
965
- const configListener = (event: ws.MessageEvent) => {
966
- if (event === undefined) return;
967
- // event can be a Buffer, string, ArrayBuffer, or Buffer[]
968
- let data: string;
969
- if (typeof event === 'string') {
970
- data = event;
971
- } else if (event instanceof Buffer) {
972
- data = event.toString();
973
- } else if (event && typeof (event as any).data === 'string') {
974
- data = (event as any).data;
975
- } else if (event && (event as any).data instanceof Buffer) {
976
- data = (event as any).data.toString();
977
- } else {
978
- // fallback for other types
979
- data = event.toString();
980
- }
981
- const message: WebsocketMessageServer = JSON.parse(data);
982
- if (message.event === 'config-update') {
559
+ const unsubscribeConfigListener = this.transport.on(
560
+ 'config-update',
561
+ () => {
983
562
  console.log('Config update received');
984
- this.socket.off('message', configListener);
563
+ unsubscribeConfigListener();
985
564
  this.eventEmitter.emit(
986
565
  'connect',
987
566
  new EventResponse<void>((screen, name, description) => {
988
- return this.userInputAsked(
989
- screen,
990
- name,
991
- description,
992
- this.socket
993
- );
567
+ return this.userInputAsked(screen, name, description);
994
568
  })
995
569
  );
996
570
  }
997
- };
998
- this.socket.on('message', configListener);
571
+ );
999
572
  });
1000
573
 
1001
- this.socket.on('error', (error) => {
1002
- if (error.message.includes('Failed to connect')) {
574
+ this.socket.addEventListener('error', (event) => {
575
+ this.transport.rejectPendingResponses('Websocket error');
576
+ const message =
577
+ event instanceof ErrorEvent
578
+ ? event.message
579
+ : event.type;
580
+ if (message.includes('Failed to connect')) {
1003
581
  throw new Error(
1004
582
  'OGI Addon Server is not running/is unreachable. Please start the server and try again.'
1005
583
  );
1006
584
  }
1007
- console.error('An error occurred:', error);
585
+ console.error('An error occurred:', event);
1008
586
  });
1009
587
 
1010
- this.socket.on('close', (code, reason) => {
1011
- if (code === 1008) {
1012
- console.error('Authentication failed:', reason);
588
+ this.socket.addEventListener('close', (event) => {
589
+ this.transport.rejectPendingResponses('Websocket closed');
590
+ if (event.code === 1008) {
591
+ console.error('Authentication failed:', event.reason);
1013
592
  return;
1014
593
  }
1015
- this.eventEmitter.emit('disconnect', reason);
594
+ this.eventEmitter.emit('disconnect', event.reason);
1016
595
  console.log('Disconnected from OGI Addon Server');
1017
- console.error(reason.toString());
596
+ console.error(event.reason);
1018
597
  this.eventEmitter.emit('exit');
1019
598
  this.socket.close();
1020
599
  });
@@ -1027,173 +606,188 @@ class OGIAddonWSListener {
1027
606
  >(
1028
607
  configBuilt: ConfigurationBuilder<U>,
1029
608
  name: string,
1030
- description: string,
1031
- socket: WebSocket
609
+ description: string
1032
610
  ): Promise<U> {
1033
611
  const config = configBuilt.build(false);
1034
- const id = Math.random().toString(36).substring(7);
1035
- if (!socket) {
1036
- throw new Error('Socket is not connected');
1037
- }
1038
- socket.send(
1039
- JSON.stringify({
612
+ const response = await this.transport.send(
613
+ {
1040
614
  event: 'input-asked',
1041
615
  args: {
1042
616
  config,
1043
617
  name,
1044
618
  description,
1045
619
  },
1046
- id: id,
1047
- })
620
+ } as AddonClientToServerWebsocketMessage,
621
+ { expectResponse: true }
1048
622
  );
1049
- return await this.waitForResponseFromServer<U>(id);
623
+ return response.args as U;
1050
624
  }
1051
625
 
1052
626
  /**
1053
627
  * Registers the message receiver for the socket. This is used to receive messages from the server and handle them.
1054
628
  */
1055
629
  private registerMessageReceiver() {
1056
- this.socket.on('message', async (data: string) => {
1057
- const message: WebsocketMessageServer = JSON.parse(data);
1058
- switch (message.event) {
1059
- case 'config-update':
1060
- const result = this.addon.config.updateConfig(message.args);
1061
- if (!result[0]) {
1062
- this.respondToMessage(
1063
- message.id!!,
1064
- {
1065
- success: false,
1066
- error: result[1],
1067
- },
1068
- undefined
630
+ const events: AddonServerToClientWebsocketMessage['event'][] = [
631
+ 'config-update',
632
+ 'search',
633
+ 'setup',
634
+ 'library-search',
635
+ 'game-details',
636
+ 'check-for-updates',
637
+ 'request-dl',
638
+ 'catalog',
639
+ 'task-run',
640
+ 'launch-app',
641
+ ];
642
+
643
+ for (const event of events) {
644
+ this.transport.on(event, async (message) => {
645
+ switch (message.event) {
646
+ case 'config-update':
647
+ const result = this.addon.config.updateConfig(
648
+ message.args as DefiniteConfig
649
+ );
650
+ if (!result[0]) {
651
+ this.respondToMessage(
652
+ message.id!!,
653
+ {
654
+ success: false,
655
+ error: result[1],
656
+ },
657
+ undefined
658
+ );
659
+ } else {
660
+ this.respondToMessage(message.id!!, { success: true }, undefined);
661
+ }
662
+ break;
663
+ case 'search':
664
+ await this.handleEventWithResponse<SearchResult[]>(
665
+ message,
666
+ (event) => this.eventEmitter.emit('search', message.args, event)
1069
667
  );
1070
- } else {
1071
- this.respondToMessage(message.id!!, { success: true }, undefined);
668
+ break;
669
+ case 'setup': {
670
+ let setupEvent = new EventResponse<SetupResponse>(
671
+ (screen, name, description) =>
672
+ this.userInputAsked(screen, name, description)
673
+ );
674
+ this.eventEmitter.emit('setup', message.args, setupEvent);
675
+ const interval = setInterval(() => {
676
+ if (setupEvent.resolved) {
677
+ clearInterval(interval);
678
+ return;
679
+ }
680
+ this.send('defer-update', {
681
+ logs: setupEvent.logs,
682
+ deferID:
683
+ message.args as AddonClientToServerEventArgs['defer-update']['deferID'],
684
+ progress: setupEvent.progress,
685
+ failed: setupEvent.failed,
686
+ } as AddonClientToServerEventArgs['defer-update']);
687
+ }, 100);
688
+ const setupResult = await this.waitForEventToRespond(setupEvent);
689
+ this.respondToMessage(message.id!!, setupResult.data, setupEvent);
690
+ break;
1072
691
  }
1073
- break;
1074
- case 'search':
1075
- await this.handleEventWithResponse<SearchResult[]>(message, (event) =>
1076
- this.eventEmitter.emit('search', message.args, event)
1077
- );
1078
- break;
1079
- case 'setup': {
1080
- let setupEvent = new EventResponse<SetupEventResponse>(
1081
- (screen, name, description) =>
1082
- this.userInputAsked(screen, name, description, this.socket)
1083
- );
1084
- this.eventEmitter.emit('setup', message.args, setupEvent);
1085
- const interval = setInterval(() => {
1086
- if (setupEvent.resolved) {
1087
- clearInterval(interval);
1088
- return;
692
+ case 'library-search':
693
+ await this.handleEventWithResponse<BasicLibraryInfo[]>(
694
+ message,
695
+ (event) =>
696
+ this.eventEmitter.emit('library-search', message.args, event)
697
+ );
698
+ break;
699
+ case 'game-details':
700
+ await this.handleEventWithResponse<StoreData | undefined>(
701
+ message,
702
+ (event) =>
703
+ this.eventEmitter.emit('game-details', message.args, event),
704
+ {
705
+ requireListener: 'game-details',
706
+ noListenerError: 'No event listener for game-details',
707
+ }
708
+ );
709
+ break;
710
+ case 'check-for-updates':
711
+ await this.handleEventWithResponse<
712
+ { available: true; version: string } | { available: false }
713
+ >(message, (event) =>
714
+ this.eventEmitter.emit('check-for-updates', message.args, event)
715
+ );
716
+ break;
717
+ case 'request-dl':
718
+ let requestDLEvent = new EventResponse<SearchResult>(
719
+ (screen, name, description) =>
720
+ this.userInputAsked(screen, name, description)
721
+ );
722
+ if (this.eventEmitter.listenerCount('request-dl') === 0) {
723
+ this.respondToMessage(
724
+ message.id!!,
725
+ {
726
+ error: 'No event listener for request-dl',
727
+ },
728
+ requestDLEvent
729
+ );
730
+ break;
1089
731
  }
1090
- this.send('defer-update', {
1091
- logs: setupEvent.logs,
1092
- deferID: message.args.deferID,
1093
- progress: setupEvent.progress,
1094
- failed: setupEvent.failed,
1095
- } as ClientSentEventTypes['defer-update']);
1096
- }, 100);
1097
- const setupResult = await this.waitForEventToRespond(setupEvent);
1098
- this.respondToMessage(message.id!!, setupResult.data, setupEvent);
1099
- break;
1100
- }
1101
- case 'library-search':
1102
- await this.handleEventWithResponse<BasicLibraryInfo[]>(
1103
- message,
1104
- (event) =>
1105
- this.eventEmitter.emit('library-search', message.args, event)
1106
- );
1107
- break;
1108
- case 'game-details':
1109
- await this.handleEventWithResponse<StoreData | undefined>(
1110
- message,
1111
- (event) =>
1112
- this.eventEmitter.emit('game-details', message.args, event),
1113
- {
1114
- requireListener: 'game-details',
1115
- noListenerError: 'No event listener for game-details',
732
+ const { appID, info } = message.args as {
733
+ appID: number;
734
+ info: SearchResult;
735
+ };
736
+ this.eventEmitter.emit(
737
+ 'request-dl',
738
+ appID,
739
+ info,
740
+ requestDLEvent as EventResponse<SearchResult>
741
+ );
742
+ const requestDLResult =
743
+ await this.waitForEventToRespond(requestDLEvent);
744
+ if (requestDLEvent.failed) {
745
+ this.respondToMessage(message.id!!, undefined, requestDLEvent);
746
+ break;
747
+ }
748
+ if (
749
+ requestDLEvent.data === undefined ||
750
+ requestDLEvent.data?.downloadType === 'request'
751
+ ) {
752
+ throw new Error(
753
+ 'Request DL event did not return a valid result. Please ensure that the event does not resolve with another `request` download type.'
754
+ );
1116
755
  }
1117
- );
1118
- break;
1119
- case 'check-for-updates':
1120
- await this.handleEventWithResponse<
1121
- { available: true; version: string } | { available: false }
1122
- >(message, (event) =>
1123
- this.eventEmitter.emit('check-for-updates', message.args, event)
1124
- );
1125
- break;
1126
- case 'request-dl':
1127
- let requestDLEvent = new EventResponse<SearchResult>(
1128
- (screen, name, description) =>
1129
- this.userInputAsked(screen, name, description, this.socket)
1130
- );
1131
- if (this.eventEmitter.listenerCount('request-dl') === 0) {
1132
756
  this.respondToMessage(
1133
757
  message.id!!,
1134
- {
1135
- error: 'No event listener for request-dl',
1136
- },
758
+ requestDLResult.data,
1137
759
  requestDLEvent
1138
760
  );
1139
761
  break;
1140
- }
1141
- this.eventEmitter.emit(
1142
- 'request-dl',
1143
- message.args.appID,
1144
- message.args.info,
1145
- requestDLEvent
1146
- );
1147
- const requestDLResult =
1148
- await this.waitForEventToRespond(requestDLEvent);
1149
- if (requestDLEvent.failed) {
1150
- this.respondToMessage(message.id!!, undefined, requestDLEvent);
762
+ case 'catalog':
763
+ await this.handleEventWithResponseNoInput<CatalogResponse>(
764
+ message,
765
+ (event) => this.eventEmitter.emit('catalog', event)
766
+ );
1151
767
  break;
1152
- }
1153
- if (
1154
- requestDLEvent.data === undefined ||
1155
- requestDLEvent.data?.downloadType === 'request'
1156
- ) {
1157
- throw new Error(
1158
- 'Request DL event did not return a valid result. Please ensure that the event does not resolve with another `request` download type.'
768
+ case 'task-run': {
769
+ let taskRunEvent = new EventResponse<void>(
770
+ (screen, name, description) =>
771
+ this.userInputAsked(screen, name, description)
1159
772
  );
1160
- }
1161
- this.respondToMessage(
1162
- message.id!!,
1163
- requestDLResult.data,
1164
- requestDLEvent
1165
- );
1166
- break;
1167
- case 'catalog':
1168
- await this.handleEventWithResponseNoInput<CatalogResponse>(
1169
- message,
1170
- (event) => this.eventEmitter.emit('catalog', event)
1171
- );
1172
- break;
1173
- case 'task-run': {
1174
- let taskRunEvent = new EventResponse<void>(
1175
- (screen, name, description) =>
1176
- this.userInputAsked(screen, name, description, this.socket)
1177
- );
1178
-
1179
- // Check for taskName: first from args directly (from SearchResult), then from manifest.__taskName (for ActionOption)
1180
- const taskName =
1181
- message.args.taskName && typeof message.args.taskName === 'string'
1182
- ? message.args.taskName
1183
- : message.args.manifest &&
1184
- typeof message.args.manifest === 'object'
1185
- ? (message.args.manifest as Record<string, unknown>).__taskName
1186
- : undefined;
1187
-
1188
- if (
1189
- taskName &&
1190
- typeof taskName === 'string' &&
1191
- this.addon.hasTaskHandler(taskName)
1192
- ) {
1193
- // Use the registered task handler
1194
- const handler = this.addon.getTaskHandler(taskName)!;
1195
- const task = new Task(taskRunEvent);
1196
- try {
773
+ const args = message.args as AddonTaskRunEventArgs;
774
+
775
+ // Check for taskName: first from args directly (from SearchResult), then from manifest.__taskName (for ActionOption)
776
+ const taskName =
777
+ args.taskName && typeof args.taskName === 'string'
778
+ ? args.taskName
779
+ : args.manifest && typeof args.manifest === 'object'
780
+ ? args.manifest.__taskName
781
+ : undefined;
782
+
783
+ if (
784
+ taskName &&
785
+ typeof taskName === 'string' &&
786
+ this.addon.hasTaskHandler(taskName)
787
+ ) {
788
+ // Use the registered task handler
789
+ const handler = this.addon.getTaskHandler(taskName)!;
790
+ const task = new Task(taskRunEvent);
1197
791
  const interval = setInterval(() => {
1198
792
  if (taskRunEvent.resolved) {
1199
793
  clearInterval(interval);
@@ -1201,48 +795,55 @@ class OGIAddonWSListener {
1201
795
  }
1202
796
  this.send('defer-update', {
1203
797
  logs: taskRunEvent.logs,
1204
- deferID: message.args.deferID,
798
+ deferID: args.deferID ?? '',
1205
799
  progress: taskRunEvent.progress,
1206
800
  failed: taskRunEvent.failed,
1207
- } as ClientSentEventTypes['defer-update']);
801
+ } as AddonClientToServerEventArgs['defer-update']);
1208
802
  }, 100);
1209
- const result = handler(task, {
1210
- manifest: message.args.manifest || {},
1211
- downloadPath: message.args.downloadPath || '',
1212
- name: message.args.name || '',
1213
- libraryInfo: message.args.libraryInfo,
1214
- });
1215
- // If handler returns a promise, wait for it
1216
- if (result instanceof Promise) {
1217
- await result;
803
+ try {
804
+ const result = handler(task, {
805
+ manifest: args.manifest || {},
806
+ downloadPath: args.downloadPath || '',
807
+ name: args.name || '',
808
+ libraryInfo: args.libraryInfo,
809
+ });
810
+ // If handler returns a promise, wait for it
811
+ if (result instanceof Promise) {
812
+ await result;
813
+ }
814
+ } catch (error) {
815
+ taskRunEvent.fail(
816
+ error instanceof Error ? error.message : String(error)
817
+ );
818
+ } finally {
819
+ clearInterval(interval);
1218
820
  }
1219
-
1220
- clearInterval(interval);
1221
- } catch (error) {
821
+ } else {
822
+ // No handler found - fail the task
1222
823
  taskRunEvent.fail(
1223
- error instanceof Error ? error.message : String(error)
824
+ taskName
825
+ ? `No task handler registered for task name: ${taskName}`
826
+ : 'No task name provided'
1224
827
  );
1225
828
  }
1226
- } else {
1227
- // No handler found - fail the task
1228
- taskRunEvent.fail(
1229
- taskName
1230
- ? `No task handler registered for task name: ${taskName}`
1231
- : 'No task name provided'
829
+
830
+ const taskRunResult =
831
+ await this.waitForEventToRespond(taskRunEvent);
832
+ this.respondToMessage(
833
+ message.id!!,
834
+ taskRunResult.data,
835
+ taskRunEvent
1232
836
  );
837
+ break;
1233
838
  }
1234
-
1235
- const taskRunResult = await this.waitForEventToRespond(taskRunEvent);
1236
- this.respondToMessage(message.id!!, taskRunResult.data, taskRunEvent);
1237
- break;
839
+ case 'launch-app':
840
+ await this.handleEventWithResponse<void>(message, (event) =>
841
+ this.eventEmitter.emit('launch-app', message.args, event)
842
+ );
843
+ break;
1238
844
  }
1239
- case 'launch-app':
1240
- await this.handleEventWithResponse<void>(message, (event) =>
1241
- this.eventEmitter.emit('launch-app', message.args, event)
1242
- );
1243
- break;
1244
- }
1245
- });
845
+ });
846
+ }
1246
847
  }
1247
848
 
1248
849
  private waitForEventToRespond<T>(
@@ -1278,12 +879,12 @@ class OGIAddonWSListener {
1278
879
  * If options.requireListener is set and that event has no listeners, responds with options.noListenerError and returns.
1279
880
  */
1280
881
  private async handleEventWithResponse<T>(
1281
- message: WebsocketMessageServer,
882
+ message: AddonServerToClientWebsocketMessage,
1282
883
  emit: (event: EventResponse<T>) => void,
1283
884
  options?: { requireListener: string; noListenerError: string }
1284
885
  ): Promise<void> {
1285
886
  const event = new EventResponse<T>((screen, name, description) =>
1286
- this.userInputAsked(screen, name, description, this.socket)
887
+ this.userInputAsked(screen, name, description)
1287
888
  );
1288
889
  if (
1289
890
  options &&
@@ -1305,7 +906,7 @@ class OGIAddonWSListener {
1305
906
  * Same as handleEventWithResponse but for events that don't need userInputAsked (e.g. catalog).
1306
907
  */
1307
908
  private async handleEventWithResponseNoInput<T>(
1308
- message: WebsocketMessageServer,
909
+ message: AddonServerToClientWebsocketMessage,
1309
910
  emit: (event: EventResponse<T>) => void
1310
911
  ): Promise<void> {
1311
912
  const event = new EventResponse<T>();
@@ -1319,49 +920,41 @@ class OGIAddonWSListener {
1319
920
  response: any,
1320
921
  originalEvent: EventResponse<any> | undefined
1321
922
  ) {
1322
- this.socket.send(
1323
- JSON.stringify({
923
+ void this.transport.send(
924
+ {
1324
925
  event: 'response',
1325
926
  id: messageID,
1326
927
  args: response,
1327
928
  statusError: originalEvent ? originalEvent.failed : undefined,
1328
- })
929
+ } as AddonClientToServerWebsocketMessage,
930
+ { expectResponse: false }
1329
931
  );
1330
932
  console.log('dispatched response to ' + messageID);
1331
933
  }
1332
934
 
1333
- public waitForResponseFromServer<T>(messageID: string): Promise<T> {
1334
- return new Promise((resolve) => {
1335
- const waiter = (data: string) => {
1336
- const message: WebsocketMessageClient = JSON.parse(data);
1337
- if (message.event !== 'response') {
1338
- this.socket.once('message', waiter);
1339
- return;
1340
- }
1341
- console.log('received response from ' + messageID);
1342
-
1343
- if (message.id === messageID) {
1344
- resolve(message.args);
1345
- } else {
1346
- this.socket.once('message', waiter);
1347
- }
1348
- };
1349
- this.socket.once('message', waiter);
1350
- });
935
+ public async requestResponse<T>(
936
+ event: AddonClientToServerEventName,
937
+ args: AddonClientToServerEventArgs[AddonClientToServerEventName]
938
+ ): Promise<T> {
939
+ const response = await this.transport.send(
940
+ { event, args } as AddonClientToServerWebsocketMessage,
941
+ { expectResponse: true }
942
+ );
943
+ return response.args as T;
1351
944
  }
1352
945
 
1353
946
  public send(
1354
- event: OGIAddonClientSentEvent,
1355
- args: ClientSentEventTypes[OGIAddonClientSentEvent]
947
+ event: AddonClientToServerEventName,
948
+ args: AddonClientToServerEventArgs[AddonClientToServerEventName]
1356
949
  ): string {
1357
- // generate a random id
1358
- const id = Math.random().toString(36).substring(7);
1359
- this.socket.send(
1360
- JSON.stringify({
950
+ const id = randomMessageId();
951
+ void this.transport.send(
952
+ {
1361
953
  event,
1362
954
  args,
1363
955
  id,
1364
- })
956
+ } as AddonClientToServerWebsocketMessage,
957
+ { expectResponse: false }
1365
958
  );
1366
959
  return id;
1367
960
  }