@serverkgg/bridge 0.1.1 → 0.1.2

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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/bot/botPanel.test.ts +30 -0
  3. package/src/bot/botPanel.ts +9 -1
  4. package/src/cli/validate.test.ts +73 -1
  5. package/src/cli/validate.ts +35 -1
  6. package/src/context/bridgeContext.ts +6 -1
  7. package/src/core/bridgeAction.ts +2 -0
  8. package/src/core/bridgeActivity.ts +9 -0
  9. package/src/core/bridgeAvailability.ts +10 -0
  10. package/src/core/bridgeUserError.test.ts +10 -0
  11. package/src/core/bridgeUserError.ts +6 -2
  12. package/src/core/describeDownloadFailure.test.ts +58 -0
  13. package/src/core/describeDownloadFailure.ts +69 -0
  14. package/src/core/index.ts +3 -0
  15. package/src/modules/bridge.ts +8 -1
  16. package/src/modules/bridgeModule.ts +13 -1
  17. package/src/panel/bridgeDetail.ts +2 -1
  18. package/src/protocol/bridgeActivity.ts +54 -0
  19. package/src/protocol/bridgeEvents.ts +30 -0
  20. package/src/protocol/bridgeFrame.ts +10 -1
  21. package/src/protocol/index.ts +1 -0
  22. package/src/runtime/bridgeActions.ts +19 -2
  23. package/src/runtime/bridgeActivity.test.ts +318 -0
  24. package/src/runtime/bridgeActivity.ts +268 -0
  25. package/src/runtime/bridgeContext.test.ts +6 -0
  26. package/src/runtime/bridgeContext.ts +8 -1
  27. package/src/runtime/bridgeDriver.test.ts +21 -0
  28. package/src/runtime/bridgeDriver.ts +10 -7
  29. package/src/runtime/bridgeLog.ts +10 -0
  30. package/src/runtime/bridgeRefusalError.ts +13 -0
  31. package/src/runtime/bridgeRegistry.test.ts +1053 -10
  32. package/src/runtime/bridgeRegistry.ts +464 -65
  33. package/src/runtime/bridgeScopedContext.ts +55 -0
  34. package/src/runtime/bridgeServer.test.ts +404 -4
  35. package/src/runtime/bridgeServer.ts +125 -35
  36. package/src/runtime/bridgeSetupHandlers.test.ts +69 -6
  37. package/src/runtime/bridgeSetupHandlers.ts +45 -10
  38. package/src/runtime/serve.ts +45 -3
  39. package/src/steam/steamcmd.test.ts +32 -1
  40. package/src/steam/steamcmd.ts +27 -4
  41. package/src/utils/changedSettingKeys.test.ts +65 -0
  42. package/src/utils/changedSettingKeys.ts +6 -0
  43. package/src/utils/describeExecFailure.test.ts +34 -0
  44. package/src/utils/describeExecFailure.ts +9 -0
  45. package/src/utils/index.ts +3 -0
  46. package/src/utils/sameSettingValue.test.ts +35 -0
  47. package/src/utils/sameSettingValue.ts +40 -0
  48. package/src/wire/activity.test.ts +95 -0
  49. package/src/wire/activity.ts +41 -0
  50. package/src/wire/index.ts +1 -0
  51. package/src/wire/panel.test.ts +67 -1
  52. package/src/wire/panel.ts +11 -0
@@ -1,5 +1,12 @@
1
1
  import { timingSafeEqual } from "node:crypto";
