@serverkgg/bridge 0.0.13 → 0.0.15
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/package.json +1 -1
- package/src/cli/validate.test.ts +194 -2
- package/src/cli/validate.ts +83 -1
- package/src/context/bridgeContext.ts +9 -2
- package/src/guides/variables.ts +1 -0
- package/src/manifest/manifest.ts +1 -1
- package/src/modules/bridgeModule.ts +2 -1
- package/src/panel/bridgePanel.ts +11 -0
- package/src/panel/bridgePlace.ts +4 -0
- package/src/panel/index.ts +1 -0
- package/src/rcon/index.ts +3 -2
- package/src/rcon/rconAccess.test.ts +304 -0
- package/src/rcon/rconAccess.ts +260 -0
- package/src/rcon/rconClient.test.ts +1544 -0
- package/src/rcon/rconClient.ts +275 -0
- package/src/rcon/rconError.ts +21 -0
- package/src/rcon/rconFrame.test.ts +269 -0
- package/src/rcon/{sourceRconFrame.ts → rconFrame.ts} +23 -14
- package/src/rcon/rconIds.test.ts +59 -0
- package/src/rcon/rconIds.ts +33 -0
- package/src/rcon/rconLink.ts +427 -0
- package/src/rcon/rconWriter.test.ts +95 -0
- package/src/rcon/rconWriter.ts +63 -0
- package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
- package/src/runtime/bridgeConsoleHandler.ts +26 -0
- package/src/runtime/bridgeDriver.test.ts +145 -0
- package/src/runtime/bridgeDriver.ts +44 -2
- package/src/runtime/bridgeRcon.ts +6 -2
- package/src/runtime/bridgeRegistry.test.ts +96 -0
- package/src/runtime/bridgeRegistry.ts +33 -1
- package/src/runtime/serve.ts +10 -1
- package/src/steam/avatars.test.ts +306 -0
- package/src/steam/avatars.ts +107 -0
- package/src/steam/index.ts +1 -0
- package/src/wire/panel.test.ts +79 -0
- package/src/wire/panel.ts +11 -0
- package/src/rcon/sourceRcon.test.ts +0 -308
- package/src/rcon/sourceRcon.ts +0 -276
- package/src/rcon/sourceRconFrame.test.ts +0 -152
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { afterEach, describe, expect, setSystemTime, test } from "bun:test";
|
|
2
|
+
import type { BridgeContext } from "../context";
|
|
3
|
+
import type { BridgeValues } from "../core";
|
|
4
|
+
import { createSteamAvatars, STEAM_AVATAR_CACHE_TTL_MS, steamAvatarHashOf } from "./avatars";
|
|
5
|
+
|
|
6
|
+
const ALICE_HASH = "a".repeat(40);
|
|
7
|
+
|
|
8
|
+
const BOB_HASH = "b".repeat(40);
|
|
9
|
+
|
|
10
|
+
const ALICE = "76561198000000001";
|
|
11
|
+
|
|
12
|
+
const BOB = "76561198000000002";
|
|
13
|
+
|
|
14
|
+
const identity = (id: string) => (id.startsWith("7656") ? id : null);
|
|
15
|
+
|
|
16
|
+
interface Warning {
|
|
17
|
+
message: string;
|
|
18
|
+
fields: BridgeValues | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Summary {
|
|
22
|
+
steamid: string;
|
|
23
|
+
avatarfull: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FakeSteam {
|
|
27
|
+
context: BridgeContext;
|
|
28
|
+
calls: string[][];
|
|
29
|
+
warnings: Warning[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const summaryOf = (steamId: string, hash: string): Summary => {
|
|
33
|
+
return {
|
|
34
|
+
steamid: steamId,
|
|
35
|
+
avatarfull: `https://avatars.steamstatic.com/${hash}_full.jpg`,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fakeSteam = (options: { key?: string | null; answer?: (steamIds: string[]) => Summary[] }): FakeSteam => {
|
|
40
|
+
const calls: string[][] = [];
|
|
41
|
+
const warnings: Warning[] = [];
|
|
42
|
+
|
|
43
|
+
const context = {
|
|
44
|
+
secret(key: string) {
|
|
45
|
+
return key === "STEAM_WEB_API_KEY" ? (options.key === undefined ? "secret-key" : options.key) : null;
|
|
46
|
+
},
|
|
47
|
+
net: {
|
|
48
|
+
async json(url: string) {
|
|
49
|
+
const steamIds = (new URL(url).searchParams.get("steamids") ?? "").split(",").filter((id) => id.length > 0);
|
|
50
|
+
|
|
51
|
+
calls.push(steamIds);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
response: {
|
|
55
|
+
players: options.answer?.(steamIds) ?? [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
log: {
|
|
61
|
+
warn(message: string, fields?: BridgeValues) {
|
|
62
|
+
warnings.push({
|
|
63
|
+
message,
|
|
64
|
+
fields,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
} as unknown as BridgeContext;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
context,
|
|
72
|
+
calls,
|
|
73
|
+
warnings,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
setSystemTime();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("reading the avatar hash out of a steam profile picture", () => {
|
|
82
|
+
test("takes the hash out of the full-size url steam answers with", () => {
|
|
83
|
+
expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${ALICE_HASH}_full.jpg`)).toBe(ALICE_HASH);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("takes the hash out of a url that carries no size suffix", () => {
|
|
87
|
+
expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${BOB_HASH}.jpg`)).toBe(BOB_HASH);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("reports no hash for a url that is not a steam avatar", () => {
|
|
91
|
+
expect(steamAvatarHashOf("https://example.com/avatar.png")).toBeNull();
|
|
92
|
+
expect(steamAvatarHashOf("")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("keeping the steam avatars a roster shows", () => {
|
|
97
|
+
test("asks steam for a hash it has never seen, and answers with it", async () => {
|
|
98
|
+
const steam = fakeSteam({
|
|
99
|
+
answer: () => [
|
|
100
|
+
summaryOf(ALICE, ALICE_HASH),
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
const avatars = createSteamAvatars({
|
|
104
|
+
steamIdOf: identity,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(
|
|
108
|
+
await avatars.resolve(steam.context, [
|
|
109
|
+
ALICE,
|
|
110
|
+
]),
|
|
111
|
+
).toEqual(
|
|
112
|
+
new Map([
|
|
113
|
+
[
|
|
114
|
+
ALICE,
|
|
115
|
+
ALICE_HASH,
|
|
116
|
+
],
|
|
117
|
+
]),
|
|
118
|
+
);
|
|
119
|
+
expect(steam.calls).toEqual([
|
|
120
|
+
[
|
|
121
|
+
ALICE,
|
|
122
|
+
],
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("answers the second time without asking steam again", async () => {
|
|
127
|
+
const steam = fakeSteam({
|
|
128
|
+
answer: () => [
|
|
129
|
+
summaryOf(ALICE, ALICE_HASH),
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
const avatars = createSteamAvatars({
|
|
133
|
+
steamIdOf: identity,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await avatars.resolve(steam.context, [
|
|
137
|
+
ALICE,
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
expect(
|
|
141
|
+
(
|
|
142
|
+
await avatars.resolve(steam.context, [
|
|
143
|
+
ALICE,
|
|
144
|
+
])
|
|
145
|
+
).get(ALICE),
|
|
146
|
+
).toBe(ALICE_HASH);
|
|
147
|
+
expect(steam.calls.length).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("asks only for the ids it is missing, and still answers for the cached one", async () => {
|
|
151
|
+
const steam = fakeSteam({
|
|
152
|
+
answer: (steamIds) => {
|
|
153
|
+
return steamIds.map((steamId) => {
|
|
154
|
+
return summaryOf(steamId, steamId.endsWith("1") ? ALICE_HASH : BOB_HASH);
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const avatars = createSteamAvatars({
|
|
159
|
+
steamIdOf: identity,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await avatars.resolve(steam.context, [
|
|
163
|
+
ALICE,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const hashes = await avatars.resolve(steam.context, [
|
|
167
|
+
ALICE,
|
|
168
|
+
BOB,
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
expect(steam.calls.at(1)).toEqual([
|
|
172
|
+
BOB,
|
|
173
|
+
]);
|
|
174
|
+
expect(hashes.get(ALICE)).toBe(ALICE_HASH);
|
|
175
|
+
expect(hashes.get(BOB)).toBe(BOB_HASH);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("asks steam again once the cached hash is six hours old", async () => {
|
|
179
|
+
setSystemTime(new Date(0));
|
|
180
|
+
|
|
181
|
+
const steam = fakeSteam({
|
|
182
|
+
answer: () => [
|
|
183
|
+
summaryOf(ALICE, ALICE_HASH),
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
const avatars = createSteamAvatars({
|
|
187
|
+
steamIdOf: identity,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await avatars.resolve(steam.context, [
|
|
191
|
+
ALICE,
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS - 1));
|
|
195
|
+
|
|
196
|
+
await avatars.resolve(steam.context, [
|
|
197
|
+
ALICE,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(steam.calls.length).toBe(1);
|
|
201
|
+
|
|
202
|
+
setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS));
|
|
203
|
+
|
|
204
|
+
expect(
|
|
205
|
+
(
|
|
206
|
+
await avatars.resolve(steam.context, [
|
|
207
|
+
ALICE,
|
|
208
|
+
])
|
|
209
|
+
).get(ALICE),
|
|
210
|
+
).toBe(ALICE_HASH);
|
|
211
|
+
expect(steam.calls.length).toBe(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("never asks steam about a player the game does not map to a steam id", async () => {
|
|
215
|
+
const steam = fakeSteam({});
|
|
216
|
+
const avatars = createSteamAvatars({
|
|
217
|
+
steamIdOf: identity,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(
|
|
221
|
+
await avatars.resolve(steam.context, [
|
|
222
|
+
"gdk_2535000000000001",
|
|
223
|
+
]),
|
|
224
|
+
).toEqual(new Map());
|
|
225
|
+
expect(steam.calls).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("answers with nothing while the platform has no steam key", async () => {
|
|
229
|
+
const steam = fakeSteam({
|
|
230
|
+
key: null,
|
|
231
|
+
});
|
|
232
|
+
const avatars = createSteamAvatars({
|
|
233
|
+
steamIdOf: identity,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(
|
|
237
|
+
await avatars.resolve(steam.context, [
|
|
238
|
+
ALICE,
|
|
239
|
+
]),
|
|
240
|
+
).toEqual(new Map());
|
|
241
|
+
expect(steam.calls).toEqual([]);
|
|
242
|
+
expect(steam.warnings).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("answers with nothing and warns once when steam refuses", async () => {
|
|
246
|
+
const steam = fakeSteam({
|
|
247
|
+
answer: () => {
|
|
248
|
+
throw new Error("429 too many requests");
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const avatars = createSteamAvatars({
|
|
252
|
+
steamIdOf: identity,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(
|
|
256
|
+
await avatars.resolve(steam.context, [
|
|
257
|
+
ALICE,
|
|
258
|
+
]),
|
|
259
|
+
).toEqual(new Map());
|
|
260
|
+
expect(steam.warnings.length).toBe(1);
|
|
261
|
+
|
|
262
|
+
await avatars.resolve(steam.context, [
|
|
263
|
+
ALICE,
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
expect(steam.warnings.length).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("never writes the api key into the warning it logs", async () => {
|
|
270
|
+
const steam = fakeSteam({
|
|
271
|
+
key: "top-secret-key",
|
|
272
|
+
answer: () => {
|
|
273
|
+
throw new Error("steam said no");
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const avatars = createSteamAvatars({
|
|
277
|
+
steamIdOf: identity,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await avatars.resolve(steam.context, [
|
|
281
|
+
ALICE,
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
expect(JSON.stringify(steam.warnings)).not.toContain("top-secret-key");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("keeps the roster moving when steam sends a profile with no usable picture", async () => {
|
|
288
|
+
const steam = fakeSteam({
|
|
289
|
+
answer: () => [
|
|
290
|
+
{
|
|
291
|
+
steamid: ALICE,
|
|
292
|
+
avatarfull: "https://example.com/none.png",
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
const avatars = createSteamAvatars({
|
|
297
|
+
steamIdOf: identity,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(
|
|
301
|
+
await avatars.resolve(steam.context, [
|
|
302
|
+
ALICE,
|
|
303
|
+
]),
|
|
304
|
+
).toEqual(new Map());
|
|
305
|
+
});
|
|
306
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { BridgeContext } from "../context";
|
|
2
|
+
import { playerSummaries, steamWebApiReady } from "./webApi";
|
|
3
|
+
|
|
4
|
+
export const STEAM_AVATAR_CACHE_TTL_MS = 21_600_000;
|
|
5
|
+
|
|
6
|
+
const STEAM_AVATAR = /\/([0-9a-f]{40})(?:_[a-z]+)?\.jpg/;
|
|
7
|
+
|
|
8
|
+
export const steamAvatarHashOf = (url: string): string | null => {
|
|
9
|
+
return url.match(STEAM_AVATAR)?.at(1) ?? null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export interface SteamAvatars {
|
|
13
|
+
resolve(context: BridgeContext, ids: string[]): Promise<Map<string, string>>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SteamAvatarsOptions {
|
|
17
|
+
steamIdOf: (id: string) => string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CachedAvatar {
|
|
21
|
+
hash: string;
|
|
22
|
+
storedAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const createSteamAvatars = (options: SteamAvatarsOptions): SteamAvatars => {
|
|
26
|
+
const { steamIdOf } = options;
|
|
27
|
+
const cached = new Map<string, CachedAvatar>();
|
|
28
|
+
|
|
29
|
+
let warned = false;
|
|
30
|
+
|
|
31
|
+
const hashOf = (steamId: string, now: number) => {
|
|
32
|
+
const entry = cached.get(steamId);
|
|
33
|
+
|
|
34
|
+
if (!entry) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (now - entry.storedAt >= STEAM_AVATAR_CACHE_TTL_MS) {
|
|
39
|
+
cached.delete(steamId);
|
|
40
|
+
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return entry.hash;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const known = (ids: string[], now: number) => {
|
|
48
|
+
const hashes = new Map<string, string>();
|
|
49
|
+
|
|
50
|
+
for (const id of ids) {
|
|
51
|
+
const steamId = steamIdOf(id);
|
|
52
|
+
const hash = steamId === null ? null : hashOf(steamId, now);
|
|
53
|
+
|
|
54
|
+
if (hash !== null) {
|
|
55
|
+
hashes.set(id, hash);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return hashes;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
async resolve(context, ids) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const missing = new Set(
|
|
66
|
+
ids.flatMap((id) => {
|
|
67
|
+
const steamId = steamIdOf(id);
|
|
68
|
+
|
|
69
|
+
return steamId === null || hashOf(steamId, now) !== null
|
|
70
|
+
? []
|
|
71
|
+
: [
|
|
72
|
+
steamId,
|
|
73
|
+
];
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
if (missing.size === 0 || !steamWebApiReady(context)) {
|
|
78
|
+
return known(ids, now);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
for (const summary of await playerSummaries(context, [
|
|
83
|
+
...missing,
|
|
84
|
+
])) {
|
|
85
|
+
const hash = steamAvatarHashOf(summary.avatarfull);
|
|
86
|
+
|
|
87
|
+
if (hash !== null) {
|
|
88
|
+
cached.set(summary.steamid, {
|
|
89
|
+
hash,
|
|
90
|
+
storedAt: now,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (!warned) {
|
|
96
|
+
warned = true;
|
|
97
|
+
|
|
98
|
+
context.log.warn("the steam web api did not answer for the roster", {
|
|
99
|
+
error: error instanceof Error ? error.message : String(error),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return known(ids, now);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
package/src/steam/index.ts
CHANGED
package/src/wire/panel.test.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
BridgeKind,
|
|
11
11
|
BridgeLayout,
|
|
12
12
|
type BridgeManifest,
|
|
13
|
+
BridgePlace,
|
|
13
14
|
type BridgeSetupManifest,
|
|
14
15
|
BridgeSetupStepKind,
|
|
15
16
|
BridgeUploadMode,
|
|
@@ -604,3 +605,81 @@ describe("the setup mirror", () => {
|
|
|
604
605
|
expect(bridgeManifestSchema.safeParse(source).success).toBe(false);
|
|
605
606
|
});
|
|
606
607
|
});
|
|
608
|
+
|
|
609
|
+
const placedManifest = (): BridgeManifest => ({
|
|
610
|
+
version: "1.0.0",
|
|
611
|
+
panel: {
|
|
612
|
+
tabs: [
|
|
613
|
+
{
|
|
614
|
+
id: "roster",
|
|
615
|
+
title: text("Roster"),
|
|
616
|
+
sections: [
|
|
617
|
+
{
|
|
618
|
+
layout: BridgeLayout.Table,
|
|
619
|
+
id: "online",
|
|
620
|
+
module: "players",
|
|
621
|
+
place: BridgePlace.Players,
|
|
622
|
+
columns: [
|
|
623
|
+
{
|
|
624
|
+
key: "name",
|
|
625
|
+
label: text("Name"),
|
|
626
|
+
},
|
|
627
|
+
],
|
|
628
|
+
actions: [
|
|
629
|
+
{
|
|
630
|
+
id: "ban",
|
|
631
|
+
label: text("Ban"),
|
|
632
|
+
confirm: BridgeConfirm.Strong,
|
|
633
|
+
offline: true,
|
|
634
|
+
},
|
|
635
|
+
],
|
|
636
|
+
},
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
id: "settings",
|
|
641
|
+
title: text("Settings"),
|
|
642
|
+
sections: [
|
|
643
|
+
{
|
|
644
|
+
layout: BridgeLayout.Form,
|
|
645
|
+
id: "world",
|
|
646
|
+
title: text("World"),
|
|
647
|
+
help: text("These settings apply the next time the server starts"),
|
|
648
|
+
target: BridgeFormTarget.Variables,
|
|
649
|
+
fields: [
|
|
650
|
+
{
|
|
651
|
+
key: "WORLD_NAME",
|
|
652
|
+
control: BridgeControl.Text,
|
|
653
|
+
label: text("World name"),
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
},
|
|
657
|
+
],
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
},
|
|
661
|
+
terminal: null,
|
|
662
|
+
setup: null,
|
|
663
|
+
modules: [
|
|
664
|
+
{
|
|
665
|
+
id: "players",
|
|
666
|
+
kind: BridgeKind.Collection,
|
|
667
|
+
requiresRunning: true,
|
|
668
|
+
refreshSeconds: 20,
|
|
669
|
+
pageSize: null,
|
|
670
|
+
dependsOn: [],
|
|
671
|
+
actions: [
|
|
672
|
+
"ban",
|
|
673
|
+
],
|
|
674
|
+
},
|
|
675
|
+
],
|
|
676
|
+
events: [],
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
describe("the placement mirror", () => {
|
|
680
|
+
test("carries place, an offline row action and a form help through unchanged", () => {
|
|
681
|
+
const source = placedManifest();
|
|
682
|
+
|
|
683
|
+
expect(bridgeManifestSchema.parse(source)).toEqual(source);
|
|
684
|
+
});
|
|
685
|
+
});
|
package/src/wire/panel.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
BridgeLayout,
|
|
13
13
|
type BridgeManifest,
|
|
14
14
|
type BridgePanel,
|
|
15
|
+
BridgePlace,
|
|
15
16
|
type BridgeSection,
|
|
16
17
|
type BridgeSetupManifest,
|
|
17
18
|
type BridgeSetupStep,
|
|
@@ -118,6 +119,7 @@ export const bridgeSectionActionSchema = z.object({
|
|
|
118
119
|
confirm: z.enum(BridgeConfirm).optional(),
|
|
119
120
|
confirmText: panelTextSchema.optional(),
|
|
120
121
|
download: z.boolean().optional(),
|
|
122
|
+
offline: z.boolean().optional(),
|
|
121
123
|
fields: z.array(bridgeFieldSchema).max(ACTION_FIELDS_LIMIT).optional(),
|
|
122
124
|
});
|
|
123
125
|
|
|
@@ -148,6 +150,7 @@ export const bridgeActionsSectionSchema = z.object({
|
|
|
148
150
|
id: zBridgeId,
|
|
149
151
|
title: panelTextSchema.optional(),
|
|
150
152
|
help: panelTextSchema.optional(),
|
|
153
|
+
place: z.enum(BridgePlace).optional(),
|
|
151
154
|
module: zBridgeModuleId,
|
|
152
155
|
actions: z.array(bridgeSectionActionSchema).min(1).max(SECTION_ACTIONS_LIMIT),
|
|
153
156
|
visibleWhen: bridgeVisibleWhenSchema.optional(),
|
|
@@ -158,6 +161,7 @@ export const bridgeFormSectionSchema = z.object({
|
|
|
158
161
|
layout: z.literal(BridgeLayout.Form),
|
|
159
162
|
id: zBridgeId,
|
|
160
163
|
title: panelTextSchema.optional(),
|
|
164
|
+
help: panelTextSchema.optional(),
|
|
161
165
|
target: z.enum(BridgeFormTarget),
|
|
162
166
|
module: zBridgeModuleId.optional(),
|
|
163
167
|
preview: bridgePreviewRefSchema.optional(),
|
|
@@ -175,6 +179,8 @@ export const bridgeDetailSectionSchema = z.object({
|
|
|
175
179
|
layout: z.literal(BridgeLayout.Detail),
|
|
176
180
|
id: zBridgeId,
|
|
177
181
|
title: panelTextSchema.optional(),
|
|
182
|
+
help: panelTextSchema.optional(),
|
|
183
|
+
place: z.enum(BridgePlace).optional(),
|
|
178
184
|
module: zBridgeModuleId,
|
|
179
185
|
actions: z.array(bridgeSectionActionSchema).max(SECTION_ACTIONS_LIMIT).optional(),
|
|
180
186
|
reinstall: z.boolean().optional(),
|
|
@@ -196,6 +202,8 @@ export const bridgeTableSectionSchema = z.object({
|
|
|
196
202
|
layout: z.literal(BridgeLayout.Table),
|
|
197
203
|
id: zBridgeId,
|
|
198
204
|
title: panelTextSchema.optional(),
|
|
205
|
+
help: panelTextSchema.optional(),
|
|
206
|
+
place: z.enum(BridgePlace).optional(),
|
|
199
207
|
module: zBridgeModuleId,
|
|
200
208
|
restartHint: z.boolean().optional(),
|
|
201
209
|
columns: z.array(bridgeColumnSchema).min(1).max(8),
|
|
@@ -211,6 +219,8 @@ export const bridgeCardsSectionSchema = z.object({
|
|
|
211
219
|
layout: z.literal(BridgeLayout.Cards),
|
|
212
220
|
id: zBridgeId,
|
|
213
221
|
title: panelTextSchema.optional(),
|
|
222
|
+
help: panelTextSchema.optional(),
|
|
223
|
+
place: z.enum(BridgePlace).optional(),
|
|
214
224
|
module: zBridgeModuleId,
|
|
215
225
|
titleKey: zKey,
|
|
216
226
|
subtitleKey: zKey.optional(),
|
|
@@ -224,6 +234,7 @@ export const bridgeCatalogSectionSchema = z.object({
|
|
|
224
234
|
layout: z.literal(BridgeLayout.Catalog),
|
|
225
235
|
id: zBridgeId,
|
|
226
236
|
title: panelTextSchema.optional(),
|
|
237
|
+
help: panelTextSchema.optional(),
|
|
227
238
|
module: zBridgeModuleId,
|
|
228
239
|
restartHint: z.boolean().optional(),
|
|
229
240
|
reinstall: z.boolean().optional(),
|