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,606 @@
@icon("res://addons/twitcher/assets/api-icon.svg")
@tool
extends Twitcher
class_name TwitchAPIGenerator
const suffixes: Array[String] = ["Response", "Body", "Opt"]
const api_output_path = "res://addons/twitcher/generated/"
const twitch_api_header : String = """@tool
extends Twitcher
# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.
## Interaction with the Twitch REST API.
class_name TwitchAPI
static var _log: TwitchLogger = TwitchLogger.new("TwitchAPI")
static var instance: TwitchAPI
## Maximal tries to reauthrorize before giving up the request.
const MAX_AUTH_ERRORS = 3
## Called when the API returns unauthenticated mostly cause the accesstoken is expired
signal unauthenticated
## Called when the API returns 403 means there are permissions / scopes missing
signal unauthorized
## To authorize against the Twitch API
@export var token: OAuthToken:
set(val):
token = val
update_configuration_warnings()
## OAuth settings needed for client information
@export var oauth_setting: OAuthSetting:
set(val):
oauth_setting = val
update_configuration_warnings()
## URI to the Twitch API
@export var api_host: String = "https://api.twitch.tv/helix"
## Client to make HTTP requests
var client: BufferedHTTPClient
func _ready() -> void:
client = BufferedHTTPClient.new()
client.name = "ApiClient"
add_child(client)
func _enter_tree() -> void:
if instance == null: instance = self
func _exit_tree() -> void:
if instance == self: instance = null
func _get_configuration_warnings() -> PackedStringArray:
var result: PackedStringArray = []
if token == null:
result.append("Please set a token to use")
if oauth_setting == null:
result.append("Please set the correct oauth settings")
return result
func request(path: String, method: int, body: Variant = "", content_type: String = "", error_count: int = 0) -> BufferedHTTPClient.ResponseData:
var header : Dictionary = {
"Authorization": "Bearer %s" % [await token.get_access_token()],
"Client-ID": oauth_setting.client_id
}
if content_type != "":
header["Content-Type"] = content_type
var request_body: String = ""
if body == null || (body is String && body == ""):
request_body = ""
elif body is Object && body.has_method("to_json"):
request_body = body.to_json()
else:
request_body = JSON.stringify(body)
var req: BufferedHTTPClient.RequestData = client.request(api_host + path, method, header, request_body)
var res: BufferedHTTPClient.ResponseData = await client.wait_for_request(req)
# Try to fix Godot TLS Bug
if res.result == 5:
return await retry(req, res, path, method, body, content_type, error_count + 1)
match res.response_code:
400:
var error_message: String = res.response_data.get_string_from_utf8()
_log.e("'%s' failed cause of: \\n%s" % [path, error_message])
401: # Token expired / or missing permissions
_log.e("'%s' is unauthorized. It is probably your scopes." % path)
unauthorized.emit()
403:
_log.i("'%s' is unauthenticated. Refresh token." % path)
unauthenticated.emit()
await token.authorized
return await retry(req, res, path, method, body, content_type, error_count + 1)
return res
func retry(request: BufferedHTTPClient.RequestData,
response: BufferedHTTPClient.ResponseData,
path: String,
method: int,
body: Variant = "",
content_type: String = "",
error_count: int = 0) -> BufferedHTTPClient.ResponseData:
if error_count + 1 < MAX_AUTH_ERRORS:
return await request(path, method, body, content_type, error_count + 1)
else:
# Give up the request after trying multiple times and
# return an empty response with correct error code
var empty_response: BufferedHTTPClient.ResponseData = client.empty_response(request)
empty_response.response_code = response.response_code
return empty_response
## Converts unix timestamp to RFC 3339 (example: 2021-10-27T00:00:00Z) when passed a string uses as is
static func get_rfc_3339_date_format(time: Variant) -> String:
if typeof(time) == TYPE_INT:
var date_time = Time.get_datetime_dict_from_unix_time(time)
return "%s-%02d-%02dT%02d:%02d:%02dZ" % [date_time['year'], date_time['month'], date_time['day'], date_time['hour'], date_time['minute'], date_time['second']]
return str(time)
"""
@export var parser: TwitchAPIParser
var grouped_files: Dictionary[String, Variant] = {}
func prepare_component(component: TwitchGenComponent) -> void:
if component._is_root:
var base_name = get_base_name(component._classname)
# No suffix class lives by its own
if base_name == component._classname:
if grouped_files.has(base_name):
push_error("That file shouldn't exist: %s" % base_name)
component._classname = "Twitch" + component._classname
grouped_files[base_name] = component
else:
var file: GroupedComponent = grouped_files.get(base_name, GroupedComponent.new())
file.base_name = "Twitch" + base_name
file.components.append(component)
grouped_files[base_name] = file
component._classname = component._classname.trim_prefix(base_name)
component.set_meta("fqdn", file.base_name + "." + component._classname)
var sub_components_to_update: Array[TwitchGenComponent] = component._sub_components.values().duplicate()
for sub_component in sub_components_to_update:
sub_component._classname = component._classname + sub_component._classname
sub_components_to_update.append_array(sub_component._sub_components.values())
pass
func generate_api() -> void:
for component: Variant in parser.components:
prepare_component(component)
# Generate TwitchAPI
var twitch_api_code = twitch_api_header
for method: TwitchGenMethod in parser.methods:
twitch_api_code += method_code(method)
write_output_file(api_output_path + "twitch_api.gd", twitch_api_code)
# Generate Components
for component: Variant in grouped_files.values():
var code = ""
if component is GroupedComponent:
code = group_code(component)
else:
code = component_code(component, 0)
write_output_file(api_output_path + component.get_filename(), code)
print("API regenerated you can find it under: %s" % api_output_path)
class GroupedComponent extends RefCounted:
var base_name: String
var prefix: String
var components: Array[TwitchGenComponent] = []
func _update_base_name(val: String) -> void:
base_name = val
func get_filename() -> String:
return base_name.to_snake_case() + ".gd"
func group_code(group: GroupedComponent) -> String:
var code = """@tool
extends TwitchData
# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.
class_name {name}
""".format({"name": group.base_name})
for component in group.components:
component._is_root = false
code += "\n\n"
code += component_code(component, 1)
return code
#region Field Code Generation
func field_declaration(field: TwitchGenField) -> String:
var type = get_type(field._type, field._is_array)
return """
## {description}
@export var {name}: {type}:
set(val):
{name} = val
track_data(&"{name}", val)\n""".format({
"name": field._name,
"description": ident(field._description, 0, "## "),
"type": type
})
#endregion
#region Parameter Code Generation
#func get_code() -> String:
#if _name == "broadcaster_id":
#var default_value = "default_broadcaster_login" if _type == "String" else "[default_broadcaster_login]"
#return "%s: %s = %s" % [_name, get_type(), default_value]
#return "%s: %s" % [_name, get_type()]
#endregion
#region Method Code Generation
func parameter_doc(method: TwitchGenMethod) -> String:
if method._required_parameters.is_empty():
return "## [no required query parameters to describe]"
var doc : String = ""
for parameter: TwitchGenParameter in method._required_parameters:
doc += "## {name} - {documentation} \n".format({
'name': parameter._name,
'documentation': ident(parameter._description, 0, "## ")
})
return doc.rstrip("\n")
func parameter_array(method: TwitchGenMethod, with_type: bool = false, fully_qualified: bool = false) -> Array[String]:
var parameters : Array[String] = []
if method._contains_body: parameters.append(get_parameter("body", method._body_type, false, with_type, fully_qualified))
if method._contains_optional: parameters.append(get_parameter("opt", method.get_optional_type(), false, with_type, fully_qualified))
method._parameters.sort_custom(TwitchGenParameter.sort)
for parameter: TwitchGenParameter in method._required_parameters:
parameters.append(get_parameter(parameter._name, parameter._type, parameter._is_array, with_type, fully_qualified))
return parameters
func method_parameter(method: TwitchGenMethod, with_type: bool = false, fully_qualified: bool = false) -> String:
return ", ".join(parameter_array(method, with_type, fully_qualified))
func path_code(method: TwitchGenMethod) -> String:
var body_code : String = "var path = \"%s?\"\n" % method._path
if method._contains_optional:
body_code += "var optionals: Dictionary[StringName, Variant] = {}\n"
body_code += "if opt != null: optionals = opt.to_dict()\n"
for parameter: TwitchGenParameter in method._parameters:
if parameter._required:
body_code += parameter_path_code(parameter) + "\n"
else:
body_code += "if optionals.has(\"%s\"):\n" % parameter._name
body_code += "\t%s\n" % ident(parameter_path_code(parameter, "optionals."), 1)
return body_code
func parameter_path_code(parameter: TwitchGenParameter, prefix: String = "") -> String:
var body: String
if parameter._is_time:
body = "path += \"{key}=\" + get_rfc_3339_date_format({value}) + \"&\""
elif parameter._is_array:
body = """
for param in {value}:
path += "{key}=" + str(param) + "&" """.trim_prefix("\n\t")
else:
body = "path += \"{key}=\" + str({value}) + \"&\""
return body.format({
'value': prefix + parameter._name,
'key': parameter._name
})
## Exceptional method cause twitch api is not uniform
func paging_code_stream_schedule() -> String:
return """
if parsed_result.data.pagination != null:
opt.after = parsed_result.data.pagination.cursor
parsed_result.data._next_page = get_channel_stream_schedule.bind(opt, broadcaster_id)\n"""
func paging_code(method: TwitchGenMethod) -> String:
if method._name == "get_channel_stream_schedule":
return paging_code_stream_schedule()
var code: String = ""
code += "if parsed_result.pagination != null:\n"
var after_parameter: TwitchGenParameter = method.get_parameter_by_name("after")
var result_component: TwitchGenComponent = get_component(method._result_type)
var pagination_parameter: TwitchGenField = result_component.get_field_by_name("pagination")
if pagination_parameter == null:
print("Check %s paging without paging?" % method._name)
pass
elif pagination_parameter._type == "String":
code += "\tvar cursor: String = parsed_result.pagination\n"
else:
code += "\tvar cursor: String = parsed_result.pagination.cursor\n"
if after_parameter._required:
code += "\t{parameter} = cursor\n"
else:
code += "\topt.{parameter} = cursor\n"
code += "\tparsed_result._next_page = {name}.bind({parameters})\n"
return code.format({
"parameter": after_parameter._name,
"name": method._name,
"parameters": method_parameter(method)
})
func response_code(method: TwitchGenMethod) -> String:
var code: String = ""
var result_type = get_type(method._result_type, false, true)
if result_type != "BufferedHTTPClient.ResponseData":
code = """
var result: Variant = JSON.parse_string(response.response_data.get_string_from_utf8())
var parsed_result: {result_type} = {result_type}.from_json(result)
parsed_result.response = response
""".format({ 'result_type': result_type })
if method._has_paging: code += paging_code(method)
code += "return parsed_result"
else:
code = "return response"
return code
func method_code(method: TwitchGenMethod) -> String:
return """
## {summary}
##
{parameter_doc}
##
## {doc_url}
func {name}({parameters}) -> {result_type}:
{path_code}
var response: BufferedHTTPClient.ResponseData = await request(path, HTTPClient.METHOD_{method}, {body_variable}, "{content_type}")
{response_code}
""".format({
"summary": method._summary,
"parameter_doc": parameter_doc(method),
"doc_url": method._doc_url,
"name": method._name,
"parameters": method_parameter(method, true, true),
"result_type": get_type(method._result_type, false, true),
"path_code": ident(path_code(method), 1),
"content_type": get_type(method._content_type, false, true),
"method": method._http_verb.to_upper(),
"body_variable": "body" if method._contains_body else "\"\"",
"response_code": ident(response_code(method), 1),
})
#endregion
#region Component Code Generation
func component_code(component: TwitchGenComponent, level: int = 0) -> String:
var code: String = ""
if component._is_root:
code += """@tool
extends TwitchData
# CLASS GOT AUTOGENERATED DON'T CHANGE MANUALLY. CHANGES CAN BE OVERWRITTEN EASILY.
## {description}
## {ref}
class_name {classname}
"""
else:
code = """
## {description}
## {ref}
class {classname} extends TwitchData:
"""
var class_code : String = ""
for field: TwitchGenField in component._fields:
class_code += field_declaration(field)
if component._is_response:
class_code += "var response: BufferedHTTPClient.ResponseData"
class_code += "\n\n"
class_code += create_code(component) + "\n\n"
class_code += from_json_code(component)
if component._has_paging:
class_code += "\n\n" + iter_code(component)
var sub_component_code: String
for sub_component in component._sub_components.values():
sub_component_code += "\n\n" + component_code(sub_component, 1)
return code.format({
"description": ident(component._description, 0, "## "),
"classname": component._classname,
"ref": component._ref
}) + ident(class_code, level) + sub_component_code
func create_code(component: TwitchGenComponent) -> String:
var parameters: Array[String] = []
for field in component._fields:
if field._is_required:
parameters.append("_" + get_parameter(field._name, field._type, field._is_array))
var variable_name = component._classname.to_snake_case()
var code : String = """
## Constructor with all required fields.
static func create({parameters}) -> {classname}:
var {variablename}: {classname} = {classname}.new()\n""".format({
"parameters": ", ".join(parameters),
"classname": component._classname,
"variablename": variable_name
})
for field in component._fields:
if field._is_required:
code += "\t{classname}.{name} = _{name}\n".format({
"name": field._name,
"classname": variable_name
})
code += "\treturn %s" % variable_name
return code
func from_json_code(component: TwitchGenComponent) -> String:
var code : String = """
static func from_json(d: Dictionary) -> {classname}:
var result: {classname} = {classname}.new()
""".format({"classname": component._classname})
for field: TwitchGenField in component._fields:
code += "\tif d.get(\"{name}\", null) != null:\n"
if field._is_typed_array:
code += """
for value in d["{name}"]:
result.{name}.append({type}.from_json(value))\n""".lstrip("\n")
elif field._is_array:
code += """
for value in d["{name}"]:
result.{name}.append(value)\n""".lstrip("\n")
elif field._is_sub_class:
code += "\t\tresult.{name} = {type}.from_json(d[\"{name}\"])\n"
else:
code += "\t\tresult.{name} = d[\"{name}\"]\n"
code = code.format({
"name": field._name,
"type": get_type(field._type, false)
})
code += "\treturn result\n"
return code
func iter_code(component: TwitchGenComponent) -> String:
var data_variable_name: String = "data"
var path_to_data: String = ""
if component._ref == "#/components/schemas/GetChannelStreamScheduleResponse/Data":
data_variable_name = "segments"
path_to_data = "data."
var code: String
if component._ref == "#/components/schemas/GetExtensionLiveChannelsResponse":
code += """
func _has_pagination() -> bool:
if pagination == null || pagination == "": return false
return true
"""
else:
code += """
func _has_pagination() -> bool:
if pagination == null: return false
if pagination.cursor == null || pagination.cursor == "": return false
return true
"""
code += """
var _next_page: Callable
var _cur_iter: int = 0
func next_page() -> {response_type}:
var response: {response_type} = await _next_page.call()
_cur_iter = 0
_next_page = response.{path_to_data}_next_page
{copy_code}
return response
func _iter_init(iter: Array) -> bool:
if {data_variable_name}.is_empty(): return false
iter[0] = {data_variable_name}[0]
return {data_variable_name}.size() > 0
func _iter_next(iter: Array) -> bool:
if {data_variable_name}.size() - 1 > _cur_iter:
_cur_iter += 1
iter[0] = {data_variable_name}[_cur_iter]
if {data_variable_name}.size() - 1 == _cur_iter && not _has_pagination():
return false
return true
func _iter_get(iter: Variant) -> Variant:
if {data_variable_name}.size() - 1 == _cur_iter && _has_pagination():
await next_page()
return iter"""
var copy_code: String
for field in component._fields:
copy_code += "\t{_name} = response.{path_to_data}{_name}\n".format(field)
return code.format({
"data_variable_name": data_variable_name,
"copy_code": copy_code,
"path_to_data": path_to_data,
"response_type": component.get_root_classname()
})
#endregion
#region Utils
func get_type(type: String, is_array: bool = false, full_qualified: bool = false) -> String:
var result_type : String = ""
if type.begins_with("#"):
var component: TwitchGenComponent = parser.get_component_by_ref(type)
result_type = component._classname
if full_qualified and component.has_meta("fqdn"):
result_type = component.get_meta("fqdn")
else:
result_type = type
return result_type if not is_array else "Array[%s]" % result_type
func get_component(type: String) -> TwitchGenComponent:
if type.begins_with("#"):
return parser.get_component_by_ref(type)
else:
return null
func ident(code: String, level: int, padding: String = "") -> String:
return code.replace("\n", "\n" + "\t".repeat(level) + padding)
# Writes the processed content to the output file.
func write_output_file(file_output: String, content: String) -> void:
var file = FileAccess.open(file_output, FileAccess.WRITE);
if file == null:
var error_message = error_string(FileAccess.get_open_error());
push_error("Failed to open output file: %s\n%s" % [file_output, error_message])
return
file.store_string(content)
file.flush()
file.close()
func get_base_name(file: String) -> String:
var new_file: String = file
for suffix: String in suffixes:
new_file = new_file.trim_suffix(suffix)
return new_file
func get_parameter(title: String, type: String, is_array = false, with_type: bool = true, fully_qualified: bool = false) -> String:
if with_type:
return "{name}: {type}".format({
"name": title,
"type": get_type(type, is_array, fully_qualified)
})
else:
return title
#endregion

