Files
step3/scripts/autoload/account_profile_service.gd
hz 489b9c3d7c Fix text zoom not actually syncing, and expose it in Settings
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.
2026-07-23 04:37:45 -05:00

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
}