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)