View file

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

View file

@ -0,0 +1,247 @@
@icon("res://addons/twitcher/assets/api-icon.svg")
@tool
extends Twitcher
class_name TwitchAPIParser
const SWAGGER_API = "https://raw.githubusercontent.com/DmitryScaletta/twitch-api-swagger/refs/heads/main/openapi.json"
var definition: Dictionary = {}
var component_map: Dictionary[String, TwitchGenComponent] = {}
var components: Array[TwitchGenComponent] = []
var methods: Array[TwitchGenMethod] = []
var client: BufferedHTTPClient = BufferedHTTPClient.new()
signal component_added(component: TwitchGenComponent)
signal method_added(method: TwitchGenMethod)
class ComponentRepo extends RefCounted:
var _component: TwitchGenComponent
var _component_map: Dictionary[String, TwitchGenComponent]
func get_comp(component_name: String) -> TwitchGenComponent:
var component = _component.get_component(component_name)
if component != null: return component
return _component_map.get(component_name)
func _init(component: TwitchGenComponent, component_map: Dictionary[String, TwitchGenComponent]) -> void:
_component = component
_component_map = component_map
func _ready() -> void:
client.name = "APIGeneratorClient"
func parse_api() -> void:
print("start generating API")
if definition == {}:
print("load Twitch definition")
definition = await _load_swagger_definition()
_parsing_components()
_parsing_paths()
func _load_swagger_definition() -> Dictionary:
add_child(client)
client.max_error_count = 3
var request = client.request(SWAGGER_API, HTTPClient.METHOD_GET, {}, "")
var response_data = await client.wait_for_request(request)
if response_data.error:
printerr("Cant generate API")
return {}
var response_str = response_data.response_data.get_string_from_utf8()
var response = JSON.parse_string(response_str)
remove_child(client)
return response
func _parsing_components() -> void:
var schemas = definition["components"]["schemas"]
for schema_name in schemas:
var schema: Dictionary = schemas[schema_name]
if schema["type"] != "object":
printerr("Not an object")
continue
var ref = "#/components/schemas/" + schema_name
var component = TwitchGenComponent.new(schema_name, ref)
component._is_root = true
component._is_response = true
_parse_properties(component, schema)
_add_component(ref, component)
func _parse_properties(component: TwitchGenComponent, schema: Dictionary) -> void:
var properties = schema["properties"]
for property_name: String in properties:
var property: Dictionary = properties[property_name]
var field: TwitchGenField = TwitchGenField.new()
field._name = property_name
field._description = property.get("description", "")
field._type = _get_param_type(property)
var classname: String = property_name.capitalize().replace(" ", "")
if property.has("properties"):
var sub_component = _add_sub_component(classname, field._description, component, property)
field._type = sub_component._ref
## Arrays that has custom types
elif property.get("type", "") == "array":
field._is_array = true
field._is_sub_class = false
var items = property.get("items", {})
if items.has("$ref"):
field._type = items.get("$ref")
elif items.has("properties"):
var sub_component = _add_sub_component(classname, field._description, component, items)
field._type = sub_component._ref
component.add_field(field)
var requires: Array = schema.get("required", [])
for required_field: String in requires:
var field: TwitchGenField = component.get_field_by_name(required_field)
field._is_required = true
func _add_sub_component(classname: String, description: String, parent_component: TwitchGenComponent, properties: Dictionary) -> TwitchGenComponent:
var ref: String = parent_component._ref + "/" + classname
var sub_component = TwitchGenComponent.new(classname, ref)
sub_component._description = description
_parse_properties(sub_component, properties)
parent_component.add_component(sub_component)
_add_component(ref, sub_component)
return sub_component
func _parsing_paths() -> void:
var paths = definition.get("paths", {})
for path in paths:
var method_specs = paths[path]
for http_verb: String in method_specs:
var method_spec = method_specs[http_verb] as Dictionary
var method = _parse_method(http_verb, method_spec)
method._path = path
if method._contains_optional:
var component : TwitchGenComponent = method.get_optional_component()
_add_component(component._ref, component)
methods.append(method)
method_added.emit(method)
func _parse_method(http_verb: String, method_spec: Dictionary) -> TwitchGenMethod:
var method: TwitchGenMethod = TwitchGenMethod.new()
method._http_verb = http_verb
method._name = method_spec.get("operationId", "method_" + http_verb).replace("-", "_")
method._summary = method_spec.get("summary", "No summary provided.")
method._description = method_spec.get("description", "No description provided.")
method._doc_url = method_spec.get("externalDocs", {}).get("url", "No link provided")
_parse_parameters(method, method_spec)
# Body Type
if method_spec.has("requestBody"):
method._body_type = "Dictionary"
var ref = method_spec.get("requestBody").get("content", {}).get("application/json", {}).get("schema", {}).get("$ref", "")
if ref != "":
method._body_type = ref
# Result Type
method._result_type = "BufferedHTTPClient.ResponseData"
var responses = method_spec.get("responses", {})
if responses.has("200") || responses.has("202"):
var content: Dictionary = {}
if responses.has("200"):
content = responses["200"].get("content", {})
elif content == {}:
content = responses["202"].get("content", {})
# Assuming the successful response is a JSON object
method._result_type = "Dictionary"
# Special case for /schedule/icalendar
if content.has("text/calendar"):
method._result_type = "BufferedHTTPClient.ResponseData"
# Try to resolve the component references
var ref = content.get("application/json", {}).get("schema", {}).get("$ref", "")
if ref != "":
method._result_type = ref
# Content Type
if method_spec.has("requestBody"):
var requestBody = method_spec.get("requestBody")
var content = requestBody.get("content")
method._content_type = content.keys()[0]
elif http_verb == "POST":
method._content_type = "application/x-www-form-urlencoded"
return method
func _parse_parameters(method: TwitchGenMethod, method_spec: Dictionary) -> void:
var parameter_specs = method_spec.get("parameters", [])
for parameter_spec in parameter_specs:
var parameter: TwitchGenParameter = TwitchGenParameter.new()
var schema = parameter_spec["schema"]
parameter._name = parameter_spec.get("name", "")
parameter._description = parameter_spec.get("description", "")
parameter._type = _get_param_type(schema)
parameter._required = parameter_spec.get("required", false)
parameter._is_time = schema.get("format", "") == "date-time"
parameter._is_array = schema.get("type", "") == "array"
method.add_parameter(parameter)
func _add_component(ref: String, component: TwitchGenComponent) -> void:
components.append(component)
component_map[ref] = component
func get_component_by_ref(ref: String) -> TwitchGenComponent:
return component_map[ref]
func _get_param_type(schema: Dictionary) -> String:
if schema.has("$ref"):
return schema["$ref"]
if not schema.has("type"):
return "Variant" # Maybe ugly
var type = schema["type"]
var format = schema.get("format", "")
match type:
"object":
if schema.has("additinalProperties"):
return _get_param_type(schema["additinalProperties"])
return "Dictionary"
"string":
# Why did I do this in the first place?
# Lets disable and see if problems appear
#if format == "date-time":
# return "Variant"
return "String"
"integer":
return "int"
"number":
return "float" if format == "float" else "int"
"boolean":
return "bool"
"array":
var ref: String = schema["items"].get("$ref", "")
if schema["items"].get("type", "") == "string":
return "String"
elif ref != "":
return ref
else:
return "Variant"
_: return "Variant"

View file

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

View file

@ -0,0 +1,3 @@
extends RefCounted
class_name TwitchGen

View file

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

View file

