Initial Commit

Initial commit of Code Base.
This commit is contained in:
Mario Steele 2025-06-12 14:31:14 -05:00
parent 293b1213e1
commit c11a4ebbc2
653 changed files with 36893 additions and 1 deletions

View file

@ -0,0 +1,6 @@
Because Godot doesn't have a good system to handle dependencies and
I want to have standalone modules that could be technically their own plugins.
Requirements:
twitcher requires http, oOuch
oOuch requires http

View file

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="0 0 16 16"
id="svg4"
sodipodi:docname="buffered-http-icon.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4">
<rect
x="0.0625"
y="10.625"
width="17.5"
height="10"
id="rect30" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.35632304,0,1 | F,0,0,1,0,0.32388529,0,1 @ F,0,0,1,0,0.30607722,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.21339985,0,1 @ F,0,0,1,0,0.41714928,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.59205278,0,1 @ F,0,0,1,0,0.62915907,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
is_visible="true"
lpeversion="1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<sodipodi:namedview
id="namedview4"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="45.254834"
inkscape:cx="8.6068153"
inkscape:cy="15.733126"
inkscape:window-width="1999"
inkscape:window-height="1360"
inkscape:window-x="712"
inkscape:window-y="1440"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<rect
style="fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.10201;stroke-linecap:butt;stroke-dasharray:none;stroke-opacity:1"
id="rect29"
width="6.1411905"
height="6.1411905"
x="6.0629044"
y="-2.3126719"
transform="matrix(0.92518611,0.37951371,-0.92518611,0.37951371,0,0)" />
<rect
style="fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.10201;stroke-linecap:butt;stroke-dasharray:none;stroke-opacity:1"
id="rect29-2"
width="6.1411905"
height="6.1411905"
x="7.9945164"
y="-0.38105631"
transform="matrix(0.92518611,0.37951371,-0.92518611,0.37951371,0,0)" />
<rect
style="fill:#e0e0e0;fill-opacity:1;stroke:#e0e0e0;stroke-width:1.10201;stroke-linecap:butt;stroke-dasharray:none;stroke-opacity:1"
id="rect29-7"
width="6.1411905"
height="6.1411905"
x="9.9507313"
y="1.5751607"
transform="matrix(0.92518611,0.37951371,-0.92518611,0.37951371,0,0)" />
<path
style="fill:#9dff70;fill-opacity:1;stroke:none;stroke-width:1.36221;stroke-linecap:butt;stroke-dasharray:none;stroke-opacity:1"
d="M 3.4588334,2.784233 C 3.6019977,3.1259911 5.9999986,8.4422283 5.9999986,8.4422283 L 8.5411634,2.784233"
id="path29"
sodipodi:nodetypes="ccc" />
<path
style="fill:#ff7070;fill-opacity:1;stroke:none;stroke-width:1.36221;stroke-linecap:butt;stroke-dasharray:none;stroke-opacity:1"
d="M 7.4588334,8.4422283 C 7.6019977,8.1004702 9.9999986,2.784233 9.9999986,2.784233 l 2.5411684,5.6579953"
id="path29-8"
sodipodi:nodetypes="ccc" />
<path
d="m 1,10 v 2 1 2 h 1 v -2 h 1 v 2 H 4 V 10 H 3 v 2 H 2 v -2 z m 4,0 v 1 h 1 v 4 h 1 v -4 h 1 v -1 z m 4,0 v 1 h 1 v 4 h 1 v -4 h 1 v -1 z m 4,0 v 2 1 2 h 1 v -2 h 1 1 v -1 -2 h -2 z m 1,1 h 1 v 1 h -1 z"
style="fill:#e0e0e0;fill-opacity:0.99608"
id="path1" />
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://517onfw47ulp"
path="res://.godot/imported/buffered-http-icon.svg-338b6a52134b1dc8e217747ec727a304.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/lib/http/buffered-http-icon.svg"
dest_files=["res://.godot/imported/buffered-http-icon.svg-338b6a52134b1dc8e217747ec727a304.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,204 @@
@icon("./buffered-http-icon.svg")
@tool
extends Twitcher
## Http client that bufferes the requests and sends them sequentialy
class_name BufferedHTTPClient
## Will be send when a new request was added to queue
signal request_added(request: RequestData)
## Will be send when a request is done.
signal request_done(response: ResponseData)
## Contains the request data to be send
class RequestData extends RefCounted:
## The client that the request belongs too
var client: BufferedHTTPClient
## The request node that is executing the request
var http_request: HTTPRequest
## Path of the request
var path: String
## The method that is used to call request
var method: int
## The request headers
var headers: Dictionary
## The body that is requested (TODO does it make more sense to make a Byte Array out of it?)
var body: String = ""
## Amount of retries
var retry: int
## When you are done free the request
func queue_free() -> void:
http_request.queue_free()
## Contains the response data
class ResponseData extends RefCounted:
## Result of the request see `HTTPRequest.Result`
var result: int
## Response code from the request like 200 for OK
var response_code: int
## the initial request data
var request_data: RequestData
## The body of the response as byte array
var response_data: PackedByteArray
## The response header as dictionary, where multiple keys are concatenated with ';'
var response_header: Dictionary
## Had the response an error
var error: bool
## When you are done free the request
func queue_free() -> void:
request_data.queue_free()
## When a request fails max_error_count then cancel that request -1 for endless amount of tries.
@export var max_error_count : int = -1
@export var custom_header : Dictionary[String, String] = { "Accept": "*/*" }
var requests : Array[RequestData] = []
var current_request : RequestData
var current_response_data : PackedByteArray = PackedByteArray()
var responses : Dictionary = {}
var error_count : int
## Only one poll at a time so block for all other tries to call it
var polling: bool
var processing: bool:
get: return not requests.is_empty() || current_request != null
## Starts a request that will be handled as soon as the client gets free.
## Use HTTPClient.METHOD_* for the method.
func request(path: String, method: int, headers: Dictionary, body: String) -> RequestData:
logInfo("[%s] start request " % [ path ])
headers = headers.duplicate()
headers.merge(custom_header)
var req = RequestData.new()
req.path = path
req.method = method
req.body = body
req.headers = headers
req.client = self
req.http_request = HTTPRequest.new()
req.http_request.use_threads = true
req.http_request.timeout = 30
req.http_request.request_completed.connect(_on_request_completed.bind(req))
add_child(req.http_request)
var err : Error = req.http_request.request(req.path, _pack_headers(req.headers), req.method, req.body)
if err != OK: logError("Problems with request to %s cause of %s" % [path, error_string(err)])
requests.append(req)
request_added.emit(req)
logDebug("[%s] request started " % [ path ])
return req
## When the response is available return it otherwise wait for the response
func wait_for_request(request_data: RequestData) -> ResponseData:
if responses.has(request_data):
var response = responses[request_data]
responses.erase(request_data)
request_data.queue_free()
logDebug("response cached return directly from wait")
return response
var latest_response : ResponseData = null
while (latest_response == null || request_data != latest_response.request_data):
latest_response = await request_done
logDebug("response received return from wait")
responses.erase(request_data)
request_data.queue_free()
return latest_response
func _on_request_completed(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, request_data: RequestData) -> void:
var response_data : ResponseData = ResponseData.new()
if result != HTTPRequest.Result.RESULT_SUCCESS:
logInfo("[%s] problems with result \n\t> response code: %s \n\t> body: %s" % [request_data.path, response_code, body.get_string_from_utf8()])
response_data.error = true
if result == HTTPRequest.Result.RESULT_CONNECTION_ERROR || result == HTTPRequest.Result.RESULT_TLS_HANDSHAKE_ERROR:
if request_data.retry == max_error_count:
printerr("Maximum amount of retries for the request. Abort request: %s" % [request_data.path])
return
var wait_time = pow(2, request_data.retry)
wait_time = min(wait_time, 30)
logDebug("Error happend during connection. Wait for %s" % wait_time)
await get_tree().create_timer(wait_time, true, false, true).timeout
var http_request: HTTPRequest = request_data.http_request.duplicate()
add_child(http_request)
request_data.http_request = http_request
request_data.retry += 1
http_request.request(request_data.path, _pack_headers(request_data.headers), request_data.method, request_data.body)
http_request.request_completed.connect(_on_request_completed.bind(http_request))
response_data.result = result
response_data.request_data = request_data
response_data.response_data = body
response_data.response_code = response_code
response_data.response_header = _get_response_headers_as_dictionary(headers)
responses[request_data] = response_data
logInfo("[%s] request done with result HTTPRequest.Result[%s] " % [ request_data.path, result])
request_done.emit(response_data)
func _get_response_headers_as_dictionary(headers: PackedStringArray) -> Dictionary:
var header_dict: Dictionary = {}
if headers == null:
return header_dict
for header in headers:
var header_data = header.split(":", true, 1)
var key = header_data[0]
var val = header_data[1]
if header_dict.has(key):
header_dict[key] += "; " + val
else:
header_dict[key] = val
return header_dict
func _pack_headers(headers: Dictionary) -> PackedStringArray:
var result: PackedStringArray = []
for header_key in headers:
var header_value = headers[header_key]
result.append("%s: %s" % [header_key, header_value])
return result
## The amount of requests that are pending
func queued_request_size() -> int:
var requests_size: int = requests.size()
if current_request != null:
requests_size += 1
return requests_size
func empty_response(request_data: RequestData) -> ResponseData:
var response_data = ResponseData.new()
response_data.request_data = request_data
response_data.response_data = []
response_data.response_code = 0
response_data.response_header = {}
response_data.result = 0
return response_data
# === LOGGER ===
static var logger: Dictionary = {}
static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
logger.debug = debug
logger.info = info
logger.error = error
static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)
static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)
static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)