2
- import { BridgeFailureError, BridgeSecretError, BridgeUserError, type BridgeValues } from "../core";
2
+ import type { Socket } from "bun";
3
+ import {
4
+ type BridgeActivity,
5
+ BridgeFailureError,
6
+ BridgeSecretError,
7
+ BridgeUserError,
8
+ type BridgeValues,
9
+ } from "../core";
3
10
  import {
4
11
  BRIDGE_FRAME_LIMIT,
5
12
  type BridgeAnnounceResult,
@@ -15,8 +22,10 @@ import {
15
22
  type BridgeSetupResult,
16
23
  type BridgeStatus,
17
24
  } from "../protocol";
18
- import { type BridgeLogger, errorText } from "./bridgeLog";
25
+ import type { BridgeActivityAnswer } from "./bridgeActivity";
26
+ import { type BridgeLogger, errorDetail } from "./bridgeLog";
19
27
  import { redactLocalized } from "./bridgeRedaction";
28
+ import { BridgeRefusalError } from "./bridgeRefusalError";
20
29
 
21
30
  export interface ServerOptions {
22
31
  socket: string;
@@ -26,7 +35,12 @@ export interface ServerOptions {
26
35
  onStart: () => Promise<void>;
27
36
  onStop: () => Promise<void>;
28
37
  onCommand: (input: string, expect: string | undefined, timeoutMs: number | undefined) => Promise<BridgeCommandResult>;
29
- onInvoke: (module: string, action: string, args: BridgeValues, recoveryOperationId?: string) => Promise<unknown>;
38
+ onInvoke: (
39
+ module: string,
40
+ action: string,
41
+ args: BridgeValues,
42
+ recoveryOperationId?: string,
43
+ ) => Promise<BridgeActivityAnswer<unknown>>;
30
44
  onEvents: (cursor: number) => BridgeEventsResult;
31
45
  onSample: () => Promise<BridgeSampleResult>;
32
46
  onQuiesce: (supportsSnapshot: boolean, leaseId?: string) => Promise<BridgeQuiesceResult>;
@@ -36,11 +50,17 @@ export interface ServerOptions {
36
50
  onPendingList: () => Promise<BridgePendingResult>;
37
51
  onPendingAdd: (input: string) => Promise<BridgePendingResult>;
38
52
  onSetupStatus: () => BridgeSetupResult;
39
- onSetupSubmit: (step: string, action: string, args: BridgeValues) => Promise<BridgeSetupResult>;
53
+ onSetupSubmit: (step: string, action: string, args: BridgeValues) => Promise<BridgeActivityAnswer<BridgeSetupResult>>;
54
+ onUndelivered: (activity: BridgeActivity[]) => void;
40
55
  onLog: BridgeLogger;
41
56
  redact?: (text: string) => string;
42
57
  }
43
58
 
59
+ interface SocketState {
60
+ buffer: string;
61
+ closed: boolean;
62
+ }
63
+
44
64
  interface IncomingFrame {
45
65
  leaseId?: unknown;
46
66
  supportsSnapshot?: unknown;
@@ -73,6 +93,23 @@ const encode = (response: BridgeResponse) => {
73
93
  return `${JSON.stringify(response)}\n`;
74
94
  };
75
95
 
96
+ interface PlainAnswer {
97
+ result: unknown;
98
+ activity: null;
99
+ }
100
+
101
+ const deliver = (socket: Socket<SocketState>, frame: string): boolean => {
102
+ if (socket.data.closed) {
103
+ return false;
104
+ }
105
+
106
+ try {
107
+ return socket.write(frame) === Buffer.byteLength(frame);
108
+ } catch {
109
+ return false;
110
+ }
111
+ };
112
+
76
113
  const asArgs = (value: unknown): BridgeValues => {
77
114
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
78
115
  return {};
@@ -90,10 +127,10 @@ const asArgs = (value: unknown): BridgeValues => {
90
127
  };
91
128
 
92
129
  const logFailure = (onLog: BridgeLogger, method: string, error: unknown) => {
93
- if (error instanceof BridgeUserError) {
130
+ if (error instanceof BridgeUserError || error instanceof BridgeRefusalError) {
94
131
  onLog("bridge call refused", {
95
132
  method,
96
- error: error.message,
133
+ error: errorDetail(error),
97
134
  });
98
135
 
99
136
  return;
@@ -101,7 +138,7 @@ const logFailure = (onLog: BridgeLogger, method: string, error: unknown) => {
101
138
 
102
139
  onLog("bridge call failed", {
103
140
  method,
104
- error: errorText(error),
141
+ error: errorDetail(error),
105
142
  stack: error instanceof Error ? (error.stack ?? null) : null,
106
143
  });
107
144
  };
@@ -137,15 +174,6 @@ export const createServer = (options: ServerOptions) => {
137
174
  );
138
175
  }
139
176
 
140
- case BridgeMethod.Invoke: {
141
- return await options.onInvoke(
142
- String(request.module ?? ""),
143
- String(request.action ?? ""),
144
- asArgs(request.args),
145
- asText(request.recoveryOperationId) ?? undefined,
146
- );
147
- }
148
-
149
177
  case BridgeMethod.Events: {
150
178
  return options.onEvents(typeof request.cursor === "number" ? request.cursor : 0);
151
179
  }
@@ -196,33 +224,68 @@ export const createServer = (options: ServerOptions) => {
196
224
  return options.onSetupStatus();
197
225
  }
198
226
 
199
- case BridgeMethod.SetupSubmit: {
200
- const step = asText(request.module);
227
+ default: {
228
+ throw new Error(`unknown method "${method}"`);
229
+ }
230
+ }
231
+ };
201
232
 
202
- if (!step) {
203
- throw new Error("setupSubmit needs a step id");
204
- }
233
+ const answer = async (
234
+ method: string,
235
+ request: IncomingFrame,
236
+ ): Promise<BridgeActivityAnswer<unknown> | PlainAnswer> => {
237
+ if (method === BridgeMethod.Invoke) {
238
+ return await options.onInvoke(
239
+ String(request.module ?? ""),
240
+ String(request.action ?? ""),
241
+ asArgs(request.args),
242
+ asText(request.recoveryOperationId) ?? undefined,
243
+ );
244
+ }
205
245
 
206
- return await options.onSetupSubmit(step, String(request.action ?? ""), asArgs(request.args));
207
- }
246
+ if (method === BridgeMethod.SetupSubmit) {
247
+ const step = asText(request.module);
208
248
 
209
- default: {
210
- throw new Error(`unknown method "${method}"`);
249
+ if (!step) {
250
+ throw new Error("setupSubmit needs a step id");
211
251
  }
252
+
253
+ return await options.onSetupSubmit(step, String(request.action ?? ""), asArgs(request.args));
212
254
  }
255
+
256
+ return {
257
+ result: await handle(method, request),
258
+ activity: null,
259
+ };
213
260
  };
214
261
 
215
- return Bun.listen<{
216
- buffer: string;
217
- }>({
262
+ const undelivered = (method: string, activity: BridgeActivity[]) => {
263
+ if (activity.length === 0) {
264
+ return;
265
+ }
266
+
267
+ options.onLog("answer not delivered, its activity goes to the events ring", {
268
+ method,
269
+ entries: activity.length,
270
+ });
271
+
272
+ options.onUndelivered(activity);
273
+ };
274
+
275
+ return Bun.listen<SocketState>({
218
276
  unix: options.socket,
219
277
  socket: {
220
278
  open(socket) {
221
279
  socket.data = {
222
280
  buffer: "",
281
+ closed: false,
223
282
  };
224
283
  },
225
284
 
285
+ close(socket) {
286
+ socket.data.closed = true;
287
+ },
288
+
226
289
  data(socket, chunk) {
227
290
  socket.data.buffer += chunk.toString();
228
291
 
@@ -278,15 +341,32 @@ export const createServer = (options: ServerOptions) => {
278
341
  continue;
279
342
  }
280
343
 
281
- void handle(String(request.method ?? ""), request)
282
- .then((result) => {
283
- socket.write(
284
- encode({
344
+ void answer(String(request.method ?? ""), request)
345
+ .then(({ result, activity }) => {
346
+ const method = String(request.method ?? "");
347
+
348
+ let frame: string;
349
+
350
+ try {
351
+ frame = encode({
285
352
  id,
286
353
  ok: true,
287
354
  result,
288
- }),
289
- );
355
+ ...(activity === null
356
+ ? {}
357
+ : {
358
+ activity,
359
+ }),
360
+ });
361
+ } catch (error) {
362
+ undelivered(method, activity ?? []);
363
+
364
+ throw error;
365
+ }
366
+
367
+ if (!deliver(socket, frame)) {
368
+ undelivered(method, activity ?? []);
369
+ }
290
370
  })
291
371
  .catch((error: unknown) => {
292
372
  logFailure(options.onLog, String(request.method ?? ""), error);
@@ -297,7 +377,7 @@ export const createServer = (options: ServerOptions) => {
297
377
  encode({
298
378
  id,
299
379
  ok: false,
300
- error: redact(errorText(error)),
380
+ error: redact(errorDetail(error)),
301
381
  ...(error instanceof BridgeSecretError
302
382
  ? {
303
383
  secret: error.key,
@@ -313,6 +393,16 @@ export const createServer = (options: ServerOptions) => {
313
393
  text: redactLocalized(error.text, redact),
314
394
  }
315
395
  : {}),
396
+ ...(error instanceof BridgeRefusalError
397
+ ? {
398
+ unchanged: true,
399
+ ...(error.text
400
+ ? {
401
+ text: redactLocalized(error.text, redact),
402
+ }
403
+ : {}),
404
+ }
405
+ : {}),
316
406
  }),
317
407
  );
318
408
  });
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import type { BridgeContext } from "../context";
4
4
  import { BridgeKind, BridgeSetupPromptKind, BridgeSetupStepKind, BridgeSetupStepState, BridgeUserError } from "../core";
5
5
  import type { BridgeDriver, BridgeSetupModule } from "../modules";
6
- import type { BridgeConfig, BridgeSetupRuntime } from "../protocol";
6
+ import { type BridgeConfig, BridgeEventName, type BridgeSetupRuntime } from "../protocol";
7
7
  import { createContext } from "./bridgeContext";
8
8
  import type { BridgeLeveledLogger } from "./bridgeLog";
9
9
  import { log } from "./bridgeLog";
@@ -51,6 +51,9 @@ const contextOf = (): BridgeContext => {
51
51
  onSetup() {
52
52
  return;
53
53
  },
54
+ onActivity() {
55
+ return;
56
+ },
54
57
  async onRestart() {
55
58
  return;
56
59
  },
@@ -146,7 +149,7 @@ describe("the setup handlers", () => {
146
149
  runtime: null,
147
150
  });
148
151
 
149
- expect(await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).toEqual({
152
+ expect((await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).result).toEqual({
150
153
  supported: false,
151
154
  runtime: null,
152
155
  });
@@ -207,7 +210,7 @@ describe("the setup handlers", () => {
207
210
  const { logger } = recorder();
208
211
  const handlers = createSetupHandlers(driverWith(setupWith()), logger);
209
212
 
210
- expect(await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).toEqual({
213
+ expect((await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).result).toEqual({
211
214
  supported: false,
212
215
  runtime: null,
213
216
  });
@@ -227,7 +230,7 @@ describe("the setup handlers", () => {
227
230
 
228
231
  handlers.onSetup(signIn());
229
232
 
230
- expect(await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).toEqual({
233
+ expect((await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).result).toEqual({
231
234
  supported: true,
232
235
  runtime: {
233
236
  steps: [
@@ -251,6 +254,65 @@ describe("the setup handlers", () => {
251
254
  expect(warnings.at(-1)?.message).toBe("setup submit rejected");
252
255
  });
253
256
 
257
+ test("add one rejected entry built from the user error", async () => {
258
+ const { logger } = recorder();
259
+
260
+ const handlers = createSetupHandlers(
261
+ driverWith(
262
+ setupWith(async () => {
263
+ throw new BridgeUserError({
264
+ ar: "الكود ما عاد ينفع",
265
+ en: "this code cannot be renewed any more",
266
+ });
267
+ }),
268
+ ),
269
+ logger,
270
+ );
271
+
272
+ handlers.onSetup(signIn());
273
+
274
+ const { activity } = await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {});
275
+
276
+ expect(activity).toEqual([
277
+ {
278
+ name: BridgeEventName.SetupRejected,
279
+ text: {
280
+ ar: "الكود ما عاد ينفع",
281
+ en: "this code cannot be renewed any more",
282
+ },
283
+ details: {
284
+ step: "sign-in",
285
+ action: "renew",
286
+ },
287
+ },
288
+ ]);
289
+ });
290
+
291
+ test("answer what the driver reported while submitting", async () => {
292
+ const { logger } = recorder();
293
+
294
+ const handlers = createSetupHandlers(
295
+ driverWith(
296
+ setupWith(async (context) => {
297
+ context.activity.report({
298
+ name: BridgeEventName.SetupCompleted,
299
+ text: text("signed in"),
300
+ });
301
+ }),
302
+ ),
303
+ logger,
304
+ );
305
+
306
+ const { activity } = await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {});
307
+
308
+ expect(activity).toEqual([
309
+ {
310
+ name: BridgeEventName.SetupCompleted,
311
+ text: text("signed in"),
312
+ },
313
+ ]);
314
+ });
315
+
254
316
  test("answer the runtime the driver reported while submitting", async () => {
255
317
  const { logger } = recorder();
256
318
 
@@ -273,7 +335,7 @@ describe("the setup handlers", () => {
273
335
 
274
336
  handlers.onSetup(signIn());
275
337
 
276
- expect(await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).toEqual({
338
+ expect((await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {})).result).toEqual({
277
339
  supported: true,
278
340
  runtime: {
279
341
  steps: [
@@ -381,11 +443,12 @@ describe("the setup handlers redaction", () => {
381
443
 
382
444
  handlers.onSetup(signIn());
383
445
 
384
- const result = await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {});
446
+ const { result, activity } = await handlers.onSetupSubmit(contextOf(), "sign-in", "renew", {});
385
447
 
386
448
  expect(result.runtime?.prompt).toMatchObject({
387
449
  kind: BridgeSetupPromptKind.Failed,
388
450
  message: text("telegram refused ••••"),
389
451
  });
452
+ expect(JSON.stringify(activity)).not.toContain(SECRET);
390
453
  });
391
454
  });
@@ -1,15 +1,22 @@
1
1
  import type { BridgeContext } from "../context";
2
2
  import { BridgeUserError, type BridgeValues } from "../core";
3
3
  import type { BridgeDriver } from "../modules";
4
- import type { BridgeSetupResult, BridgeSetupRuntime } from "../protocol";
5
- import type { BridgeLeveledLogger } from "./bridgeLog";
6
- import { type BridgeRedact, redactLocalized, redactSetupRuntime } from "./bridgeRedaction";
4
+ import { BridgeEventName, type BridgeSetupResult, type BridgeSetupRuntime } from "../protocol";
5
+ import { type ActivityNormalizeOptions, type BridgeActivityAnswer, createActivityScope } from "./bridgeActivity";
6
+ import { type BridgeLeveledLogger, errorDetail } from "./bridgeLog";
7
+ import { type BridgeRedact, redactLocalized, redactSetupRuntime, secretFieldKeys } from "./bridgeRedaction";
8
+ import { scopedContext } from "./bridgeScopedContext";
7
9
  import { failedSetup, normalizeSetupRuntime, unsupportedSetup } from "./bridgeSetup";
8
10
 
9
11
  export interface BridgeSetupHandlers {
10
12
  onSetup(runtime: BridgeSetupRuntime | null): void;
11
13
  onSetupStatus(): BridgeSetupResult;
12
- onSetupSubmit(context: BridgeContext, step: string, action: string, args: BridgeValues): Promise<BridgeSetupResult>;
14
+ onSetupSubmit(
15
+ context: BridgeContext,
16
+ step: string,
17
+ action: string,
18
+ args: BridgeValues,
19
+ ): Promise<BridgeActivityAnswer<BridgeSetupResult>>;
13
20
  current(): BridgeSetupRuntime | null;
14
21
  }
15
22
 
@@ -22,6 +29,12 @@ export const createSetupHandlers = (
22
29
  ): BridgeSetupHandlers => {
23
30
  const declared: ReadonlySet<string> = new Set((driver.setup?.steps ?? []).map((step) => step.id));
24
31
 
32
+ const activity: ActivityNormalizeOptions = {
33
+ secretKeys: new Set(secretFieldKeys(driver)),
34
+ redact,
35
+ warn: log.warn,
36
+ };
37
+
25
38
  let current: BridgeSetupRuntime | null = null;
26
39
 
27
40
  return {
@@ -53,28 +66,50 @@ export const createSetupHandlers = (
53
66
  const module = driver.setup;
54
67
 
55
68
  if (!module?.submit) {
56
- return unsupportedSetup();
69
+ return {
70
+ result: unsupportedSetup(),
71
+ activity: [],
72
+ };
57
73
  }
58
74
 
75
+ const scope = createActivityScope(context.activity, activity);
76
+
59
77
  try {
60
- await module.submit(context, step, action, args);
78
+ await module.submit(scopedContext(context, scope.reporter), step, action, args);
61
79
  } catch (error) {
62
80
  if (!(error instanceof BridgeUserError)) {
81
+ scope.drain();
82
+
63
83
  throw error;
64
84
  }
65
85
 
66
86
  log.warn("setup submit rejected", {
67
87
  step,
68
88
  action,
69
- error: error.message,
89
+ error: errorDetail(error),
70
90
  });
71
91
 
72
- return failedSetup(current, redactLocalized(error.text, redact));
92
+ scope.reporter.report({
93
+ name: BridgeEventName.SetupRejected,
94
+ text: error.text,
95
+ details: {
96
+ step,
97
+ action,
98
+ },
99
+ });
100
+
101
+ return {
102
+ result: failedSetup(current, redactLocalized(error.text, redact)),
103
+ activity: scope.close(),
104
+ };
73
105
  }
74
106
 
75
107
  return {
76
- supported: true,
77
- runtime: current,
108
+ result: {
109
+ supported: true,
110
+ runtime: current,
111
+ },
112
+ activity: scope.close(),
78
113
  };
79
114
  },
80
115
  };
@@ -5,10 +5,12 @@ import {
5
5
  BRIDGE_INSTALL_ERROR_LIMIT,
6
6
  BRIDGE_VERSION,
7
7
  type BridgeConfig,
8
+ BridgeEventName,
8
9
  type BridgePendingResult,
9
10
  type BridgeSampleResult,
10
11
  type BridgeStatus,
11
12
  } from "../protocol";
13
+ import { type ActivityNormalizeOptions, createActivityReporter } from "./bridgeActivity";
12
14
  import { createBackupLease } from "./bridgeBackupLease";
13
15
  import { configPath, readConfig, socketPath, statePath } from "./bridgeConfigReader";
14
16
  import { createConsoleHandler } from "./bridgeConsoleHandler";
@@ -17,7 +19,7 @@ import { validateDriver } from "./bridgeDriver";
17
19
  import { createEventRing } from "./bridgeEvents";
18
20
  import { decideInstall, recountHold } from "./bridgeInstall";
19
21
  import { createBridgeLiveConfig } from "./bridgeLiveConfig";
20
- import { errorText, log as stdoutLog } from "./bridgeLog";
22
+ import { errorDetail, errorText, log as stdoutLog } from "./bridgeLog";
21
23
  import { pendingResult, rejectedPending, unsupportedPending } from "./bridgePending";
22
24
  import { createBridgeRedaction, redactLocalized, redactLogger, secretFieldKeys } from "./bridgeRedaction";
23
25
  import { createRegistry } from "./bridgeRegistry";
@@ -37,6 +39,17 @@ const DEFAULT_STOP_TIMEOUT_SECONDS = 30;
37
39
 
38
40
  const DEFAULT_SAMPLE_REFRESH_SECONDS = 30;
39
41
 
42
+ const INSTALL_FAILED_TEXT: Record<BridgeLifecycleSubject, LocalizedText> = {
43
+ [BridgeLifecycleSubject.Game]: {
44
+ ar: "ما قدرنا نثبّت اللعبة. جرّب مرة ثانية.",
45
+ en: "We could not install the game. Try again.",
46
+ },
47
+ [BridgeLifecycleSubject.Bot]: {
48
+ ar: "ما قدرنا نثبّت كود بوتك. جرّب مرة ثانية.",
49
+ en: "We could not install your bot's code. Try again.",
50
+ },
51
+ };
52
+
40
53
  const prepare = async (config: BridgeConfig) => {
41
54
  await mkdir(config.cwd, {
42
55
  recursive: true,
@@ -130,6 +143,19 @@ export const serve = async (driver: BridgeDriver) => {
130
143
 
131
144
  const ring = createEventRing(driver.events, supervisor);
132
145
 
146
+ const activity: ActivityNormalizeOptions = {
147
+ secretKeys: new Set(secretFieldKeys(driver)),
148
+ redact,
149
+ warn: log.warn,
150
+ };
151
+
152
+ const reporter = createActivityReporter({
153
+ ...activity,
154
+ onEmit(event, payload) {
155
+ ring.emit(event, payload);
156
+ },
157
+ });
158
+
133
159
  const context = createContext(
134
160
  config,
135
161
  supervisor,
@@ -141,6 +167,9 @@ export const serve = async (driver: BridgeDriver) => {
141
167
  onSetup(runtime) {
142
168
  setup.onSetup(runtime);
143
169
  },
170
+ onActivity(entry) {
171
+ reporter.report(entry);
172
+ },
144
173
  async onRestart() {
145
174
  if (current !== BridgeDesired.Running) {
146
175
  throw new Error(
@@ -154,7 +183,7 @@ export const serve = async (driver: BridgeDriver) => {
154
183
  live,
155
184
  );
156
185
 
157
- const registry = createRegistry(driver, context, supervisor, ring);
186
+ const registry = createRegistry(driver, context, supervisor, ring, activity);
158
187
  const backupLease = createBackupLease(async () => {
159
188
  if (supervisor.running) {
160
189
  await driver.backup?.release?.(context);
@@ -409,6 +438,11 @@ export const serve = async (driver: BridgeDriver) => {
409
438
  onSetupStatus() {
410
439
  return setup.onSetupStatus();
411
440
  },
441
+ onUndelivered(activity) {
442
+ for (const entry of activity) {
443
+ reporter.report(entry);
444
+ }
445
+ },
412
446
  async onSetupSubmit(step, action, args) {
413
447
  return await setup.onSetupSubmit(context, step, action, args);
414
448
  },
@@ -462,7 +496,7 @@ export const serve = async (driver: BridgeDriver) => {
462
496
 
463
497
  log("install complete");
464
498
  } catch (error) {
465
- installError = redact(errorText(error)).slice(0, BRIDGE_INSTALL_ERROR_LIMIT);
499
+ installError = redact(errorDetail(error)).slice(0, BRIDGE_INSTALL_ERROR_LIMIT);
466
500
  installErrorText = error instanceof BridgeUserError ? redactLocalized(error.text, redact) : null;
467
501
 
468
502
  supervisor.setRuntime(BridgeRuntime.Crashed);
@@ -471,6 +505,14 @@ export const serve = async (driver: BridgeDriver) => {
471
505
  error: installError,
472
506
  });
473
507
 
508
+ context.activity.report({
509
+ name: BridgeEventName.InstallFailed,
510
+ text:
511
+ error instanceof BridgeUserError
512
+ ? error.text
513
+ : INSTALL_FAILED_TEXT[driver.lifecycle.subject ?? BridgeLifecycleSubject.Game],
514
+ });
515
+
474
516
  return;
475
517
  }
476
518
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { steamcmdArgv, steamcmdScript, steamcmdUpdateArgs } from "./steamcmd";
2
+ import { steamcmdArgv, steamcmdFailureText, steamcmdScript, steamcmdUpdateArgs } from "./steamcmd";
3
3
  import { SteamcmdLoginKind, steamcmdLoginArgs } from "./steamcmdLogin";
4
4
  import { SteamcmdPlatform } from "./steamcmdPlatform";
5
5
 
@@ -155,3 +155,34 @@ describe("steamcmdUpdateArgs", () => {
155
155
  ]);
156
156
  });
157
157
  });
158
+
159
+ describe("steamcmdFailureText", () => {
160
+ test("says the download account does not own the app when steam answers no subscription", () => {
161
+ expect(
162
+ steamcmdFailureText(
163
+ {
164
+ stdout: "No subscription",
165
+ stderr: "",
166
+ code: 8,
167
+ },
168
+ "assetto",
169
+ ).en,
170
+ ).toBe("Steam refused to download assetto because the download account does not own it. Contact support.");
171
+ });
172
+
173
+ test("says the download failed for any other exit", () => {
174
+ expect(
175
+ steamcmdFailureText(
176
+ {
177
+ stdout: "",
178
+ stderr: "",
179
+ code: 8,
180
+ },
181
+ "palworld",
182
+ ),
183
+ ).toEqual({
184
+ ar: "ما قدرنا ننزّل palworld من Steam. جرّب مرة ثانية.",
185
+ en: "We could not download palworld from Steam. Try again.",
186
+ });
187
+ });
188
+ });