Skip to content

Commit 1396c8e

Browse files
Fix inputs on BYOND 516 getting cut off (tgui payload chunking) (#6088)
## About The Pull Request Ports tgstation/tgstation#90295 > Dream Seeker will not send topic calls greater than 2kb in size. There are cases where tgui will attempt to send ui_act payloads larger than this, such as writing on paper. This PR takes payloads that would be larger than 2kb, splits them into payloads that would be roughly 1kb (after URL encoding), and sends them to the server in sequence. To prevent abuse and/or topic spam, a config option has been added to put a limit on the number of chunks for which the server will accept a payload, defaulting to 10. ## Why It's Good For The Game > Fixes (tg issue 90056), along with several other things that were affected by the change to WebView2 in 516. ## Changelog :cl: Absolucy, Y0SH1M4S73R code: Any tgui message that would be too big to send to the server is now split into chunks and sent in sequence. This fixes several issues, such as... fix: It is once again possible to save large amounts of text on paper at once. /:cl: --------- Co-authored-by: Y0SH1M4S73R <[email protected]> Co-authored-by: Y0SH1M4S73R <[email protected]>
1 parent 2a31010 commit 1396c8e

File tree

6 files changed

+215
-6
lines changed

6 files changed

+215
-6
lines changed

code/controllers/configuration/entries/general.dm

+7
Original file line numberDiff line numberDiff line change
@@ -705,3 +705,10 @@
705705

706706
/datum/config_entry/flag/vpn_kick
707707
default = FALSE
708+
709+
/**
710+
* Tgui ui_act payloads larger than 2kb are split into chunks a maximum of 1kb in size.
711+
* This flag represents the maximum chunk count the server is willing to receive.
712+
*/
713+
/datum/config_entry/number/tgui_max_chunk_count
714+
default = 32

code/modules/tgui/tgui_window.dm

+42
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"Ctrl+UP" = "byond/ctrlup",
3939
)
4040