View file

@ -0,0 +1 @@
uid://b7i5j62lmuh71

View file

@ -0,0 +1,58 @@
extends Control
@onready var clients: Tree = %Clients
## Key: BufferedHTTPClient | value: TreeItem
var client_map : Dictionary[BufferedHTTPClient, TreeItem] = {}
## Key: RequestData | value: TreeItem
var request_map : Dictionary[BufferedHTTPClient.RequestData, TreeItem] = {}
func _ready() -> void:
get_tree().root.child_entered_tree.connect(_on_child_enter)
_add_http_clients(get_tree().root)
func _add_http_clients(parent: Node) -> void:
for child in parent.get_children():
_on_child_enter(child)
_add_http_clients(child)
func _on_child_enter(node: Node) -> void:
if node is BufferedHTTPClient:
_new_client(node)
func _new_client(client: BufferedHTTPClient):
var parent = clients.create_item()
parent.set_text(0, client.name)
client_map[client] = parent
client.request_added.connect(_on_add_request.bind(parent))
client.request_done.connect(_on_done_request)
for request in client.requests:
_on_add_request(request, parent)
func _on_add_request(request: BufferedHTTPClient.RequestData, http_item: TreeItem):
var request_item = clients.create_item(http_item)
request_item.set_text(0, request.path)
request_item.set_text(1, "Queued")
request_map[request] = request_item
func _on_done_request(response: BufferedHTTPClient.ResponseData):
var request_item = request_map[response.request_data] as TreeItem
request_item.set_text(1, "DONE")
await get_tree().create_timer(60, true, false, true).timeout
if request_item != null: request_item.free()
func _close_client(client: BufferedHTTPClient):
var http_item = client_map[client] as TreeItem
client_map.erase(client)
http_item.set_text(1, "CLOSED")
await get_tree().create_timer(60, true, false, true).timeout
http_item.free()

View file

@ -0,0 +1 @@
uid://bpvg80y7vsysx

View file

@ -0,0 +1,25 @@
[gd_scene load_steps=2 format=3 uid="uid://b0ebhuv2yaow0"]
[ext_resource type="Script" uid="uid://bpvg80y7vsysx" path="res://addons/twitcher/lib/http/debug_buffered_http_client.gd" id="1_ij68s"]
[node name="DebugBufferedHttpClient" type="VBoxContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
size_flags_vertical = 3
script = ExtResource("1_ij68s")
[node name="Label" type="Label" parent="."]
layout_mode = 2
text = "Debug HTTP Clients"
horizontal_alignment = 1
[node name="Clients" type="Tree" parent="."]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
columns = 2
hide_root = true

View file

@ -0,0 +1,177 @@
@tool
extends Node
## Provides a simple HTTP Service to serve web stuff
class_name HTTPServer
## Key: int | Value: WeakRef(Server)
static var _servers : Dictionary = {}
class Server extends TCPServer:
var _bind_address: String
var _port: int
var _clients: Array[Client] = []
var _listeners: int
signal request_received(client: Client)
signal client_connected(client: Client)
signal client_disconnected(client: Client)
signal client_error_occured(client: Client, error: Error)
func _init(bind_address: String, port: int) -> void:
_bind_address = bind_address
_port = port
HTTPServer._servers[_port] = weakref(self)
func start_listening() -> void:
_listeners += 1
if !is_listening():
var status: Error = listen(_port, _bind_address)
Engine.get_main_loop().process_frame.connect(_process)
if status != OK:
HTTPServer.logError("Could not listen to port %d: %s" % [_port, error_string(status)])
else:
HTTPServer.logInfo("{%s:%s} listening" % [ _bind_address, _port ])
else:
HTTPServer.logDebug("{%s:%s} already listening" % [ _bind_address, _port ])
func stop_listening() -> void:
_listeners -= 1
HTTPServer.logDebug("{%s:%s} listener node detached %s left" % [ _bind_address, _port, _listeners ])
if _listeners <= 0:
_stop_server()
func _stop_server() -> void:
HTTPServer.logInfo("{%s:%s} stop" % [ _bind_address, _port ])
Engine.get_main_loop().process_frame.disconnect(_process)
for client in _clients:
client.peer.disconnect_from_host()
stop()
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
HTTPServer.logInfo("{%s:%s} removed" % [ _bind_address, _port ])
HTTPServer._servers.erase(_port)
func _process() -> void:
if !is_listening(): return
if is_connection_available():
_handle_connect()
for client in _clients:
_process_request(client)
_handle_disconnect(client)
func _process_request(client: Client) -> void:
var peer := client.peer
if peer.get_status() == StreamPeerTCP.STATUS_CONNECTED:
var error = peer.poll()
if error != OK:
HTTPServer.logError("Could not poll client %d: %s" % [_port, error_string(error)])
client_error_occured.emit(client, error)
elif peer.get_available_bytes() > 0:
request_received.emit(client)
func _handle_connect() -> void:
var peer := take_connection()
var client := Client.new()
client.peer = peer
_clients.append(client)
client_connected.emit(client)
HTTPServer.logInfo("{%s:%s} client connected" % [ _bind_address, _port ])
func _handle_disconnect(client: Client) -> void:
if client.peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
client_disconnected.emit(client)
HTTPServer.logInfo("{%s:%s} client disconnected" % [ _bind_address, _port ])
_clients.erase(client)
class Client extends RefCounted:
var peer: StreamPeerTCP
## Called when a new request was made
signal request_received(client: Client)
@export var _port: int
@export var _bind_address: String
var _server : Server
var _listening: bool
static func create(port: int, bind_address: String = "*") -> HTTPServer:
var server = HTTPServer.new()
server._bind_address = bind_address
server._port = port
return server
func _ready() -> void:
if _servers.has(_port) && _servers[_port] != null:
_server = _servers[_port].get_ref()
else:
_server = Server.new(_bind_address, _port)
_server.request_received.connect(_on_request_received)
logInfo("{%s:%s} start" % [ _bind_address, _port ])
func _on_request_received(client: Client) -> void:
request_received.emit(client)
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
stop_listening()
func start_listening() -> void:
_listening = true
_server.start_listening()
func stop_listening() -> void:
if _listening:
_listening = false
_server.stop_listening()
func send_response(client: Client, response_code : String, body : PackedByteArray) -> void:
var peer = client.peer
peer.put_data(("HTTP/1.1 %s\r\n" % response_code).to_utf8_buffer())
peer.put_data("Server: Godot Engine (Twitcher)\r\n".to_utf8_buffer())
peer.put_data(("Content-Length: %d\r\n"% body.size()).to_utf8_buffer())
peer.put_data("Connection: close\r\n".to_utf8_buffer())
peer.put_data("Content-Type: text/html charset=UTF-8\r\n".to_utf8_buffer())
peer.put_data("\r\n".to_utf8_buffer())
peer.put_data(body)
# === LOGGER ===
static var logger: Dictionary = {}
static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
logger.debug = debug
logger.info = info
logger.error = error
static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)
static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)
static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)

View file

@ -0,0 +1 @@
uid://bnepo370sikkb

View file

@ -0,0 +1,25 @@
extends Object
## Parses a query string and returns a dictionary with the parameters.
static func parse_query(query: String) -> Dictionary:
var parameters = Dictionary()
# Split the query by '&' to separate different parameters.
var pairs = query.split("&")
# Iterate over each pair of key-value.
for pair in pairs:
# Split the pair by '=' to separate the key from the value.
var kv = pair.split("=")
if kv.size() == 2:
var key = kv[0].strip_edges()
var value = kv[1].strip_edges()
var decoded_key = key.uri_decode()
var decoded_value = value.uri_decode()
parameters[decoded_key] = decoded_value
return parameters
## Method to set all logger within this package
static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
BufferedHTTPClient.set_logger(error, info, debug)
HTTPServer.set_logger(error, info, debug)
WebsocketClient.set_logger(error, info, debug)

View file

@ -0,0 +1 @@
uid://5esrbr8ikth8

View file

