Two real gaps meant the account-synced zoom (from the previous "sync through the account" change) never reached the screen: _load_cache() updated AccountProfileService.preferences but never emitted profile_changed, so question_player.gd/ccs_service.gd (already built and listening before login even happens) never found out a cached value had loaded. And nothing forced a server refresh right after login - it only fired if the user happened to open the profile/progress screen, so a fresh device/session had no path to the true synced value at all. main.gd now calls AccountProfileService.refresh() once per login (not on every silent token refresh) so the real preferences GET always runs right after signing in, on any device. Also added Question bank/CCS case text size steppers to the Settings screen next to dark mode, per user request, reusing the same AccountProfileService/CCSService plumbing as the in-screen A-/A+ buttons.
368 lines
15 KiB
GDScript
368 lines
15 KiB
GDScript
extends Node
|
|
|
|
signal profile_changed
|
|
signal request_failed(message: String)
|
|
signal preferences_saved
|
|
signal password_changed
|
|
signal account_deleted
|
|
signal archive_saved(path: String)
|
|
signal external_link_started(provider: String)
|
|
signal external_linked(provider: String)
|
|
|
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
|
const AUTH_URL := "https://us3q.ranaze.com/api/Auth"
|
|
const PROFILE_URL := "https://us3q.ranaze.com/api/profile"
|
|
|
|
var account: Dictionary = {}
|
|
var preferences: Dictionary = _default_preferences()
|
|
var auth_request: HTTPRequest
|
|
var profile_request: HTTPRequest
|
|
var external_request: HTTPRequest
|
|
var external_poll_timer: Timer
|
|
var auth_operation := ""
|
|
var profile_operation := ""
|
|
var auth_token := ""
|
|
var profile_token := ""
|
|
var preferences_pending := false
|
|
var external_operation := ""
|
|
var external_provider := ""
|
|
var external_provider_name := ""
|
|
var external_transaction_id := ""
|
|
var external_poll_deadline_msec := 0
|
|
|
|
|
|
func _ready() -> void:
|
|
auth_request = HTTPRequest.new()
|
|
auth_request.timeout = 25.0
|
|
auth_request.request_completed.connect(_on_auth_completed)
|
|
add_child(auth_request)
|
|
profile_request = HTTPRequest.new()
|
|
profile_request.timeout = 30.0
|
|
profile_request.request_completed.connect(_on_profile_completed)
|
|
add_child(profile_request)
|
|
external_request = HTTPRequest.new()
|
|
external_request.timeout = 25.0
|
|
external_request.request_completed.connect(_on_external_completed)
|
|
add_child(external_request)
|
|
external_poll_timer = Timer.new()
|
|
external_poll_timer.wait_time = 2.0
|
|
external_poll_timer.timeout.connect(_poll_external_link)
|
|
add_child(external_poll_timer)
|
|
AuthState.auth_changed.connect(_on_auth_changed)
|
|
|
|
|
|
func refresh() -> void:
|
|
if not AuthState.can_send_authenticated_request():
|
|
return
|
|
_load_cache()
|
|
_request_auth("identity", HTTPClient.METHOD_GET, "/me")
|
|
if preferences_pending:
|
|
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
|
|
else:
|
|
_request_profile("preferences", HTTPClient.METHOD_GET, "/me/preferences")
|
|
|
|
|
|
func update_account(display_name: String, email: String) -> void:
|
|
_request_auth("update_account", HTTPClient.METHOD_PATCH, "/me", {
|
|
"displayName": display_name.strip_edges(), "email": email.strip_edges()
|
|
})
|
|
|
|
|
|
func update_preferences(target_exam_date: String, preparation_stage: String, weekly_goal_hours: int, preferred_mode: String) -> void:
|
|
var target_date: Variant = null
|
|
if not target_exam_date.strip_edges().is_empty():
|
|
target_date = "%sT00:00:00Z" % target_exam_date.strip_edges()
|
|
preferences = {
|
|
"targetExamDate": target_date,
|
|
"preparationStage": preparation_stage,
|
|
"weeklyGoalHours": weekly_goal_hours,
|
|
"preferredStudyMode": preferred_mode
|
|
}
|
|
preferences_pending = true
|
|
_save_cache(true)
|
|
profile_changed.emit()
|
|
_request_profile("update_preferences", HTTPClient.METHOD_PUT, "/me/preferences", preferences)
|
|
|
|
|
|
func update_text_scale(question_text_scale: Variant = null, ccs_text_scale: Variant = null) -> void:
|
|
var payload := {}
|
|
if question_text_scale != null:
|
|
payload["questionTextScale"] = question_text_scale
|
|
preferences["questionTextScale"] = question_text_scale
|
|
if ccs_text_scale != null:
|
|
payload["ccsTextScale"] = ccs_text_scale
|
|
preferences["ccsTextScale"] = ccs_text_scale
|
|
if payload.is_empty():
|
|
return
|
|
_save_cache(preferences_pending)
|
|
_request_profile("update_text_scale", HTTPClient.METHOD_PUT, "/me/text-scale", payload)
|
|
|
|
|
|
func change_password(current_password: String, new_password: String) -> void:
|
|
_request_auth("change_password", HTTPClient.METHOD_POST, "/me/change-password", {
|
|
"currentPassword": current_password, "newPassword": new_password
|
|
})
|
|
|
|
|
|
func export_archive() -> void:
|
|
_request_profile("export", HTTPClient.METHOD_GET, "/me/export")
|
|
|
|
|
|
func delete_account(password: String, confirmation: String) -> void:
|
|
_request_auth("delete_account", HTTPClient.METHOD_DELETE, "/me", {
|
|
"password": password, "confirmation": confirmation
|
|
})
|
|
|
|
|
|
func connect_provider(provider: String, provider_name: String) -> void:
|
|
if not AuthState.can_send_authenticated_request():
|
|
request_failed.emit("Sign in again before connecting an account.")
|
|
return
|
|
if external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
request_failed.emit("Another connection request is still in progress.")
|
|
return
|
|
external_provider = provider
|
|
external_provider_name = provider_name
|
|
var storage_key := _external_storage_key()
|
|
if OS.has_feature("web"):
|
|
var popup_result: Variant = JavaScriptBridge.eval(
|
|
"window.localStorage.removeItem(%s); var bridge = new URL('/external-redirect.html?key=' + encodeURIComponent(%s) + '&provider=' + encodeURIComponent(%s), window.location.origin).href; window.ranazeProfileExternalWindow = window.open(bridge, 'ranaze_profile_external', 'popup=yes,width=560,height=720,resizable=yes,scrollbars=yes'); window.ranazeProfileExternalWindow !== null;" % [JSON.stringify(storage_key), JSON.stringify(storage_key), JSON.stringify(provider_name)], true)
|
|
if not bool(popup_result):
|
|
request_failed.emit("Allow popups for this site, then try again.")
|
|
_reset_external_link()
|
|
return
|
|
external_operation = "start"
|
|
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token, "Content-Type: application/json"])
|
|
var error := external_request.request("%s/external/%s/start" % [_auth_base(), provider], headers,
|
|
HTTPClient.METHOD_POST, JSON.stringify({"purpose": "link"}))
|
|
if error != OK:
|
|
request_failed.emit("The account provider could not be reached.")
|
|
_reset_external_link()
|
|
|
|
|
|
func _request_auth(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
|
|
if auth_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
request_failed.emit("Another account request is still in progress.")
|
|
return
|
|
auth_operation = operation
|
|
auth_token = AuthState.access_token
|
|
var headers := PackedStringArray(["Authorization: Bearer %s" % auth_token, "Content-Type: application/json"])
|
|
var url := "%s%s" % [EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/"), path]
|
|
var error := auth_request.request(url, headers, method, "" if payload == null else JSON.stringify(payload))
|
|
if error != OK:
|
|
auth_operation = ""
|
|
request_failed.emit("The account service could not be reached.")
|
|
|
|
|
|
func _request_profile(operation: String, method: HTTPClient.Method, path: String, payload: Variant = null) -> void:
|
|
if profile_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
request_failed.emit("Another profile request is still in progress.")
|
|
return
|
|
profile_operation = operation
|
|
profile_token = AuthState.access_token
|
|
var headers := PackedStringArray(["Authorization: Bearer %s" % profile_token, "Content-Type: application/json"])
|
|
var base := EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/profile_api_url", PROFILE_URL)), "/api/profile")
|
|
var error := profile_request.request("%s%s" % [base.rstrip("/"), path], headers, method, "" if payload == null else JSON.stringify(payload))
|
|
if error != OK:
|
|
profile_operation = ""
|
|
request_failed.emit("The profile service could not be reached.")
|
|
|
|
|
|
func _on_auth_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
var operation := auth_operation
|
|
auth_operation = ""
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(auth_token)
|
|
return
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
|
request_failed.emit(_error_message(body, "The account request failed."))
|
|
return
|
|
if operation in ["identity", "update_account"]:
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
request_failed.emit("The account service returned invalid data.")
|
|
return
|
|
account = parsed
|
|
profile_changed.emit()
|
|
elif operation == "change_password":
|
|
password_changed.emit()
|
|
AuthState.clear_session()
|
|
elif operation == "delete_account":
|
|
_clear_cache()
|
|
account_deleted.emit()
|
|
AuthState.clear_session()
|
|
|
|
|
|
func _on_profile_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
var operation := profile_operation
|
|
profile_operation = ""
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(profile_token)
|
|
return
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
|
request_failed.emit(_error_message(body, "The profile request failed."))
|
|
return
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
request_failed.emit("The profile service returned invalid data.")
|
|
return
|
|
if operation in ["preferences", "update_preferences", "update_text_scale"]:
|
|
preferences = parsed
|
|
preferences_pending = false
|
|
_save_cache(false)
|
|
profile_changed.emit()
|
|
if operation == "update_preferences":
|
|
preferences_saved.emit()
|
|
elif operation == "export":
|
|
var archive: Dictionary = parsed
|
|
archive["account"] = account.duplicate(true)
|
|
_save_archive(JSON.stringify(archive, " "))
|
|
|
|
|
|
func _on_external_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
|
var operation := external_operation
|
|
external_operation = ""
|
|
if response_code == 401:
|
|
AuthState.handle_unauthorized(AuthState.access_token)
|
|
_reset_external_link()
|
|
return
|
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
|
request_failed.emit(_error_message(body, "The external account request failed."))
|
|
_reset_external_link()
|
|
return
|
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
|
if typeof(parsed) != TYPE_DICTIONARY:
|
|
request_failed.emit("The external account service returned invalid data.")
|
|
_reset_external_link()
|
|
return
|
|
var data: Dictionary = parsed
|
|
if operation == "start":
|
|
external_transaction_id = str(data.get("transactionId", ""))
|
|
var auth_url := str(data.get("authUrl", ""))
|
|
if external_transaction_id.is_empty() or auth_url.is_empty():
|
|
request_failed.emit("The provider returned an invalid response.")
|
|
_reset_external_link()
|
|
return
|
|
if OS.has_feature("web"):
|
|
JavaScriptBridge.eval("var url=%s; window.localStorage.setItem(%s,url); if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.location.replace(url);window.ranazeProfileExternalWindow.focus();}" % [JSON.stringify(auth_url), JSON.stringify(_external_storage_key())], true)
|
|
else:
|
|
OS.shell_open(auth_url)
|
|
external_poll_deadline_msec = Time.get_ticks_msec() + 300000
|
|
external_poll_timer.start()
|
|
external_link_started.emit(external_provider)
|
|
elif operation == "status":
|
|
match str(data.get("status", "pending")):
|
|
"linked":
|
|
var linked_provider := external_provider
|
|
_reset_external_link()
|
|
external_linked.emit(linked_provider)
|
|
refresh()
|
|
"failed":
|
|
request_failed.emit(str(data.get("message", "The provider could not connect this account.")))
|
|
_reset_external_link()
|
|
|
|
|
|
func _poll_external_link() -> void:
|
|
if external_transaction_id.is_empty() or external_request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
|
return
|
|
if Time.get_ticks_msec() >= external_poll_deadline_msec:
|
|
request_failed.emit("The account connection timed out.")
|
|
_reset_external_link()
|
|
return
|
|
external_operation = "status"
|
|
var error := external_request.request("%s/external/%s/status/%s" % [_auth_base(), external_provider, external_transaction_id])
|
|
if error != OK:
|
|
external_operation = ""
|
|
|
|
|
|
func _reset_external_link() -> void:
|
|
if external_poll_timer != null:
|
|
external_poll_timer.stop()
|
|
if OS.has_feature("web") and not external_provider.is_empty():
|
|
JavaScriptBridge.eval("window.localStorage.removeItem(%s);if(window.ranazeProfileExternalWindow&&!window.ranazeProfileExternalWindow.closed){window.ranazeProfileExternalWindow.close();}window.ranazeProfileExternalWindow=null;" % JSON.stringify(_external_storage_key()), true)
|
|
external_operation = ""
|
|
external_provider = ""
|
|
external_provider_name = ""
|
|
external_transaction_id = ""
|
|
external_poll_deadline_msec = 0
|
|
|
|
|
|
func _external_storage_key() -> String:
|
|
return "ranaze_profile_external_auth_url_%s" % external_provider
|
|
|
|
|
|
func _auth_base() -> String:
|
|
return EndpointConfig.resolve(str(ProjectSettings.get_setting("step3/auth_api_base_url", AUTH_URL)), "/api/Auth").rstrip("/")
|
|
|
|
|
|
func _save_archive(content: String) -> void:
|
|
var filename := "ranaze-account-export-%s.json" % Time.get_date_string_from_system()
|
|
if OS.has_feature("web"):
|
|
var encoded := Marshalls.raw_to_base64(content.to_utf8_buffer())
|
|
JavaScriptBridge.eval("(function(){const a=document.createElement('a');a.href='data:application/json;base64,%s';a.download=%s;document.body.appendChild(a);a.click();a.remove();})();" % [encoded, JSON.stringify(filename)], true)
|
|
archive_saved.emit(filename)
|
|
return
|
|
var directory := OS.get_system_dir(OS.SYSTEM_DIR_DOWNLOADS)
|
|
var path := directory.path_join(filename)
|
|
var file := FileAccess.open(path, FileAccess.WRITE)
|
|
if file == null:
|
|
request_failed.emit("The archive could not be saved.")
|
|
return
|
|
file.store_string(content)
|
|
archive_saved.emit(path)
|
|
|
|
|
|
func _on_auth_changed() -> void:
|
|
if AuthState.is_logged_in:
|
|
_load_cache()
|
|
else:
|
|
account = {}
|
|
preferences = _default_preferences()
|
|
profile_changed.emit()
|
|
|
|
|
|
func _cache_path() -> String:
|
|
return "user://profile-%s.json" % AuthState.username.sha256_text().left(16)
|
|
|
|
|
|
func _load_cache() -> void:
|
|
if AuthState.username.is_empty() or not FileAccess.file_exists(_cache_path()):
|
|
return
|
|
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(_cache_path()))
|
|
if typeof(parsed) == TYPE_DICTIONARY:
|
|
preferences = parsed.get("preferences", _default_preferences())
|
|
preferences_pending = bool(parsed.get("pending", false))
|
|
profile_changed.emit()
|
|
|
|
|
|
func _save_cache(pending: bool) -> void:
|
|
if AuthState.username.is_empty():
|
|
return
|
|
var file := FileAccess.open(_cache_path(), FileAccess.WRITE)
|
|
if file != null:
|
|
preferences_pending = pending
|
|
file.store_string(JSON.stringify({"preferences": preferences, "pending": pending}))
|
|
|
|
|
|
func _clear_cache() -> void:
|
|
if not AuthState.username.is_empty() and FileAccess.file_exists(_cache_path()):
|
|
DirAccess.remove_absolute(ProjectSettings.globalize_path(_cache_path()))
|
|
|
|
|
|
func _error_message(body: PackedByteArray, fallback: String) -> String:
|
|
var raw := body.get_string_from_utf8().strip_edges()
|
|
var parsed: Variant = JSON.parse_string(raw)
|
|
if typeof(parsed) == TYPE_DICTIONARY:
|
|
var detail := str(parsed.get("detail", parsed.get("title", ""))).strip_edges()
|
|
if not detail.is_empty():
|
|
return detail
|
|
var text := raw.trim_prefix("\"").trim_suffix("\"")
|
|
return fallback if text.is_empty() else text
|
|
|
|
|
|
static func _default_preferences() -> Dictionary:
|
|
return {
|
|
"targetExamDate": null, "preparationStage": "pre_dedicated", "weeklyGoalHours": 10, "preferredStudyMode": "mixed",
|
|
"questionTextScale": 1.0, "ccsTextScale": 1.0
|
|
}
|