From 55bd3ecc2603ce3bd924e71f106e72efb075612f Mon Sep 17 00:00:00 2001
From: Letter N <24603524+LetterN@users.noreply.github.com>
Date: Mon, 17 Aug 2020 21:07:09 +0800
Subject: [PATCH] actual tgui code
---
code/modules/tgui/external.dm | 28 ++++-
code/modules/tgui/states/debug.dm | 6 ++
code/modules/tgui/tgui.dm | 32 ++++--
code/modules/tgui/tgui_window.dm | 150 +++++++++++++++++++-------
code/modules/tgui_panel/audio.dm | 42 ++++++++
code/modules/tgui_panel/external.dm | 53 +++++++++
code/modules/tgui_panel/telemetry.dm | 80 ++++++++++++++
code/modules/tgui_panel/tgui_panel.dm | 95 ++++++++++++++++
code/modules/tgui_panel/to_chat.dm | 71 ++++++++++++
9 files changed, 507 insertions(+), 50 deletions(-)
create mode 100644 code/modules/tgui/states/debug.dm
create mode 100644 code/modules/tgui_panel/audio.dm
create mode 100644 code/modules/tgui_panel/external.dm
create mode 100644 code/modules/tgui_panel/telemetry.dm
create mode 100644 code/modules/tgui_panel/tgui_panel.dm
create mode 100644 code/modules/tgui_panel/to_chat.dm
diff --git a/code/modules/tgui/external.dm b/code/modules/tgui/external.dm
index 46b324e151..c4515b8a76 100644
--- a/code/modules/tgui/external.dm
+++ b/code/modules/tgui/external.dm
@@ -130,6 +130,13 @@
*/
/client/var/list/tgui_windows = list()
+/**
+ * global
+ *
+ * TRUE if cache was reloaded by tgui dev server at least once.
+ */
+/client/var/tgui_cache_reloaded = FALSE
+
/**
* public
*
@@ -159,16 +166,29 @@
/**
* Middleware for /client/Topic.
*
- * return bool Whether the topic is passed (TRUE), or cancelled (FALSE).
+ * return bool If TRUE, prevents propagation of the topic call.
*/
/proc/tgui_Topic(href_list)
// Skip non-tgui topics
if(!href_list["tgui"])
- return TRUE
+ return FALSE
var/type = href_list["type"]
// Unconditionally collect tgui logs
if(type == "log")
log_tgui(usr, href_list["message"])
+ // Reload all tgui windows
+ if(type == "cacheReloaded")
+ if(!check_rights(R_ADMIN) || usr.client.tgui_cache_reloaded)
+ return TRUE
+ // Mark as reloaded
+ usr.client.tgui_cache_reloaded = TRUE
+ // Notify windows
+ var/list/windows = usr.client.tgui_windows
+ for(var/window_id in windows)
+ var/datum/tgui_window/window = windows[window_id]
+ if (window.status == TGUI_WINDOW_READY)
+ window.on_message(type, null, href_list)
+ return TRUE
// Locate window
var/window_id = href_list["window_id"]
var/datum/tgui_window/window
@@ -177,7 +197,7 @@
if(!window)
log_tgui(usr, "Error: Couldn't find the window datum, force closing.")
SStgui.force_close_window(usr, window_id)
- return FALSE
+ return TRUE
// Decode payload
var/payload
if(href_list["payload"])
@@ -185,4 +205,4 @@
// Pass message to window
if(window)
window.on_message(type, payload, href_list)
- return FALSE
+ return TRUE
diff --git a/code/modules/tgui/states/debug.dm b/code/modules/tgui/states/debug.dm
new file mode 100644
index 0000000000..6c600b38ce
--- /dev/null
+++ b/code/modules/tgui/states/debug.dm
@@ -0,0 +1,6 @@
+GLOBAL_DATUM_INIT(debug_state, /datum/ui_state/debug_state, new)
+
+/datum/ui_state/debug_state/can_use_topic(src_object, mob/user)
+ if(check_rights_for(user.client, R_DEBUG))
+ return UI_INTERACTIVE
+ return UI_CLOSE
diff --git a/code/modules/tgui/tgui.dm b/code/modules/tgui/tgui.dm
index d0d5ff8ebb..b3b07eb178 100644
--- a/code/modules/tgui/tgui.dm
+++ b/code/modules/tgui/tgui.dm
@@ -80,14 +80,20 @@
opened_at = world.time
window.acquire_lock(src)
if(!window.is_ready())
- window.initialize(inline_assets = list(
- get_asset_datum(/datum/asset/simple/tgui),
- ))
+ window.initialize(
+ fancy = user.client.prefs.tgui_fancy,
+ inline_assets = list(
+ get_asset_datum(/datum/asset/simple/tgui_common),
+ get_asset_datum(/datum/asset/simple/tgui),
+ ))
else
window.send_message("ping")
- window.send_asset(get_asset_datum(/datum/asset/simple/fontawesome))
+ var/flush_queue = window.send_asset(get_asset_datum(
+ /datum/asset/simple/namespaced/fontawesome))
for(var/datum/asset/asset in src_object.ui_assets(user))
- window.send_asset(asset)
+ flush_queue |= window.send_asset(asset)
+ if (flush_queue)
+ user.client.browse_queue_flush()
window.send_message("update", get_payload(
with_data = TRUE,
with_static_data = TRUE))
@@ -143,11 +149,13 @@
* Makes an asset available to use in tgui.
*
* required asset datum/asset
+ *
+ * return bool - true if an asset was actually sent
*/
/datum/tgui/proc/send_asset(datum/asset/asset)
if(!window)
CRASH("send_asset() can only be called after open().")
- window.send_asset(asset)
+ return window.send_asset(asset)
/**
* public
@@ -199,13 +207,17 @@
"key" = window_key,
"size" = window_size,
"fancy" = user.client.prefs.tgui_fancy,
- "locked" = user.client.prefs.tgui_lock
+ "locked" = user.client.prefs.tgui_lock,
+ ),
+ "client" = list(
+ "ckey" = user.client.ckey,
+ "address" = user.client.address,
+ "computer_id" = user.client.computer_id,
),
"user" = list(
"name" = "[user]",
- "ckey" = "[user.ckey]",
- "observer" = isobserver(user)
- )
+ "observer" = isobserver(user),
+ ),
)
var/data = custom_data || with_data && src_object.ui_data(user)
if(data)
diff --git a/code/modules/tgui/tgui_window.dm b/code/modules/tgui/tgui_window.dm
index 3f271163c9..b511fe4057 100644
--- a/code/modules/tgui/tgui_window.dm
+++ b/code/modules/tgui/tgui_window.dm
@@ -8,12 +8,18 @@
var/client/client
var/pooled
var/pool_index
+ var/is_browser = FALSE
var/status = TGUI_WINDOW_CLOSED
var/locked = FALSE
var/datum/tgui/locked_by
+ var/datum/subscriber_object
+ var/subscriber_delegate
var/fatally_errored = FALSE
var/message_queue
var/sent_assets = list()
+ // Vars passed to initialize proc (and saved for later)
+ var/inline_assets
+ var/fancy
/**
* public
@@ -26,9 +32,9 @@
/datum/tgui_window/New(client/client, id, pooled = FALSE)
src.id = id
src.client = client
+ src.client.tgui_windows[id] = src
src.pooled = pooled
if(pooled)
- client.tgui_windows[id] = src
src.pool_index = TGUI_WINDOW_INDEX(id)
/**
@@ -39,18 +45,24 @@
* will be put into the queue until the window finishes loading.
*
* optional inline_assets list List of assets to inline into the html.
+ * optional inline_html string Custom HTML to inject.
+ * optional fancy bool If TRUE, will hide the window titlebar.
*/
-/datum/tgui_window/proc/initialize(inline_assets = list())
+/datum/tgui_window/proc/initialize(
+ inline_assets = list(),
+ inline_html = "",
+ fancy = FALSE)
log_tgui(client, "[id]/initialize")
if(!client)
return
+ src.inline_assets = inline_assets
+ src.fancy = fancy
status = TGUI_WINDOW_LOADING
fatally_errored = FALSE
- message_queue = null
// Build window options
var/options = "file=[id].html;can_minimize=0;auto_format=0;"
// Remove titlebar and resize handles for a fancy window
- if(client.prefs.tgui_fancy)
+ if(fancy)
options += "titlebar=0;can_resize=0;"
else
options += "titlebar=1;can_resize=1;"
@@ -69,13 +81,17 @@
inline_styles += "\n"
else if(copytext(name, -3) == ".js")
inline_scripts += "\n"
- asset.send()
+ asset.send(client)
html = replacetextEx(html, "\n", inline_styles)
html = replacetextEx(html, "\n", inline_scripts)
+ // Inject custom HTML
+ html = replacetextEx(html, "\n", inline_html)
// Open the window
client << browse(html, "window=[id];[options]")
// Instruct the client to signal UI when the window is closed.
winset(client, id, "on-close=\"uiclose [id]\"")
+ // Detect whether the control is a browser
+ is_browser = winexists(client, id) == "BROWSER"
/**
* public
@@ -107,8 +123,8 @@
* Acquire the window lock. Pool will not be able to provide this window
* to other UIs for the duration of the lock.
*
- * Can be given an optional tgui datum, which will hook its on_message
- * callback into the message stream.
+ * Can be given an optional tgui datum, which will be automatically
+ * subscribed to incoming messages via the on_message proc.
*
* optional ui /datum/tgui
*/
@@ -117,6 +133,8 @@
locked_by = ui
/**
+ * public
+ *
* Release the window lock.
*/
/datum/tgui_window/proc/release_lock()
@@ -126,6 +144,28 @@
locked = FALSE
locked_by = null
+/**
+ * public
+ *
+ * Subscribes the datum to consume window messages on a specified proc.
+ *
+ * Note, that this supports only one subscriber, because code for that
+ * is simpler and therefore faster. If necessary, this can be rewritten
+ * to support multiple subscribers.
+ */
+/datum/tgui_window/proc/subscribe(datum/object, delegate)
+ subscriber_object = object
+ subscriber_delegate = delegate
+
+/**
+ * public
+ *
+ * Unsubscribes the datum. Do not forget to call this when cleaning up.
+ */
+/datum/tgui_window/proc/unsubscribe(datum/object)
+ subscriber_object = null
+ subscriber_delegate = null
+
/**
* public
*
@@ -159,25 +199,40 @@
* required payload list Message payload
* optional force bool Send regardless of the ready status.
*/
-/datum/tgui_window/proc/send_message(type, list/payload, force)
+/datum/tgui_window/proc/send_message(type, payload, force)
if(!client)
return
- var/message = json_encode(list(
- "type" = type,
- "payload" = payload,
- ))
- // Strip #255/improper.
- message = replacetext(message, "\proper", "")
- message = replacetext(message, "\improper", "")
- // Pack for sending via output()
- message = url_encode(message)
+ var/message = TGUI_CREATE_MESSAGE(type, payload)
// Place into queue if window is still loading
if(!force && status != TGUI_WINDOW_READY)
if(!message_queue)
message_queue = list()
message_queue += list(message)
return
- client << output(message, "[id].browser:update")
+ client << output(message, is_browser \
+ ? "[id]:update" \
+ : "[id].browser:update")
+
+/**
+ * public
+ *
+ * Sends a raw payload to tgui window.
+ *
+ * required message string JSON+urlencoded blob to send.
+ * optional force bool Send regardless of the ready status.
+ */
+/datum/tgui_window/proc/send_raw_message(message, force)
+ if(!client)
+ return
+ // Place into queue if window is still loading
+ if(!force && status != TGUI_WINDOW_READY)
+ if(!message_queue)
+ message_queue = list()
+ message_queue += list(message)
+ return
+ client << output(message, is_browser \
+ ? "[id]:update" \
+ : "[id].browser:update")
/**
* public
@@ -185,16 +240,18 @@
* Makes an asset available to use in tgui.
*
* required asset datum/asset
+ *
+ * return bool - TRUE if any assets had to be sent to the client
*/
/datum/tgui_window/proc/send_asset(datum/asset/asset)
if(!client || !asset)
return
+ sent_assets |= list(asset)
+ . = asset.send(client)
if(istype(asset, /datum/asset/spritesheet))
var/datum/asset/spritesheet/spritesheet = asset
send_message("asset/stylesheet", spritesheet.css_filename())
send_message("asset/mappings", asset.get_url_mappings())
- sent_assets += list(asset)
- asset.send(client)
/**
* private
@@ -205,7 +262,9 @@
if(!client || !message_queue)
return
for(var/message in message_queue)
- client << output(message, "[id].browser:update")
+ client << output(message, is_browser \
+ ? "[id]:update" \
+ : "[id].browser:update")
message_queue = null
/**
@@ -213,26 +272,45 @@
*
* Callback for handling incoming tgui messages.
*/
-/datum/tgui_window/proc/on_message(type, list/payload, list/href_list)
- switch(type)
- if("ready")
- // Status can be READY if user has refreshed the window.
- if(status == TGUI_WINDOW_READY)
- // Resend the assets
- for(var/asset in sent_assets)
- send_asset(asset)
- status = TGUI_WINDOW_READY
- if("log")
- if(href_list["fatal"])
- fatally_errored = TRUE
+/datum/tgui_window/proc/on_message(type, payload, href_list)
+ // Status can be READY if user has refreshed the window.
+ if(type == "ready" && status == TGUI_WINDOW_READY)
+ // Resend the assets
+ for(var/asset in sent_assets)
+ send_asset(asset)
+ // Mark this window as fatally errored which prevents it from
+ // being suspended.
+ if(type == "log" && href_list["fatal"])
+ fatally_errored = TRUE
+ // Mark window as ready since we received this message from somewhere
+ if(status != TGUI_WINDOW_READY)
+ status = TGUI_WINDOW_READY
+ flush_message_queue()
// Pass message to UI that requested the lock
if(locked && locked_by)
- locked_by.on_message(type, payload, href_list)
- flush_message_queue()
- return
+ var/prevent_default = locked_by.on_message(type, payload, href_list)
+ if(prevent_default)
+ return
+ // Pass message to the subscriber
+ else if(subscriber_object)
+ var/prevent_default = call(
+ subscriber_object,
+ subscriber_delegate)(type, payload, href_list)
+ if(prevent_default)
+ return
// If not locked, handle these message types
switch(type)
+ if("ping")
+ send_message("pingReply", payload)
if("suspend")
close(can_be_suspended = TRUE)
if("close")
close(can_be_suspended = FALSE)
+ if("openLink")
+ client << link(href_list["url"])
+ if("cacheReloaded")
+ // Reinitialize
+ initialize(inline_assets = inline_assets, fancy = fancy)
+ // Resend the assets
+ for(var/asset in sent_assets)
+ send_asset(asset)
diff --git a/code/modules/tgui_panel/audio.dm b/code/modules/tgui_panel/audio.dm
new file mode 100644
index 0000000000..e62c4b5bc1
--- /dev/null
+++ b/code/modules/tgui_panel/audio.dm
@@ -0,0 +1,42 @@
+/**
+ * Copyright (c) 2020 Aleksej Komarov
+ * SPDX-License-Identifier: MIT
+ */
+
+/// Admin music volume, from 0 to 1.
+/client/var/admin_music_volume = 1
+
+/**
+ * public
+ *
+ * Sends music data to the browser.
+ *
+ * Optional settings:
+ * - pitch: the playback rate
+ * - start: the start time of the sound
+ * - end: when the musics stops playing
+ *
+ * required url string Must be an https URL.
+ * optional extra_data list Optional settings.
+ */
+/datum/tgui_panel/proc/play_music(url, extra_data)
+ if(!is_ready())
+ return
+ if(!findtext(url, GLOB.is_http_protocol))
+ return
+ var/list/payload = list()
+ if(length(extra_data) > 0)
+ for(var/key in extra_data)
+ payload[key] = extra_data[key]
+ payload["url"] = url
+ window.send_message("audio/playMusic", payload)
+
+/**
+ * public
+ *
+ * Stops playing music through the browser.
+ */
+/datum/tgui_panel/proc/stop_music()
+ if(!is_ready())
+ return
+ window.send_message("audio/stopMusic")
diff --git a/code/modules/tgui_panel/external.dm b/code/modules/tgui_panel/external.dm
new file mode 100644
index 0000000000..57c89dc194
--- /dev/null
+++ b/code/modules/tgui_panel/external.dm
@@ -0,0 +1,53 @@
+/**
+ * Copyright (c) 2020 Aleksej Komarov
+ * SPDX-License-Identifier: MIT
+ */
+
+/client/var/datum/tgui_panel/tgui_panel
+
+/**
+ * tgui panel / chat troubleshooting verb
+ */
+/client/verb/fix_chat()
+ set name = "Fix chat"
+ set category = "OOC"
+ var/action
+ log_tgui(src, "tgui_panel: Started fix_chat.")
+ // Not initialized
+ if(!tgui_panel || !istype(tgui_panel))
+ log_tgui(src, "tgui_panel: datum is missing")
+ action = alert(src, "tgui panel was not initialized!\nSet it up again?", "", "OK", "Cancel")
+ if(action != "OK")
+ return
+ tgui_panel = new(src)
+ tgui_panel.initialize()
+ action = alert(src, "Wait a bit and tell me if it's fixed", "", "Fixed", "Nope")
+ if(action == "Fixed")
+ log_tgui(src, "tgui_panel: Fixed by calling 'new' + 'initialize'")
+ return
+ // Not ready
+ if(!tgui_panel?.is_ready())
+ log_tgui(src, "tgui_panel: not ready")
+ action = alert(src, "tgui panel looks like it's waiting for something.\nSend it a ping?", "", "OK", "Cancel")
+ if(action != "OK")
+ return
+ tgui_panel.window.send_message("ping", force = TRUE)
+ action = alert(src, "Wait a bit and tell me if it's fixed", "", "Fixed", "Nope")
+ if(action == "Fixed")
+ log_tgui(src, "tgui_panel: Fixed by sending a ping")
+ return
+ // Catch all solution
+ action = alert(src, "Looks like tgui panel was already setup, but we can always try again.\nSet it up again?", "", "OK", "Cancel")
+ if(action != "OK")
+ return
+ tgui_panel.initialize(force = TRUE)
+ action = alert(src, "Wait a bit and tell me if it's fixed", "", "Fixed", "Nope")
+ if(action == "Fixed")
+ log_tgui(src, "tgui_panel: Fixed by calling 'initialize'")
+ return
+ // 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")
+ log_tgui(src, "tgui_panel: Failed to fix.")
diff --git a/code/modules/tgui_panel/telemetry.dm b/code/modules/tgui_panel/telemetry.dm
new file mode 100644
index 0000000000..79087d8500
--- /dev/null
+++ b/code/modules/tgui_panel/telemetry.dm
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2020 Aleksej Komarov
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * Maximum number of connection records allowed to analyze.
+ * Should match the value set in the browser.
+ */
+#define TGUI_TELEMETRY_MAX_CONNECTIONS 5
+
+/**
+ * Maximum time allocated for sending a telemetry packet.
+ */
+#define TGUI_TELEMETRY_RESPONSE_WINDOW 30 SECONDS
+
+/// Time of telemetry request
+/datum/tgui_panel/var/telemetry_requested_at
+/// Time of telemetry analysis completion
+/datum/tgui_panel/var/telemetry_analyzed_at
+/// List of previous client connections
+/datum/tgui_panel/var/list/telemetry_connections
+
+/**
+ * private
+ *
+ * Requests some telemetry from the client.
+ */
+/datum/tgui_panel/proc/request_telemetry()
+ telemetry_requested_at = world.time
+ telemetry_analyzed_at = null
+ window.send_message("telemetry/request", list(
+ "limits" = list(
+ "connections" = TGUI_TELEMETRY_MAX_CONNECTIONS,
+ ),
+ ))
+
+/**
+ * private
+ *
+ * Analyzes a telemetry packet.
+ *
+ * Is currently only useful for detecting ban evasion attempts.
+ */
+/datum/tgui_panel/proc/analyze_telemetry(payload)
+ if(world.time > telemetry_requested_at + TGUI_TELEMETRY_RESPONSE_WINDOW)
+ message_admins("[key_name(client)] sent telemetry outside of the allocated time window.")
+ return
+ if(telemetry_analyzed_at)
+ message_admins("[key_name(client)] sent telemetry more than once.")
+ return
+ telemetry_analyzed_at = world.time
+ if(!payload)
+ return
+ telemetry_connections = payload["connections"]
+ var/len = length(telemetry_connections)
+ if(len == 0)
+ return
+ if(len > TGUI_TELEMETRY_MAX_CONNECTIONS)
+ message_admins("[key_name(client)] was kicked for sending a huge telemetry payload")
+ qdel(client)
+ return
+ var/list/found
+ for(var/i in 1 to len)
+ if(QDELETED(client))
+ // He got cleaned up before we were done
+ return
+ var/list/row = telemetry_connections[i]
+ // Check for a malformed history object
+ if (!row || row.len < 3 || (!row["ckey"] || !row["address"] || !row["computer_id"]))
+ return
+ if (world.IsBanned(row["ckey"], row["address"], row["computer_id"], real_bans_only = TRUE))
+ found = row
+ break
+ CHECK_TICK
+ // This fucker has a history of playing on a banned account.
+ if(found)
+ var/msg = "[key_name(client)] has a banned account in connection history! (Matched: [found["ckey"]], [found["address"]], [found["computer_id"]])"
+ message_admins(msg)
+ log_admin_private(msg)
diff --git a/code/modules/tgui_panel/tgui_panel.dm b/code/modules/tgui_panel/tgui_panel.dm
new file mode 100644
index 0000000000..b983484046
--- /dev/null
+++ b/code/modules/tgui_panel/tgui_panel.dm
@@ -0,0 +1,95 @@
+/**
+ * Copyright (c) 2020 Aleksej Komarov
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * tgui_panel datum
+ * Hosts tgchat and other nice features.
+ */
+/datum/tgui_panel
+ var/client/client
+ var/datum/tgui_window/window
+ var/broken = FALSE
+ var/initialized_at
+
+/datum/tgui_panel/New(client/client)
+ src.client = client
+ window = new(client, "browseroutput")
+ window.subscribe(src, .proc/on_message)
+
+/datum/tgui_panel/Del()
+ window.unsubscribe(src)
+ window.close()
+ return ..()
+
+/**
+ * public
+ *
+ * TRUE if panel is initialized and ready to receive messages.
+ */
+/datum/tgui_panel/proc/is_ready()
+ return !broken && window.is_ready()
+
+/**
+ * public
+ *
+ * Initializes tgui panel.
+ */
+/datum/tgui_panel/proc/initialize(force = FALSE)
+ initialized_at = world.time
+ // Perform a clean initialization
+ window.initialize(inline_assets = list(
+ get_asset_datum(/datum/asset/simple/tgui_common),
+ get_asset_datum(/datum/asset/simple/tgui_panel),
+ ))
+ window.send_asset(get_asset_datum(/datum/asset/simple/namespaced/fontawesome))
+ window.send_asset(get_asset_datum(/datum/asset/spritesheet/chat))
+ request_telemetry()
+ addtimer(CALLBACK(src, .proc/on_initialize_timed_out), 2 SECONDS)
+
+/**
+ * private
+ *
+ * Called when initialization has timed out.
+ */
+/datum/tgui_panel/proc/on_initialize_timed_out()
+ // Currently does nothing but sending a message to old chat.
+ SEND_TEXT(client, "Failed to load fancy chat, reverting to old chat. Certain features won't work.")
+
+/**
+ * private
+ *
+ * Callback for handling incoming tgui messages.
+ */
+/datum/tgui_panel/proc/on_message(type, payload)
+ if(type == "ready")
+ broken = FALSE
+ window.send_message("update", list(
+ "config" = list(
+ "client" = list(
+ "ckey" = client.ckey,
+ "address" = client.address,
+ "computer_id" = client.computer_id,
+ ),
+ "window" = list(
+ "fancy" = FALSE,
+ "locked" = FALSE,
+ ),
+ ),
+ ))
+ return TRUE
+ if(type == "audio/setAdminMusicVolume")
+ client.admin_music_volume = payload["volume"]
+ return TRUE
+ if(type == "telemetry")
+ analyze_telemetry(payload)
+ return TRUE
+
+/**
+ * public
+ *
+ * Sends a round restart notification.
+ */
+/datum/tgui_panel/proc/send_roundrestart()
+ window.send_message("roundrestart")
diff --git a/code/modules/tgui_panel/to_chat.dm b/code/modules/tgui_panel/to_chat.dm
new file mode 100644
index 0000000000..aad27d4872
--- /dev/null
+++ b/code/modules/tgui_panel/to_chat.dm
@@ -0,0 +1,71 @@
+/**
+ * Copyright (c) 2020 Aleksej Komarov
+ * SPDX-License-Identifier: MIT
+ */
+
+/**
+ * global
+ *
+ * Circumvents the message queue and sends the message
+ * to the recipient (target) as soon as possible.
+ */
+/proc/to_chat_immediate(
+ target,
+ text,
+ handle_whitespace = TRUE,
+ trailing_newline = TRUE,
+ confidential = FALSE)
+ if(!target || !text)
+ return
+ if(target == world)
+ target = GLOB.clients
+ var/flags = handle_whitespace \
+ | trailing_newline << 1 \
+ | confidential << 2
+ var/message = TGUI_CREATE_MESSAGE("chat/message", list(
+ "text" = text,
+ "flags" = flags,
+ ))
+ if(islist(target))
+ for(var/_target in target)
+ var/client/client = CLIENT_FROM_VAR(_target)
+ if(client)
+ // Send to tgchat
+ client.tgui_panel?.window.send_raw_message(message)
+ // Send to old chat
+ SEND_TEXT(client, text)
+ return
+ var/client/client = CLIENT_FROM_VAR(target)
+ if(client)
+ // Send to tgchat
+ client.tgui_panel?.window.send_raw_message(message)
+ // Send to old chat
+ SEND_TEXT(client, text)
+
+/**
+ * global
+ *
+ * Sends the message to the recipient (target).
+ */
+/proc/to_chat(
+ target,
+ text,
+ handle_whitespace = TRUE,
+ trailing_newline = TRUE,
+ confidential = FALSE)
+ if(Master.current_runlevel == RUNLEVEL_INIT || !SSchat?.initialized)
+ to_chat_immediate(
+ target,
+ text,
+ handle_whitespace,
+ trailing_newline,
+ confidential)
+ return
+ if(!target || !text)
+ return
+ if(target == world)
+ target = GLOB.clients
+ var/flags = handle_whitespace \
+ | trailing_newline << 1 \
+ | confidential << 2
+ SSchat.queue(target, text, flags)