@ -0,0 +1,145 @@
@tool
extends Node
## Advanced websocket client that automatically reconnects to the server
class_name WebsocketClient
## Called as soon the websocket got a connection
signal connection_established
## Called as soon the websocket closed the connection
signal connection_closed
## Called when a complete message got received
signal message_received(message: PackedByteArray)
## Called when the state of the websocket changed
signal connection_state_changed(state : WebSocketPeer.State)
@export var connection_url: String:
set(val):
_logDebug("Set connection to %s" % val)
connection_url = val
var connection_state : WebSocketPeer.State = WebSocketPeer.STATE_CLOSED:
set(new_state):
if new_state != connection_state:
connection_state_changed.emit(new_state)
if new_state == WebSocketPeer.STATE_OPEN: connection_established.emit()
if new_state == WebSocketPeer.STATE_CLOSED: connection_closed.emit()
connection_state = new_state
## Determines if a connection should be established or not
@export var auto_reconnect: bool:
set(val):
auto_reconnect = val
_logDebug("New auto_reconnect value: %s" % val)
## True if currently connecting to prevent 2 connectionen processes at the same time
var _is_already_connecting: bool
var is_open: bool:
get(): return _peer.get_ready_state() == WebSocketPeer.STATE_OPEN
var is_closed: bool:
get(): return _peer.get_ready_state() == WebSocketPeer.STATE_CLOSED
var _peer: WebSocketPeer = WebSocketPeer.new()
var _tries: int
func open_connection() -> void:
if not is_closed: return
auto_reconnect = true
_logInfo("Open connection")
await _establish_connection()
func wait_connection_established() -> void:
if is_open: return
await connection_established
func _establish_connection() -> void:
if _is_already_connecting || not is_closed: return
_is_already_connecting = true
var wait_time = pow(2, _tries)
_logDebug("Wait %s before connecting" % [wait_time])
await get_tree().create_timer(wait_time, true, false, true).timeout
_logInfo("Connecting to %s" % connection_url)
var err = _peer.connect_to_url(connection_url)
if err != OK:
logError("Couldn't connect cause of %s" % [error_string(err)])
_tries += 1
_is_already_connecting = false
func _enter_tree() -> void:
if auto_reconnect: open_connection()
func _exit_tree() -> void:
if not is_open: return
_peer.close(1000, "resource got freed")
func _process(delta: float) -> void:
_poll()
func _poll() -> void:
if connection_url == "": return
var state := _peer.get_ready_state()
if state == WebSocketPeer.STATE_CLOSED and auto_reconnect:
_establish_connection()
_peer.poll()
_handle_state_changes(state)
connection_state = state
if state == WebSocketPeer.STATE_OPEN:
_read_data()
func _handle_state_changes(state: WebSocketPeer.State) -> void:
if connection_state != WebSocketPeer.STATE_OPEN && state == WebSocketPeer.STATE_OPEN:
_logInfo("connected")
_tries = 0
if connection_state != WebSocketPeer.STATE_CLOSED && state == WebSocketPeer.STATE_CLOSED:
_logInfo("connection was closed [%s]: %s" % [_peer.get_close_code(), _peer.get_close_reason()])
func _read_data() -> void:
while (_peer.get_available_packet_count()):
message_received.emit(_peer.get_packet())
func send_text(message: String) -> Error:
return _peer.send_text(message)
func close(status: int = 1000, message: String = "Normal Closure") -> void:
_logDebug("Websocket activly closed")
auto_reconnect = false
_peer.close(status, message)
# === LOGGER ===
static var logger: Dictionary = {}
static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
logger.debug = debug
logger.info = info
logger.error = error
func _logDebug(text: String) -> void:
logDebug("[%s]: %s" % [connection_url, text])
static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)
func _logInfo(text: String) -> void:
logInfo("[%s]: %s" % [connection_url, text])
static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)
static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)

View file

@ -0,0 +1 @@
uid://buqbforpa7b8a

View file

@ -0,0 +1,94 @@
@icon("./security-icon.svg")
@tool
extends Resource
## Provides a key to encrypt secrets in the application.
## Please don't store the key in the project,
## otherwise your secrets may revealed easily!
class_name CryptoKeyProvider
## Identify oOuch library specifics without collisions
const _CONFIG_PACKAGE_KEY: String = "dev.kani.oouch"
const _CONFIG_SECRET_KEY: String = "encryption"
const _AES_BLOCK_SIZE : int = 16
## Location of the encryption secrets
@export_global_file var encrpytion_secret_location: String = "user://encryption_key.cfg"
static var aes: AESContext = AESContext.new()
## To prevent accidental spoiler in the debugger
class KeyData extends RefCounted:
var key: String
var current_key_data: KeyData
func _init() -> void:
# Call defered cause the setter of encrpytion_secret_location isn't set otherwise
_get_encryption_secret.call_deferred()
## Don't cache it in a variable so that you accidently leak your secret when you debug
func _get_encryption_secret() -> String:
if is_instance_valid(current_key_data):
return current_key_data.key
var config = ConfigFile.new()
var error = config.load(encrpytion_secret_location)
if error == ERR_FILE_NOT_FOUND:
_create_secret(config)
elif error != OK:
printerr("Can't open %s cause of %s" % [encrpytion_secret_location, error_string(error)])
return ""
var key: String = config.get_value(_CONFIG_PACKAGE_KEY, _CONFIG_SECRET_KEY, "")
if key == "":
key = _create_secret(config)
current_key_data = KeyData.new()
current_key_data.key = key
return key
func _create_secret(config: ConfigFile) -> String:
print("Creating a new secret for encryption you can find it %s" % encrpytion_secret_location)
var crypto : Crypto = Crypto.new()
var secret_data : PackedByteArray = crypto.generate_random_bytes(16)
var secret : String = secret_data.hex_encode()
config.set_value(_CONFIG_PACKAGE_KEY, _CONFIG_SECRET_KEY, secret)
var err = config.save(encrpytion_secret_location)
if err != OK: push_error("Couldn't save encryption key cause of ", error_string(err))
return secret
func _pad(value: PackedByteArray) -> PackedByteArray:
var pad_len : int = _AES_BLOCK_SIZE - (value.size() % _AES_BLOCK_SIZE)
for i in range(pad_len):
value.append(pad_len)
return value
func _unpad(value: PackedByteArray) -> PackedByteArray:
if value.is_empty():
return value
var pad_len : int = value[-1]
if pad_len <= 0 or pad_len > _AES_BLOCK_SIZE or value.size() < pad_len:
push_error("Invalid padding detected (%s)" % pad_len)
return PackedByteArray()
return value.slice(0, -pad_len)
func encrypt(value: PackedByteArray) -> PackedByteArray:
var padded_value = _pad(value)
aes.start(AESContext.MODE_ECB_ENCRYPT, _get_encryption_secret().to_utf8_buffer())
var encrypted_value: PackedByteArray = aes.update(padded_value)
aes.finish()
return encrypted_value
func decrypt(value: PackedByteArray) -> PackedByteArray:
aes.start(AESContext.MODE_ECB_DECRYPT, _get_encryption_secret().to_utf8_buffer())
var decrypted_value: PackedByteArray = aes.update(value)
aes.finish()
return _unpad(decrypted_value)

View file

@ -0,0 +1 @@
uid://dcrliedgr6eol

View file

@ -0,0 +1,7 @@
[gd_resource type="Resource" script_class="CryptoKeyProvider" load_steps=2 format=3 uid="uid://c4scwuk8q0r40"]
[ext_resource type="Script" uid="uid://dcrliedgr6eol" path="res://addons/twitcher/lib/oOuch/crypto_key_provider.gd" id="1_q12uq"]
[resource]
script = ExtResource("1_q12uq")
encrpytion_secret_location = "user://encryption_key.cfg"

View file

