clockwork-city/addons/tube/tube_tracker.gd
duncgibbs 99de9e8b40
Some checks failed
linting & formatting / build (push) Failing after 5s
itch.io publish action / build (linux64, x86_64) (push) Failing after 34s
itch.io publish action / build (osx, app) (push) Failing after 30s
itch.io publish action / build (win64, exe) (push) Failing after 31s
initial commit
2026-04-13 11:34:00 -05:00

362 lines
7.9 KiB
GDScript

class_name TubeTracker extends RefCounted
const MAX_INTERVAL := 120.0 #sec
signal failed
signal connected
signal disconnected
signal received_answer(data: Dictionary)
signal interval_timeout
signal warning_raised(message: String)
signal state_changed
signal data_sent(data: Dictionary)
signal received_data(data: Dictionary)
const CLOSE_CODE_CLIENT: int = 3001
const CLOSE_CODE_FAILED: int = 3002
# https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/close, custom code 3000-4999
# https://www.rfc-editor.org/rfc/rfc6455.html#section-7.4.1
var error_message: String
var socket := WebSocketPeer.new()
var state := socket.get_ready_state()
var connecting_time: float = 0.0 #sec
var up_time: float = 0.0 #sec
var interval_time: float = 0.0 #sec
var interval_time_left: float = -1.0
func _to_string() -> String:
var string := socket.get_requested_url()
if "Web" != OS.get_name():
if WebSocketPeer.STATE_OPEN == socket.get_ready_state():
string += "({protocol}://{address}:{port})".format({
"protocol": socket.get_selected_protocol(),
"address": socket.get_connected_host(),
"port": socket.get_connected_port(),
})
return string
func raise_warning(message: String):
push_warning(message)
warning_raised.emit(message)
func connect_to_url(p_url: String) -> Error:
var error := socket.connect_to_url(p_url)
if error:
error_message = "connection failed: {error}".format({
"error": error_string(error)
})
failed.emit()
return error
func is_open() -> bool:
return WebSocketPeer.STATE_OPEN == socket.get_ready_state()
func is_close() -> bool:
return WebSocketPeer.STATE_CLOSED == socket.get_ready_state()
func close(p_info_hash: String, p_peer_id_hash: String):
if is_open():
send_stop(
p_info_hash,
p_peer_id_hash
)
if not is_close():
socket.close(
CLOSE_CODE_CLIENT,
"Close by client",
)
func _socket_connection_opened():
connected.emit()
func _socket_connection_closed(p_code: int, p_reason: String):
#if -1 == p_code: # error
if WebSocketPeer.State.STATE_CONNECTING == state:
error_message = "connection impossible"
p_reason = p_reason if p_reason else "Closed unexpectedly, code: {code}".format({
"code": p_code,
})
if WebSocketPeer.State.STATE_OPEN == state:
error_message = "connection failed: {reason}".format({
"reason": p_reason,
})
disconnected.emit()
## Encodes tracker packet data as JSON string.
func encode_data(data: Dictionary) -> String:
var json := JSON.stringify(data)
return json
## Decodes tracker packet data from a [PackedByteArray].
func decode_packet(p_packet: PackedByteArray) -> Variant:
var string := p_packet.get_string_from_utf8()
var data = JSON.parse_string(string)
return data
func send_data(p_data: Dictionary) -> Error:
var text := encode_data(p_data)
var error := socket.send_text(
text
)
if error:
raise_warning(
"Cannot send text: {error}".format({
"error": error_string(error)
}))
else:
data_sent.emit(p_data)
return error
func send_announce(p_info_hash: String, p_peer_id_hash: String) -> Error:
return send_data({
"action": "announce",
"info_hash": p_info_hash,
"peer_id": p_peer_id_hash,
"uploaded": 0,
"downloaded": 0,
})
func send_answer(
p_info_hash: String,
p_peer_id_hash: String,
p_to_peer_id_hash: String,
description: Dictionary,
ice_candidates: Array
) -> Error:
return send_data({
"action": "announce",
"info_hash": p_info_hash,
"peer_id": p_peer_id_hash,
"to_peer_id": p_to_peer_id_hash,
"answer": {
"type": description.type,
"sdp": description.sdp,
"ice_candidates": ice_candidates,
},
"offer_id": "0",
})
func send_stop(p_info_hash: String, p_peer_id_hash: String) -> Error:
return send_data({
"action": "announce",
"info_hash": p_info_hash,
"peer_id": p_peer_id_hash,
"event": "stopped"
})
func _received_packet(p_packet: PackedByteArray):
var data = decode_packet(p_packet)
if not data is Dictionary:
raise_warning("Received invalid packet: {packet}".format({
"packet": str(p_packet)
}))
return
received_data.emit(data)
if data.has("answer"):
_handle_answer(data)
return
_handle_announce(data)
func _handle_announce(p_data: Dictionary):
if not p_data.has("interval"):
raise_warning("announce data has no interval")
return
if not p_data.interval is float:
raise_warning("interval invalid data type")
return
interval_time = min(p_data.interval, MAX_INTERVAL)
interval_time_left = interval_time
func _handle_answer(p_data: Dictionary):
if not p_data is Dictionary:
raise_warning("answer data invalid data type")
return
if not p_data.has("peer_id"):
raise_warning("answer data has no peer_id")
return
if not p_data.peer_id is String:
raise_warning("peer_id invalid data type")
return
if not p_data.has("answer"):
raise_warning("answer data has no answer")
return
if not p_data.answer is Dictionary:
raise_warning("answer invalid data type")
return
var answer: Dictionary = p_data.answer
if not answer.has("sdp"):
raise_warning("answer data has no sdp")
if not answer.sdp is String:
raise_warning("sdp invalid data type")
return
if not answer.has("type"):
raise_warning("answer data has no type")
return
if not answer.type is String:
raise_warning("type invalid data type")
return
if not answer.has("ice_candidates"):
raise_warning("answer data has no ice_candidates")
return
if not answer.ice_candidates is Array:
raise_warning("ice_candidates invalid data type")
return
received_answer.emit(p_data)
static func get_peer_id_hash_from_answer_data(p_data: Dictionary) -> String:
return p_data.peer_id
static func get_type_from_answer_data(p_data: Dictionary) -> String:
return p_data.answer.type
static func get_sdp_from_answer_data(p_data: Dictionary) -> String:
return p_data.answer.sdp
static func get_ice_candidates_from_answer_data(p_data: Dictionary) -> Array:
return p_data.answer.ice_candidates
static func is_ice_candidate_data_valid(p_data: Variant) -> bool:
if not p_data is Dictionary:
push_error("Ice candidate data invalid data type")
return false
if not p_data.has("media"):
push_error("Ice candidate data has no media")
return false
if not p_data.media is String:
push_error("media invalid data type")
return false
if not p_data.has("index"):
push_error("Ice candidate has no index")
return false
if not (typeof(p_data.index) in [TYPE_INT, TYPE_FLOAT]):
push_error("index invalid data type")
return false
if not p_data.has("sdp"):
push_error("Ice candidate has no sdp")
return false
if not p_data.sdp is String:
push_error("Ice candidate sdp invalid data type")
return false
return true
static func get_media_from_ice_candidate_data(p_data: Dictionary) -> String:
return p_data.media
static func get_index_from_ice_candidate_data(p_data: Dictionary) -> int:
return int(p_data.index)
static func get_sdp_from_ice_candidate_data(p_data: Dictionary) -> String:
return p_data.sdp
func _process(delta: float):
socket.poll() # push error when 502 bad gateway, doesn't block anything
var old_state := state
state = socket.get_ready_state()
if state != old_state:
state_changed.emit()
if WebSocketPeer.STATE_CONNECTING == state:
connecting_time += delta
if WebSocketPeer.STATE_OPEN == state:
if WebSocketPeer.STATE_OPEN != old_state:
_socket_connection_opened()
while socket.get_available_packet_count():
var packet := socket.get_packet()
_received_packet(packet)
up_time += delta
if 0.0 < interval_time:
interval_time_left -= delta
if interval_time_left < 0.0:
interval_time_left = interval_time
interval_timeout.emit()
elif WebSocketPeer.STATE_CLOSING == state:
# Keep polling to achieve proper close.
pass
elif WebSocketPeer.STATE_CLOSED == state:
var code = socket.get_close_code()
var reason = socket.get_close_reason()
_socket_connection_closed(code, reason)
#
#if WebRTCPeerConnection.STATE_CONNECTED == connection_state:
#