41+
var/list/oversized_payloads = list()
42+
4143
/**
4244
* public
4345
*
@@ -386,6 +388,17 @@
386388
reinitialize()
387389
if("chat/resend")
388390
SSchat.handle_resend(client, payload)
391+
if("oversizedPayloadRequest")
392+
var/payload_id = payload["id"]
393+
var/chunk_count = payload["chunkCount"]
394+
var/permit_payload = chunk_count <= CONFIG_GET(number/tgui_max_chunk_count)
395+
if(permit_payload)
396+
create_oversized_payload(payload_id, payload["type"], chunk_count)
397+
send_message("oversizePayloadResponse", list("allow" = permit_payload, "id" = payload_id))
398+
if("payloadChunk")
399+
var/payload_id = payload["id"]
400+
append_payload_chunk(payload_id, payload["chunk"])
401+
send_message("acknowlegePayloadChunk", list("id" = payload_id))
389402

390403
/datum/tgui_window/vv_edit_var(var_name, var_value)
391404
return var_name != NAMEOF(src, id) && ..()
@@ -418,3 +431,32 @@
418431
for(var/mouseMacro in byondToTguiEventMap)
419432
winset(client, null, "[mouseMacro]Window[id]Macro.parent=null")
420433
mouse_event_macro_set = FALSE
434+
435+
/datum/tgui_window/proc/create_oversized_payload(payload_id, message_type, chunk_count)
436+
if(oversized_payloads[payload_id])
437+
stack_trace("Attempted to create oversized tgui payload with duplicate ID.")
438+
return
439+
oversized_payloads[payload_id] = list(
440+
"type" = message_type,
441+
"count" = chunk_count,
442+
"chunks" = list(),
443+
"timeout" = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE)
444+
)
445+
446+
/datum/tgui_window/proc/append_payload_chunk(payload_id, chunk)
447+
var/list/payload = oversized_payloads[payload_id]
448+
if(!payload)
449+
return
450+
var/list/chunks = payload["chunks"]
451+
chunks += chunk
452+
if(chunks.len >= payload["count"])
453+
deltimer(payload["timeout"])
454+
var/message_type = payload["type"]
455+
var/final_payload = chunks.Join()
456+
remove_oversized_payload(payload_id)
457+
on_message(message_type, json_decode(final_payload), list("type" = message_type, "payload" = final_payload, "tgui" = TRUE, "window_id" = id))
458+
else
459+
payload["timeout"] = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE)
460+
461+
/datum/tgui_window/proc/remove_oversized_payload(payload_id)
462+
oversized_payloads -= payload_id

config/config.txt

+3
Original file line numberDiff line numberDiff line change
@@ -572,3 +572,6 @@ CONFIG_ERRORS_RUNTIME
572572
## The age in days if minimum account age is on
573573
#MINIMUM_AGE
574574

575+
## Tgui payloads larger than the 2kb limit for BYOND topic requests are split into roughly 1kb chunks and sent in sequence.
576+
## This config option limits the maximum chunk count for which the server will accept a payload, default is 32
577+
TGUI_MAX_CHUNK_COUNT 32

tgui/packages/tgui/backend.ts

+155-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ export const setGlobalStore = (store) => {
3535
export const backendUpdate = createAction('backend/update');
3636
export const backendSetSharedState = createAction('backend/setSharedState');
3737
export const backendSuspendStart = createAction('backend/suspendStart');
38+
export const backendCreatePayloadQueue = createAction(
39+
'backend/createPayloadQueue',
40+
);
41+
export const backendDequeuePayloadQueue = createAction(
42+
'backend/dequeuePayloadQueue',
43+
);
44+
export const backendRemovePayloadQueue = createAction(
45+
'backend/removePayloadQueue',
46+
);
47+
export const nextPayloadChunk = createAction('nextPayloadChunk');
3848

3949
export const backendSuspendSuccess = () => ({
4050
type: 'backend/suspendSuccess',
@@ -47,6 +57,7 @@ const initialState = {
4757
config: {},
4858
data: {},
4959
shared: {},
60+
outgoingPayloadQueues: {} as Record<string, string[]>,
5061
// Start as suspended
5162
suspended: Date.now(),
5263
suspending: false,
@@ -123,6 +134,44 @@ export const backendReducer = (state = initialState, action) => {
123134
};
124135
}
125136

137+
if (type === 'backend/createPayloadQueue') {
138+
const { id, chunks } = payload;
139+
const { outgoingPayloadQueues } = state;
140+
return {
141+
...state,
142+
outgoingPayloadQueues: {
143+
...outgoingPayloadQueues,
144+
[id]: chunks,
145+
},
146+
};
147+
}
148+
149+
if (type === 'backend/dequeuePayloadQueue') {
150+
const { id } = payload;
151+
const { outgoingPayloadQueues } = state;
152+
const { [id]: targetQueue, ...otherQueues } = outgoingPayloadQueues;
153+
const [_, ...rest] = targetQueue;
154+
return {
155+
...state,
156+
outgoingPayloadQueues: rest.length
157+
? {
158+
...otherQueues,
159+
[id]: rest,
160+
}
161+
: otherQueues,
162+
};
163+
}
164+
165+
if (type === 'backend/removePayloadQueue') {
166+
const { id } = payload;
167+
const { outgoingPayloadQueues } = state;
168+
const { [id]: _, ...otherQueues } = outgoingPayloadQueues;
169+
return {
170+
...state,
171+
outgoingPayloadQueues: otherQueues,
172+
};
173+
}
174+
126175
return state;
127176
};
128177

@@ -131,7 +180,9 @@ export const backendMiddleware = (store) => {
131180
let suspendInterval;
132181

133182
return (next) => (action) => {
134-
const { suspended } = selectBackend(store.getState());
183+
const { suspended, outgoingPayloadQueues } = selectBackend(
184+
store.getState(),
185+
);
135186
const { type, payload } = action;
136187

137188
if (type === 'update') {
@@ -234,10 +285,87 @@ export const backendMiddleware = (store) => {
234285
});
235286
}
236287

288+
if (type === 'oversizePayloadResponse') {
289+
const { allow } = payload;
290+
if (allow) {
291+
store.dispatch(nextPayloadChunk(payload));
292+
} else {
293+
store.dispatch(backendRemovePayloadQueue(payload));
294+
}
295+
}
296+
297+
if (type === 'acknowlegePayloadChunk') {
298+
store.dispatch(backendDequeuePayloadQueue(payload));
299+
store.dispatch(nextPayloadChunk(payload));
300+
}
301+
302+
if (type === 'nextPayloadChunk') {
303+
const { id } = payload;
304+
const chunk = outgoingPayloadQueues[id][0];
305+
Byond.sendMessage('payloadChunk', {
306+
id,
307+
chunk,
308+
});
309+
}
310+
237311
return next(action);
238312
};
239313
};
240314

315+
const encodedLengthBinarySearch = (haystack: string[], length: number) => {
316+
const haystackLength = haystack.length;
317+
let high = haystackLength - 1;
318+
let low = 0;
319+
let mid = 0;
320+
while (low < high) {
321+
mid = Math.round((low + high) / 2);
322+
const substringLength = encodeURIComponent(
323+
haystack.slice(0, mid).join(''),
324+
).length;
325+
if (substringLength === length) {
326+
break;
327+
}
328+
if (substringLength < length) {
329+
low = mid + 1;
330+
} else {
331+
high = mid - 1;
332+
}
333+
}
334+
return mid;
335+
};
336+
337+
const chunkSplitter = {
338+
[Symbol.split]: (string: string) => {
339+
// TODO: get rid of the "as any" whenever we upgrade typescript
340+
const charSeq = (string[Symbol.iterator]() as any).toArray();
341+
const length = charSeq.length;
342+
let chunks: string[] = [];
343+
let startIndex = 0;
344+
let endIndex = 1024;
345+
while (startIndex < length) {
346+
const cut = charSeq.slice(
347+
startIndex,
348+
endIndex < length ? endIndex : undefined,
349+
);
350+
const cutString = cut.join('');
351+
if (encodeURIComponent(cutString).length > 1024) {
352+
const splitIndex = startIndex + encodedLengthBinarySearch(cut, 1024);
353+
chunks.push(
354+
charSeq
355+
.slice(startIndex, splitIndex < length ? splitIndex : undefined)
356+
.join(''),
357+
);
358+
startIndex = splitIndex;
359+
} else {
360+
chunks.push(cutString);
361+
startIndex = endIndex;
362+
}
363+
endIndex = startIndex + 1024;
364+
}
365+
return chunks;
366+
},
367+
};
368+
241369
/**
242370
* Sends an action to `ui_act` on `src_object` that this tgui window
243371
* is associated with.
@@ -252,6 +380,31 @@ export const sendAct = (action: string, payload: object = {}) => {
252380
logger.error(`Payload for act() must be an object, got this:`, payload);
253381
return;
254382
}
383+
if (!Byond.TRIDENT) {
384+
const stringifiedPayload = JSON.stringify(payload);
385+
const urlSize = Object.entries({
386+
type: 'act/' + action,
387+
payload: stringifiedPayload,
388+
tgui: 1,
389+
windowId: Byond.windowId,
390+
}).reduce(
391+
(url, [key, value], i) =>
392+
url +
393+
`${i > 0 ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
394+
'',
395+
).length;
396+
if (urlSize > 2048) {
397+
let chunks: string[] = stringifiedPayload.split(chunkSplitter);
398+
const id = `${Date.now()}`;
399+
globalStore?.dispatch(backendCreatePayloadQueue({ id, chunks }));
400+
Byond.sendMessage('oversizedPayloadRequest', {
401+
type: 'act/' + action,
402+
id,
403+
chunkCount: chunks.length,
404+
});
405+
return;
406+
}
407+
}
255408
Byond.sendMessage('act/' + action, payload);
256409
};
257410

@@ -279,6 +432,7 @@ type BackendState<TData> = {
279432
};
280433
data: TData;
281434
shared: Record<string, any>;
435+
outgoingPayloadQueues: Record<string, any[]>;
282436
suspending: boolean;
283437
suspended: boolean;
284438
debug?: {

tgui/public/tgui.html

+7-5
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,13 @@
123123
location.href = 'byond://' + url;
124124
return;
125125
}
126-
// Send an HTTP request to DreamSeeker's HTTP server.
127-
// Allows sending much bigger payloads.
128-
var xhr = new XMLHttpRequest();
129-
xhr.open('GET', url);
130-
xhr.send();
126+
if (Byond.TRIDENT !== null) {
127+
// Send an HTTP request to DreamSeeker's HTTP server.
128+
// Allows sending much bigger payloads.
129+
var xhr = new XMLHttpRequest();
130+
xhr.open('GET', url);
131+
xhr.send();
132+
}
131133
};
132134

133135
Byond.callAsync = function (path, params) {

tools/build/build.js

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export const DmTarget = new Juke.Target({
7575
'html/**',
7676
'icons/**',
7777
'interface/**',
78+
'tgui/public/tgui.html',
7879
'monkestation/code/**', // monke edit: ensure it also checks for updates in modular code
7980
'monkestation/icons/**',
8081
`${DME_NAME}.dme`,

0 commit comments

Comments
 (0)