@ -0,0 +1,375 @@
@icon("./security-icon.svg")
@tool
extends Node
## Orchestrates the complete authentication process
class_name OAuth
const OAuthHTTPServer = preload("res://addons/twitcher/lib/http/http_server.gd")
const OAuthHTTPClient = preload("res://addons/twitcher/lib/http/buffered_http_client.gd")
const OAuthDeviceCodeResponse = preload("./oauth_device_code_response.gd")
## Called when the authorization for AuthCodeFlow is complete to handle the auth code
signal _auth_succeed(code: String)
## In case the authorization wasn't succesfull
signal auth_error(error: String, error_description: String)
## The requested devicecode to show to the user for authorization
signal device_code_requested(device_code: OAuthDeviceCodeResponse)
## Called when the token has changed
signal token_changed(access_token: String)
@export var oauth_setting: OAuthSetting
@export var scopes: OAuthScopes
@export var token_handler: OAuthTokenHandler
## Customize how you want to open the authorization page (advanced usage for example multi user authentication)
@export var shell_command: String
## Parameters for the shell command (advanced usage for example multi user authentication)
@export var shell_parameter: Array[String] = []
## Some oauth provide doesn't return the provided scopes so you can disable the scope check
@export var check_scope_changed: bool = true
var login_in_process: bool
## Special solution just for twitch ignore it in all other providers
var force_verify: String
var _query_parser = RegEx.create_from_string("GET (.*?/?)\\??(.*?)? HTTP/1\\.1.*?")
var _auth_http_server: OAuthHTTPServer
var _last_login_attempt: int
## State for the current authcode request to compare with
var _current_state: String
var _client: OAuthHTTPClient
var _crypto: Crypto = Crypto.new()
var _login_timeout_timer: Timer
var _initialized: bool
enum AuthorizationFlow {
AUTHORIZATION_CODE_FLOW,
IMPLICIT_FLOW,
DEVICE_CODE_FLOW,
CLIENT_CREDENTIALS
}
func _on_unauthenticated() -> void:
login()
func _on_token_resolved(token: OAuthToken) -> void:
if token == null: return
token_changed.emit(await token.get_access_token())
## Checks if the authentication is valid.
func is_authenticated() -> bool:
return token_handler.is_token_valid()
## Starts the token refresh process to rotate the tokens
func refresh_token() -> void:
await token_handler.refresh_tokens()
func _setup_nodes() -> void:
if _initialized: return
_initialized = true
if _client == null:
_client = OAuthHTTPClient.new()
_client.name = "OAuthClient"
add_child(_client)
if _auth_http_server == null:
_auth_http_server = OAuthHTTPServer.create(oauth_setting.redirect_port)
_auth_http_server.name = "OAuthServer"
add_child(_auth_http_server)
if token_handler == null:
token_handler = OAuthTokenHandler.new()
add_child(token_handler)
token_handler.unauthenticated.connect(_on_unauthenticated)
token_handler.token_resolved.connect(_on_token_resolved)
_login_timeout_timer = Timer.new()
_login_timeout_timer.name = "LoginTimeoutTimer"
_login_timeout_timer.one_shot = true
_login_timeout_timer.wait_time = 30
_login_timeout_timer.timeout.connect(_on_login_timeout)
add_child(_login_timeout_timer)
## Depending on the authorization_flow it gets resolves the token via the different
## Flow types. Only one login process at the time. All other tries wait until the first process
## was succesful.
func login() -> bool:
if not is_node_ready(): await ready
_setup_nodes()
if token_handler.is_token_valid() && not _got_scopes_changed(): return true
logDebug("Token is valid (%s) and not scopes changed (%s)" % [ token_handler.is_token_valid(), _got_scopes_changed()])
if login_in_process:
logInfo("Another process tries already to login. Abort")
if (await token_handler.token_resolved) == null:
return false
return true
if _last_login_attempt != 0 && Time.get_ticks_msec() - 60 * 1000 < _last_login_attempt:
print("[OAuth] Last Login attempt was within 1 minute wait 1 minute before trying again. Please enable and consult logs, cause there is an issue with your authentication!")
await get_tree().create_timer(60, true, false, true).timeout
_last_login_attempt = Time.get_ticks_msec()
login_in_process = true
_login_timeout_timer.start()
logInfo("do login")
match oauth_setting.authorization_flow:
AuthorizationFlow.AUTHORIZATION_CODE_FLOW:
await _start_login_process("code")
AuthorizationFlow.CLIENT_CREDENTIALS:
await token_handler.request_token("client_credentials")
AuthorizationFlow.IMPLICIT_FLOW:
await _start_login_process("token")
AuthorizationFlow.DEVICE_CODE_FLOW:
await _start_device_login_process()
login_in_process = false
_login_timeout_timer.stop()
return true
func _got_scopes_changed() -> bool:
if not check_scope_changed: return false
var existing_scopes = token_handler.get_scopes()
var requested_scopes = scopes.used_scopes
if existing_scopes.size() != requested_scopes.size():
return true
for scope in existing_scopes:
if requested_scopes.find(scope) == -1:
return true
return false
## Called when the login process is timing out cause of misconfiguration or other natural catastrophes.
func _on_login_timeout() -> void:
if token_handler.is_token_valid(): return
logError("Login run into a timeout. Stop all login processes.")
_auth_succeed.emit("")
token_handler.token_resolved.emit(null)
func _start_login_process(response_type: String) -> void:
if scopes == null: scopes = OAuthScopes.new()
_auth_http_server.start_listening()
if response_type == "code":
_auth_http_server.request_received.connect(_process_code_request.bind(_auth_http_server))
elif response_type == "token":
_auth_http_server.request_received.connect(_process_implicit_request.bind(_auth_http_server))
_current_state = _crypto.generate_random_bytes(16).hex_encode()
var query_param = "&".join([
"force_verify=%s" % force_verify.uri_encode(),
"response_type=%s" % response_type.uri_encode(),
"client_id=%s" % oauth_setting.client_id.uri_encode(),
"scope=%s" % scopes.ssv_scopes().uri_encode(),
"redirect_uri=%s" % oauth_setting.redirect_url.uri_encode(),
"state=%s" % _current_state
])
var url = oauth_setting.authorization_url + "?" + query_param
logInfo("start login process to get token for scopes %s" % (",".join(scopes.used_scopes)))
logDebug("login to %s" % url)
if not shell_command.is_empty():
var parameters: PackedStringArray = shell_parameter.duplicate() \
.map(func(param: String): return param.format({"url": url}))
OS.create_process(shell_command, parameters)
else:
OS.shell_open(url)
logDebug("waiting for user to login.")
if response_type == "code":
var auth_code = await _auth_succeed
if auth_code == "":
logDebug("Auth code was empty. Abort Login.")
return
token_handler.request_token("authorization_code", auth_code)
await token_handler.token_resolved
_auth_http_server.request_received.disconnect(_process_code_request.bind(_auth_http_server))
elif response_type == "token":
await token_handler.token_resolved
_auth_http_server.request_received.disconnect(_process_implicit_request.bind(_auth_http_server))
logInfo("authorization is done stop server")
_auth_http_server.stop_listening()
#region DeviceCodeFlow
## Starts the device flow.
func _start_device_login_process():
var scopes = scopes.used_scopes
var device_code_response = await _fetch_device_code_response(scopes)
device_code_requested.emit(device_code_response)
# print the information instead of opening the browser so that the developer can decide if
# he want to open the browser manually. Also use print not the logger so that the information
# is sent always.
print("Visit %s and enter the code %s for authorization." % [device_code_response.verification_uri, device_code_response.user_code])
await token_handler.request_device_token(device_code_response, scopes)
func _fetch_device_code_response(scopes: String) -> OAuthDeviceCodeResponse:
logInfo("Start device code flow")
logDebug("Request Scopes: %s" % scopes)
var body = "client_id=%s&scopes=%s" % [oauth_setting.client_id, scopes.uri_encode()]
var request = _client.request(oauth_setting.device_authorization_url, HTTPClient.METHOD_POST, {
"Content-Type": "application/x-www-form-urlencoded"
}, body)
var initial_response_data = await _client.wait_for_request(request)
if initial_response_data.response_code != 200:
logError("Couldn't initiate device code flow response code %s" % initial_response_data.response_code)
var initial_response_string = initial_response_data.response_data.get_string_from_ascii()
var initial_response_dict = JSON.parse_string(initial_response_string) as Dictionary
return OAuthDeviceCodeResponse.new(initial_response_dict)
#endregion
#region ImplicitFlow
## Handles the response after auth endpoint redirects to our server with the response
func _process_implicit_request(client: OAuthHTTPServer.Client, server: OAuthHTTPServer) -> void:
var request = client.peer.get_utf8_string(client.peer.get_available_bytes())
if request == "":
logError("Empty response. Check if your redirect URL is set to %s." % oauth_setting.redirect_url)
client.peer.disconnect_from_host()
return
var first_linebreak = request.find("\n")
var first_line = request.substr(0, first_linebreak)
if first_line.begins_with("GET"):
var matcher = _query_parser.search(first_line)
if matcher == null:
logDebug("Response from auth server was not right expected redirect url. It's ok browser asked probably for favicon etc.")
return
var redirect_path = oauth_setting.redirect_path
var request_path = matcher.get_string(1)
if redirect_path == request_path:
server.send_response(client, "200 OK", ("<html><head><title>Login</title></head><body>
<script>
var params = Object.fromEntries(new URLSearchParams(window.location.hash.substring(1)));
fetch('" + oauth_setting.redirect_url + "', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(params) })
.then(window.close);
</script>
Redirect Token to Godot
</body></html>").to_utf8_buffer())
logInfo("Send Response to send it via POST")
elif first_line.begins_with("POST"):
var parts = request.split("\r\n\r\n")
if parts.size() < 2:
return # Not a valid request
var json_body = parts[1]
var token_request = JSON.parse_string(json_body)
token_handler.update_tokens(token_request["access_token"])
logInfo("Received Access Token update it")
server.send_response(client, "200 OK", "<html><head><title>Login</title><script>window.close()</script></head><body>Success!</body></html>".to_utf8_buffer())
#endregion
#region AuthCodeFlow
## Handles the response after auth endpoint redirects to our server with the response
func _process_code_request(client: OAuthHTTPServer.Client, server: OAuthHTTPServer) -> void:
if client.peer.get_status() != StreamPeerTCP.STATUS_CONNECTED:
logError("Client not connected can't process code response.")
return
var request = client.peer.get_utf8_string(client.peer.get_available_bytes())
if request == "":
logError("Empty response. Check if your redirect URL is set to %s." % oauth_setting.redirect_url)
client.peer.disconnect_from_host()
return
# Firstline contains request path and parameters
var first_line = request.substr(0, request.find("\n"))
var matcher = _query_parser.search(first_line)
if matcher == null:
logDebug("Response from auth server was not right expected query params. It's ok browser asked probably for favicon etc.")
client.peer.disconnect_from_host()
return
var query_params_str = matcher.get_string(2)
var query_params = parse_query(query_params_str)
var state = query_params.get("state")
if query_params.has("error"):
_handle_error(server, client, query_params)
elif state == _current_state:
_handle_success(server, client, query_params)
else:
_handle_other_requests(server, client, first_line)
client.peer.disconnect_from_host()
## Returns the response for the given auth request back to the browser also emits the auth code
func _handle_success(server: OAuthHTTPServer, client: OAuthHTTPServer.Client, query_params : Dictionary) -> void:
logInfo("Authentication success. Send auth code.")
if query_params.has("code"):
var succes_page = FileAccess.get_file_as_bytes("res://addons/twitcher/assets/success-page.txt")
server.send_response(client, "200 OK", succes_page)
_auth_succeed.emit(query_params['code'])
else:
var error_page = FileAccess.get_file_as_bytes("res://addons/twitcher/assets/error-page.txt")
server.send_response(client, "200 OK", error_page)
logError("Auth code expected wasn't send!")
## Handles the error in case that Auth API has a problem
func _handle_error(server: OAuthHTTPServer, client: OAuthHTTPServer.Client, query_params : Dictionary) -> void:
var msg = "Error %s: %s" % [query_params["error"], query_params["error_description"]]
logError(msg)
server.send_response(client, "400 BAD REQUEST", msg.to_utf8_buffer())
#endregion
func _handle_other_requests(server: OAuthHTTPServer, client: OAuthHTTPServer.Client, fist_line: String) -> void:
if fist_line.contains("favicon.ico"):
var favicon: PackedByteArray = FileAccess.get_file_as_bytes("res://addons/twitcher/assets/favicon.ico")
server.send_response(client, "200", favicon)
## Parses a query string and returns a dictionary with the parameters.
static func parse_query(query: String) -> Dictionary:
var parameters = Dictionary()
# Split the query by '&' to separate different parameters.
var pairs = query.split("&")
# Iterate over each pair of key-value.
for pair in pairs:
# Split the pair by '=' to separate the key from the value.
var kv = pair.split("=")
if kv.size() == 2:
var key = kv[0].strip_edges()
var value = kv[1].strip_edges()
var decoded_key = key.uri_decode()
var decoded_value = value.uri_decode()
parameters[decoded_key] = decoded_value
return parameters
# === LOGGER ===
static var logger: Dictionary = {}
static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
logger.debug = debug
logger.info = info
logger.error = error
OAuthTokenHandler.set_logger(error, info, debug)
static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)
static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)
static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)