@ -0,0 +1,60 @@
@tool
extends TwitchGen
class_name TwitchGenComponent
var _classname: String
var _ref: String
var _description: String
var _fields: Array[TwitchGenField] = []
var _field_map: Dictionary[String, TwitchGenField] = {}
var _parent_component: TwitchGenComponent
var _sub_components: Dictionary[String, TwitchGenComponent] = {}
var _is_root: bool
var _is_response: bool
var _has_paging: bool
var _filename: String:
get(): return _classname.to_snake_case() + ".gd"
func _init(classname: String, ref: String) -> void:
_classname = _sanatize_classname(classname)
_ref = ref
func add_field(field: TwitchGenField) -> void:
_fields.append(field)
_field_map[field._name] = field
if field._name == "pagination":
_has_paging = true
func get_field_by_name(field_name: String) -> TwitchGenField:
return _field_map.get(field_name, null)
func get_root_classname() -> String:
var parent: TwitchGenComponent = self
while parent._parent_component != null:
parent = parent._parent_component
return parent._classname
func get_filename() -> String:
return get_root_classname().to_snake_case() + ".gd"
func _sanatize_classname(val: String) -> String:
match val:
"Image": return "TwitchImage"
"Panel": return "TwitchPanel"
_: return val
func get_component(component_name: String) -> TwitchGenComponent:
return _sub_components.get(component_name)
func add_component(sub_component: TwitchGenComponent) -> void:
_sub_components[sub_component._classname] = sub_component
sub_component._parent_component = self

View file

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

View file

@ -0,0 +1,31 @@
@tool
extends TwitchGen
class_name TwitchGenField
var _name: String:
set = _update_name
var _description: String
var _type: String
var _is_required: bool
var _is_sub_class: bool:
get(): return _type.begins_with("#")
var _is_array: bool
var _is_typed_array: bool:
get(): return _is_array && _type.begins_with("#")
## Couple of names from the Twitch API are messed up like keywords for godot or numbers
func _update_name(val: String) -> void:
match val:
"animated": _name = "animated_format"
"static": _name = "static_format"
"1": _name = "_1"
"2": _name = "_2"
"3": _name = "_3"
"4": _name = "_4"
"1.5": _name = "_1_5"
"100x100": _name = "_100x100"
"24x24": _name = "_24x24"
"300x200": _name = "_300x200"
_: _name = val

View file

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

View file

@ -0,0 +1,59 @@
@tool
extends TwitchGen
class_name TwitchGenMethod
var _http_verb: String
var _name: String
var _summary: String
var _description: String
var _path: String
var _doc_url: String
var _parameters: Array[TwitchGenParameter] = []
var _parameter_map: Dictionary[String, TwitchGenParameter] = {}
var _required_parameters: Array[TwitchGenParameter]:
get(): return _parameters.filter(func(p): return p._required)
var _optional_parameters: Array[TwitchGenParameter]:
get(): return _parameters.filter(func(p): return not p._required)
var _body_type: String
var _result_type: String
var _content_type: String
var _has_paging: bool
var _contains_optional: bool
var _contains_body: bool:
get(): return _body_type != null and _body_type != ""
func add_parameter(parameter: TwitchGenParameter) -> void:
_parameters.append(parameter)
_contains_optional = _contains_optional || not parameter._required
_parameter_map[parameter._name] = parameter
if parameter._name == "after":
_has_paging = true
func get_parameter_by_name(name: String) -> TwitchGenParameter:
return _parameter_map.get(name)
func get_optional_classname() -> String:
return _name.capitalize().replace(" ", "") + "Opt"
func get_optional_type() -> String:
return "#/components/schemas/" + get_optional_classname()
func get_optional_component() -> TwitchGenComponent:
var component = TwitchGenComponent.new(get_optional_classname(), get_optional_type())
component._description = "All optional parameters for TwitchAPI.%s" % _name
component._is_root = true
for parameter: TwitchGenParameter in _optional_parameters:
var field = TwitchGenField.new()
field._name = parameter._name
field._type = parameter._type
field._description = parameter._description
field._is_required = false
field._is_array = parameter._is_array
component.add_field(field)
return component

View file

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

View file

@ -0,0 +1,22 @@
@tool
extends TwitchGen
class_name TwitchGenParameter
var _name: String
var _description: String
var _required: bool
var _type: String
var _is_time: bool
var _is_array: bool
static func sort(p1: TwitchGenParameter, p2: TwitchGenParameter) -> bool:
if p1._name == "broadcaster_id":
return false
if p2._name == "broadcaster_id":
return true
if p1._required && not p2._required:
return true
if not p1._required && p2._required:
return false
return p1._name < p2._name

View file

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

View file

@ -0,0 +1,37 @@
extends EditorInspectorPlugin
const EventsubConfigProperty = preload("res://addons/twitcher/editor/inspector/twitch_eventsub_config_property.gd")
func _can_handle(object: Object) -> bool:
return object is TwitchEventsubConfig
func _parse_property(object: Object, type: Variant.Type, name: String, \
hint_type: PropertyHint, hint_string: String, usage_flags: int, \
wide: bool) -> bool:
if name == &"condition":
add_property_editor("condition", EventsubConfigProperty.new(), true)
return true
if name == &"type":
add_property_editor("type", ToDocs.new(), true, "Documentation")
return false
class ToDocs extends EditorProperty:
const EXT_LINK = preload("res://addons/twitcher/assets/ext-link.svg")
var docs = Button.new()
func _init() -> void:
docs.text = "To dev.twitch.tv"
docs.icon = EXT_LINK
docs.pressed.connect(_on_to_docs)
add_child(docs)
add_focusable(docs)
func _on_to_docs() -> void:
var eventsub_config: TwitchEventsubConfig = get_edited_object()
OS.shell_open(eventsub_config.definition.documentation_link)

View file

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

View file

@ -0,0 +1,75 @@
extends EditorProperty
const USER_CONVERTER = preload("res://addons/twitcher/editor/inspector/user_converter.tscn")
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
var _container: Node = GridContainer.new()
func _init():
_container.columns = 2
add_child(_container)
set_bottom_editor(_container)
func _on_type_change(new_type: TwitchEventsubDefinition.Type) -> void:
var eventsub_config: TwitchEventsubConfig = get_edited_object();
if eventsub_config != null:
for meta in eventsub_config.get_meta_list():
if meta.ends_with("_user"):
eventsub_config.remove_meta(meta)
_create_conditions()
func _update_property() -> void:
_create_conditions()
func _create_conditions() -> void:
for node in _container.get_children():
node.queue_free()
var eventsub_config: TwitchEventsubConfig = get_edited_object();
if eventsub_config == null || eventsub_config.get_class() == &"EditorDebuggerRemoteObject": return
for condition_name: StringName in eventsub_config.definition.conditions:
var condition_value = eventsub_config.condition.get_or_add(condition_name, "")
var condition_title = Label.new()
condition_title.text = condition_name.capitalize()
_container.add_child(condition_title)
var editor_token = TwitchEditorSettings.editor_oauth_token
if condition_name.to_lower().ends_with("user_id") && editor_token.is_token_valid():
var user_converter = USER_CONVERTER.instantiate()
user_converter.changed.connect(_on_changed_user.bind(condition_name))
_container.add_child(user_converter)
if eventsub_config.has_meta(condition_name + "_user"):
var user = eventsub_config.get_meta(condition_name + "_user")
user_converter.update_user(user)
elif condition_value != "":
user_converter.user_id = condition_value
user_converter.reload()
else:
var input = LineEdit.new()
input.text_submitted.connect(_on_change_text.bind(condition_name, input))
input.focus_exited.connect(_on_change_text.bind("", condition_name, input))
input.text = condition_value
input.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_container.add_child(input)
func _on_changed_user(user: TwitchUser, condition_name: StringName) -> void:
var eventsub_config: TwitchEventsubConfig = get_edited_object();
if user == null:
eventsub_config.condition[condition_name] = ""
eventsub_config.remove_meta(condition_name + "_user")
emit_changed(&"condition", eventsub_config.condition)
else:
eventsub_config.condition[condition_name] = user.id
eventsub_config.set_meta(condition_name + "_user", user)
emit_changed(&"condition", eventsub_config.condition)
func _on_change_text(new_text: String, condition_name: StringName, input: LineEdit) -> void:
print("BLUB")
var eventsub_config: TwitchEventsubConfig = get_edited_object();
eventsub_config.condition[condition_name] = input.text
#emit_changed(&"condition", eventsub_config.condition)

View file

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

View file

@ -0,0 +1,83 @@
extends EditorInspectorPlugin
func _can_handle(object: Object) -> bool:
return object is TwitchEventsub || object is TwitchService
func _parse_property(object: Object, type: Variant.Type, name: String, hint_type: PropertyHint, hint_string: String, usage_flags: int, wide: bool) -> bool:
if name == &"scopes" && object.get_class() != &"EditorDebuggerRemoteObject":
if (object is TwitchService && object.eventsub != null) || object is TwitchEventsub:
add_property_editor(&"scope_validation", ScopeValidation.new(), true, "Scope Validation")
return false
class ScopeValidation extends EditorProperty:
const WARNING_LABEL_SETTINGS = preload("res://addons/twitcher/assets/warning_label_settings.tres")
const INFO_LABEL_SETTINGS = preload("res://addons/twitcher/assets/info_label_settings.tres")
var _warning_label: Label = Label.new();
var _apply_scopes: Button = Button.new();
var _needed_scopes: Dictionary = {}
var container: Control = VBoxContainer.new()
func _init():
_warning_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_warning_label.text = "Press validate to check if scopes maybe are missing."
var validate_button = Button.new();
validate_button.text = "Validate";
validate_button.tooltip_text = "Checks the scopes of the subscriptions " \
+ " if they match the defined scopes in the scope " \
+ " property";
validate_button.pressed.connect(_on_validate_scopes);
_apply_scopes.text = "Apply Scopes";
_apply_scopes.tooltip_text = "Apply Scopes to the scope resource in " \
+ " this TwitchEventsub. It maybe not needed depending on the " \
+ " Subscription. Please check the documentation if there is a logical " \
+ " condition and apply the scopes accordingly.";
_apply_scopes.pressed.connect(_on_apply_scopes);
add_child(validate_button)
container.add_child(_warning_label)
add_child(container)
set_bottom_editor(container)
func _on_apply_scopes() -> void:
var scopes = get_edited_object().scopes;
var scopes_to_add: Array[StringName] = [];
for scope in _needed_scopes.values():
scopes_to_add.append(scope);
scopes.add_scopes(scopes_to_add);
_clear_warning();
func _on_validate_scopes() -> void:
var scopes = get_edited_object().scopes;
var subscriptions = get_edited_object().get_subscriptions();
_needed_scopes.clear()
for subscription: TwitchEventsubConfig in subscriptions:
if subscription == null: continue
for scope in subscription.definition.scopes:
_needed_scopes[scope] = scope
for scope in scopes.used_scopes:
_needed_scopes.erase(scope)
if !_needed_scopes.is_empty():
if _apply_scopes.get_parent() == null: container.add_child(_apply_scopes)
_warning_label.label_settings = WARNING_LABEL_SETTINGS
var needed_scopes = ", ".join(_needed_scopes.values())
_warning_label.text = "You may miss scopes please check documentation if you need to add: %s" % needed_scopes;
else:
_clear_warning()
func _clear_warning() -> void:
_warning_label.text = "Scopes seems to be OK for this EventSub."
_warning_label.label_settings = INFO_LABEL_SETTINGS
if _apply_scopes.get_parent() != null:
container.remove_child(_apply_scopes)

View file

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

View file

@ -0,0 +1,46 @@
@tool
extends EditorInspectorPlugin
func _can_handle(object: Object) -> bool:
return object is TwitchMediaLoader
func _parse_property(object: Object, type: Variant.Type, name: String, hint_type: PropertyHint, hint_string: String, usage_flags: int, wide: bool) -> bool:
if name == &"cache_emote":
var clear_emote_button = ClearCacheEditor.new(object.cache_emote)
add_property_editor("cache_cheermote", clear_emote_button, true, "Clear Emote Cache")
if name == &"cache_badge":
var clear_badge_button = ClearCacheEditor.new(object.cache_badge)
add_property_editor("cache_cheermote", clear_badge_button, true, "Clear Badge Cache")
if name == &"cache_cheermote":
var clear_cheermote_button = ClearCacheEditor.new(object.cache_cheermote)
add_property_editor("cache_cheermote", clear_cheermote_button, true, "Clear Cheermotes Cache")
return false
class ClearCacheEditor extends EditorProperty:
var _button: Button
func _init(path: String) -> void:
_button = Button.new()
_button.text = "Clear"
_button.pressed.connect(_clear.bind(path))
add_child(_button)
func _clear(path: String) -> void:
var dir: DirAccess = DirAccess.open(path)
for file: String in dir.get_files():
if file.ends_with(".res"):
var err: Error = dir.remove(file)
if err != OK:
push_error("Can't delete %s/%s cause of %s" % [dir, file, error_string(err)])
var tween = create_tween()
var _button_color = _button.modulate
tween.tween_property(_button, "modulate", Color.GREEN, .25)
tween.tween_property(_button, "modulate", _button_color, .25)
tween.play()

View file

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

View file

