refactor: harden client runtime and exports

This commit is contained in:
ANG-Server
2026-07-23 00:59:00 +08:00
parent 5d2339a29b
commit fc872fe8af
27 changed files with 902 additions and 856 deletions

View File

@@ -0,0 +1,54 @@
extends RefCounted
# 统一转换后端聊天载荷,避免 WebSocket 实时消息和 HTTP 历史消息
# 各自维护一套时间戳兼容规则。
static func normalize_history(messages: Array, current_username: String) -> Array[Dictionary]:
var normalized: Array[Dictionary] = []
for message_variant: Variant in messages:
if not (message_variant is Dictionary):
continue
var message: Dictionary = message_variant
var sender := str(message.get("sender", message.get("from_user", "")))
normalized.append({
"from_user": sender,
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
"content": str(message.get("content", "")),
"timestamp": parse_timestamp(message.get("timestamp", 0.0)),
"is_self": sender == current_username,
"scope": str(message.get("scope", "local")),
"show_bubble": bool(message.get("bubble", false)),
"is_history": true,
})
return normalized
static func parse_timestamp(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
var numeric_timestamp := float(timestamp_raw)
return numeric_timestamp if numeric_timestamp > 0.0 else Time.get_unix_time_from_system()
var timestamp_text := str(timestamp_raw)
if timestamp_text.strip_edges().is_empty():
return Time.get_unix_time_from_system()
var numeric_regex := RegEx.new()
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
if numeric_regex.search(timestamp_text) != null:
var parsed_numeric := float(timestamp_text)
return parsed_numeric if parsed_numeric > 0.0 else Time.get_unix_time_from_system()
var iso_regex := RegEx.new()
iso_regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var match_result := iso_regex.search(timestamp_text)
if match_result == null:
return Time.get_unix_time_from_system()
var utc_datetime := {
"year": int(match_result.get_string(1)),
"month": int(match_result.get_string(2)),
"day": int(match_result.get_string(3)),
"hour": int(match_result.get_string(4)),
"minute": int(match_result.get_string(5)),
"second": int(match_result.get_string(6)),
}
return Time.get_unix_time_from_datetime_dict(utc_datetime)

View File

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

View File

@@ -21,24 +21,24 @@ func _exit_tree() -> void:
request.queue_free()
_activeRequests.clear()
func get_json(endpoint: String, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated)
func get_json(endpoint: String, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, {}, callback, HTTPClient.METHOD_GET, authenticated, timeout)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated)
func post_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_POST, authenticated, timeout)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated)
func patch_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PATCH, authenticated, timeout)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated)
func put_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_PUT, authenticated, timeout)
func delete_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_DELETE, authenticated)
func delete_json(endpoint: String, payload: Dictionary, callback: Callable, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
request_json(endpoint, payload, callback, HTTPClient.METHOD_DELETE, authenticated, timeout)
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true) -> void:
func request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_GET, authenticated: bool = true, timeout: float = REQUEST_TIMEOUT) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
request.timeout = timeout
add_child(request)
_activeRequests.append(request)
@@ -100,6 +100,9 @@ func _handle_response(endpoint: String, result: int, responseCode: int, body: Pa
"response_code": responseCode,
"error_code": str(response.get("error_code", ""))
}
for key in response.keys():
if not errorInfo.has(key):
errorInfo[key] = response[key]
request_failed.emit(endpoint, str(errorInfo.get("message", "请求失败")))
callback.call(false, response, errorInfo)

View File