View file

@ -0,0 +1 @@
uid://bf0wi70haua35

View file

@ -0,0 +1,16 @@
extends RefCounted
## Response of the inital device code request
var device_code: String;
var expires_in: int;
var interval: int;
var user_code: String;
var verification_uri: String;
func _init(json: Dictionary):
device_code = json["device_code"];
expires_in = int(json["expires_in"]);
interval = int(json["interval"]);
user_code = json["user_code"];
verification_uri = json["verification_uri"];

View file

@ -0,0 +1 @@
uid://doqj7o6fwi8dp

View file

@ -0,0 +1,31 @@
@icon("./scope-icon.svg")
@tool
extends Resource
## Contains the information about a set of scopes.
class_name OAuthScopes
## Called when new scopes was added or removed
signal scopes_changed
@export var used_scopes: Array[StringName] = []:
set(val):
used_scopes = val;
scopes_changed.emit()
## Returns the scopes space separated
func ssv_scopes() -> String:
return " ".join(used_scopes)
func add_scopes(scopes: Array[StringName]) -> void:
for scope in scopes:
if used_scopes.find(scope) != -1: continue
used_scopes.append(scope)
scopes_changed.emit()
func remove_scopes(scopes: Array[StringName]) -> void:
used_scopes = used_scopes.filter(func(s): return scopes.find(s) != -1)
scopes_changed.emit()

View file

@ -0,0 +1 @@
uid://dexweyb521tu0

View file

@ -0,0 +1,112 @@
@icon("./scope-icon.svg")
@tool
extends Resource
class_name OAuthSetting
## That will be called when the authcode was received to send the code to the backend
@export var redirect_url: String = "http://localhost:7170":
set = _update_redirect_url
## Wellknown endpoint to receive the common paths for the IAM provider (optional)
@export var well_known_url: String
## Path where tokens can be get
@export var token_url: String
## Path to the authorization endpoint
@export var authorization_url: String
## Path to the device code flow URL.
@export var device_authorization_url: String
## Where should the tokens be cached
@export var cache_file: String = "res://auth.key"
## Client ID to authorize
@export var client_id: String:
set(val):
client_id = val
emit_changed()
## Defines the authorization flow.
@export var authorization_flow: OAuth.AuthorizationFlow = OAuth.AuthorizationFlow.AUTHORIZATION_CODE_FLOW:
set(val):
authorization_flow = val
notify_property_list_changed()
emit_changed()
@export var _encryption_key_provider: CryptoKeyProvider = preload("res://addons/twitcher/lib/oOuch/default_key_provider.tres")
# Calculated Values
var redirect_path: String:
get():
if redirect_path == "" and redirect_url != "": _update_redirect_url(redirect_url)
return redirect_path
var redirect_port: int:
get():
if redirect_port == 0 and redirect_url != "": _update_redirect_url(redirect_url)
return redirect_port
## Client Secret to authorize (optional depending on flow)
@export_storage var client_secret: String:
set(val):
client_secret = val if val != null || val != "" else ""
emit_changed()
var _crypto: Crypto = Crypto.new()
var _well_known_setting: Dictionary
var _url_regex = RegEx.create_from_string("((https?://)?([^:/]+))(:([0-9]+))?(/.*)?")
func _update_redirect_url(value: String) -> void:
redirect_url = value;
var matches = _url_regex.search(value)
if matches == null:
redirect_path = "/"
redirect_port = 7170
emit_changed()
return
var path = matches.get_string(6)
var port = matches.get_string(5)
redirect_path = path if path != "" else "/"
redirect_port = int(port) if port != "" else 7170
emit_changed()
func get_client_secret() -> String:
if client_secret == "" || client_secret == null: return ""
var value_raw = Marshalls.base64_to_raw(client_secret)
var value_bytes := _encryption_key_provider.decrypt(value_raw)
return value_bytes.get_string_from_utf8()
func set_client_secret(plain_secret: String) -> void:
var encrypted_value := _encryption_key_provider.encrypt(plain_secret.to_utf8_buffer())
client_secret = Marshalls.raw_to_base64(encrypted_value)
func _validate_property(property: Dictionary) -> void:
if property.name == "client_secret":
if _is_client_secret_need():
property.usage |= PROPERTY_USAGE_READ_ONLY
else:
property.usage &= ~PROPERTY_USAGE_READ_ONLY
func _is_client_secret_need() -> bool:
return authorization_flow == OAuth.AuthorizationFlow.AUTHORIZATION_CODE_FLOW || \
authorization_flow == OAuth.AuthorizationFlow.CLIENT_CREDENTIALS
func is_valid() -> bool:
var problems = get_valididation_problems()
return problems.is_empty()
func get_valididation_problems() -> PackedStringArray:
var result: PackedStringArray = []
if client_id == "" || client_id == null:
result.append("Client ID is missing")
if _is_client_secret_need() && (client_secret == "" || client_secret == null):
result.append("Client Secret is missing")
return result

View file

@ -0,0 +1 @@
uid://00xbijwpi8xa

View file