@ -0,0 +1,91 @@
extends RefCounted
## Helper class for easier editing of Project Settings
class_name TwitchProperty
var key: String;
var default_value: Variant;
func _init(k: String, default_val: Variant = "") -> void:
key = k;
default_value = default_val;
_add_property()
func _add_property():
if not ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, default_value);
ProjectSettings.set_initial_value(key, default_value);
func get_val() -> Variant:
return ProjectSettings.get_setting_with_override(key);
func set_val(val) -> void:
ProjectSettings.set(key, val);
func basic() -> TwitchProperty:
ProjectSettings.set_as_basic(key, true);
return self;
func as_str(description: String = "") -> TwitchProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_PLACEHOLDER_TEXT, description);
func as_select(values: Array[String], optional: bool = true) -> TwitchProperty:
var hint_string = ",".join(values);
var enum_hint = PROPERTY_HINT_ENUM;
if optional: enum_hint = PROPERTY_HINT_ENUM_SUGGESTION;
return _add_type_def(TYPE_STRING, enum_hint, hint_string);
func as_bit_field(values: Array[String]) -> TwitchProperty:
var hint_string = ",".join(values);
return _add_type_def(TYPE_INT, PROPERTY_HINT_FLAGS, hint_string);
func as_password(description: String = "") -> TwitchProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_PASSWORD, description);
func as_bool(description: String = "") -> TwitchProperty:
return _add_type_def(TYPE_BOOL, PROPERTY_HINT_PLACEHOLDER_TEXT, description)
func as_num() -> TwitchProperty:
return _add_type_def(TYPE_INT, PROPERTY_HINT_NONE, "")
func as_global() -> TwitchProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_GLOBAL_FILE, "");
func as_image() -> TwitchProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_FILE, "*.png,*.jpg,*.tres")
func as_dir() -> TwitchProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_DIR, "");
## Type should be the generic type of the array
func as_list(type: Variant = "") -> TwitchProperty:
return _add_type_def(TYPE_ARRAY, PROPERTY_HINT_ARRAY_TYPE, type);
## The hint string can be a set of filters with wildcards like "*.png,*.jpg"
func as_global_save(file_types: String = "") -> TwitchProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_GLOBAL_SAVE_FILE, file_types)
func _add_type_def(type: int, hint: int, hint_string: Variant) -> TwitchProperty:
ProjectSettings.add_property_info({
"name": key,
"type": type,
"hint": hint,
"hint_string": hint_string
})
return self

View file

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

View file

@ -0,0 +1,13 @@
extends EditorInspectorPlugin
const TwitchScopeProperty = preload("res://addons/twitcher/editor/inspector/twitch_scope_property.gd")
func _can_handle(object: Object) -> bool:
return object is TwitchOAuthScopes
func _parse_property(object: Object, type: Variant.Type, name: String, hint_type: PropertyHint, hint_string: String, usage_flags: int, wide: bool) -> bool:
if name == "used_scopes":
add_property_editor("used_scopes", TwitchScopeProperty.new(), true);
return false;
return false

View file

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

View file

@ -0,0 +1,51 @@
extends EditorProperty
const TITLE_SETTING := preload("res://addons/twitcher/assets/title_label_settings.tres")
var _scope_checkboxes: Dictionary[StringName, CheckBox]
var grid: GridContainer = GridContainer.new();
signal scope_selected(scope: TwitchScope.Definition)
func _init() -> void:
grid.columns = 1;
var grouped_scopes = TwitchScope.get_grouped_scopes();
for category: String in grouped_scopes:
var title_category = Label.new();
title_category.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER;
title_category.text = category.capitalize();
title_category.label_settings = TITLE_SETTING;
grid.add_child(title_category);
grid.add_child(Control.new());
for scope: TwitchScope.Definition in grouped_scopes[category]:
var checkbox: CheckBox = CheckBox.new();
checkbox.text = scope.value;
checkbox.toggled.connect(_on_checkbox_pressed.bind(scope))
checkbox.tooltip_text = scope.description
_scope_checkboxes[scope.value] = checkbox
grid.add_child(checkbox);
add_focusable(checkbox);
add_child(grid);
func _on_scope_changed() -> void:
update_property()
func _update_property() -> void:
for scope: StringName in _scope_checkboxes.keys():
var checkbox: CheckBox = _scope_checkboxes[scope];
var scopes: OAuthScopes = get_edited_object()
checkbox.button_pressed = scopes.used_scopes.find(scope) != -1;
func _on_checkbox_pressed(toggled_on: bool, scope: TwitchScope.Definition) -> void:
var scopes: OAuthScopes = get_edited_object()
if toggled_on:
if scopes.used_scopes.find(scope.value) == -1:
scopes.used_scopes.append(scope.value)
else:
scopes.used_scopes.erase(scope.value)
emit_changed("used_scopes", scopes.used_scopes, &"", true)
scope_selected.emit(scope)

View file

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

View file

@ -0,0 +1,14 @@
@tool
extends "res://addons/twitcher/lib/oOuch/oauth_token_info.gd"
const TWITCH_TOKEN_REVOKE_POPUP = preload("res://addons/twitcher/editor/inspector/twitch_token_revoke_popup.tscn")
const TwitchTokenRevokePopup = preload("res://addons/twitcher/editor/inspector/twitch_token_revoke_popup.gd")
func _on_revoke_pressed() -> void:
var popup: TwitchTokenRevokePopup = TWITCH_TOKEN_REVOKE_POPUP.instantiate()
popup.token = token
add_child(popup)
popup.popup_centered()
var success = await popup.revoked
if success: _reset_token()

View file

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

View file

@ -0,0 +1,7 @@
[gd_scene load_steps=3 format=3 uid="uid://c7qvkjw425jaf"]
[ext_resource type="PackedScene" uid="uid://6d2jst8ga4le" path="res://addons/twitcher/lib/oOuch/oauth_token_info.tscn" id="1_0mxfe"]
[ext_resource type="Script" uid="uid://c8no6da8ae0xt" path="res://addons/twitcher/editor/inspector/twitch_token_info.gd" id="2_kejyg"]
[node name="TokenInfo" instance=ExtResource("1_0mxfe")]
script = ExtResource("2_kejyg")

View file

@ -0,0 +1,50 @@
@tool
extends Window
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
@export var token: OAuthToken
@onready var inspector: HBoxContainer = %Inspector
@onready var cancel: Button = %Cancel
@onready var revoke_locally: Button = %RevokeLocally
@onready var revoke_twitch: Button = %RevokeTwitch
@onready var twitch_token_handler: TwitchTokenHandler = %TwitchTokenHandler
signal revoked(success: bool)
var picker: EditorResourcePicker
func _ready() -> void:
picker = EditorResourcePicker.new()
picker.base_type = "OAuthSetting"
picker.edited_resource = TwitchEditorSettings.game_oauth_setting
picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
inspector.add_child(picker)
cancel.pressed.connect(_on_cancel)
revoke_locally.pressed.connect(_on_revoke_locally)
revoke_twitch.pressed.connect(_on_revoke_twitch)
twitch_token_handler.token = token
close_requested.connect(_on_cancel)
func _on_cancel() -> void:
revoked.emit(false)
queue_free()
func _on_revoke_locally() -> void:
revoked.emit(true)
token.remove_tokens()
queue_free()
func _on_revoke_twitch() -> void:
revoked.emit(true)
if is_instance_valid(picker.edited_resource):
twitch_token_handler.oauth_setting = picker.edited_resource
await twitch_token_handler.revoke_token()
queue_free()

View file

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

View file