@@ -18,32 +18,25 @@ signal profile_update_succeeded(profile: Dictionary)
signal profile_update_failed(message: String)
signal logout_completed()
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const SecureSessionStore = preload("res://_Core/security/SecureSessionStore.gd")
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
const REQUEST_TIMEOUT: float = 12.0
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.secure"
const LEGACY_AUTH_CONFIG_PATH: String = "user://auth.cfg"
var _access_token: String = ""
var _refresh_token: String = ""
var _current_user: Dictionary = {}
var _current_profile: Dictionary = {}
var _active_requests: Array[HTTPRequest] = []
var _session_generation: int = 0
var _account_generation: int = 0
var _refresh_in_flight: bool = false
var _auth_config_path: String = DEFAULT_AUTH_CONFIG_PATH
var _show_welcome_after_registration: bool = false
var _secure_session_store: RefCounted = SecureSessionStore.new()
func _ready() -> void:
_load_cached_session()
func _exit_tree() -> void:
for request in _active_requests:
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_active_requests.clear()
func is_authenticated() -> bool:
return not _access_token.strip_edges().is_empty()
@@ -213,69 +206,30 @@ func refresh_session() -> void:
func logout() -> void:
_clear_session_memory()
if FileAccess.file_exists(_auth_config_path):
DirAccess.remove_absolute(_auth_config_path)
_secure_session_store.call("clear", _auth_config_path)
auth_state_changed.emit(false, {})
_emit_event(EventNames.AUTH_LOGOUT, {})
logout_completed.emit()
call_deferred("_return_to_auth_scene")
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST, authenticated: bool = false, accountBound: bool = false) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
request.set_meta("account_bound", accountBound)
add_child(request)
_active_requests.append(request)
var requestGeneration := _account_generation
request.request_completed.connect(func(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_active_requests.erase(request)
request.queue_free()
if accountBound and requestGeneration != _account_generation:
return
_handle_json_response(result, response_code, body, callback)
var api_client := get_node_or_null("/root/ApiClient")
if api_client == null or not api_client.has_method("request_json"):
callback.call(false, {}, {"message": "ApiClient 不可用"})
return
var request_generation := _account_generation
api_client.call(
"request_json",
endpoint,
payload,
func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if accountBound and request_generation != _account_generation:
return
callback.call(success, response, error_info),
method,
authenticated
)
var url := "%s%s" % [NetworkConfig.get_api_base_url(), endpoint]
var headers := PackedStringArray(["Content-Type: application/json"])
if authenticated and not _access_token.strip_edges().is_empty():
headers.append("Authorization: Bearer %s" % _access_token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request(url, headers, method, body)
if err != OK:
_active_requests.erase(request)
request.queue_free()
if not accountBound or requestGeneration == _account_generation:
callback.call(false, {}, {"message": "网络请求发送失败: %s" % error_string(err)})
func _handle_json_response(result: int, response_code: int, body: PackedByteArray, callback: Callable) -> void:
var body_text := body.get_string_from_utf8()
if result != HTTPRequest.RESULT_SUCCESS:
callback.call(false, {}, {"message": "网络请求失败: %s" % _http_result_to_string(result)})
return
var json := JSON.new()
if json.parse(body_text) != OK:
callback.call(false, {}, {"message": "服务器响应解析失败"})
return
var payload_variant: Variant = json.data
if not (payload_variant is Dictionary):
callback.call(false, {}, {"message": "服务器响应格式错误"})
return
var response: Dictionary = payload_variant
var success := response_code >= 200 and response_code < 300 and bool(response.get("success", true))
if success:
callback.call(true, response, {})
return
callback.call(false, response, {
"message": str(response.get("message", "请求失败")),
"response_code": response_code,
"error_code": str(response.get("error_code", ""))
})
func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
if not success:
login_failed.emit(str(error_info.get("message", "登录失败")))
@@ -415,35 +369,34 @@ func _refresh_player_snapshot() -> void:
playerStateManager.call_deferred("refresh_snapshot")
func _save_cached_session() -> void:
var config := ConfigFile.new()
config.set_value("auth", "refresh_token", _refresh_token)
config.set_value("auth", "access_token", _access_token)
config.set_value("auth", "saved_at", Time.get_unix_time_from_system())
var err := config.save(_auth_config_path)
if err != OK:
push_warning("AuthManager: 保存本地登录状态失败: %s" % error_string(err))
if _refresh_token.is_empty():
_secure_session_store.call("clear", _auth_config_path)
return
if not bool(_secure_session_store.call("save_refresh_token", _auth_config_path, _refresh_token)):
push_warning("AuthManager: 当前平台不支持安全持久化,将仅保留本次会话")
func _load_cached_session(emit_cached_state: bool = true) -> void:
if not FileAccess.file_exists(_auth_config_path):
return
var config := ConfigFile.new()
if config.load(_auth_config_path) != OK:
return
_refresh_token = str(config.get_value("auth", "refresh_token", "")).strip_edges()
_access_token = str(config.get_value("auth", "access_token", "")).strip_edges()
_migrate_legacy_cached_session()
_refresh_token = str(_secure_session_store.call("load_refresh_token", _auth_config_path)).strip_edges()
_access_token = ""
_current_user.clear()
_current_profile.clear()
if _refresh_token.is_empty():
return
_session_generation += 1
_account_generation += 1
if emit_cached_state:
call_deferred("refresh_session")
if is_authenticated():
_session_generation += 1
_account_generation += 1
if emit_cached_state and is_authenticated():
call_deferred("_emit_cached_auth_state")
func _emit_cached_auth_state() -> void:
auth_state_changed.emit(true, get_current_user())
func _migrate_legacy_cached_session() -> void:
if not FileAccess.file_exists(LEGACY_AUTH_CONFIG_PATH):
return
var config := ConfigFile.new()
if config.load(LEGACY_AUTH_CONFIG_PATH) == OK:
var legacy_refresh_token := str(config.get_value("auth", "refresh_token", "")).strip_edges()
if not legacy_refresh_token.is_empty():
_secure_session_store.call("save_refresh_token", _auth_config_path, legacy_refresh_token)
DirAccess.remove_absolute(ProjectSettings.globalize_path(LEGACY_AUTH_CONFIG_PATH))
func _clear_session_memory() -> void:
_access_token = ""
@@ -456,11 +409,6 @@ func _advance_account_generation() -> void:
_session_generation += 1
_account_generation += 1
_refresh_in_flight = false
for request in _active_requests.duplicate():
if is_instance_valid(request) and bool(request.get_meta("account_bound", false)):
request.cancel_request()
request.queue_free()
_active_requests.erase(request)
func _return_to_auth_scene() -> void:
var tree := get_tree()
@@ -503,20 +451,3 @@ func _password_has_letter_and_number(password: String) -> bool:
if code >= 48 and code <= 57:
has_number = true
return has_letter and has_number
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "UNKNOWN_%d" % result

View File

@@ -16,11 +16,8 @@ signal cafe_companion_agent_registered(data: Dictionary)
signal cafe_companion_employment_resigned(data: Dictionary)
signal cafe_companion_models_ready(data: Dictionary)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 24.0
var _activeRequests: Array[HTTPRequest] = []
var _cachedProducts: Array = []
var _cachedServicePoints: Array = []
var _accountGeneration: int = -1
@@ -31,9 +28,6 @@ func _ready() -> void:
if authManager != null and authManager.has_signal("auth_state_changed"):
authManager.auth_state_changed.connect(_on_auth_state_changed)
func _exit_tree() -> void:
_cancel_active_requests()
func get_chat_time_products(forceRefresh: bool = false) -> void:
if not forceRefresh and not _cachedProducts.is_empty():
var cachedData := {"products": _cachedProducts}
@@ -114,56 +108,15 @@ func get_employment_models(protocol: String, baseUrl: String, token: String) ->
}, _on_employment_models_response)
func _request_json(endpoint: String, payload: Dictionary, callback: Callable, method: int = HTTPClient.METHOD_POST) -> void:
var request := HTTPRequest.new()
request.timeout = REQUEST_TIMEOUT
add_child(request)
_activeRequests.append(request)
var requestGeneration := _current_account_generation()
request.request_completed.connect(func(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration != _current_account_generation():
var request_generation := _current_account_generation()
ApiClient.request_json(endpoint, payload, func(success: bool, response: Dictionary, error_info: Dictionary) -> void:
if request_generation != _current_account_generation():
return
_handle_json_response(result, responseCode, body, callback)
)
var headers := PackedStringArray(["Content-Type: application/json"])
var token := _get_auth_token()
if not token.is_empty():
headers.append("Authorization: Bearer %s" % token)
var body := "" if method == HTTPClient.METHOD_GET else JSON.stringify(payload)
var err := request.request("%s%s" % [NetworkConfig.get_api_base_url(), endpoint], headers, method, body)
if err != OK:
_activeRequests.erase(request)
request.queue_free()
if requestGeneration == _current_account_generation():
_emit_chat_error("咖啡店陪聊请求发送失败: %s" % error_string(err))
func _handle_json_response(result: int, responseCode: int, body: PackedByteArray, callback: Callable) -> void:
if result != HTTPRequest.RESULT_SUCCESS:
_emit_chat_error("咖啡店陪聊网络请求失败: %s" % _http_result_to_string(result))
return
var bodyText := body.get_string_from_utf8()
var json := JSON.new()
if json.parse(bodyText) != OK:
_emit_chat_error("咖啡店陪聊响应解析失败")
return
var payloadVariant: Variant = json.data
if not (payloadVariant is Dictionary):
_emit_chat_error("咖啡店陪聊响应格式错误")
return
var response: Dictionary = payloadVariant
var success := responseCode >= 200 and responseCode < 300 and bool(response.get("success", true))
if not success:
_emit_chat_error(str(response.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
if not success:
_emit_chat_error(str(error_info.get("message", "咖啡店陪聊请求失败")))
return
callback.call(response)
, method, true, REQUEST_TIMEOUT)
func _on_chat_time_products_response(response: Dictionary) -> void:
var data := _response_data(response)
@@ -257,30 +210,5 @@ func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
_cancel_active_requests()
_cachedProducts.clear()
_cachedServicePoints.clear()
func _cancel_active_requests() -> void:
for request in _activeRequests.duplicate():
if is_instance_valid(request):
request.cancel_request()
request.queue_free()
_activeRequests.clear()
func _http_result_to_string(result: int) -> String:
match result:
HTTPRequest.RESULT_SUCCESS:
return "SUCCESS"
HTTPRequest.RESULT_TIMEOUT:
return "TIMEOUT"
HTTPRequest.RESULT_CANT_CONNECT:
return "CANT_CONNECT"
HTTPRequest.RESULT_CANT_RESOLVE:
return "CANT_RESOLVE"
HTTPRequest.RESULT_CONNECTION_ERROR:
return "CONNECTION_ERROR"
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "TLS_HANDSHAKE_ERROR"
_:
return "HTTP_RESULT_%d" % result

View File

@@ -63,10 +63,7 @@ signal chat_position_updated(stream: String, topic: String)
# ============================================================================
const CHAT_WEBSOCKET_MANAGER_SCRIPT: Script = preload("res://_Core/managers/WebSocketManager.gd")
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
# WebSocket 服务器 URL原生 WebSocket
const WEBSOCKET_URL: String = "wss://whaletownend.xinghangee.icu/game"
const ChatMessageCodec = preload("res://_Core/chat/ChatMessageCodec.gd")
# 重连配置
const RECONNECT_MAX_ATTEMPTS: int = 5
@@ -107,7 +104,6 @@ const CHAT_ERROR_MESSAGES: Dictionary = {
# WebSocket 管理器
var _websocket_manager: Node
var _history_request: HTTPRequest
# 是否已登录
var _is_logged_in: bool = false
@@ -119,6 +115,7 @@ var _message_history: Array[Dictionary] = []
var _history_loading: bool = false
var _has_more_history: bool = true
var _oldest_message_timestamp: float = 0.0
var _history_request_generation: int = 0
# 消息发送时间戳(用于频率限制)
var _message_timestamps: Array[float] = []
@@ -160,10 +157,6 @@ func _ready() -> void:
# 创建 WebSocket 管理器
_websocket_manager = CHAT_WEBSOCKET_MANAGER_SCRIPT.new()
add_child(_websocket_manager)
_history_request = HTTPRequest.new()
_history_request.timeout = 12.0
_history_request.request_completed.connect(_on_history_request_completed)
add_child(_history_request)
# 连接信号
_connect_signals()
@@ -661,6 +654,7 @@ func clear_message_history() -> void:
# - 用户登录成功后
# - 重新连接到聊天服务器后
func reset_session() -> void:
_history_request_generation += 1
_message_history.clear()
_history_loading = false
_has_more_history = true
@@ -695,59 +689,31 @@ func load_history(count: int = HISTORY_PAGE_SIZE) -> void:
var mapId := _current_map.strip_edges()
if mapId.is_empty():
mapId = "whale_port"
var url := "%s/chat/history?mapId=%s&limit=%d&offset=%d" % [
NetworkConfig.get_api_base_url(),
var endpoint := "/chat/history?mapId=%s&limit=%d&offset=%d" % [
mapId.uri_encode(),
max(1, count),
_message_history.size(),
]
var err := _history_request.request(url, PackedStringArray([
"Accept: application/json",
"Authorization: Bearer %s" % token,
]), HTTPClient.METHOD_GET)
if err != OK:
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史请求发送失败")
var request_generation := _history_request_generation
ApiClient.get_json(endpoint, _on_history_request_completed.bind(request_generation))
func _on_history_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史读取失败")
func _on_history_request_completed(success: bool, response: Dictionary, error_info: Dictionary, request_generation: int) -> void:
if request_generation != _history_request_generation:
return
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
if not success:
_history_loading = false
_handle_error("INTERNAL_ERROR", "聊天历史响应解析失败")
_handle_error("INTERNAL_ERROR", str(error_info.get("message", "聊天历史读取失败")))
return
var response: Dictionary = json.data
var messagesVariant: Variant = response.get("messages", [])
var dataVariant: Variant = response.get("data", {})
if messagesVariant is Array and (messagesVariant as Array).is_empty() and dataVariant is Dictionary:
messagesVariant = (dataVariant as Dictionary).get("messages", [])
if messagesVariant is Array:
_on_history_loaded(_normalize_history_messages(messagesVariant as Array))
_on_history_loaded(ChatMessageCodec.normalize_history(messagesVariant as Array, _current_username))
return
_history_loading = false
_has_more_history = false
func _normalize_history_messages(messages: Array) -> Array:
var normalized: Array = []
for messageVariant in messages:
if not (messageVariant is Dictionary):
continue
var message: Dictionary = messageVariant
normalized.append({
"from_user": str(message.get("sender", message.get("from_user", ""))),
"from_user_id": str(message.get("fromUserId", message.get("from_user_id", ""))),
"content": str(message.get("content", "")),
"timestamp": _parse_chat_timestamp_to_unix(message.get("timestamp", 0.0)),
"is_self": str(message.get("sender", "")) == _current_username,
"scope": str(message.get("scope", "local")),
"show_bubble": bool(message.get("bubble", false)),
"is_history": true,
})
return normalized
# 历史消息加载完成回调
func _on_history_loaded(messages: Array) -> void:
_history_loading = false
@@ -1061,7 +1027,7 @@ func _handle_system_presence(data: Dictionary) -> void:
var content := str(data.get("content", "")).strip_edges()
if content.is_empty():
return
var timestamp := _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
var timestamp := ChatMessageCodec.parse_timestamp(data.get("timestamp", 0.0))
var message := {
"from_user": "系统",
"from_user_id": "",
@@ -1265,7 +1231,7 @@ func _handle_chat_render(data: Dictionary) -> void:
var private_context: String = str(data.get("privateContext", data.get("private_context", ""))).strip_edges()
var is_private: bool = scope == "private"
var timestamp: float = _parse_chat_timestamp_to_unix(data.get("timestamp", 0.0))
var timestamp: float = ChatMessageCodec.parse_timestamp(data.get("timestamp", 0.0))
var is_self: bool = (not _current_username.is_empty() and from_user == _current_username)
if is_self and _consume_pending_self_message(content, scope, to_user_id):
@@ -1344,40 +1310,6 @@ func _current_user_id() -> String:
return str((user_variant as Dictionary).get("id", ""))
return ""
# 解析聊天消息时间戳(兼容 unix 秒 / ISO 8601 字符串)
func _parse_chat_timestamp_to_unix(timestamp_raw: Variant) -> float:
if typeof(timestamp_raw) == TYPE_INT or typeof(timestamp_raw) == TYPE_FLOAT:
var ts := float(timestamp_raw)
return ts if ts > 0.0 else Time.get_unix_time_from_system()
var ts_str := str(timestamp_raw)
if ts_str.strip_edges().is_empty():
return Time.get_unix_time_from_system()
# 纯数字字符串(必须整串都是数字/小数点,避免把 ISO 字符串前缀 "2026" 误判成时间戳)
var numeric_regex := RegEx.new()
numeric_regex.compile("^\\s*-?\\d+(?:\\.\\d+)?\\s*$")
if numeric_regex.search(ts_str) != null:
var ts_num := float(ts_str)
return ts_num if ts_num > 0.0 else Time.get_unix_time_from_system()
# ISO 8601: 2026-01-19T15:15:43.930Z
var regex := RegEx.new()
regex.compile("(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2}):(\\d{2}):(\\d{2})")
var result := regex.search(ts_str)
if result == null:
return Time.get_unix_time_from_system()
var utc_dict := {
"year": int(result.get_string(1)),
"month": int(result.get_string(2)),
"day": int(result.get_string(3)),
"hour": int(result.get_string(4)),
"minute": int(result.get_string(5)),
"second": int(result.get_string(6))
}
return Time.get_unix_time_from_datetime_dict(utc_dict)
# 处理位置更新成功
func _handle_position_updated(data: Dictionary) -> void:
var stream: String = data.get("stream", "")

View File

@@ -2,67 +2,80 @@ extends Node
signal decor_save_succeeded(item: Dictionary)
signal decor_save_failed(item: Dictionary, message: String)
signal decor_revision_conflict(current_revision: int, message: String)
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
const REQUEST_TIMEOUT: float = 12.0
const MAX_SAVE_RETRIES: int = 3
const RETRY_BASE_DELAY: float = 0.75
var _request: HTTPRequest
var _retryTimer: Timer
var _retry_timer: Timer
var _queue: Array[Dictionary] = []
var _inFlight: Dictionary = {}
var _requestGeneration: int = -1
var _accountGeneration: int = -1
var _in_flight: Dictionary = {}
var _account_generation: int = -1
var _request_serial: int = 0
var _confirmed_revision: int = 0
func _ready() -> void:
_request = HTTPRequest.new()
_request.name = "roomDecorSaveRequest"
_request.timeout = REQUEST_TIMEOUT
_request.request_completed.connect(_on_request_completed)
add_child(_request)
_retry_timer = Timer.new()
_retry_timer.name = "roomDecorSaveRetryTimer"
_retry_timer.one_shot = true
_retry_timer.timeout.connect(_process_next_save)
add_child(_retry_timer)
_retryTimer = Timer.new()
_retryTimer.name = "roomDecorSaveRetryTimer"
_retryTimer.one_shot = true
_retryTimer.timeout.connect(_process_next_save)
add_child(_retryTimer)
var auth_manager := get_node_or_null("/root/AuthManager")
if auth_manager != null and auth_manager.has_signal("auth_state_changed"):
_account_generation = _current_account_generation()
auth_manager.auth_state_changed.connect(_on_auth_state_changed)
var authManager := get_node_or_null("/root/AuthManager")
if authManager != null and authManager.has_signal("auth_state_changed"):
_accountGeneration = _current_account_generation()
authManager.auth_state_changed.connect(_on_auth_state_changed)
func set_layout_revision(revision: int, clear_pending: bool = true) -> void:
if clear_pending:
clear_pending_saves()
_confirmed_revision = maxi(0, revision)
func get_layout_revision() -> int:
return _confirmed_revision
func clear_pending_saves() -> void:
_request_serial += 1
if is_instance_valid(_retry_timer):
_retry_timer.stop()
_queue.clear()
_in_flight.clear()
func enqueue_save(item: Dictionary) -> bool:
if not _is_authenticated():
decor_save_failed.emit(item.duplicate(true), "请先登录后保存摆放")
return false
var decorId := str(item.get("decor_id", "")).strip_edges()
if decorId.is_empty():
var decor_id := str(item.get("decor_id", "")).strip_edges()
if decor_id.is_empty():
decor_save_failed.emit(item.duplicate(true), "家具数据缺少 decor_id")
return false
# Only the newest queued placement for a decor matters.
for index in range(_queue.size() - 1, -1, -1):
var queuedItem: Dictionary = _queue[index].get("item", {})
if str(queuedItem.get("decor_id", "")) == decorId:
var queued_item: Dictionary = _queue[index].get("item", {})
if str(queued_item.get("decor_id", "")) == decor_id:
_queue.remove_at(index)
_queue.append({"item": item.duplicate(true), "retry_count": 0})
_queue.append({
"item": item.duplicate(true),
"retry_count": 0,
"mutation_id": _create_mutation_id(),
})
_process_next_save()
return true
func has_pending_saves() -> bool:
return not _inFlight.is_empty() or not _queue.is_empty() or (_retryTimer != null and not _retryTimer.is_stopped())
return not _in_flight.is_empty() or not _queue.is_empty() or (_retry_timer != null and not _retry_timer.is_stopped())
func _process_next_save() -> void:
if not _inFlight.is_empty() or _queue.is_empty() or not _is_authenticated():
if not _in_flight.is_empty() or _queue.is_empty() or not _is_authenticated():
return
if _retryTimer != null and not _retryTimer.is_stopped():
if _retry_timer != null and not _retry_timer.is_stopped():
return
_inFlight = _queue.pop_front()
_requestGeneration = _current_account_generation()
var item: Dictionary = _inFlight.get("item", {})
_in_flight = _queue.pop_front()
if not _in_flight.has("layout_revision"):
_in_flight["layout_revision"] = _confirmed_revision
_in_flight["mutation_revision"] = _confirmed_revision + 1
var item: Dictionary = _in_flight.get("item", {})
var payload := {
"decor_id": str(item.get("decor_id", "")),
"placed": bool(item.get("placed", false)),
@@ -71,114 +84,106 @@ func _process_next_save() -> void:
"scale": float(item.get("scale", item.get("default_scale", 1.0))),
"rotation_degrees": int(item.get("rotation_degrees", item.get("default_rotation_degrees", 0))),
"z_index": int(item.get("z_index", item.get("default_z_index", 0))),
"layout_revision": int(_in_flight.get("layout_revision", _confirmed_revision)),
"mutation_revision": int(_in_flight.get("mutation_revision", _confirmed_revision + 1)),
"mutation_id": str(_in_flight.get("mutation_id", "")),
}
var decorId := str(item.get("decor_id", "")).uri_encode()
var err := _request.request(
"%s/rooms/me/decor-placements/%s" % [NetworkConfig.get_api_base_url(), decorId],
_auth_headers(),
HTTPClient.METHOD_PUT,
JSON.stringify(payload)
var api_client := get_node_or_null("/root/ApiClient")
if api_client == null or not api_client.has_method("put_json"):
_retry_or_fail("家具位置保存服务不可用")
return
_request_serial += 1
var serial := _request_serial
var generation := _current_account_generation()
var decor_id := str(item.get("decor_id", "")).uri_encode()
api_client.call(
"put_json",
"/rooms/me/decor-placements/%s" % decor_id,
payload,
Callable(self, "_on_save_response").bind(serial, generation),
true
)
if err != OK:
_retry_or_fail("家具位置保存请求发送失败")
func _on_request_completed(result: int, responseCode: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
if _inFlight.is_empty():
func _on_save_response(
success: bool,
response: Dictionary,
error_info: Dictionary,
serial: int,
generation: int
) -> void:
if serial != _request_serial or _in_flight.is_empty():
return
if _requestGeneration != _current_account_generation():
_inFlight.clear()
_requestGeneration = -1
_process_next_save()
if generation != _current_account_generation():
clear_pending_saves()
return
if result != HTTPRequest.RESULT_SUCCESS or responseCode < 200 or responseCode >= 300:
_retry_or_fail(_read_error_message(body, "家具位置保存失败"))
return
var savedItem: Dictionary = (_inFlight.get("item", {}) as Dictionary).duplicate(true)
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) == OK and json.data is Dictionary:
var response: Dictionary = json.data
if not bool(response.get("success", true)):
_retry_or_fail(str(response.get("message", "家具位置保存失败")))
if not success:
var message := str(error_info.get("message", "家具位置保存失败"))
if int(error_info.get("response_code", 0)) == 409:
_handle_revision_conflict(error_info, message)
return
var dataVariant: Variant = response.get("data", {})
if dataVariant is Dictionary:
savedItem = (dataVariant as Dictionary).duplicate(true)
_retry_or_fail(message)
return
_inFlight.clear()
_requestGeneration = -1
decor_save_succeeded.emit(savedItem)
var saved_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
var data_variant: Variant = response.get("data", {})
if data_variant is Dictionary:
saved_item = (data_variant as Dictionary).duplicate(true)
var accepted_revision := int(saved_item.get("layout_revision", _in_flight.get("mutation_revision", _confirmed_revision)))
_confirmed_revision = maxi(_confirmed_revision, accepted_revision)
_in_flight.clear()
decor_save_succeeded.emit(saved_item)
_process_next_save()
func _retry_or_fail(message: String) -> void:
if _inFlight.is_empty():
if _in_flight.is_empty():
return
var failedEntry := _inFlight.duplicate(true)
var failedItem: Dictionary = failedEntry.get("item", {})
var decorId := str(failedItem.get("decor_id", ""))
_inFlight.clear()
_requestGeneration = -1
var failed_entry := _in_flight.duplicate(true)
var failed_item: Dictionary = failed_entry.get("item", {})
_in_flight.clear()
if _queue_has_decor(decorId):
_process_next_save()
return
var retryCount := int(failedEntry.get("retry_count", 0)) + 1
if retryCount <= MAX_SAVE_RETRIES and _is_authenticated():
failedEntry["retry_count"] = retryCount
_queue.push_front(failedEntry)
_retryTimer.start(RETRY_BASE_DELAY * pow(2.0, retryCount - 1))
var retry_count := int(failed_entry.get("retry_count", 0)) + 1
if retry_count <= MAX_SAVE_RETRIES and _is_authenticated():
failed_entry["retry_count"] = retry_count
_queue.push_front(failed_entry)
_retry_timer.start(RETRY_BASE_DELAY * pow(2.0, retry_count - 1))
return
decor_save_failed.emit(failedItem.duplicate(true), message)
_process_next_save()
decor_save_failed.emit(failed_item.duplicate(true), message)
_fail_queued_entries("前序装修写入失败,请重新保存")
func _queue_has_decor(decorId: String) -> bool:
for entryVariant in _queue:
if entryVariant is Dictionary:
var item: Dictionary = (entryVariant as Dictionary).get("item", {})
if str(item.get("decor_id", "")) == decorId:
return true
return false
func _handle_revision_conflict(error_info: Dictionary, message: String) -> void:
var current_revision := int(error_info.get("current_revision", _confirmed_revision))
var failed_item: Dictionary = (_in_flight.get("item", {}) as Dictionary).duplicate(true)
_in_flight.clear()
decor_save_failed.emit(failed_item, message)
_fail_queued_entries(message)
_confirmed_revision = maxi(_confirmed_revision, current_revision)
decor_revision_conflict.emit(current_revision, message)
func _on_auth_state_changed(_isAuthenticated: bool, _user: Dictionary) -> void:
var currentGeneration := _current_account_generation()
if currentGeneration == _accountGeneration:
return
_accountGeneration = currentGeneration
if is_instance_valid(_request):
_request.cancel_request()
if is_instance_valid(_retryTimer):
_retryTimer.stop()
func _fail_queued_entries(message: String) -> void:
for entry in _queue:
var item_variant: Variant = entry.get("item", {})
if item_variant is Dictionary:
decor_save_failed.emit((item_variant as Dictionary).duplicate(true), message)
_queue.clear()
_inFlight.clear()
_requestGeneration = -1
if is_instance_valid(_retry_timer):
_retry_timer.stop()
func _auth_headers() -> PackedStringArray:
var authManager := get_node_or_null("/root/AuthManager")
var accessToken := str(authManager.call("get_access_token")).strip_edges() if authManager != null and authManager.has_method("get_access_token") else ""
return PackedStringArray([
"Content-Type: application/json",
"Authorization: Bearer %s" % accessToken,
])
func _on_auth_state_changed(_is_authenticated_value: bool, _user: Dictionary) -> void:
var current_generation := _current_account_generation()
if current_generation == _account_generation:
return
_account_generation = current_generation
clear_pending_saves()
_confirmed_revision = 0
func _is_authenticated() -> bool:
var authManager := get_node_or_null("/root/AuthManager")
return authManager != null and authManager.has_method("is_authenticated") and bool(authManager.call("is_authenticated"))
var auth_manager := get_node_or_null("/root/AuthManager")
return auth_manager != null and auth_manager.has_method("is_authenticated") and bool(auth_manager.call("is_authenticated"))
func _current_account_generation() -> int:
var authManager := get_node_or_null("/root/AuthManager")
return int(authManager.call("get_account_generation")) if authManager != null and authManager.has_method("get_account_generation") else -1
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else -1
func _read_error_message(body: PackedByteArray, fallback: String) -> String:
var json := JSON.new()
if json.parse(body.get_string_from_utf8()) != OK or not (json.data is Dictionary):
return fallback
var response: Dictionary = json.data
var messageVariant: Variant = response.get("message", fallback)
if messageVariant is Array:
var parts: Array[String] = []
for part in messageVariant:
parts.append(str(part))
return "; ".join(parts) if not parts.is_empty() else fallback
var message := str(messageVariant).strip_edges()
return message if not message.is_empty() else fallback
func _create_mutation_id() -> String:
return Crypto.new().generate_random_bytes(16).hex_encode()

View File

@@ -8,6 +8,11 @@ signal reward_claimed(task_id: String, wallet: Dictionary)
var _board: Dictionary = {}
var _loading_board: bool = false
var _account_generation: int = -1
var _recent_activity_at: Dictionary = {}
const ACTIVITY_DEBOUNCE_MSEC: int = 500
const MAX_ACTIVITY_RETRIES: int = 3
const ACTIVITY_RETRY_BASE_SECONDS: float = 0.5
func _ready() -> void:
var auth_manager := get_node_or_null("/root/AuthManager")
@@ -37,14 +42,34 @@ func refresh_board() -> void:
func report_activity(activity: String, target_id: String = "") -> void:
if not _is_authenticated():
return
var normalized_target_id := target_id.strip_edges()
var activity_key := "%s:%s" % [activity, normalized_target_id]
var now_msec := Time.get_ticks_msec()
if now_msec - int(_recent_activity_at.get(activity_key, 0)) < ACTIVITY_DEBOUNCE_MSEC:
return
_recent_activity_at[activity_key] = now_msec
_send_activity(activity, normalized_target_id, 0, _current_account_generation())
func _send_activity(activity: String, target_id: String, retry_count: int, request_generation: int) -> void:
if request_generation != _current_account_generation() or not _is_authenticated():
return
var api := _api_client()
if api == null:
return
var payload := {"activity": activity}
if not target_id.strip_edges().is_empty():
payload["target_id"] = target_id.strip_edges()
var request_generation := _current_account_generation()
api.call("post_json", "/tasks/activities", payload, Callable(self, "_on_activity_response").bind(request_generation), true)
var payload := {
"activity": activity,
"nonce": _create_activity_nonce(),
"occurred_at": Time.get_datetime_string_from_system(true, false),
}
if not target_id.is_empty():
payload["target_id"] = target_id
api.call(
"post_json",
"/tasks/activities",
payload,
Callable(self, "_on_activity_response").bind(activity, target_id, retry_count, request_generation),
true
)
func claim_task(task_id: String) -> void:
if task_id.strip_edges().is_empty() or not _is_authenticated():
@@ -63,6 +88,7 @@ func _refresh_after_ready() -> void:
func _on_auth_state_changed(is_authenticated: bool, _user: Dictionary) -> void:
if not is_authenticated:
_board.clear()
_recent_activity_at.clear()
_loading_board = false
_account_generation = -1
board_changed.emit({})
@@ -78,10 +104,33 @@ func _on_board_response(success: bool, response: Dictionary, error_info: Diction
return
_apply_board_response(response)
func _on_activity_response(success: bool, response: Dictionary, _error_info: Dictionary, request_generation: int) -> void:
if not success or request_generation != _current_account_generation():
func _on_activity_response(
success: bool,
response: Dictionary,
error_info: Dictionary,
activity: String,
target_id: String,
retry_count: int,
request_generation: int
) -> void:
if request_generation != _current_account_generation():
return
_apply_board_response(response)
if success:
_apply_board_response(response)
return
if retry_count >= MAX_ACTIVITY_RETRIES or not _is_retriable_session_conflict(error_info):
return
var delay := ACTIVITY_RETRY_BASE_SECONDS * pow(2.0, retry_count)
get_tree().create_timer(delay).timeout.connect(
Callable(self, "_send_activity").bind(activity, target_id, retry_count + 1, request_generation),
CONNECT_ONE_SHOT
)
func _is_retriable_session_conflict(error_info: Dictionary) -> bool:
if int(error_info.get("response_code", 0)) != 409:
return false
var message := str(error_info.get("message", ""))
return message.contains("会话") or message.contains("位置") or message.contains("交互范围")
func _on_claim_response(success: bool, response: Dictionary, error_info: Dictionary, task_id: String, request_generation: int) -> void:
if request_generation != _current_account_generation():
@@ -126,3 +175,7 @@ func _is_authenticated() -> bool:
func _current_account_generation() -> int:
var auth_manager := get_node_or_null("/root/AuthManager")
return int(auth_manager.call("get_account_generation")) if auth_manager != null and auth_manager.has_method("get_account_generation") else 0
func _create_activity_nonce() -> String:
var random_bytes := Crypto.new().generate_random_bytes(16)
return random_bytes.hex_encode()

View File

@@ -0,0 +1,92 @@
extends RefCounted
var _parent: Node2D
var _collision_layer: int
var _collision_mask: int
var _bodies: Dictionary = {}
func _init(parent: Node2D, collision_layer: int, collision_mask: int) -> void:
_parent = parent
_collision_layer = collision_layer
_collision_mask = collision_mask
func sync(decor_id: String, item: Dictionary) -> void:
var collision_size := _to_vector2(item.get("collision_size", Vector2.ZERO))
if collision_size == Vector2.ZERO:
remove(decor_id)
return
var collision_offset := _to_vector2(item.get("collision_offset", Vector2.ZERO))
var body := _bodies.get(decor_id, null) as StaticBody2D
var shape_node: CollisionShape2D
if body == null or not is_instance_valid(body):
body = StaticBody2D.new()
body.name = "DecorCollision_%s" % decor_id
body.collision_layer = _collision_layer
body.collision_mask = _collision_mask
shape_node = CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
shape_node.shape = RectangleShape2D.new()
body.add_child(shape_node)
_parent.add_child(body)
_bodies[decor_id] = body
else:
shape_node = body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null:
shape_node = CollisionShape2D.new()
shape_node.name = "CollisionShape2D"
shape_node.shape = RectangleShape2D.new()
body.add_child(shape_node)
var scale := _to_float(item.get("scale", item.get("default_scale", 1.0)), 1.0)
var rotation_degrees := _to_float(
item.get("rotation_degrees", item.get("default_rotation_degrees", 0.0)),
0.0
)
body.rotation_degrees = rotation_degrees
body.global_position = Vector2(
_to_float(item.get("position_x", 0.0), 0.0),
_to_float(item.get("position_y", 0.0), 0.0)
) + collision_offset.rotated(deg_to_rad(rotation_degrees)) * scale
shape_node.position = Vector2.ZERO
var rectangle := shape_node.shape as RectangleShape2D
if rectangle == null:
rectangle = RectangleShape2D.new()
shape_node.shape = rectangle
rectangle.size = Vector2(maxf(8.0, collision_size.x * scale), maxf(8.0, collision_size.y * scale))
func set_disabled(decor_id: String, disabled: bool) -> void:
var body := _bodies.get(decor_id, null) as StaticBody2D
if body == null or not is_instance_valid(body):
return
var shape_node := body.get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node != null:
shape_node.disabled = disabled
func remove(decor_id: String) -> void:
var body := _bodies.get(decor_id, null) as Node
if body != null and is_instance_valid(body):
body.queue_free()
_bodies.erase(decor_id)
func _to_vector2(value: Variant) -> Vector2:
if value is Vector2:
return value
if value is Dictionary:
var dictionary: Dictionary = value
return Vector2(
_to_float(dictionary.get("x", 0.0), 0.0),
_to_float(dictionary.get("y", 0.0), 0.0)
)
return Vector2.ZERO
func _to_float(value: Variant, fallback: float) -> float:
match typeof(value):
TYPE_FLOAT, TYPE_INT:
return float(value)
TYPE_STRING:
var text := str(value).strip_edges()
return fallback if text.is_empty() else text.to_float()
TYPE_BOOL:
return 1.0 if bool(value) else 0.0
_:
return fallback

View File

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

View File

@@ -0,0 +1,60 @@
extends RefCounted
var _undo_entries: Array[Dictionary] = []
var _redo_entries: Array[Dictionary] = []
func clear() -> void:
_undo_entries.clear()
_redo_entries.clear()
func record_item(before: Dictionary, after: Dictionary) -> bool:
if before == after:
return false
var decor_id := str(after.get("decor_id", before.get("decor_id", ""))).strip_edges()
if decor_id.is_empty():
return false
_undo_entries.append({
"decor_id": decor_id,
"before": before.duplicate(true),
"after": after.duplicate(true),
})
_redo_entries.clear()
return true
func record_bulk(before_items: Dictionary, after_items: Dictionary) -> bool:
if before_items == after_items:
return false
_undo_entries.append({
"before_items": _duplicate_item_map(before_items),
"after_items": _duplicate_item_map(after_items),
})
_redo_entries.clear()
return true
func can_undo() -> bool:
return not _undo_entries.is_empty()
func can_redo() -> bool:
return not _redo_entries.is_empty()
func take_undo() -> Dictionary:
if _undo_entries.is_empty():
return {}
var entry: Dictionary = _undo_entries.pop_back()
_redo_entries.append(entry)
return entry
func take_redo() -> Dictionary:
if _redo_entries.is_empty():
return {}
var entry: Dictionary = _redo_entries.pop_back()
_undo_entries.append(entry)
return entry
func _duplicate_item_map(source: Dictionary) -> Dictionary:
var duplicate: Dictionary = {}
for decor_id_variant in source.keys():
var item_variant: Variant = source.get(decor_id_variant, {})
if item_variant is Dictionary:
duplicate[str(decor_id_variant)] = (item_variant as Dictionary).duplicate(true)
return duplicate

View File

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

View File

@@ -0,0 +1,130 @@
extends RefCounted
const STORAGE_VERSION: int = 2
const BLOCK_SIZE: int = 16
const KEY_CONTEXT: String = "WhaleTown-V2/session-store/v1"
func save_refresh_token(path: String, refresh_token: String) -> bool:
if refresh_token.is_empty():
clear(path)
return true
var keys := _derive_keys()
if keys.is_empty():
return false
var iv := Crypto.new().generate_random_bytes(BLOCK_SIZE)
var encryption_key: PackedByteArray = keys.get("encryption", PackedByteArray())
var authentication_key: PackedByteArray = keys.get("authentication", PackedByteArray())
var encrypted := _encrypt(refresh_token.to_utf8_buffer(), encryption_key, iv)
if encrypted.is_empty():
return false
var authenticated_data := iv.duplicate()
authenticated_data.append_array(encrypted)
var mac := Crypto.new().hmac_digest(HashingContext.HASH_SHA256, authentication_key, authenticated_data)
var payload := {
"version": STORAGE_VERSION,
"iv": Marshalls.raw_to_base64(iv),
"ciphertext": Marshalls.raw_to_base64(encrypted),
"mac": Marshalls.raw_to_base64(mac),
"saved_at": Time.get_unix_time_from_system(),
}
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(payload))
file.close()
return true
func load_refresh_token(path: String) -> String:
if not FileAccess.file_exists(path):
return ""
var keys := _derive_keys()
if keys.is_empty():
clear(path)
return ""
var json := JSON.new()
if json.parse(FileAccess.get_file_as_string(path)) != OK or not (json.data is Dictionary):
clear(path)
return ""
var payload: Dictionary = json.data
if int(payload.get("version", 0)) != STORAGE_VERSION:
clear(path)
return ""
var iv := Marshalls.base64_to_raw(str(payload.get("iv", "")))
var encrypted := Marshalls.base64_to_raw(str(payload.get("ciphertext", "")))
var expected_mac := Marshalls.base64_to_raw(str(payload.get("mac", "")))
if iv.size() != BLOCK_SIZE or encrypted.is_empty() or expected_mac.is_empty():
clear(path)
return ""
var authenticated_data := iv.duplicate()
authenticated_data.append_array(encrypted)
var encryption_key: PackedByteArray = keys.get("encryption", PackedByteArray())
var authentication_key: PackedByteArray = keys.get("authentication", PackedByteArray())
var actual_mac := Crypto.new().hmac_digest(HashingContext.HASH_SHA256, authentication_key, authenticated_data)
if not _constant_time_equals(actual_mac, expected_mac):
clear(path)
return ""
var decrypted := _decrypt(encrypted, encryption_key, iv)
return decrypted.get_string_from_utf8().strip_edges()
func clear(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
func _derive_keys() -> Dictionary:
if OS.get_name() == "Web":
return {}
var device_id := OS.get_unique_id().strip_edges()
if device_id.is_empty():
return {}
var root_key := _sha256((KEY_CONTEXT + ":" + device_id).to_utf8_buffer())
if root_key.is_empty():
return {}
return {
"encryption": _sha256(root_key + ":encryption".to_utf8_buffer()),
"authentication": _sha256(root_key + ":authentication".to_utf8_buffer()),
}
func _sha256(bytes: PackedByteArray) -> PackedByteArray:
var hash_context := HashingContext.new()
if hash_context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
if hash_context.update(bytes) != OK:
return PackedByteArray()
return hash_context.finish()
func _encrypt(plain_text: PackedByteArray, key: PackedByteArray, iv: PackedByteArray) -> PackedByteArray:
var padded := plain_text.duplicate()
var padding_size := BLOCK_SIZE - (padded.size() % BLOCK_SIZE)
for _index in range(padding_size):
padded.append(padding_size)
var aes := AESContext.new()
if aes.start(AESContext.MODE_CBC_ENCRYPT, key, iv) != OK:
return PackedByteArray()
var encrypted := aes.update(padded)
aes.finish()
return encrypted
func _decrypt(encrypted: PackedByteArray, key: PackedByteArray, iv: PackedByteArray) -> PackedByteArray:
var aes := AESContext.new()
if aes.start(AESContext.MODE_CBC_DECRYPT, key, iv) != OK:
return PackedByteArray()
var padded := aes.update(encrypted)
aes.finish()
if padded.is_empty():
return PackedByteArray()
var padding_size := int(padded[padded.size() - 1])
if padding_size < 1 or padding_size > BLOCK_SIZE or padding_size > padded.size():
return PackedByteArray()
for index in range(padded.size() - padding_size, padded.size()):
if int(padded[index]) != padding_size:
return PackedByteArray()
padded.resize(padded.size() - padding_size)
return padded
func _constant_time_equals(left: PackedByteArray, right: PackedByteArray) -> bool:
if left.size() != right.size():
return false
var difference := 0
for index in range(left.size()):
difference |= int(left[index]) ^ int(right[index])
return difference == 0

View File

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