@ -0,0 +1,115 @@
@tool
extends EditorInspectorPlugin
const BufferedHttpClient = preload("res://addons/twitcher/lib/http/buffered_http_client.gd")
const EncryptionKeyProvider: CryptoKeyProvider = preload("res://addons/twitcher/lib/oOuch/default_key_provider.tres")
func _can_handle(object: Object) -> bool:
return object is OAuthSetting
func _parse_property(object: Object, type: Variant.Type, name: String, hint_type: PropertyHint, hint_string: String, usage_flags: int, wide: bool) -> bool:
if name == "well_known_url":
add_property_editor("well_known_url", WellKnownUriProperty.new())
return true
if name == "client_id":
add_property_editor("client_secret", SecretProperty.new(), true, "Client Secret")
return false
class SecretProperty extends EditorProperty:
var _line_edit: LineEdit = LineEdit.new()
func _init() -> void:
_line_edit.secret = true
_line_edit.text_submitted.connect(_on_text_changed)
_line_edit.focus_exited.connect(_on_focus_exited)
add_child(_line_edit)
add_focusable(_line_edit)
func _update_property() -> void:
var secret = get_edited_object()[get_edited_property()]
if secret == "": _line_edit.text = ""
var value_raw := Marshalls.base64_to_raw(secret)
var value_bytes := EncryptionKeyProvider.decrypt(value_raw)
_line_edit.text = value_bytes.get_string_from_utf8()
func _on_focus_exited() -> void:
_save()
func _on_text_changed(_new_text: String) -> void:
_save()
func _save() -> void:
var plain_value = _line_edit.text
if plain_value == "":
emit_changed(get_edited_property(), "")
return
var encrypted_value := EncryptionKeyProvider.encrypt(plain_value.to_utf8_buffer())
emit_changed(get_edited_property(), Marshalls.raw_to_base64(encrypted_value))
class WellKnownUriProperty extends EditorProperty:
var _url_regex = RegEx.create_from_string("((https?://)?([^:/]+))(:([0-9]+))?(/.*)?")
var _container: VBoxContainer
var _well_known_url: LineEdit
var _submit: Button
var _client: BufferedHttpClient
func _init() -> void:
_container = VBoxContainer.new()
_client = BufferedHttpClient.new()
_client.name = "OauthSettingInspectorClient"
add_child(_client)
_well_known_url = LineEdit.new()
_well_known_url.placeholder_text = "https://id.twitch.tv/oauth2/.well-known/openid-configuration"
_well_known_url.text_changed.connect(_on_text_changed)
add_focusable(_well_known_url)
_container.add_child(_well_known_url)
_submit = Button.new()
_submit.pressed.connect(_on_submit_clicked)
_submit.text = "Update URIs"
_container.add_child(_submit)
add_focusable(_submit)
add_child(_container)
func _on_text_changed(new_text: String) -> void:
emit_changed(get_edited_property(), new_text)
func _update_property() -> void:
_well_known_url.text = get_edited_object()[get_edited_property()]
func load_from_wellknown(wellknow_url: String) -> void:
var request = _client.request(wellknow_url, HTTPClient.METHOD_GET, {}, "")
var response = await _client.wait_for_request(request) as BufferedHttpClient.ResponseData
var json = JSON.parse_string(response.response_data.get_string_from_utf8())
var device_code = json.get("device_authorization_endpoint", "")
if device_code != "":
emit_changed(&"device_authorization_url", device_code)
var token_endpoint = json["token_endpoint"]
if token_endpoint != "":
emit_changed(&"token_url", token_endpoint)
var authorization_endpoint = json["authorization_endpoint"]
if authorization_endpoint != "":
emit_changed(&"authorization_url", authorization_endpoint)
func _on_submit_clicked() -> void:
_submit.disabled = true
var wellknownurl = _well_known_url.text if _well_known_url.text != "" else _well_known_url.placeholder_text
await load_from_wellknown(wellknownurl)
_submit.disabled = false

View file

@ -0,0 +1 @@
uid://bi2tjog6pfa5a

View file

@ -0,0 +1,141 @@
@icon("./security-icon.svg")
@tool
extends Resource
## Used to store and load token's and to exchange them through the code.
## Try to avoid debugging this object cause it leaks your access and refresh tokens
## Hint never store the token value as string in your code to reduce the chance
## to leak the tokens always use the getter.
class_name OAuthToken
static var CRYPTO: Crypto = Crypto.new()
## Key for encryption purpose to save the tokens
@export var _crypto_key_provider: CryptoKeyProvider = preload("res://addons/twitcher/lib/oOuch/default_key_provider.tres")
## Unique identifier to store multiple tokens within one config file
@export var _identifier: String = "Auth-%s" % randi_range(0, 10000)
## Storage where the tokens should be saved encrypted (multiple secrets can be put in the same file see _identifier)
@export var _cache_path: String = "user://auth.conf":
set(val):
_cache_path = val
_load_tokens()
var _scopes: PackedStringArray = []
var _expire_date: int
var _config_file: ConfigFile = ConfigFile.new()
var _access_token: String = "":
set(val):
_access_token = val
if val != "": authorized.emit()
var _refresh_token: String = ""
## Called when the token was resolved / accesstoken got refreshed
signal authorized
func update_values(access_token: String, refresh_token: String, expire_in: int, scopes: Array[String]):
_expire_date = roundi(Time.get_unix_time_from_system() + expire_in)
_access_token = access_token
_refresh_token = refresh_token
_scopes = scopes
_persist_tokens()
emit_changed()
## Persists the tokesn with the expire date
func _persist_tokens():
var encrypted_access_token = _crypto_key_provider.encrypt(_access_token.to_utf8_buffer())
var encrypted_refresh_token = _crypto_key_provider.encrypt(_refresh_token.to_utf8_buffer())
_config_file.load(_cache_path)
_config_file.set_value(_identifier, "expire_date", _expire_date)
_config_file.set_value(_identifier, "access_token", Marshalls.raw_to_base64(encrypted_access_token))
_config_file.set_value(_identifier, "refresh_token", Marshalls.raw_to_base64(encrypted_refresh_token))
_config_file.set_value(_identifier, "scopes", ",".join(_scopes))
var err = _config_file.save(_cache_path)
if err != OK: push_error("Couldn't save tokens cause of ", error_string(err))
## Loads the tokens and returns the information if the file got created
func _load_tokens() -> bool:
var status = _config_file.load(_cache_path)
if status == OK && _config_file.has_section(_identifier):
_expire_date = _config_file.get_value(_identifier, "expire_date", 0)
var encrypted_access_token: PackedByteArray = Marshalls.base64_to_raw(_config_file.get_value(_identifier, "access_token"))
var encrypted_refresh_token: PackedByteArray = Marshalls.base64_to_raw(_config_file.get_value(_identifier, "refresh_token"))
_access_token = _crypto_key_provider.decrypt(encrypted_access_token).get_string_from_utf8()
_refresh_token = _crypto_key_provider.decrypt(encrypted_refresh_token).get_string_from_utf8()
_scopes = _config_file.get_value(_identifier, "scopes", "").split(",", false)
emit_changed()
return true
return false
func remove_tokens() -> void:
var status = _config_file.load(_cache_path)
if status == OK && _config_file.has_section(_identifier):
_access_token = ""
_refresh_token = ""
_expire_date = 0
_scopes.clear()
_config_file.erase_section(_identifier)
var err = _config_file.save(_cache_path)
if err != OK: push_error("Couldn't save tokens cause of ", error_string(err))
emit_changed()
print("%s got revoked" % _identifier)
else:
print("%s not found" % _identifier)
func get_refresh_token() -> String:
return _refresh_token
func get_access_token() -> String:
if not is_token_valid(): await authorized
return _access_token
func get_scopes() -> PackedStringArray:
return _scopes
## The unix timestamp when the token is expiring
func get_expiration() -> int:
return _expire_date
func get_expiration_readable() -> String:
if _expire_date == 0:
return "Not available"
return Time.get_datetime_string_from_unix_time(_expire_date, true)
func invalidate() -> void:
_expire_date = 0
_refresh_token = ""
_access_token = ""
_scopes = []
emit_changed()
## Does this accesstoken has a refresh token
func has_refresh_token() -> bool:
return _refresh_token != "" && _refresh_token != null
## Checks if the access token is still valid
func is_token_valid() -> bool:
var current_time = Time.get_unix_time_from_system()
return current_time < _expire_date
## Get all token names within a config file
static func get_identifiers(cache_file: String) -> PackedStringArray:
var _config_file: ConfigFile = ConfigFile.new()
var status = _config_file.load(cache_file)
if status != OK: return []
return _config_file.get_sections()

View file

@ -0,0 +1 @@
uid://b52xp7c23ucfk

View file