@ -0,0 +1,74 @@
[gd_scene load_steps=6 format=3 uid="uid://b4n67bt8ni6ge"]
[ext_resource type="Script" uid="uid://bp1fga8addrlc" path="res://addons/twitcher/editor/inspector/twitch_token_revoke_popup.gd" id="1_4n8su"]
[ext_resource type="Script" uid="uid://blnbogtrshw4r" path="res://addons/twitcher/auth/twitch_token_handler.gd" id="2_iycl8"]
[ext_resource type="Resource" uid="uid://c4scwuk8q0r40" path="res://addons/twitcher/lib/oOuch/default_key_provider.tres" id="3_4n8su"]
[ext_resource type="Script" uid="uid://b52xp7c23ucfk" path="res://addons/twitcher/lib/oOuch/oauth_token.gd" id="4_iycl8"]
[sub_resource type="Resource" id="Resource_twsgi"]
script = ExtResource("4_iycl8")
_crypto_key_provider = ExtResource("3_4n8su")
_identifier = "Auth-2409"
_cache_path = "user://auth.conf"
[node name="TokenRevokePopup" type="Window"]
title = "Revoke Token"
position = Vector2i(0, 36)
size = Vector2i(400, 200)
script = ExtResource("1_4n8su")
[node name="MarginContainer" type="MarginContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 10
[node name="VBoxContainer" type="VBoxContainer" parent="MarginContainer"]
layout_mode = 2
[node name="Label" type="Label" parent="MarginContainer/VBoxContainer"]
custom_minimum_size = Vector2(0, 50)
layout_mode = 2
text = "You can decide to revoke the token locally or actually invalidate it on Twitch side too. To revoke it on Twitch the client id must be known: "
autowrap_mode = 3
[node name="Inspector" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
[node name="Label" type="Label" parent="MarginContainer/VBoxContainer/Inspector"]
layout_mode = 2
text = "OAuth Setting:"
[node name="HBoxContainer" type="HBoxContainer" parent="MarginContainer/VBoxContainer"]
layout_mode = 2
size_flags_vertical = 10
[node name="Cancel" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "Cancel"
[node name="RevokeTwitch" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "Revoke on Twitch"
[node name="RevokeLocally" type="Button" parent="MarginContainer/VBoxContainer/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "Revoke Locally"
[node name="TwitchTokenHandler" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("2_iycl8")
token = SubResource("Resource_twsgi")
metadata/_custom_type_script = "uid://blnbogtrshw4r"

View file

@ -0,0 +1,44 @@
@tool
extends EditorInspectorPlugin
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
const UserProperty = preload("res://addons/twitcher/editor/inspector/user_property.gd")
const TEST_CREDENTIALS = preload("res://addons/twitcher/editor/setup/test_credentials.tscn")
const TestCredentials = preload("res://addons/twitcher/editor/setup/test_credentials.gd")
func _can_handle(object: Object) -> bool:
return true
func _parse_property(object: Object, type: Variant.Type, name: String, hint_type: PropertyHint, hint_string: String, usage_flags: int, wide: bool) -> bool:
if hint_string == "TwitchUser":
if TwitchEditorSettings.is_valid():
add_property_editor(name, UserProperty.new())
return true
else:
var info_label: Label = Label.new()
info_label.text = "Authorize editor to have a custom inspector for the '%s'." % name.capitalize()
info_label.label_settings = LabelSettings.new()
info_label.label_settings.font_size = 13
info_label.label_settings.font_color = Color.AQUA
info_label.autowrap_mode = TextServer.AUTOWRAP_WORD
info_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var authorize_editor: TestCredentials = TEST_CREDENTIALS.instantiate()
authorize_editor.text = "Authorize Editor"
authorize_editor.authorized.connect(_on_authorized.bind(object), CONNECT_DEFERRED)
var hbox: HBoxContainer = HBoxContainer.new()
hbox.add_child(info_label)
hbox.add_child(authorize_editor)
hbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL
add_custom_control(hbox)
return false
func _on_authorized(object: Object) -> void:
EditorInterface.get_inspector().edit(null)
EditorInterface.get_inspector().edit(object)

View file

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

View file

@ -0,0 +1,134 @@
@tool
extends HBoxContainer
class_name UserConverter
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
const TwitchTweens = preload("res://addons/twitcher/editor/twitch_tweens.gd")
@onready var _login: LineEdit = %Login
@onready var _id: LineEdit = %Id
@onready var _swap_view: Button = %SwapView
@onready var search: Button = %Search
@export var user: TwitchUser
@export var token: OAuthToken
@export var setting: OAuthSetting
static var _current_user: TwitchUser
var user_login: String:
set(val):
user_login = val
_login.text = val
_login.caret_column = val.length()
get(): return _login.text
var user_id: String:
set(val):
user_id = val
_id.text = val
_id.caret_column = val.length()
get(): return _id.text
signal changed(user: TwitchUser)
func _ready() -> void:
if token == null: token = TwitchEditorSettings.editor_oauth_token
if setting == null: setting = TwitchEditorSettings.editor_oauth_setting
_login.text_changed.connect(_on_login_changed)
_login.text_submitted.connect(_on_text_submitted)
_id.text_changed.connect(_on_id_changed)
_id.text_submitted.connect(_on_text_submitted)
_swap_view.pressed.connect(_on_swap_view)
_load_current_user()
search.pressed.connect(_on_changed)
## Experimental tries to load user from api key
func _load_current_user() -> void:
if _current_user == null:
var users: TwitchGetUsers.Opt = TwitchGetUsers.Opt.new()
_current_user = await _get_user(users)
if _current_user != null:
user_login = _current_user.login
user_id = _current_user.id
changed.emit(_current_user)
func _on_swap_view() -> void:
if _login.visible:
_login.visible = false
_id.visible = true
_swap_view.text = "ID"
else:
_login.visible = true
_id.visible = false
_swap_view.text = "Name"
func _on_id_changed(new_text: String) -> void:
_login.text = ""
TwitchTweens.loading(self, Color.AQUA)
func _on_login_changed(new_text: String) -> void:
_id.text = ""
TwitchTweens.loading(self, Color.AQUA)
func reload() -> void:
TwitchTweens.loading(self)
var new_user_login: String = _login.text
var new_user_id: String = _id.text
if new_user_id == "" && new_user_login == "":
changed.emit(null)
return
var users: TwitchGetUsers.Opt = TwitchGetUsers.Opt.new()
if new_user_login != "" && (user == null || user.login != new_user_login):
users.login = [ new_user_login ]
elif new_user_id != "" && (user == null || user.id != new_user_id):
users.id = [ new_user_id ]
if users.id != null || users.login != null:
user = await _get_user(users)
if user == null:
await TwitchTweens.flash(self, Color.RED)
else:
await TwitchTweens.flash(self, Color.GREEN)
user_login = user.login
user_id = user.id
changed.emit(user)
func update_user(user: TwitchUser) -> void:
user_login = user.login
user_id = user.id
func _on_text_submitted(new_text: String) -> void:
reload()
func _on_changed() -> void:
reload()
func _get_user(get_user_opt: TwitchGetUsers.Opt) -> TwitchUser:
var api: TwitchAPI = TwitchAPI.new()
api.token = token
api.oauth_setting = setting
add_child(api)
var response: TwitchGetUsers.Response = await api.get_users(get_user_opt)
var data: Array[TwitchUser] = response.data
if data.is_empty():
printerr("User %s%s was not found." % [ get_user_opt.login, get_user_opt.id ])
return null
remove_child(api)
return data[0]

View file

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

View file

@ -0,0 +1,33 @@
[gd_scene load_steps=3 format=3 uid="uid://cus81w3pidhjo"]
[ext_resource type="Script" uid="uid://b6qdiwr7rawx1" path="res://addons/twitcher/editor/inspector/user_converter.gd" id="1_ior8m"]
[ext_resource type="Texture2D" uid="uid://1e6nrtqsuc6" path="res://addons/twitcher/assets/icon_search.tres" id="2_t7vdb"]
[node name="UserConverter" type="HBoxContainer"]
offset_right = 40.0
offset_bottom = 40.0
size_flags_horizontal = 3
script = ExtResource("1_ior8m")
[node name="Login" type="LineEdit" parent="."]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "User Login"
[node name="Id" type="LineEdit" parent="."]
unique_name_in_owner = true
visible = false
layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "User ID"
[node name="SwapView" type="Button" parent="."]
unique_name_in_owner = true
layout_mode = 2
text = "Name"
[node name="Search" type="Button" parent="."]
unique_name_in_owner = true
layout_mode = 2
icon = ExtResource("2_t7vdb")

View file

@ -0,0 +1,29 @@
@tool
extends EditorProperty
const USER_CONVERTER = preload("res://addons/twitcher/editor/inspector/user_converter.tscn")
const UserConverter = preload("res://addons/twitcher/editor/inspector/user_converter.gd")
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
var _converter: UserConverter
func _init():
_converter = USER_CONVERTER.instantiate()
_converter.changed.connect(_on_changed)
add_child(_converter)
func _update_property() -> void:
var user: TwitchUser = get_edited_object()[get_edited_property()]
if user == null:
_converter.user_id = ""
_converter.user_login = ""
else:
_converter.user_id = user.id
_converter.user_login = user.login
func _on_changed(user: TwitchUser) -> void:
emit_changed(get_edited_property(), user, &"", true)
_update_property()

View file

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

View file

@ -0,0 +1,98 @@
@tool
extends RefCounted
class_name ProjectSettingProperty
var key: String
var default_value: Variant
func _init(k: String, default_val: Variant = "") -> void:
key = k
default_value = default_val
_add_property()
func _add_property():
if not ProjectSettings.has_setting(key):
ProjectSettings.set_setting(key, default_value)
ProjectSettings.set_initial_value(key, default_value)
func get_val() -> Variant:
return ProjectSettings.get_setting_with_override(key)
func set_val(val) -> void:
ProjectSettings.set(key, val)
func basic() -> ProjectSettingProperty:
ProjectSettings.set_as_basic(key, true)
return self
func as_str(description: String = "") -> ProjectSettingProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_PLACEHOLDER_TEXT, description)
func as_select(values: Array[String], optional: bool = true) -> ProjectSettingProperty:
var hint_string = ",".join(values)
var enum_hint = PROPERTY_HINT_ENUM
if optional: enum_hint = PROPERTY_HINT_ENUM_SUGGESTION
return _add_type_def(TYPE_STRING, enum_hint, hint_string)
# Won't work in godot 4.4 the resource is not loaded when you select the project and it will just drop it out of the list.
#func as_resoruce(resource_name: StringName) -> ProjectSettingProperty:
# return _add_type_def(TYPE_OBJECT, PROPERTY_HINT_RESOURCE_TYPE, resource_name)
func as_bit_field(values: Array[String]) -> ProjectSettingProperty:
var hint_string = ",".join(values)
return _add_type_def(TYPE_INT, PROPERTY_HINT_FLAGS, hint_string)
func as_password(description: String = "") -> ProjectSettingProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_PASSWORD, description)
func as_bool(description: String = "") -> ProjectSettingProperty:
return _add_type_def(TYPE_BOOL, PROPERTY_HINT_PLACEHOLDER_TEXT, description)
func as_num() -> ProjectSettingProperty:
return _add_type_def(TYPE_INT, PROPERTY_HINT_NONE, "")
func as_global() -> ProjectSettingProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_GLOBAL_FILE, "")
## file_type is comma separated values "*.png,*.jpg,*.tres"
func as_file(file_type: String) -> ProjectSettingProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_FILE, file_type)
func as_dir() -> ProjectSettingProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_DIR, "")
## Type should be the generic type of the array
func as_list(type: Variant = "") -> ProjectSettingProperty:
return _add_type_def(TYPE_ARRAY, PROPERTY_HINT_ARRAY_TYPE, type)
## The hint string can be a set of filters with wildcards like "*.png,*.jpg"
func as_global_save(file_types: String = "") -> ProjectSettingProperty:
return _add_type_def(TYPE_STRING, PROPERTY_HINT_GLOBAL_SAVE_FILE, file_types)
func _add_type_def(type: int, hint: int, hint_string: Variant) -> ProjectSettingProperty:
ProjectSettings.add_property_info({
"name": key,
"type": type,
"hint": hint,
"hint_string": hint_string
})
return self

View file

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

View file

@ -0,0 +1,55 @@
@tool
extends Control
class_name FileSelect
@export var default_path: String
@export var path: String: set = update_filepath
@export var filters: PackedStringArray: set = update_filters
@onready var line_edit: LineEdit = %LineEdit
@onready var button: Button = %Button
@onready var file_dialog: FileDialog = %FileDialog
signal file_selected(path: String)
func _ready() -> void:
var icon = EditorInterface.get_editor_theme().get_icon(&"FileBrowse", &"EditorIcons")
button.icon = icon
button.pressed.connect(_on_open_file_dialog)
file_dialog.file_selected.connect(_on_file_selected)
line_edit.text_changed.connect(_on_path_changed)
update_filepath(path)
update_filters(filters)
func update_filepath(new_path: String) -> void:
if new_path == null || new_path == "":
new_path = default_path
path = new_path
if is_inside_tree():
line_edit.text = new_path
file_dialog.current_path = new_path
func update_filters(new_filters: PackedStringArray) -> void:
filters = new_filters
if is_inside_tree():
file_dialog.filters = new_filters
func _on_open_file_dialog() -> void:
file_dialog.show()
func _on_path_changed(new_path: String) -> void:
file_dialog.current_path = new_path
path = new_path
file_selected.emit(new_path)
func _on_file_selected(new_path: String) -> void:
line_edit.text = new_path
path = new_path
file_selected.emit(new_path)

View file

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

View file

@ -0,0 +1,40 @@
[gd_scene load_steps=4 format=3 uid="uid://b7smp156mdns6"]
[ext_resource type="Script" uid="uid://cgfc1nq4f4nae" path="res://addons/twitcher/editor/setup/file_select.gd" id="1_lnnjv"]
[sub_resource type="Image" id="Image_lnnjv"]
data = {
"data": PackedByteArray(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 225, 225, 225, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 224, 224, 224, 255, 225, 225, 225, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 127, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 238, 225, 225, 225, 150, 225, 225, 225, 150, 224, 224, 224, 221, 224, 224, 224, 148, 224, 224, 224, 168, 224, 224, 224, 228, 224, 224, 224, 156, 224, 224, 224, 160, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 238, 230, 230, 230, 30, 255, 255, 255, 1, 255, 255, 255, 1, 0, 0, 0, 0, 255, 255, 255, 1, 255, 255, 255, 1, 0, 0, 0, 0, 255, 255, 255, 1, 255, 255, 255, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 225, 225, 225, 150, 0, 0, 0, 0, 224, 224, 224, 194, 224, 224, 224, 193, 0, 0, 0, 0, 224, 224, 224, 194, 224, 224, 224, 193, 0, 0, 0, 0, 224, 224, 224, 194, 224, 224, 224, 193, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 224, 224, 224, 255, 224, 224, 224, 255, 224, 224, 224, 153, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 189, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 189, 0, 0, 0, 0, 225, 225, 225, 191, 224, 224, 224, 189, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
"format": "RGBA8",
"height": 16,
"mipmaps": false,
"width": 16
}
[sub_resource type="ImageTexture" id="ImageTexture_7udr7"]
image = SubResource("Image_lnnjv")
[node name="FileSelect" type="HBoxContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
size_flags_horizontal = 3
script = ExtResource("1_lnnjv")
[node name="LineEdit" type="LineEdit" parent="."]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
tooltip_text = "Location where the OAuth Settings get saved to."
[node name="Button" type="Button" parent="."]
unique_name_in_owner = true
layout_mode = 2
icon = SubResource("ImageTexture_7udr7")
[node name="FileDialog" type="FileDialog" parent="."]
unique_name_in_owner = true
auto_translate_mode = 1
initial_position = 1

View file

@ -0,0 +1,24 @@
extends Node
@export var show_elements: Array[Control] = []
func _ready() -> void:
for element: Control in show_elements:
element.hide()
for child in get_children():
if child.has_signal(&"focus_entered"):
child.connect(&"focus_entered", _on_focus_entered)
if child.has_signal(&"focus_exited"):
child.connect(&"focus_exited", _on_focus_exited)
func _on_focus_entered() -> void:
for element: Node in show_elements:
element.show()
func _on_focus_exited() -> void:
for element: Node in show_elements:
element.hide()

View file

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

View file

@ -0,0 +1,101 @@
@tool
extends Node
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
const TwitchTweens = preload("res://addons/twitcher/editor/twitch_tweens.gd")
const TWITCH_SERVICE = preload("res://addons/twitcher/twitch_service.tscn")
@onready var authorization_explaination: RichTextLabel = %AuthExplain
@onready var client_id: LineEdit = %ClientId
@onready var client_secret: LineEdit = %ClientSecret
@onready var redirect_url: LineEdit = %RedirectURL
@onready var oauth_setting_file_select: FileSelect = %OauthSettingFileSelect
@onready var token_file_select: FileSelect = %TokenFileSelect
@onready var to_documentation: Button = %ToDocumentation
@onready var o_auth_save: Button = %OAuthSave
@onready var test_response: Label = %TestResponse
var has_changes: bool:
set(val):
has_changes = val
changed.emit.call_deferred()
o_auth_save.text = o_auth_save.text.trim_suffix(" (unsaved changes)")
if has_changes: o_auth_save.text += " (unsaved changes)"
signal changed
func _ready() -> void:
authorization_explaination.meta_clicked.connect(_on_link_clicked)
redirect_url.text_changed.connect(_on_text_changed)
client_id.text_changed.connect(_on_text_changed)
client_secret.text_changed.connect(_on_text_changed)
to_documentation.pressed.connect(_on_to_documentation_pressed)
oauth_setting_file_select.file_selected.connect(_on_file_changed)
token_file_select.file_selected.connect(_on_file_changed)
o_auth_save.pressed.connect(_on_save)
_load_oauth_setting()
func _load_oauth_setting() -> void:
var setting: OAuthSetting = TwitchEditorSettings.editor_oauth_setting
client_id.text = setting.client_id
client_secret.text = setting.get_client_secret()
redirect_url.text = setting.redirect_url
func _on_link_clicked(link: Variant) -> void:
OS.shell_open(link)
func _on_text_changed(val: String) -> void:
reset_response_message()
var setting: OAuthSetting = TwitchEditorSettings.editor_oauth_setting
setting.client_id = client_id.text
setting.set_client_secret(client_secret.text)
setting.redirect_url = redirect_url.text
has_changes = true
func reset_response_message() -> void:
test_response.text = ""
func _on_file_changed() -> void:
has_changes = true
func is_auth_existing() -> bool:
return is_instance_valid(TwitchEditorSettings.editor_oauth_setting)
func _on_save() -> void:
TwitchEditorSettings.save_editor_oauth_setting()
TwitchEditorSettings.save_editor_oauth_token()
var setting_path = oauth_setting_file_select.path
var setting = TwitchEditorSettings.editor_oauth_setting.duplicate(true)
setting.take_over_path(setting_path)
ResourceSaver.save(setting, setting_path)
TwitchEditorSettings.game_oauth_setting = setting
var token_path = token_file_select.path
var token = TwitchEditorSettings.editor_oauth_token.duplicate()
token.take_over_path(token_path)
ResourceSaver.save(token, token_path)
TwitchEditorSettings.game_oauth_token = token
TwitchTweens.flash(o_auth_save, Color.GREEN)
ProjectSettings.save()
has_changes = false
func _on_to_documentation_pressed() -> void:
OS.shell_open("https://dev.twitch.tv/docs/authentication/")

View file

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

View file

@ -0,0 +1,182 @@
[gd_scene load_steps=6 format=3 uid="uid://dm6jvnuikxtei"]
[ext_resource type="Script" uid="uid://dxql15j5ornlc" path="res://addons/twitcher/editor/setup/page_authorization.gd" id="1_78hk7"]
[ext_resource type="LabelSettings" uid="uid://bnsxy6gcm8q11" path="res://addons/twitcher/assets/title_label_settings.tres" id="2_owlil"]
[ext_resource type="PackedScene" uid="uid://b7smp156mdns6" path="res://addons/twitcher/editor/setup/file_select.tscn" id="3_dbhpx"]
[ext_resource type="Script" uid="uid://ddugotjvuahex" path="res://addons/twitcher/editor/setup/focus_child_show.gd" id="3_o4tdm"]
[ext_resource type="PackedScene" uid="uid://bfksyo3klyvdn" path="res://addons/twitcher/editor/setup/test_credentials.tscn" id="5_dbhpx"]
[node name="Authorization" type="MarginContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 10
script = ExtResource("1_78hk7")
metadata/_tab_index = 1
[node name="Layout" type="VBoxContainer" parent="."]
layout_mode = 2
size_flags_vertical = 3
[node name="Title" type="Label" parent="Layout"]
layout_mode = 2
text = "Step 2: Authorization"
label_settings = ExtResource("2_owlil")
horizontal_alignment = 1
[node name="ToDocumentation" type="Button" parent="Layout/Title"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 6
anchor_left = 1.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_left = -52.0
offset_top = -15.5
offset_bottom = 15.5
grow_horizontal = 0
grow_vertical = 2
text = "DOCS"
metadata/_edit_use_anchors_ = true
[node name="PanelContainer" type="PanelContainer" parent="Layout"]
layout_mode = 2
[node name="MarginContainer" type="MarginContainer" parent="Layout/PanelContainer"]
layout_mode = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 10
[node name="VBoxContainer" type="VBoxContainer" parent="Layout/PanelContainer/MarginContainer"]
layout_mode = 2
[node name="AuthorizationTitle" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
text = "General"
label_settings = ExtResource("2_owlil")
horizontal_alignment = 1
[node name="AuthExplain" type="RichTextLabel" parent="Layout/PanelContainer/MarginContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/normal_font_size = 12
bbcode_enabled = true
text = "The credentials that are used by the editor and game to connect to Twitch. You can request your credentials [url=https://dev.twitch.tv/console/apps/create]Twitch Dev Console[/url] for more informations see [url=https://twitcher.kani.dev/#authorization]Documentation[/url]"
fit_content = true
vertical_alignment = 1
[node name="AuthorizationOptions" type="GridContainer" parent="Layout/PanelContainer/MarginContainer/VBoxContainer" node_paths=PackedStringArray("show_elements")]
layout_mode = 2
size_flags_vertical = 3
size_flags_stretch_ratio = 3.0
columns = 2
script = ExtResource("3_o4tdm")
show_elements = [NodePath("../AuthExplain")]
[node name="ClientIdLabel" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/AuthorizationOptions"]
layout_mode = 2
text = "Client ID:"
[node name="ClientId" type="LineEdit" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/AuthorizationOptions"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "1ae0idgxbvn6vi97ls7d89cyd919oq"
[node name="ClientSecretLabel" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/AuthorizationOptions"]
layout_mode = 2
text = "Client Secret:"
[node name="ClientSecret" type="LineEdit" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/AuthorizationOptions"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "k22x037lmmrmkmwvy79xr19qfy993g"
secret = true
[node name="RedirectURLLabel" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/AuthorizationOptions"]
layout_mode = 2
text = "Redirect URL:"
[node name="RedirectURL" type="LineEdit" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/AuthorizationOptions"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
tooltip_text = "Location that Twitch is calling after the login process. Take care that this one is the same that you used during creation of the application within the twitch dev console."
text = "http://localhost:7170"
[node name="HSeparator2" type="HSeparator" parent="Layout/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="GameSettingTitle" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
text = "Game"
label_settings = ExtResource("2_owlil")
horizontal_alignment = 1
[node name="GameExplain" type="RichTextLabel" parent="Layout/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_font_sizes/normal_font_size = 12
bbcode_enabled = true
text = "These settings are needed for the game to connect with Twitch."
fit_content = true
vertical_alignment = 1
[node name="GameSetting" type="GridContainer" parent="Layout/PanelContainer/MarginContainer/VBoxContainer" node_paths=PackedStringArray("show_elements")]
layout_mode = 2
columns = 2
script = ExtResource("3_o4tdm")
show_elements = [NodePath("../GameExplain")]
[node name="OauthSettingLabel" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/GameSetting"]
layout_mode = 2
text = "Auth File Path:"
[node name="OauthSettingFileSelect" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/GameSetting" instance=ExtResource("3_dbhpx")]
unique_name_in_owner = true
layout_mode = 2
default_path = "res://addons/twitcher/twitch_oauth_setting.tres"
path = "res://addons/twitcher/twitch_oauth_setting.tres"
filters = PackedStringArray("*.tres", "*.res")
[node name="TokenLabel" type="Label" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/GameSetting"]
layout_mode = 2
text = "Token File Path:"
[node name="TokenFileSelect" parent="Layout/PanelContainer/MarginContainer/VBoxContainer/GameSetting" instance=ExtResource("3_dbhpx")]
unique_name_in_owner = true
layout_mode = 2
default_path = "res://addons/twitcher/default_oauth_token.tres"
path = "res://addons/twitcher/default_oauth_token.tres"
filters = PackedStringArray("*.tres", "*.res")
[node name="HSeparator" type="HSeparator" parent="Layout/PanelContainer/MarginContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="HBoxContainer" type="HBoxContainer" parent="Layout"]
layout_mode = 2
[node name="TestCredentials" parent="Layout/HBoxContainer" node_paths=PackedStringArray("test_response") instance=ExtResource("5_dbhpx")]
layout_mode = 2
size_flags_horizontal = 3
test_response = NodePath("../../TestResponse")
[node name="OAuthSave" type="Button" parent="Layout/HBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
text = "Save"
[node name="TestResponse" type="Label" parent="Layout"]
unique_name_in_owner = true
layout_mode = 2

View file

@ -0,0 +1,139 @@
@tool
extends MarginContainer
enum UseCase {
Overlay, Game, Other
}
const PRESET_GAME_SCOPES = preload("res://addons/twitcher/auth/preset_game_scopes.tres")
const PRESET_OVERLAY_SCOPES = preload("res://addons/twitcher/auth/preset_overlay_scopes.tres")
const TwitchScopeProperty = preload("res://addons/twitcher/editor/inspector/twitch_scope_property.gd")
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
const TwitchTweens = preload("res://addons/twitcher/editor/twitch_tweens.gd")
@export var choose_button_group: ButtonGroup
@onready var overlay: CheckBox = %Overlay
@onready var game: CheckBox = %Game
@onready var something_else: CheckBox = %SomethingElse
@onready var scope_list: RichTextLabel = %ScopeList
@onready var to_documentation: Button = %ToDocumentation
@onready var scope_file_select: FileSelect = %ScopeFileSelect
@onready var scopes_container: HBoxContainer = %Scopes
@onready var advanced_edit: CheckButton = %AdvancedEdit
@onready var other_scope_options: PanelContainer = %OtherScopeOptions
@onready var extended_scope_info: PanelContainer = %ExtendedScopeInfo
@onready var save: Button = %Save
var other_scope_property: TwitchScopeProperty = TwitchScopeProperty.new()
var scopes: TwitchOAuthScopes: set = update_scopes
var has_changes: bool:
set(val):
has_changes = val
changed.emit()
save.text = save.text.trim_suffix(" (unsaved changes)")
if has_changes: save.text += " (unsaved changes)"
signal changed
signal use_case_changed(use_case: UseCase)
func _ready() -> void:
to_documentation.pressed.connect(_on_to_documentation_pressed)
choose_button_group.pressed.connect(_on_choose)
scope_file_select.file_selected.connect(_on_scope_file_selected)
save.pressed.connect(_on_save_pressed)
other_scope_property.scope_selected.connect(_on_scope_info)
advanced_edit.toggled.connect(_on_toggle_advanced_edit)
scopes_container.hide()
extended_scope_info.hide()
extended_scope_info.add_child(other_scope_property)
# Reset radio buttons cause it's a tool script and the radio button stay and won't throw another signal otherwise
game.set_pressed_no_signal(false)
overlay.set_pressed_no_signal(false)
something_else.set_pressed_no_signal(false)
scope_file_select.path = TwitchEditorSettings.get_scope_path()
match TwitchEditorSettings.project_preset:
TwitchEditorSettings.PRESET_GAME:
game.button_pressed = true
TwitchEditorSettings.PRESET_OVERLAY:
overlay.button_pressed = true
TwitchEditorSettings.PRESET_OTHER:
something_else.button_pressed = true
# Needs to be resetted cause the radio reset will change the has_changges to true
has_changes = false
func _on_scope_file_selected(path: String) -> void:
has_changes = true
if FileAccess.file_exists(path):
var resource = load(path)
if resource is OAuthScopes: scopes = resource
else: OS.alert("The selected scope is not a scope file, it will be overwritten!")
func _on_toggle_advanced_edit(toggled_on: bool) -> void:
extended_scope_info.visible = toggled_on
func _on_choose(button: BaseButton) -> void:
match button:
overlay:
use_case_changed.emit(UseCase.Overlay)
scopes = PRESET_OVERLAY_SCOPES.duplicate(true)
advanced_edit.button_pressed = false
TwitchEditorSettings.project_preset = TwitchEditorSettings.PRESET_OVERLAY
game:
use_case_changed.emit(UseCase.Game)
scopes = PRESET_GAME_SCOPES.duplicate(true)
advanced_edit.button_pressed = false
TwitchEditorSettings.project_preset = TwitchEditorSettings.PRESET_GAME
something_else:
use_case_changed.emit(UseCase.Other)
scopes = TwitchOAuthScopes.new()
advanced_edit.button_pressed = true
TwitchEditorSettings.project_preset = TwitchEditorSettings.PRESET_OTHER
_show_selected_scopes()
has_changes = true
func _on_scope_info(scope: TwitchScope.Definition) -> void:
_show_selected_scopes()
func _on_save_pressed() -> void:
var s_path = scope_file_select.path
scopes.take_over_path(s_path)
ResourceSaver.save(scopes, s_path)
TwitchEditorSettings.set_scope_path(s_path)
TwitchTweens.flash(save, Color.GREEN)
has_changes = false
func _show_selected_scopes() -> void:
scopes_container.show()
if scopes.used_scopes.is_empty():
scope_list.text = "[i]No scopes selected yet[/i]"
return
var scope_description: String = ""
for scope_name: StringName in scopes.used_scopes:
var scope: TwitchScope.Definition = TwitchScope.SCOPE_MAP[scope_name]
scope_description += "[b]%s[/b] - %s\n\n" % [scope.value, scope.description]
scope_list.text = scope_description
func _on_to_documentation_pressed() -> void:
OS.shell_open("https://dev.twitch.tv/docs/authentication/scopes/")
func update_scopes(val: TwitchOAuthScopes) -> void:
scopes = val
other_scope_property.set_object_and_property(scopes, "")
other_scope_property.update_property()
_show_selected_scopes()

View file

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

View file

@ -0,0 +1,162 @@
[gd_scene load_steps=5 format=3 uid="uid://c7pja1druikbn"]
[ext_resource type="Script" uid="uid://cjni881olloyf" path="res://addons/twitcher/editor/setup/page_use_case.gd" id="1_2qemh"]
[ext_resource type="LabelSettings" uid="uid://bnsxy6gcm8q11" path="res://addons/twitcher/assets/title_label_settings.tres" id="1_r6qea"]
[ext_resource type="ButtonGroup" uid="uid://bkocyfdqvh4t" path="res://addons/twitcher/editor/setup/use_case_button_group.tres" id="1_vqr26"]
[ext_resource type="PackedScene" uid="uid://b7smp156mdns6" path="res://addons/twitcher/editor/setup/file_select.tscn" id="4_c6y6e"]
[node name="UseCase" type="MarginContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 10
script = ExtResource("1_2qemh")
choose_button_group = ExtResource("1_vqr26")
metadata/_tab_index = 0
[node name="ScrollContainer" type="ScrollContainer" parent="."]
layout_mode = 2
[node name="SelectionContainer" type="VBoxContainer" parent="ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="Title" type="Label" parent="ScrollContainer/SelectionContainer"]
layout_mode = 2
text = "Step 1: Use Case"
label_settings = ExtResource("1_r6qea")
horizontal_alignment = 1
[node name="ToDocumentation" type="Button" parent="ScrollContainer/SelectionContainer/Title"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 6
anchor_left = 1.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_left = -52.0
offset_top = -15.5
offset_bottom = 15.5
grow_horizontal = 0
grow_vertical = 2
text = "DOCS"
metadata/_edit_use_anchors_ = true
[node name="Explaination" type="RichTextLabel" parent="ScrollContainer/SelectionContainer"]
layout_mode = 2
theme_override_font_sizes/normal_font_size = 12
bbcode_enabled = true
text = "To help you with scopes and authentication please select your use case."
fit_content = true
vertical_alignment = 1
[node name="ChooseLabel" type="Label" parent="ScrollContainer/SelectionContainer"]
layout_mode = 2
text = "What do you want to make:"
[node name="Overlay" type="CheckBox" parent="ScrollContainer/SelectionContainer"]
unique_name_in_owner = true
layout_mode = 2
button_pressed = true
button_group = ExtResource("1_vqr26")
text = "Overlay"
[node name="Game" type="CheckBox" parent="ScrollContainer/SelectionContainer"]
unique_name_in_owner = true
layout_mode = 2
button_group = ExtResource("1_vqr26")
text = "Game"
[node name="SomethingElse" type="CheckBox" parent="ScrollContainer/SelectionContainer"]
unique_name_in_owner = true
layout_mode = 2
button_group = ExtResource("1_vqr26")
text = "I know what I do / Something else"
[node name="HSeparator" type="HSeparator" parent="ScrollContainer/SelectionContainer"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="Scopes" type="HBoxContainer" parent="ScrollContainer/SelectionContainer"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
[node name="ExtendedScopeInfo" type="PanelContainer" parent="ScrollContainer/SelectionContainer/Scopes"]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="OtherScopeOptions" type="PanelContainer" parent="ScrollContainer/SelectionContainer/Scopes"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
[node name="VBoxContainer" type="VBoxContainer" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions"]
layout_mode = 2
[node name="ScopeListLabel" type="Label" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer"]
layout_mode = 2
text = "Scopes:"
label_settings = ExtResource("1_r6qea")
[node name="AdvancedEdit" type="CheckButton" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer/ScopeListLabel"]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 6
anchor_left = 1.0
anchor_top = 0.5
anchor_right = 1.0
anchor_bottom = 0.5
offset_left = -168.0
offset_top = -15.5
offset_bottom = 15.5
grow_horizontal = 0
grow_vertical = 2
text = "Edit (Advanced)"
[node name="ScopeList" type="RichTextLabel" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
bbcode_enabled = true
text = "[b]user:read:chat[/b] - Receive chatroom messages and informational notifications relating to a channels chatroom.
[b]user:write:chat[/b] - Send chat messages to a chatroom.
[b]moderator:read:followers[/b] - Read the followers of a broadcaster.
[b]bits:read[/b] - View Bits information for a channel.
[b]channel:read:redemptions[/b] - View Channel Points custom rewards and their redemptions on a channel.
[b]channel:manage:redemptions[/b] - Manage Channel Points custom rewards and their redemptions on a channel.
"
fit_content = true
[node name="HSeparator" type="HSeparator" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 10
[node name="ScopeFileLabel" type="Label" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer"]
layout_mode = 2
text = "Save selected scopes:"
[node name="ScopeFileSelect" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer" instance=ExtResource("4_c6y6e")]
unique_name_in_owner = true
layout_mode = 2
default_path = "res://twitch_scopes.tres"
path = "res://twitch_scopes.tres"
filters = PackedStringArray("*.tres", "*.res")
[node name="Save" type="Button" parent="ScrollContainer/SelectionContainer/Scopes/OtherScopeOptions/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Save Scopes"

View file

@ -0,0 +1,74 @@
@tool
extends MarginContainer
const script_path: String = "res://addons/twitcher/twitch_service.tscn"
const autoload_name: String = "Twitch"
const setting_key: String = "autoload/%s" % autoload_name
const setting_value: String = "*" + script_path
@onready var autoload_install: Button = %AutoloadInstall
@onready var autoload_info: Label = %AutoloadInfo
@onready var autoload_description: RichTextLabel = %AutoloadDescription
func _ready() -> void:
autoload_description.text = autoload_description.text.format({
"autoload_name": autoload_name
})
autoload_install.pressed.connect(_on_install_autoload_pressed)
_update_install_autoload()
func _update_install_autoload() -> void:
if ProjectSettings.has_setting(setting_key):
autoload_install.text = "Uninstall Autoload"
else:
autoload_install.text = "Install Autoload"
func _on_install_autoload_pressed() -> void:
if ProjectSettings.has_setting(setting_key):
_uninstall_autoload()
else:
_install_autload()
_update_install_autoload()
func _uninstall_autoload() -> void:
ProjectSettings.clear(setting_key)
var err = ProjectSettings.save()
if err == OK:
autoload_info.text = "Autoload '%s' uninstalled successfully!\nYou might need to reload the current project for changes to fully apply everywhere in the editor immediately." % autoload_name
print("Successfully removed autoload: %s" % autoload_name)
else:
autoload_info.text = "Failed to save project settings.\nError code: %s" % error_string(err)
printerr("Failed to save project settings! Error: ", error_string(err))
func _install_autload() -> void:
if not FileAccess.file_exists(script_path):
OS.alert("The TwitchService file does not exist at:\n" + script_path, "Error")
return
var setting_key: String = "autoload/%s" % autoload_name
var setting_value: String = "*" + script_path
if ProjectSettings.has_setting(setting_key):
var existing_value = ProjectSettings.get_setting(setting_key)
if existing_value == setting_value:
autoload_info.text = "Autoload '%s' with the same path is already installed." % autoload_name
return
else:
autoload_info.text = "Autoload '%s' already exists but points to a different path (%s)." % [autoload_name, existing_value]
return
ProjectSettings.set_setting(setting_key, setting_value)
var err = ProjectSettings.save()
if err == OK:
autoload_info.text = "Autoload '%s' installed successfully!\nYou might need to reload the current project for changes to fully apply everywhere in the editor immediately." % autoload_name
print("Successfully added autoload: %s -> %s" % [autoload_name, script_path])
else:
autoload_info.text = "Failed to save project settings.\nError code: %s" % error_string(err)
printerr("Failed to save project settings! Error: ", error_string(err))

View file

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

View file

@ -0,0 +1,96 @@
[gd_scene load_steps=4 format=3 uid="uid://d4l63q706mkhw"]
[ext_resource type="Script" uid="uid://dw37fk6mah3jk" path="res://addons/twitcher/editor/setup/page_utilities.gd" id="1_sexj5"]
[ext_resource type="LabelSettings" uid="uid://bnsxy6gcm8q11" path="res://addons/twitcher/assets/title_label_settings.tres" id="1_yi5sa"]
[ext_resource type="LabelSettings" uid="uid://cng881nsuud80" path="res://addons/twitcher/assets/warning_label_settings.tres" id="3_fj7h2"]
[node name="Utilities" type="MarginContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/margin_left = 10
theme_override_constants/margin_top = 10
theme_override_constants/margin_right = 10
theme_override_constants/margin_bottom = 10
script = ExtResource("1_sexj5")
metadata/_tab_index = 2
[node name="ScrollContainer" type="ScrollContainer" parent="."]
layout_mode = 2
[node name="Container" type="VBoxContainer" parent="ScrollContainer"]
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
[node name="Header" type="Label" parent="ScrollContainer/Container"]
layout_mode = 2
text = "Utilities"
label_settings = ExtResource("1_yi5sa")
horizontal_alignment = 1
[node name="HSeparator" type="HSeparator" parent="ScrollContainer/Container"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="AutoloadDescription" type="RichTextLabel" parent="ScrollContainer/Container"]
unique_name_in_owner = true
layout_mode = 2
bbcode_enabled = true
text = "For basic use cases an autoload is the easiest way to setup Twitcher. Adds an autoload named [b]{autoload_name}[/b]
[i]Advantage:[/i]
+ easy access everywhere
[i]Disadvantage:[/i]
- It will be always initialized, even when you want to test a small scene standalone Tiwtcher will do authorization, subscribing to eventsub etc.
[b]Alternative:[/b]
The first nodes in the scene tree of every major nodes like TwitchAPI, TwitchEventsub, TwitchChat, TwitchMediaLoader and TwitchService are available as Singleton via their instance variable.
Example
[code]TwitchAPI.instance.get_users(...)[/code]"
fit_content = true
[node name="AutoloadInstall" type="Button" parent="ScrollContainer/Container"]
unique_name_in_owner = true
layout_mode = 2
text = "Uninstall Autoload"
[node name="AutoloadInfo" type="Label" parent="ScrollContainer/Container"]
unique_name_in_owner = true
custom_minimum_size = Vector2(0, 100)
layout_mode = 2
label_settings = ExtResource("3_fj7h2")
autowrap_mode = 3
[node name="HSeparator2" type="HSeparator" parent="ScrollContainer/Container"]
layout_mode = 2
theme_override_constants/separation = 20
[node name="AutoloadDescription2" type="RichTextLabel" parent="ScrollContainer/Container"]
visible = false
layout_mode = 2
bbcode_enabled = true
text = "For a game with Twitch integration you probably want to read the chat from the streamer to act on the commands. For that the streamer need to authorize your application. For the authorization flow use the [url=https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#device-code-grant-flow]Device Code Flow[/url] so that you don't share the client secret in the code.
[b]Hints:[/b]
- Take care that you take the least amount of scopes for the game otherwise you could scare the streamer away.
[i]Advantage:[/i]
+ easy access everywhere
[i]Disadvantage:[/i]
- It will be always initialized, even when you want to test a small scene standalone Tiwtcher will do authorization, subscribing to eventsub etc.
[b]Alternative:[/b]
The first nodes in the scene tree of every major nodes like TwitchAPI, TwitchEventsub, TwitchChat, TwitchMediaLoader and TwitchService are available as Singleton via their instance variable.
Example
[code]TwitchAPI.instance.get_users(...)[/code]"
fit_content = true
[node name="Button" type="Button" parent="ScrollContainer/Container"]
visible = false
layout_mode = 2
text = "Add Game Example"

View file

@ -0,0 +1,58 @@
@tool
extends Window
const PageUseCase = preload("res://addons/twitcher/editor/setup/page_use_case.gd")
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
const PageAuthorization = preload("res://addons/twitcher/editor/setup/page_authorization.gd")
#Setup
#- Check for Authorization Stuff
#-- Client Credentials
#-- Editor Token
#-- Scopes
#- Auth Button
#- Create Base Node Structure
@onready var authorization: PageAuthorization = %Authorization
@onready var use_case: PageUseCase = %UseCase as PageUseCase
@onready var close: Button = %Close
@onready var startup_check: CheckButton = %StartupCheck
func _ready():
close_requested.connect(_on_close)
close.pressed.connect(_on_close)
startup_check.toggled.connect(_on_toggle_startup_check)
startup_check.button_pressed = TwitchEditorSettings.show_setup_on_startup
use_case.changed.connect(_on_changed)
authorization.changed.connect(_on_changed)
pass
func _on_changed() -> void:
close.text = close.text.trim_suffix(" (unsaved changes)")
if use_case.has_changes || authorization.has_changes:
close.text = close.text + " (unsaved changes)"
func _on_toggle_startup_check(toggle_on: bool) -> void:
TwitchEditorSettings.show_setup_on_startup = toggle_on
ProjectSettings.save()
func _input(event: InputEvent) -> void:
if event is InputEventKey:
var key_event: InputEventKey = event as InputEventKey
if key_event.keycode == KEY_ESCAPE:
_on_close()
func _on_close() -> void:
if use_case.has_changes || authorization.has_changes:
var popup = ConfirmationDialog.new()
popup.dialog_text = "You have unsaved changes! Are you sure to close the setup?"
popup.confirmed.connect(queue_free)
add_child(popup)
popup.popup_centered()
else:
queue_free()

View file

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

View file

@ -0,0 +1,51 @@
[gd_scene load_steps=6 format=3 uid="uid://wu1fprbhr62"]
[ext_resource type="Script" uid="uid://bbguje3a0cl8t" path="res://addons/twitcher/editor/setup/setup.gd" id="1_o5snq"]
[ext_resource type="PackedScene" uid="uid://c7pja1druikbn" path="res://addons/twitcher/editor/setup/page_use_case.tscn" id="2_6678v"]
[ext_resource type="PackedScene" uid="uid://dm6jvnuikxtei" path="res://addons/twitcher/editor/setup/page_authorization.tscn" id="3_qcivh"]
[ext_resource type="PackedScene" uid="uid://d4l63q706mkhw" path="res://addons/twitcher/editor/setup/page_utilities.tscn" id="4_qcivh"]
[sub_resource type="ButtonGroup" id="ButtonGroup_6678v"]
[node name="SetupWindow" type="Window"]
title = "Setup Twitcher"
initial_position = 2
size = Vector2i(800, 800)
script = ExtResource("1_o5snq")
[node name="VBoxContainer" type="VBoxContainer" parent="."]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
[node name="Setup" type="TabContainer" parent="VBoxContainer"]
layout_mode = 2
size_flags_vertical = 3
current_tab = 0
[node name="UseCase" parent="VBoxContainer/Setup" instance=ExtResource("2_6678v")]
unique_name_in_owner = true
layout_mode = 2
choose_button_group = SubResource("ButtonGroup_6678v")
[node name="Authorization" parent="VBoxContainer/Setup" instance=ExtResource("3_qcivh")]
unique_name_in_owner = true
visible = false
layout_mode = 2
[node name="Utilities" parent="VBoxContainer/Setup" instance=ExtResource("4_qcivh")]
visible = false
layout_mode = 2
[node name="StartupCheck" type="CheckButton" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
button_pressed = true
text = "Show on startup (you can open it via 'Project/Tools/Twitcher Setup')"
[node name="Close" type="Button" parent="VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2
text = "Close"

View file

@ -0,0 +1,58 @@
@tool
extends Button
const TwitchTweens = preload("res://addons/twitcher/editor/twitch_tweens.gd")
const TwitchEditorSettings = preload("res://addons/twitcher/editor/twitch_editor_settings.gd")
@export var oauth_setting: OAuthSetting: set = update_oauth_setting
@export var oauth_token: OAuthToken: set = update_oauth_token
@export var test_response: Label
@onready var twitch_auth: TwitchAuth = %TwitchAuth
signal authorized
func _ready() -> void:
pressed.connect(_pressed)
oauth_setting = TwitchEditorSettings.editor_oauth_setting
oauth_token = TwitchEditorSettings.editor_oauth_token
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE:
oauth_token.authorized.disconnect(_on_authorized)
func _pressed() -> void:
TwitchTweens.loading(self)
await twitch_auth.authorize()
if twitch_auth.token.is_token_valid():
if test_response:
test_response.text = "Credentials are valid!"
test_response.add_theme_color_override(&"font_color", Color.GREEN)
TwitchTweens.flash(self, Color.GREEN)
authorized.emit()
else:
if test_response:
test_response.text = "Credentials are invalid!"
test_response.add_theme_color_override(&"font_color", Color.RED)
TwitchTweens.flash(self, Color.RED)
func update_oauth_token(new_oauth_token: OAuthToken) -> void:
oauth_token = new_oauth_token
oauth_token.authorized.connect(_on_authorized)
if is_inside_tree():
twitch_auth.token = new_oauth_token
func update_oauth_setting(new_oauth_setting: OAuthSetting) -> void:
oauth_setting = new_oauth_setting
disabled = not oauth_setting.is_valid()
if is_inside_tree():
twitch_auth.oauth_setting = oauth_setting
func _on_authorized() -> void:
if is_inside_tree(): authorized.emit()

View file

@ -0,0 +1 @@
uid://13afcys4swos

View file

@ -0,0 +1,37 @@
[gd_scene load_steps=9 format=3 uid="uid://bfksyo3klyvdn"]
[ext_resource type="Script" uid="uid://13afcys4swos" path="res://addons/twitcher/editor/setup/test_credentials.gd" id="1_j2v3o"]
[ext_resource type="Script" uid="uid://iv0mgv0lu8b0" path="res://addons/twitcher/auth/twitch_auth.gd" id="1_kojf4"]
[ext_resource type="Script" uid="uid://b3n3et8mebjcc" path="res://addons/twitcher/auth/twitch_oauth_scopes.gd" id="3_2rqpn"]
[ext_resource type="Resource" path="user://editor_oauth_setting.tres" id="3_kojf4"]
[ext_resource type="Resource" path="user://editor_oauth_token.tres" id="4_j2v3o"]
[ext_resource type="Script" uid="uid://bf0wi70haua35" path="res://addons/twitcher/lib/oOuch/oauth.gd" id="6_hkawa"]
[ext_resource type="Script" uid="uid://blnbogtrshw4r" path="res://addons/twitcher/auth/twitch_token_handler.gd" id="7_v5ghs"]
[sub_resource type="Resource" id="Resource_1cpcx"]
script = ExtResource("3_2rqpn")
used_scopes = Array[StringName]([])
metadata/_custom_type_script = "uid://b3n3et8mebjcc"
[node name="TestCredentials" type="Button"]
text = "Test Credentials"
script = ExtResource("1_j2v3o")
[node name="TwitchAuth" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("1_kojf4")
oauth_setting = ExtResource("3_kojf4")
token = ExtResource("4_j2v3o")
scopes = SubResource("Resource_1cpcx")
metadata/_custom_type_script = "uid://iv0mgv0lu8b0"
[node name="OAuth" type="Node" parent="TwitchAuth" node_paths=PackedStringArray("token_handler")]
script = ExtResource("6_hkawa")
oauth_setting = ExtResource("3_kojf4")
scopes = SubResource("Resource_1cpcx")
token_handler = NodePath("../TokenHandler")
[node name="TokenHandler" type="Node" parent="TwitchAuth"]
script = ExtResource("7_v5ghs")
oauth_setting = ExtResource("3_kojf4")
token = ExtResource("4_j2v3o")

View file

@ -0,0 +1,3 @@
[gd_resource type="ButtonGroup" format=3 uid="uid://bkocyfdqvh4t"]
[resource]

View file

@ -0,0 +1,145 @@
@tool
extends RefCounted
## Utilitiy Node for Inspector to calling API functionallity at any point
const EDITOR_OAUTH_TOKEN: String = "user://editor_oauth_token.tres"
const EDITOR_OAUTH_SETTING: String = "user://editor_oauth_setting.tres"
const GAME_OAUTH_TOKEN: String = "res://twitch_oauth_token.tres"
const GAME_OAUTH_SETTING: String = "res://twitch_oauth_setting.tres"
const TWITCH_DEFAULT_SCOPE: String = "res://addons/twitcher/auth/preset_overlay_scopes.tres"
const PRESET_GAME: StringName = &"Game"
const PRESET_OVERLAY: StringName = &"Overlay"
const PRESET_OTHER: StringName = &"Other"
static var _editor_oauth_token_property: ProjectSettingProperty
static var editor_oauth_token: OAuthToken:
set(val):
editor_oauth_token = val
_editor_oauth_token_property.set_val(val.resource_path)
static var _editor_oauth_setting_property: ProjectSettingProperty
static var editor_oauth_setting: OAuthSetting:
set(val):
editor_oauth_setting = val
_editor_oauth_setting_property.set_val(val.resource_path)
static var _game_oauth_token_property: ProjectSettingProperty
static var game_oauth_token: OAuthToken:
set(val):
game_oauth_token = val
_game_oauth_token_property.set_val(val.resource_path)
static var _game_oauth_setting_property: ProjectSettingProperty
static var game_oauth_setting: OAuthSetting:
set(val):
game_oauth_setting = val
_game_oauth_setting_property.set_val(val.resource_path)
static var _scope_property: ProjectSettingProperty
static var scopes: TwitchOAuthScopes:
set(val):
scopes = val
_scope_property.set_val(val.resource_path)
static var _show_setup_on_startup: ProjectSettingProperty
static var show_setup_on_startup: bool:
set(val): _show_setup_on_startup.set_val(val)
get: return _show_setup_on_startup.get_val()
static var _project_preset: ProjectSettingProperty
static var project_preset: StringName:
set(val): _project_preset.set_val(val)
get: return _project_preset.get_val()
static var _initialized: bool
static func setup() -> void:
if not _initialized:
_initialized = true
_setup_project_settings()
_reload_setting()
static func _setup_project_settings() -> void:
_editor_oauth_token_property = ProjectSettingProperty.new("twitcher/editor/editor_oauth_token", EDITOR_OAUTH_TOKEN)
_editor_oauth_token_property.as_file("*.res,*.tres")
_editor_oauth_setting_property = ProjectSettingProperty.new("twitcher/editor/editor_oauth_setting", EDITOR_OAUTH_SETTING)
_editor_oauth_setting_property.as_file("*.res,*.tres")
_game_oauth_token_property = ProjectSettingProperty.new("twitcher/editor/game_oauth_token", GAME_OAUTH_TOKEN)
_game_oauth_token_property.as_file("*.res,*.tres")
_game_oauth_setting_property = ProjectSettingProperty.new("twitcher/editor/game_oauth_setting", GAME_OAUTH_SETTING)
_game_oauth_setting_property.as_file("*.res,*.tres")
_scope_property = ProjectSettingProperty.new("twitcher/editor/default_scopes", TWITCH_DEFAULT_SCOPE)
_scope_property.as_file("*.res,*.tres")
_show_setup_on_startup = ProjectSettingProperty.new("twitcher/editor/show_setup_on_startup", true)
_project_preset = ProjectSettingProperty.new("twitcher/editor/project_preset")
_project_preset.as_select([PRESET_GAME, PRESET_OVERLAY, PRESET_OTHER], false)
static func _reload_setting() -> void:
editor_oauth_setting = load(_editor_oauth_setting_property.get_val())
editor_oauth_token = load(_editor_oauth_token_property.get_val())
game_oauth_setting = load(_game_oauth_setting_property.get_val())
game_oauth_token = load(_game_oauth_token_property.get_val())
var editor_oauth_token_path: String = _editor_oauth_token_property.get_val()
if editor_oauth_token_path:
if not FileAccess.file_exists(editor_oauth_token_path):
_create_editor_oauth_token()
var editor_oauth_setting_path: String = _editor_oauth_setting_property.get_val()
if editor_oauth_setting_path:
if not FileAccess.file_exists(editor_oauth_setting_path):
_create_editor_oauth_setting()
var scope_path: String = get_scope_path()
if scope_path and FileAccess.file_exists(scope_path):
scopes = load(scope_path)
static func save_editor_oauth_setting() -> void:
ResourceSaver.save(editor_oauth_setting)
static func save_editor_oauth_token() -> void:
ResourceSaver.save(editor_oauth_token)
static func get_scope_path() -> String:
return _scope_property.get_val()
static func set_scope_path(path: String) -> void:
_scope_property.set_val(path)
static func is_valid() -> bool:
var token_valid = is_instance_valid(editor_oauth_token) && editor_oauth_token.is_token_valid()
var setting_valid = is_instance_valid(editor_oauth_setting) && editor_oauth_setting.is_valid()
return token_valid && setting_valid
static func _create_editor_oauth_token() -> void:
var path: String = _editor_oauth_token_property.get_val()
var token = OAuthToken.new()
token._identifier = "EditorToken"
token.take_over_path(path)
editor_oauth_token = token
save_editor_oauth_token()
static func _create_editor_oauth_setting() -> void:
var path: String = _editor_oauth_setting_property.get_val()
var setting = TwitchAuth.create_default_oauth_setting()
setting.take_over_path(path)
editor_oauth_setting = setting
save_editor_oauth_setting()

View file

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

View file

@ -0,0 +1,17 @@
static func flash(object: Control, color: Color, duration: float = .25) -> void:
var tween = object.create_tween()
tween.tween_property(object, ^"modulate", color, duration)\
.set_ease(Tween.EASE_OUT)\
.set_trans(Tween.TRANS_CIRC)
tween.tween_property(object, ^"modulate", Color.WHITE, duration)\
.set_ease(Tween.EASE_OUT)\
.set_trans(Tween.TRANS_CIRC)
await tween.finished
static func loading(object: Control, color: Color = Color.YELLOW) -> void:
var tween: Tween = object.create_tween()
tween.tween_property(object, ^"modulate", color, 0.2) \
.set_trans(Tween.TRANS_LINEAR) \
.set_ease(Tween.EASE_IN_OUT)
await tween.finished

View file

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