diff --git a/code/controllers/configuration/entries/general.dm b/code/controllers/configuration/entries/general.dm index 450fcb66c0..6d54fde7a6 100644 --- a/code/controllers/configuration/entries/general.dm +++ b/code/controllers/configuration/entries/general.dm @@ -527,3 +527,10 @@ /datum/config_entry/string/elasticsearch_metrics_endpoint /datum/config_entry/string/elasticsearch_metrics_apikey + +/** + * Tgui ui_act payloads larger than 2kb are split into chunks a maximum of 1kb in size. + * This flag represents the maximum chunk count the server is willing to receive. + */ +/datum/config_entry/number/tgui_max_chunk_count + default = 32 diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm index 844ba6239a..c632bc7a97 100644 --- a/code/modules/tgui/tgui_window.dm +++ b/code/modules/tgui/tgui_window.dm @@ -26,6 +26,8 @@ var/initial_inline_css var/mouse_event_macro_set = FALSE + var/list/oversized_payloads = list() + /** * public * @@ -356,11 +358,18 @@ if("openLink") client << link(href_list["url"]) if("cacheReloaded") - // Reinitialize reinitialize() - // Resend the assets - for(var/asset in sent_assets) - send_asset(asset) + if("oversizedPayloadRequest") + var/payload_id = payload["id"] + var/chunk_count = payload["chunkCount"] + var/permit_payload = chunk_count <= CONFIG_GET(number/tgui_max_chunk_count) + if(permit_payload) + create_oversized_payload(payload_id, payload["type"], chunk_count) + send_message("oversizePayloadResponse", list("allow" = permit_payload, "id" = payload_id)) + if("payloadChunk") + var/payload_id = payload["id"] + append_payload_chunk(payload_id, payload["chunk"]) + send_message("acknowlegePayloadChunk", list("id" = payload_id)) /datum/tgui_window/proc/set_mouse_macro() if(mouse_event_macro_set) @@ -398,3 +407,34 @@ for(var/mouseMacro in byondToTguiEventMap) winset(client, null, "[mouseMacro]Window[id]Macro.parent=null") mouse_event_macro_set = FALSE + +/datum/tgui_window/vv_edit_var(var_name, var_value) + return var_name != NAMEOF(src, id) && ..() + +/datum/tgui_window/proc/create_oversized_payload(payload_id, message_type, chunk_count) + if(oversized_payloads[payload_id]) + CRASH("Attempted to create oversized tgui payload with duplicate ID.") + oversized_payloads[payload_id] = list( + "type" = message_type, + "count" = chunk_count, + "chunks" = list(), + "timeout" = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + ) + +/datum/tgui_window/proc/append_payload_chunk(payload_id, chunk) + var/list/payload = oversized_payloads[payload_id] + if(!payload) + return + var/list/chunks = payload["chunks"] + chunks += chunk + if(length(chunks) >= payload["count"]) + deltimer(payload["timeout"]) + var/message_type = payload["type"] + var/final_payload = chunks.Join() + remove_oversized_payload(payload_id) + on_message(message_type, json_decode(final_payload), list("type" = message_type, "payload" = final_payload, "tgui" = TRUE, "window_id" = id)) + else + payload["timeout"] = addtimer(CALLBACK(src, PROC_REF(remove_oversized_payload), payload_id), 1 SECONDS, TIMER_UNIQUE|TIMER_OVERRIDE|TIMER_STOPPABLE) + +/datum/tgui_window/proc/remove_oversized_payload(payload_id) + oversized_payloads -= payload_id diff --git a/code/modules/tgui_panel/external.dm b/code/modules/tgui_panel/external.dm index 35aa31eca7..56de556125 100644 --- a/code/modules/tgui_panel/external.dm +++ b/code/modules/tgui_panel/external.dm @@ -31,8 +31,7 @@ tgui_panel = new(src) tgui_panel.initialize(force = TRUE) // Force show the panel to see if there are any errors - winset(src, "output", "is-disabled=1&is-visible=0") - winset(src, "browseroutput", "is-disabled=0;is-visible=1") + winset(src, "legacy_output_selector", "left=output_browser") action = alert(src, "Method: Reinitializing the panel.\nWait a bit and tell me if it's fixed", "", "Fixed", "Nope") if(action == "Fixed") log_tgui(src, "Fixed by calling 'initialize'", @@ -41,7 +40,6 @@ // Failed to fix action = alert(src, "Welp, I'm all out of ideas. Try closing BYOND and reconnecting.\nWe could also disable tgui_panel and re-enable the old UI", "", "Thanks anyways", "Switch to old UI") if (action == "Switch to old UI") - winset(src, "output", "on-show=&is-disabled=0&is-visible=1") - winset(src, "browseroutput", "is-disabled=1;is-visible=0") + winset(src, "legacy_output_selector", "left=output_legacy") log_tgui(src, "Failed to fix.", context = "verb/fix_tgui_panel") diff --git a/config/config.txt b/config/config.txt index 7d6ec0fad0..c85a0843a2 100644 --- a/config/config.txt +++ b/config/config.txt @@ -583,3 +583,7 @@ ELASTICSEARCH_METRICS_ENDPOINT http://10.0.0.40:9201/ss13-metrics-stream/_doc ## ElasticSearch API key. This is formatted into the headers. Look at the ElasticSearch doc for how to make this ELASTICSEARCH_METRICS_APIKEY thisIsSomethingThatsBased64Encoded== + +## Tgui payloads larger than the 2kb limit for BYOND topic requests are split into roughly 1kb chunks and sent in sequence. +## This config option limits the maximum chunk count for which the server will accept a payload, default is 32 +TGUI_MAX_CHUNK_COUNT 32 diff --git a/interface/skin.dmf b/interface/skin.dmf index 18122c5e3e..7a79c1a54f 100644 --- a/interface/skin.dmf +++ b/interface/skin.dmf @@ -292,16 +292,25 @@ window "outputwindow" text = "Me" command = ".winset \"mebutton.is-checked=true ? input.command=\"!me \\\"\" : input.command=\"\"mebutton.is-checked=true ? saybutton.is-checked=false\"\"mebutton.is-checked=true ? oocbutton.is-checked=false\"" button-type = pushbox - elem "browseroutput" - type = BROWSER + elem "legacy_output_selector" + type = CHILD pos = 0,0 size = 640x456 anchor1 = 0,0 anchor2 = 100,100 - background-color = #ffffff - is-visible = false - is-disabled = true - saved-params = "" + saved-params = "splitter" + left = "output_legacy" + is-vert = false + +window "output_legacy" + elem "output_legacy" + type = MAIN + pos = 0,0 + size = 640x456 + anchor1 = -1,-1 + anchor2 = -1,-1 + saved-params = "pos;size;is-minimized;is-maximized" + is-pane = true elem "output" type = OUTPUT pos = 0,0 @@ -311,6 +320,23 @@ window "outputwindow" is-default = true saved-params = "" +window "output_browser" + elem "output_browser" + type = MAIN + pos = 0,0 + size = 640x456 + anchor1 = -1,-1 + anchor2 = -1,-1 + saved-params = "pos;size;is-minimized;is-maximized" + is-pane = true + elem "browseroutput" + type = BROWSER + pos = 0,0 + size = 640x456 + anchor1 = 0,0 + anchor2 = 100,100 + saved-params = "" + window "popupwindow" elem "popupwindow" type = MAIN diff --git a/tgui/global.d.ts b/tgui/global.d.ts index f7cf413766..d402f30c96 100644 --- a/tgui/global.d.ts +++ b/tgui/global.d.ts @@ -132,6 +132,11 @@ type ByondType = { */ parseJson(text: string): any; + /** + * Downloads a blob, platform-agnostic + */ + saveBlob(blob: Blob, filename: string, ext: string): void; + /** * Sends a message to `/datum/tgui_window` which hosts this window instance. */ diff --git a/tgui/packages/tgui-panel/chat/renderer.js b/tgui/packages/tgui-panel/chat/renderer.js index c1a057d560..ce3dbd81f4 100644 --- a/tgui/packages/tgui-panel/chat/renderer.js +++ b/tgui/packages/tgui-panel/chat/renderer.js @@ -580,7 +580,7 @@ class ChatRenderer { + '\n' + '\n'; // Create and send a nice blob - const blob = new Blob([pageHtml]); + const blob = new Blob([pageHtml], { type: 'text/plain' }); const timestamp = new Date() .toISOString() .substring(0, 19) diff --git a/tgui/packages/tgui-panel/index.js b/tgui/packages/tgui-panel/index.js index 6bc6b32c46..297ecd8585 100644 --- a/tgui/packages/tgui-panel/index.js +++ b/tgui/packages/tgui-panel/index.js @@ -75,14 +75,8 @@ const setupApp = () => { Byond.subscribe((type, payload) => store.dispatch({ type, payload })); // Unhide the panel - Byond.winset('output', { - 'is-visible': false, - }); - Byond.winset('browseroutput', { - 'is-visible': true, - 'is-disabled': false, - 'pos': '0x0', - 'size': '0x0', + Byond.winset('legacy_output_selector', { + left: 'output_browser', }); // Resize the panel to match the non-browser output diff --git a/tgui/packages/tgui/backend.ts b/tgui/packages/tgui/backend.ts index 79dc7dd6ac..511f479b59 100644 --- a/tgui/packages/tgui/backend.ts +++ b/tgui/packages/tgui/backend.ts @@ -24,6 +24,16 @@ const logger = createLogger('backend'); export const backendUpdate = createAction('backend/update'); export const backendSetSharedState = createAction('backend/setSharedState'); export const backendSuspendStart = createAction('backend/suspendStart'); +export const backendCreatePayloadQueue = createAction( + 'backend/createPayloadQueue' +); +export const backendDequeuePayloadQueue = createAction( + 'backend/dequeuePayloadQueue' +); +export const backendRemovePayloadQueue = createAction( + 'backend/removePayloadQueue' +); +export const nextPayloadChunk = createAction('nextPayloadChunk'); export const backendSuspendSuccess = () => ({ type: 'backend/suspendSuccess', @@ -36,6 +46,7 @@ const initialState = { config: {}, data: {}, shared: {}, + outgoingPayloadQueues: {} as Record, // Start as suspended suspended: Date.now(), suspending: false, @@ -112,6 +123,44 @@ export const backendReducer = (state = initialState, action) => { }; } + if (type === 'backend/createPayloadQueue') { + const { id, chunks } = payload; + const { outgoingPayloadQueues } = state; + return { + ...state, + outgoingPayloadQueues: { + ...outgoingPayloadQueues, + [id]: chunks, + }, + }; + } + + if (type === 'backend/dequeuePayloadQueue') { + const { id } = payload; + const { outgoingPayloadQueues } = state; + const { [id]: targetQueue, ...otherQueues } = outgoingPayloadQueues; + const [_, ...rest] = targetQueue; + return { + ...state, + outgoingPayloadQueues: rest.length + ? { + ...otherQueues, + [id]: rest, + } + : otherQueues, + }; + } + + if (type === 'backend/removePayloadQueue') { + const { id } = payload; + const { outgoingPayloadQueues } = state; + const { [id]: _, ...otherQueues } = outgoingPayloadQueues; + return { + ...state, + outgoingPayloadQueues: otherQueues, + }; + } + return state; }; @@ -120,7 +169,9 @@ export const backendMiddleware = (store) => { let suspendInterval; return (next) => (action) => { - const { suspended } = selectBackend(store.getState()); + const { suspended, outgoingPayloadQueues } = selectBackend( + store.getState() + ); const { type, payload } = action; if (type === 'update') { @@ -212,10 +263,86 @@ export const backendMiddleware = (store) => { }); } + if (type === 'oversizePayloadResponse') { + const { allow } = payload; + if (allow) { + store.dispatch(nextPayloadChunk(payload)); + } else { + store.dispatch(backendRemovePayloadQueue(payload)); + } + } + + if (type === 'acknowlegePayloadChunk') { + store.dispatch(backendDequeuePayloadQueue(payload)); + store.dispatch(nextPayloadChunk(payload)); + } + + if (type === 'nextPayloadChunk') { + const { id } = payload; + const chunk = outgoingPayloadQueues[id][0]; + Byond.sendMessage('payloadChunk', { + id, + chunk, + }); + } + return next(action); }; }; +const encodedLengthBinarySearch = (haystack: string[], length: number) => { + const haystackLength = haystack.length; + let high = haystackLength - 1; + let low = 0; + let mid = 0; + while (low < high) { + mid = Math.round((low + high) / 2); + const substringLength = encodeURIComponent( + haystack.slice(0, mid).join('') + ).length; + if (substringLength === length) { + break; + } + if (substringLength < length) { + low = mid + 1; + } else { + high = mid - 1; + } + } + return mid; +}; + +const chunkSplitter = { + [Symbol.split]: (string: string) => { + const charSeq = Array.from(string[Symbol.iterator]()); + const length = charSeq.length; + let chunks: string[] = []; + let startIndex = 0; + let endIndex = 1024; + while (startIndex < length) { + const cut = charSeq.slice( + startIndex, + endIndex < length ? endIndex : undefined + ); + const cutString = cut.join(''); + if (encodeURIComponent(cutString).length > 1024) { + const splitIndex = startIndex + encodedLengthBinarySearch(cut, 1024); + chunks.push( + charSeq + .slice(startIndex, splitIndex < length ? splitIndex : undefined) + .join('') + ); + startIndex = splitIndex; + } else { + chunks.push(cutString); + startIndex = endIndex; + } + endIndex = startIndex + 1024; + } + return chunks; + }, +}; + /** * Sends an action to `ui_act` on `src_object` that this tgui window * is associated with. @@ -230,6 +357,31 @@ export const sendAct = (action: string, payload: object = {}) => { logger.error(`Payload for act() must be an object, got this:`, payload); return; } + const stringifiedPayload = JSON.stringify(payload); + const urlSize = Object.entries({ + type: 'act/' + action, + payload: stringifiedPayload, + tgui: 1, + windowId: Byond.windowId, + }).reduce( + (url, [key, value], i) => + url + + `${i > 0 ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent( + value + )}`, + '' + ).length; + if (urlSize > 2048) { + let chunks: string[] = stringifiedPayload.split(chunkSplitter); + const id = `${Date.now()}`; + window.__store__?.dispatch(backendCreatePayloadQueue({ id, chunks })); + Byond.sendMessage('oversizedPayloadRequest', { + type: 'act/' + action, + id, + chunkCount: chunks.length, + }); + return; + } Byond.sendMessage('act/' + action, payload); }; @@ -257,6 +409,7 @@ type BackendState = { }; data: TData; shared: Record; + outgoingPayloadQueues: Record; suspending: boolean; suspended: boolean; }; diff --git a/tgui/packages/tgui/styles/themes/ntos_cat.scss b/tgui/packages/tgui/styles/themes/ntos_cat.scss index ae6236b5aa..8eb742b31b 100644 --- a/tgui/packages/tgui/styles/themes/ntos_cat.scss +++ b/tgui/packages/tgui/styles/themes/ntos_cat.scss @@ -28,10 +28,9 @@ $scrollbar-color-multiplier: 0.5; ); @use '../base.scss' with ( - $color-bg: orange, - $color-bg-grad-spread: 12%, - //$border-radius: 0, - ); + $color-bg: orange, + $color-bg-grad-spread: 12% //$border-radius: 0, +); .theme-ntos_cat { // Atomic classes diff --git a/tools/_pentest_scripts/run_prettier.cmd b/tools/_pentest_scripts/run_prettier.cmd new file mode 100644 index 0000000000..04034965fc --- /dev/null +++ b/tools/_pentest_scripts/run_prettier.cmd @@ -0,0 +1,22 @@ +@echo off +setlocal +REM Script to run prettier on the tgui folder +REM This uses the project's local prettier version (2.8.4) via yarn + +cd /d "%~dp0..\..\tgui" +echo Running prettier on tgui folder... +call yarn prettier . --write + +if %errorlevel% neq 0 ( + echo. + echo Prettier failed with error code %errorlevel% + echo. + pause + exit /b %errorlevel% +) + +echo. +echo Prettier formatting complete! +echo. +pause +exit /b 0 diff --git a/tools/build/build.js b/tools/build/build.js index 9881242322..c47cf60101 100755 --- a/tools/build/build.js +++ b/tools/build/build.js @@ -87,6 +87,7 @@ export const DmTarget = new Juke.Target({ "icons/**", "interface/**", "modular_pentest/**", + 'tgui/public/tgui.html', `${DME_NAME}.dme`, NamedVersionFile, ],