@ -0,0 +1,245 @@
@icon("./security-icon.svg")
@tool
extends Node
class_name OAuthTokenHandler
const OAuthHTTPClient = preload("res://addons/twitcher/lib/http/buffered_http_client.gd")
const OAuthDeviceCodeResponse = preload("./oauth_device_code_response.gd")
## Handles refreshing and resolving access and refresh tokens.
const HEADERS = {
"Accept": "*/*",
"Content-Type": "application/x-www-form-urlencoded"
}
const SECONDS_TO_CHECK_EARLIER = 60
## Called when new access token is available
signal token_resolved(tokens: OAuthToken)
## Called when token can't be refreshed cause auth was removed or refresh token expired
signal unauthenticated()
## Where to get the tokens from
@export var oauth_setting: OAuthSetting
## Holds the current set of tokens
@export var token: OAuthToken: set = _update_token
## Client to request new tokens
var _http_client : OAuthHTTPClient
## Is currently requesting tokens
var _requesting_token: bool = false
## Timer to refresh tokens
var _expiration_check_timer: Timer
func _ready() -> void:
_http_client = OAuthHTTPClient.new()
_http_client.name = "OAuthTokenClient"
add_child(_http_client)
_expiration_check_timer = Timer.new()
_expiration_check_timer.name = "ExpirationCheck"
_expiration_check_timer.timeout.connect(refresh_tokens)
add_child(_expiration_check_timer)
update_expiration_check()
func _enter_tree() -> void:
if not is_instance_valid(token):
token = OAuthToken.new()
else:
token.changed.connect(update_expiration_check)
func _exit_tree() -> void:
if is_instance_valid(token):
token.changed.disconnect(update_expiration_check)
func _update_token(val: OAuthToken) -> void:
if is_instance_valid(token) and is_inside_tree():
token.changed.disconnect(update_expiration_check)
token = val
if is_instance_valid(token) and is_inside_tree():
token.changed.connect(update_expiration_check)
func update_expiration_check() -> void:
var current_time = Time.get_unix_time_from_system()
var expiration = token.get_expiration()
if expiration == 0:
_expiration_check_timer.stop()
return
_expiration_check_timer.start(expiration - current_time - SECONDS_TO_CHECK_EARLIER)
logDebug("Refresh token (%s) in %s seconds" % [token._identifier, roundf(_expiration_check_timer.wait_time)])
## Checks if tokens expires and starts refreshing it. (called often hold footprintt small)
func _check_token_refresh() -> void:
if _requesting_token: return
if token_needs_refresh():
logInfo("Token (%s) needs refresh" % token._identifier)
refresh_tokens()
## Requests the tokens
func request_token(grant_type: String, auth_code: String = ""):
if _requesting_token: return
_requesting_token = true
logInfo("Request token (for %s) via '%s'" % [token._identifier, grant_type])
var request_params = [
"grant_type=%s" % grant_type,
"client_id=%s" % oauth_setting.client_id,
"client_secret=%s" % oauth_setting.get_client_secret()
]
if auth_code != "":
request_params.append("code=%s" % auth_code)
if grant_type == "authorization_code":
request_params.append("&redirect_uri=%s" % oauth_setting.redirect_url)
var request_body = "&".join(request_params)
var request = _http_client.request(oauth_setting.token_url, HTTPClient.METHOD_POST, HEADERS, request_body)
await _handle_token_request(request)
_requesting_token = false
func request_device_token(device_code_repsonse: OAuthDeviceCodeResponse, scopes: String, grant_type: String = "urn:ietf:params:oauth:grant-type:device_code") -> void:
if _requesting_token: return
_requesting_token = true
logInfo("request token (for %s) via urn:ietf:params:oauth:grant-type:device_code" % token._identifier)
var parameters = [
"client_id=%s" % oauth_setting.client_id,
"grant_type=%s" % grant_type,
"device_code=%s" % device_code_repsonse.device_code,
"scopes=%s" % scopes
]
var request_body = "&".join(parameters)
# Time when the code is expired and we don't poll anymore
var expire_data = Time.get_unix_time_from_system() + device_code_repsonse.expires_in
while expire_data > Time.get_unix_time_from_system():
var request = _http_client.request(oauth_setting.token_url, HTTPClient.METHOD_POST, HEADERS, request_body)
var response = await _http_client.wait_for_request(request)
var response_string: String = response.response_data.get_string_from_utf8()
var response_data = JSON.parse_string(response_string)
if response.response_code == 200:
_update_tokens_from_response(response_data)
_requesting_token = false
return
elif response.response_code == 400 && response_string.contains("authorization_pending"):
# Awaits for this amount of time until retry
await get_tree().create_timer(device_code_repsonse.interval, true, false, true).timeout
elif response.response_code == 400:
unauthenticated.emit()
_requesting_token = false
return
# Handle Timeout
unauthenticated.emit()
_requesting_token = false
## Uses the refresh token if possible to refresh all tokens
func refresh_tokens() -> void:
if not oauth_setting.is_valid():
logDebug("Try to refresh token (%s) but oauth settings are invalid. Can't refresh token." % token._identifier)
return
if _requesting_token: return
_requesting_token = true
logInfo("use refresh (%s) token" % token._identifier)
if token.has_refresh_token():
var request_body = "client_id=%s&client_secret=%s&refresh_token=%s&grant_type=refresh_token" % [oauth_setting.client_id, oauth_setting.get_client_secret(), token.get_refresh_token()]
var request = _http_client.request(oauth_setting.token_url, HTTPClient.METHOD_POST, HEADERS, request_body)
if await _handle_token_request(request):
logInfo("token (%s) got refreshed" % token._identifier)
else:
unauthenticated.emit()
else:
unauthenticated.emit()
_requesting_token = false
## Gets information from the response and update values returns true when success otherwise false
func _handle_token_request(request: OAuthHTTPClient.RequestData) -> bool:
var response = await _http_client.wait_for_request(request)
var response_string = response.response_data.get_string_from_utf8()
var result = JSON.parse_string(response_string)
if response.response_code == 200:
_update_tokens_from_response(result)
return true
else:
# Reset expiration cause token wasn't refreshed correctly.
token.invalidate()
logError("token (for %s) could not be fetched ResponseCode %s / Body %s" % [token._identifier, response.response_code, response_string])
return false
func _update_tokens_from_response(result: Dictionary):
var scopes: Array[String] = []
for scope in result.get("scope", []): scopes.append(scope)
update_tokens(result["access_token"], \
result.get("refresh_token", ""), \
result.get("expires_in", -1), \
scopes)
## Updates the token. Result is the response data of an token request.
func update_tokens(access_token: String, refresh_token: String = "", expires_in: int = -1, scopes: Array[String] = []):
token.update_values(access_token, refresh_token, expires_in, scopes)
token_resolved.emit(token)
logInfo("token (%s) resolved" % token._identifier)
func get_token_expiration() -> String:
return Time.get_datetime_string_from_unix_time(token._expire_date)
## Checks if the token are valud
func is_token_valid() -> bool:
return token.is_token_valid()
## Checks if the token is expired and can be refreshed
func token_needs_refresh() -> bool:
return !token.is_token_valid() && token.has_refresh_token()
func get_access_token() -> String: return await token.get_access_token()
func has_refresh_token() -> bool: return token.has_refresh_token()
func get_scopes() -> PackedStringArray: return token.get_scopes()
# === LOGGER ===
static var logger: Dictionary = {}
static func set_logger(error: Callable, info: Callable, debug: Callable) -> void:
logger.debug = debug
logger.info = info
logger.error = error
static func logDebug(text: String) -> void:
if logger.has("debug"): logger.debug.call(text)
static func logInfo(text: String) -> void:
if logger.has("info"): logger.info.call(text)
static func logError(text: String) -> void:
if logger.has("error"): logger.error.call(text)

View file

@ -0,0 +1 @@
uid://bsfi3u26qkfrc

View file

@ -0,0 +1,87 @@
@tool
extends Node
signal revoked
@export var token: OAuthToken: set = _update_token
@onready var title: Label = %Title
@onready var token_valid_value: Label = %TokenValidValue
@onready var refresh_token_value: CheckBox = %RefreshTokenValue
@onready var token_scope_value: Node = %TokenScopeValue
@onready var reload_button: Button = %ReloadButton
@onready var revoke_button: Button = %RevokeButton
func _ready() -> void:
if token == null:
_reset_token()
return
update_token_view()
revoke_button.pressed.connect(_on_revoke_pressed)
reload_button.pressed.connect(_on_reload_pressed)
func _enter_tree() -> void:
if is_instance_valid(token):
token.changed.connect(_on_token_changed)
func _exit_tree() -> void:
if is_instance_valid(token):
token.changed.disconnect(_on_token_changed)
func _update_token(val: OAuthToken) -> void:
if is_instance_valid(token):
token.changed.disconnect(_on_token_changed)
token = val
if is_instance_valid(token) and is_inside_tree():
token.changed.connect(_on_token_changed)
func update_token_view() -> void:
title.text = token._identifier
token_valid_value.text = token.get_expiration_readable()
if token.is_token_valid():
token_valid_value.add_theme_color_override(&"font_color", Color.GREEN)
else:
token_valid_value.add_theme_color_override(&"font_color", Color.RED)
if token.has_refresh_token():
refresh_token_value.text = "Available"
refresh_token_value.add_theme_color_override(&"font_color", Color.GREEN)
refresh_token_value.button_pressed = true
else:
refresh_token_value.text = "Not Available"
refresh_token_value.add_theme_color_override(&"font_color", Color.YELLOW)
refresh_token_value.button_pressed = false
for scope in token.get_scopes():
var scope_name = Label.new()
scope_name.text = scope
token_scope_value.add_child(scope_name)
revoke_button.disabled = false
func _on_revoke_pressed() -> void:
token.remove_tokens()
_reset_token()
func _on_reload_pressed() -> void:
_reset_token()
token._load_tokens()
func _reset_token() -> void:
title.text = ""
token_valid_value.text = ""
refresh_token_value.button_pressed = false
revoke_button.disabled = true
for child in token_scope_value.get_children():
child.queue_free()
func _on_token_changed() -> void:
update_token_view()

View file

@ -0,0 +1 @@
uid://cht8c01quk1mb

View file

@ -0,0 +1,67 @@
[gd_scene load_steps=2 format=3 uid="uid://6d2jst8ga4le"]
[ext_resource type="Script" uid="uid://cht8c01quk1mb" path="res://addons/twitcher/lib/oOuch/oauth_token_info.gd" id="1_xfn6u"]
[node name="TokenInfo" type="MarginContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 5
theme_override_constants/margin_top = 5
theme_override_constants/margin_right = 5
theme_override_constants/margin_bottom = 5
script = ExtResource("1_xfn6u")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
layout_mode = 2
[node name="Title" type="Label" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_type_variation = &"HeaderLarge"
[node name="GridContainer" type="GridContainer" parent="VBoxContainer"]
layout_mode = 2
columns = 2
[node name="TokenValidTitle" type="Label" parent="VBoxContainer/GridContainer"]
layout_mode = 2
text = "Token Valid:"
[node name="TokenValidValue" type="Label" parent="VBoxContainer/GridContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="RefreshTokenTitle" type="Label" parent="VBoxContainer/GridContainer"]
layout_mode = 2
text = "Refresh Token Available:"
[node name="RefreshTokenValue" type="CheckBox" parent="VBoxContainer/GridContainer"]
unique_name_in_owner = true
layout_mode = 2
disabled = true
[node name="TokenScopeTitle" type="Label" parent="VBoxContainer/GridContainer"]
layout_mode = 2
text = "Token Scope:"
[node name="TokenScopeValue" type="VBoxContainer" parent="VBoxContainer/GridContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="ReloadButton" type="Button" parent="VBoxContainer/GridContainer"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "Reloads the token from file use to update the infos when the token was fetched by an application run."
text = "Reload Info"
[node name="RevokeButton" type="Button" parent="VBoxContainer/GridContainer"]
unique_name_in_owner = true
layout_mode = 2
tooltip_text = "Will remove the cached access token / refresh token to fetch a new one"
disabled = true
text = "Revoke Token"

View file

@ -0,0 +1,13 @@
@tool
extends EditorInspectorPlugin
var token_info_scene: PackedScene = preload("res://addons/twitcher/lib/oOuch/oauth_token_info.tscn")
func _can_handle(object: Object) -> bool:
return object is OAuthToken
func _parse_begin(object: Object) -> void:
var token_info = token_info_scene.instantiate()
token_info.token = object
add_custom_control(token_info)

View file

@ -0,0 +1 @@
uid://djylbydr6sh64

View file

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="16"
height="16"
version="1.1"
id="svg1"
sodipodi:docname="scope-icon.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect5-8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.35632304,0,1 | F,0,0,1,0,0.32388529,0,1 @ F,0,0,1,0,0.30607722,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.21339985,0,1 @ F,0,0,1,0,0.41714928,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="37.15625"
inkscape:cx="4.9251472"
inkscape:cy="10.913373"
inkscape:window-width="3440"
inkscape:window-height="1369"
inkscape:window-x="-8"
inkscape:window-y="1432"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<path
fill="#e0e0e0"
d="m 9,1 0.564,2.258 0.69,0.28 1.988,-1.194 1.414,1.414 L 12.46,5.752 12.746,6.436 15,7 V 9 H 9.75 C 10.487539,7.6669775 9.5234194,6.0317774 8,6.0317774 6.4765806,6.0317774 5.5124609,7.6669775 6.25,9 H 1 V 7 L 3.26,6.436 3.538,5.748 2.344,3.758 3.758,2.344 5.752,3.54 6.438,3.254 7,1 Z"
id="path1"
sodipodi:nodetypes="ccccccccccsccccccccccc" />
<path
d="m 3.3046875,4.0184324 v 4.5744634 a 0.35632304,0.35632304 45 0 0 0.356323,0.356323 H 5.2335207 A 0.55892328,0.55892328 147.4818 0 0 5.7402271,8.6261849 L 6.5,6.9941406 h 2.9726563 l 0.46875,1.3222657 h 1.2675777 l 0.550782,-1.3222657 h 1.006958 a 0.35632304,0.35632304 135 0 0 0.356323,-0.356323 l 0,-0.677979 A 0.35632304,0.35632304 45 0 0 12.766724,5.6035156 H 6.5 L 5.7410959,3.9847375 A 0.56058591,0.56058591 32.441105 0 0 5.2335207,3.6621094 H 3.6610105 a 0.35632304,0.35632304 135 0 0 -0.356323,0.356323 z M 4.417788,4.7970081 h 0.3991817 a 0.45136078,0.45136078 34.14202 0 1 0.4193275,0.2843548 l 0.4962594,1.2460344 v 0.00195 l 0.00195,0.00391 h -0.00195 L 5.202006,7.6653918 A 0.31469287,0.31469287 145.85798 0 1 4.909647,7.8636466 H 4.511052 A 0.41714928,0.41714928 45 0 1 4.0939027,7.4464973 V 5.1208934 A 0.32388529,0.32388529 135 0 1 4.417788,4.7970081 Z"
style="fill:#e0e0e0"
id="path4"
sodipodi:nodetypes="cccccccccccccccccccccc"
inkscape:path-effect="#path-effect5-8"
inkscape:original-d="M 3.3046875,3.6621094 V 8.9492188 H 5.5898437 L 6.5,6.9941406 h 2.9726563 l 0.46875,1.3222657 h 1.2675777 l 0.550782,-1.3222657 h 1.363281 V 5.6035156 H 6.5 L 5.5898437,3.6621094 Z m 0.7892152,1.1348987 h 1.0291442 l 0.6095097,1.5303892 v 0.00195 l 0.00195,0.00391 h -0.00195 L 5.1230469,7.8636466 H 4.0939027 Z"
transform="matrix(1.4259001,0,0,0.94569634,-3.7121542,6.5367565)" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bdu2sqj8cfh80"
path="res://.godot/imported/scope-icon.svg-760ec336caee34c24da7cfc19dbbd818.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/lib/oOuch/scope-icon.svg"
dest_files=["res://.godot/imported/scope-icon.svg-760ec336caee34c24da7cfc19dbbd818.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
version="1.1"
viewBox="0 0 16 16"
id="svg4"
sodipodi:docname="security-icon.svg"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs4">
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect5"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.35632304,0,1 | F,0,0,1,0,0.32388529,0,1 @ F,0,0,1,0,0.30607722,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.21339985,0,1 @ F,0,0,1,0,0.41714928,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect4"
nodesatellites_param="F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.59205278,0,1 @ F,0,0,1,0,0.62915907,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 | F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1"
is_visible="true"
lpeversion="1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
<inkscape:path-effect
effect="fillet_chamfer"
id="path-effect5-8"
is_visible="true"
lpeversion="1"
nodesatellites_param="F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,1,1,0,0.35632304,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.35632304,0,1 | F,0,0,1,0,0.32388529,0,1 @ F,0,0,1,0,0.30607722,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0,0,1 @ F,0,0,1,0,0.21339985,0,1 @ F,0,0,1,0,0.41714928,0,1"
radius="0"
unit="px"
method="auto"
mode="F"
chamfer_steps="1"
flexible="false"
use_knot_distance="true"
apply_no_radius="true"
apply_with_radius="true"
only_selected="false"
hide_knots="false" />
</defs>
<sodipodi:namedview
id="namedview4"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="26.074563"
inkscape:cx="0.80538264"
inkscape:cy="11.965685"
inkscape:window-width="1999"
inkscape:window-height="1360"
inkscape:window-x="712"
inkscape:window-y="1440"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<path
d="m 3.3046875,4.0184324 v 4.5744634 a 0.35632304,0.35632304 45 0 0 0.356323,0.356323 H 5.2335207 A 0.55892328,0.55892328 147.4818 0 0 5.7402271,8.6261849 L 6.5,6.9941406 h 2.9726563 l 0.46875,1.3222657 h 1.2675777 l 0.550782,-1.3222657 h 1.006958 a 0.35632304,0.35632304 135 0 0 0.356323,-0.356323 l 0,-0.677979 A 0.35632304,0.35632304 45 0 0 12.766724,5.6035156 H 6.5 L 5.7410959,3.9847375 A 0.56058591,0.56058591 32.441105 0 0 5.2335207,3.6621094 H 3.6610105 a 0.35632304,0.35632304 135 0 0 -0.356323,0.356323 z M 4.417788,4.7970081 h 0.3991817 a 0.45136078,0.45136078 34.14202 0 1 0.4193275,0.2843548 l 0.4962594,1.2460344 v 0.00195 l 0.00195,0.00391 h -0.00195 L 5.202006,7.6653918 A 0.31469287,0.31469287 145.85798 0 1 4.909647,7.8636466 H 4.511052 A 0.41714928,0.41714928 45 0 1 4.0939027,7.4464973 V 5.1208934 A 0.32388529,0.32388529 135 0 1 4.417788,4.7970081 Z"
style="fill:#e0e0e0"
id="path4"
sodipodi:nodetypes="cccccccccccccccccccccc"
inkscape:path-effect="#path-effect5-8"
inkscape:original-d="M 3.3046875,3.6621094 V 8.9492188 H 5.5898437 L 6.5,6.9941406 h 2.9726563 l 0.46875,1.3222657 h 1.2675777 l 0.550782,-1.3222657 h 1.363281 V 5.6035156 H 6.5 L 5.5898437,3.6621094 Z m 0.7892152,1.1348987 h 1.0291442 l 0.6095097,1.5303892 v 0.00195 l 0.00195,0.00391 h -0.00195 L 5.1230469,7.8636466 H 4.0939027 Z"
transform="matrix(1.4497664,0,0,1.4497664,-3.9081888,-1.1417399)" />
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ctgrmafl803p0"
path="res://.godot/imported/security-icon.svg-8d6ec29771c7aa01a3aece2fa8908f4c.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://addons/twitcher/lib/oOuch/security-icon.svg"
dest_files=["res://.godot/imported/security-icon.svg-8d6ec29771c7aa01a3aece2fa8908f4c.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false