forked from xiangwang25/whale-town-front-v2
fix: stabilize web registration and asset loading
This commit is contained in:
@@ -17,11 +17,14 @@ signal email_verification_failed(message: String)
|
||||
signal profile_update_succeeded(profile: Dictionary)
|
||||
signal profile_update_failed(message: String)
|
||||
signal logout_completed()
|
||||
signal browser_bootstrap_received(kind: String)
|
||||
|
||||
const NetworkConfig = preload("res://_Core/utils/NetworkConfig.gd")
|
||||
|
||||
const DEFAULT_AUTH_CONFIG_PATH: String = "user://auth.cfg"
|
||||
const REQUEST_TIMEOUT: float = 12.0
|
||||
const BROWSER_BOOTSTRAP_STORAGE_KEY: String = "whaletown.auth.bootstrap"
|
||||
const BROWSER_BOOTSTRAP_POLL_INTERVAL: float = 0.25
|
||||
|
||||
var _access_token: String = ""
|
||||
var _refresh_token: String = ""
|
||||
@@ -32,9 +35,25 @@ 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 _pending_registration_username: String = ""
|
||||
var _pending_registration_password: String = ""
|
||||
var _registration_recovery_in_flight: bool = false
|
||||
var _registration_recovery_error: String = ""
|
||||
var _browser_bootstrap_kind: String = ""
|
||||
var _browser_bootstrap_poll_elapsed: float = 0.0
|
||||
|
||||
func _ready() -> void:
|
||||
_load_cached_session()
|
||||
_try_import_browser_bootstrap(false)
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if OS.get_name() != "Web" or not _browser_bootstrap_kind.is_empty():
|
||||
return
|
||||
_browser_bootstrap_poll_elapsed += delta
|
||||
if _browser_bootstrap_poll_elapsed < BROWSER_BOOTSTRAP_POLL_INTERVAL:
|
||||
return
|
||||
_browser_bootstrap_poll_elapsed = 0.0
|
||||
_try_import_browser_bootstrap(true)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
for request in _active_requests:
|
||||
@@ -70,6 +89,14 @@ func get_account_generation() -> int:
|
||||
func get_auth_config_path() -> String:
|
||||
return _auth_config_path
|
||||
|
||||
func consume_browser_bootstrap_kind() -> String:
|
||||
var kind := _browser_bootstrap_kind
|
||||
_browser_bootstrap_kind = ""
|
||||
return kind
|
||||
|
||||
func get_browser_bootstrap_kind() -> String:
|
||||
return _browser_bootstrap_kind
|
||||
|
||||
func login(identifier: String, password: String) -> void:
|
||||
var normalized_identifier := identifier.strip_edges()
|
||||
if normalized_identifier.is_empty():
|
||||
@@ -144,6 +171,10 @@ func register(username: String, password: String, nickname: String = "", email:
|
||||
if not normalized_skin_id.is_empty():
|
||||
payload["skin_id"] = normalized_skin_id
|
||||
|
||||
_pending_registration_username = normalized_username
|
||||
_pending_registration_password = password
|
||||
_registration_recovery_in_flight = false
|
||||
_registration_recovery_error = ""
|
||||
_advance_account_generation()
|
||||
_request_json("/auth/register", payload, _on_register_response, HTTPClient.METHOD_POST, false, true)
|
||||
|
||||
@@ -289,14 +320,21 @@ func _on_login_response(success: bool, data: Dictionary, error_info: Dictionary)
|
||||
|
||||
func _on_register_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||
if not success:
|
||||
register_failed.emit(str(error_info.get("message", "注册失败")))
|
||||
var message := str(error_info.get("message", "注册失败"))
|
||||
if _should_recover_registration(message):
|
||||
_recover_registration_session(message)
|
||||
return
|
||||
_clear_pending_registration()
|
||||
register_failed.emit(message)
|
||||
return
|
||||
|
||||
_apply_auth_payload(data)
|
||||
if not is_authenticated():
|
||||
_clear_pending_registration()
|
||||
register_failed.emit("注册响应缺少 access_token")
|
||||
return
|
||||
|
||||
_clear_pending_registration()
|
||||
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||
"user": get_current_user()
|
||||
})
|
||||
@@ -304,6 +342,49 @@ func _on_register_response(success: bool, data: Dictionary, error_info: Dictiona
|
||||
auth_state_changed.emit(true, get_current_user())
|
||||
_refresh_player_snapshot()
|
||||
|
||||
func _should_recover_registration(message: String) -> bool:
|
||||
if _registration_recovery_in_flight or _pending_registration_username.is_empty() or _pending_registration_password.is_empty():
|
||||
return false
|
||||
return message.begins_with("网络请求失败") \
|
||||
or message.begins_with("网络请求发送失败") \
|
||||
or message.contains("用户名已存在") \
|
||||
or message.contains("用户名已被注册")
|
||||
|
||||
func _recover_registration_session(original_error: String) -> void:
|
||||
_registration_recovery_in_flight = true
|
||||
_registration_recovery_error = original_error
|
||||
_request_json("/auth/login", {
|
||||
"identifier": _pending_registration_username,
|
||||
"password": _pending_registration_password,
|
||||
}, _on_registration_recovery_response, HTTPClient.METHOD_POST, false, true)
|
||||
|
||||
func _on_registration_recovery_response(success: bool, data: Dictionary, error_info: Dictionary) -> void:
|
||||
if not success:
|
||||
var original_error := _registration_recovery_error
|
||||
_clear_pending_registration()
|
||||
register_failed.emit(str(error_info.get("message", original_error if not original_error.is_empty() else "注册状态确认失败")))
|
||||
return
|
||||
|
||||
_apply_auth_payload(data)
|
||||
if not is_authenticated():
|
||||
_clear_pending_registration()
|
||||
register_failed.emit("账号已创建,但登录确认失败")
|
||||
return
|
||||
|
||||
_clear_pending_registration()
|
||||
_emit_event(EventNames.AUTH_REGISTER_SUCCESS, {
|
||||
"user": get_current_user()
|
||||
})
|
||||
register_succeeded.emit(get_current_user())
|
||||
auth_state_changed.emit(true, get_current_user())
|
||||
_refresh_player_snapshot()
|
||||
|
||||
func _clear_pending_registration() -> void:
|
||||
_pending_registration_username = ""
|
||||
_pending_registration_password = ""
|
||||
_registration_recovery_in_flight = false
|
||||
_registration_recovery_error = ""
|
||||
|
||||
func _on_refresh_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
||||
_refresh_in_flight = false
|
||||
if not success:
|
||||
@@ -334,6 +415,38 @@ func _apply_auth_payload(payload: Dictionary) -> void:
|
||||
_session_generation += 1
|
||||
_save_cached_session()
|
||||
|
||||
func _try_import_browser_bootstrap(emit_signal: bool) -> bool:
|
||||
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||
return false
|
||||
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||
if js_bridge == null:
|
||||
return false
|
||||
var raw: String = str(js_bridge.eval(
|
||||
"window.sessionStorage.getItem('%s') || ''" % BROWSER_BOOTSTRAP_STORAGE_KEY,
|
||||
true
|
||||
)).strip_edges()
|
||||
if raw.is_empty():
|
||||
return false
|
||||
var json := JSON.new()
|
||||
if json.parse(raw) != OK or not (json.data is Dictionary):
|
||||
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||
return false
|
||||
var bootstrap := json.data as Dictionary
|
||||
var kind := str(bootstrap.get("kind", "")).strip_edges()
|
||||
var payload_variant: Variant = bootstrap.get("payload", {})
|
||||
if not ["login", "register"].has(kind) or not (payload_variant is Dictionary):
|
||||
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||
return false
|
||||
_apply_auth_payload(payload_variant as Dictionary)
|
||||
if not is_authenticated():
|
||||
return false
|
||||
_browser_bootstrap_kind = kind
|
||||
js_bridge.eval("window.sessionStorage.removeItem('%s')" % BROWSER_BOOTSTRAP_STORAGE_KEY)
|
||||
if emit_signal:
|
||||
browser_bootstrap_received.emit(kind)
|
||||
auth_state_changed.emit(true, get_current_user())
|
||||
return true
|
||||
|
||||
func _on_profile_response(success: bool, data: Dictionary, _error_info: Dictionary) -> void:
|
||||
if not success:
|
||||
profile_update_failed.emit(str(_error_info.get("message", "玩家资料保存失败")))
|
||||
|
||||
@@ -30,6 +30,8 @@ signal scene_changed(scene_name: String)
|
||||
# 场景切换开始信号
|
||||
# 参数: scene_name - 即将切换到的场景名称
|
||||
signal scene_change_started(scene_name: String)
|
||||
signal scene_pack_progress(scene_name: String, downloaded_bytes: int, total_bytes: int)
|
||||
signal scene_pack_failed(scene_name: String, message: String)
|
||||
|
||||
# ============ 成员变量 ============
|
||||
|
||||
@@ -38,6 +40,25 @@ var current_scene_name: String = "" # 当前场景名称
|
||||
var is_changing_scene: bool = false # 是否正在切换场景
|
||||
var _next_scene_position: Variant = null # 下一个场景的初始位置 (Vector2 or null)
|
||||
var _next_spawn_name: String = "" # 下一个场景的出生点名称 (String)
|
||||
var _pack_manifest: Dictionary = {}
|
||||
var _loaded_scene_packs: Dictionary = {}
|
||||
var _active_pack_request: HTTPRequest
|
||||
var _active_pack_scene_name: String = ""
|
||||
var _pack_overlay: CanvasLayer
|
||||
var _pack_status_label: Label
|
||||
var _pack_progress_bar: ProgressBar
|
||||
|
||||
const PACK_MANIFEST_PATH: String = "/packs/manifest.json"
|
||||
const PACK_CACHE_DIR: String = "user://scene-packs"
|
||||
const SCENE_PACK_KEYS: Dictionary = {
|
||||
"main": "auth",
|
||||
"auth": "auth",
|
||||
"square": "square",
|
||||
"work_zone": "work_zone",
|
||||
"cafe_interior": "cafe_interior",
|
||||
"room": "personal_space",
|
||||
"personal_space": "personal_space",
|
||||
}
|
||||
|
||||
# 场景路径映射表
|
||||
# 将场景名称映射到实际的文件路径
|
||||
@@ -101,6 +122,14 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
||||
if use_transition:
|
||||
await show_transition()
|
||||
|
||||
if OS.get_name() == "Web" and SCENE_PACK_KEYS.has(scene_name):
|
||||
var packLoaded := await _ensure_scene_pack(scene_name)
|
||||
if not packLoaded:
|
||||
is_changing_scene = false
|
||||
if use_transition:
|
||||
await hide_transition()
|
||||
return false
|
||||
|
||||
# 执行场景切换
|
||||
var error = get_tree().change_scene_to_file(scene_path)
|
||||
if error != OK:
|
||||
@@ -119,6 +148,162 @@ func change_scene(scene_name: String, use_transition: bool = true):
|
||||
|
||||
return true
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not is_instance_valid(_active_pack_request):
|
||||
return
|
||||
var downloaded := _active_pack_request.get_downloaded_bytes()
|
||||
var total := _active_pack_request.get_body_size()
|
||||
if is_instance_valid(_pack_progress_bar):
|
||||
_pack_progress_bar.indeterminate = total <= 0
|
||||
if total > 0:
|
||||
_pack_progress_bar.max_value = total
|
||||
_pack_progress_bar.value = downloaded
|
||||
if is_instance_valid(_pack_status_label):
|
||||
_pack_status_label.text = "正在加载场景 %s" % _format_download_progress(downloaded, total)
|
||||
scene_pack_progress.emit(_active_pack_scene_name, downloaded, total)
|
||||
|
||||
func _ensure_scene_pack(scene_name: String) -> bool:
|
||||
var packKey := str(SCENE_PACK_KEYS.get(scene_name, ""))
|
||||
if packKey.is_empty() or bool(_loaded_scene_packs.get(packKey, false)):
|
||||
return true
|
||||
_show_pack_overlay("正在准备场景...")
|
||||
if _pack_manifest.is_empty():
|
||||
_pack_manifest = await _download_pack_manifest()
|
||||
if _pack_manifest.is_empty():
|
||||
_show_pack_error(scene_name, "场景清单加载失败,请检查网络后重试")
|
||||
return false
|
||||
var entryVariant: Variant = _pack_manifest.get(packKey, {})
|
||||
if not (entryVariant is Dictionary):
|
||||
_show_pack_error(scene_name, "服务器缺少场景资源:%s" % packKey)
|
||||
return false
|
||||
var entry := entryVariant as Dictionary
|
||||
var fileName := str(entry.get("file", "")).get_file()
|
||||
if fileName.is_empty() or not fileName.ends_with(".pck"):
|
||||
_show_pack_error(scene_name, "场景资源清单格式错误")
|
||||
return false
|
||||
var cacheDirAbsolute := ProjectSettings.globalize_path(PACK_CACHE_DIR)
|
||||
DirAccess.make_dir_recursive_absolute(cacheDirAbsolute)
|
||||
var localPath := "%s/%s" % [PACK_CACHE_DIR, fileName]
|
||||
if FileAccess.file_exists(localPath) and ProjectSettings.load_resource_pack(localPath, true):
|
||||
_loaded_scene_packs[packKey] = true
|
||||
_hide_pack_overlay()
|
||||
return true
|
||||
if FileAccess.file_exists(localPath):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||
var packUrl := _resolve_web_url("/packs/%s" % fileName)
|
||||
if packUrl.is_empty() or not await _download_pack(scene_name, packUrl, localPath):
|
||||
_show_pack_error(scene_name, "场景下载失败,请检查网络后重试")
|
||||
return false
|
||||
if not ProjectSettings.load_resource_pack(localPath, true):
|
||||
DirAccess.remove_absolute(ProjectSettings.globalize_path(localPath))
|
||||
_show_pack_error(scene_name, "场景资源损坏,请重新进入")
|
||||
return false
|
||||
_loaded_scene_packs[packKey] = true
|
||||
_hide_pack_overlay()
|
||||
return true
|
||||
|
||||
func _download_pack_manifest() -> Dictionary:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 12.0
|
||||
add_child(request)
|
||||
var url := _resolve_web_url(PACK_MANIFEST_PATH)
|
||||
if url.is_empty() or request.request(url, ["Cache-Control: no-cache"]) != OK:
|
||||
request.queue_free()
|
||||
return {}
|
||||
var response: Array = await request.request_completed
|
||||
request.queue_free()
|
||||
if response.size() < 4 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
|
||||
return {}
|
||||
var responseCode := int(response[1])
|
||||
if responseCode < 200 or responseCode >= 300:
|
||||
return {}
|
||||
var json := JSON.new()
|
||||
if json.parse((response[3] as PackedByteArray).get_string_from_utf8()) != OK:
|
||||
return {}
|
||||
if not (json.data is Dictionary):
|
||||
return {}
|
||||
var root := json.data as Dictionary
|
||||
var packsVariant: Variant = root.get("packs", {})
|
||||
return packsVariant as Dictionary if packsVariant is Dictionary else {}
|
||||
|
||||
func _download_pack(scene_name: String, url: String, local_path: String) -> bool:
|
||||
var request := HTTPRequest.new()
|
||||
request.timeout = 180.0
|
||||
request.download_file = local_path
|
||||
add_child(request)
|
||||
_active_pack_request = request
|
||||
_active_pack_scene_name = scene_name
|
||||
if request.request(url) != OK:
|
||||
_active_pack_request = null
|
||||
_active_pack_scene_name = ""
|
||||
request.queue_free()
|
||||
return false
|
||||
var response: Array = await request.request_completed
|
||||
_active_pack_request = null
|
||||
_active_pack_scene_name = ""
|
||||
request.queue_free()
|
||||
if response.size() < 2 or int(response[0]) != HTTPRequest.RESULT_SUCCESS:
|
||||
return false
|
||||
var responseCode := int(response[1])
|
||||
return responseCode >= 200 and responseCode < 300
|
||||
|
||||
func _resolve_web_url(path: String) -> String:
|
||||
if not Engine.has_singleton("JavaScriptBridge"):
|
||||
return ""
|
||||
var jsBridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||
if jsBridge == null:
|
||||
return ""
|
||||
return str(jsBridge.eval("new URL('%s', window.location.href).href" % path, true)).strip_edges()
|
||||
|
||||
func _show_pack_overlay(message: String) -> void:
|
||||
if not is_instance_valid(_pack_overlay):
|
||||
_pack_overlay = CanvasLayer.new()
|
||||
_pack_overlay.layer = 1000
|
||||
add_child(_pack_overlay)
|
||||
var backdrop := ColorRect.new()
|
||||
backdrop.color = Color(0.025, 0.09, 0.15, 0.92)
|
||||
backdrop.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_pack_overlay.add_child(backdrop)
|
||||
var center := CenterContainer.new()
|
||||
center.set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||
_pack_overlay.add_child(center)
|
||||
var box := VBoxContainer.new()
|
||||
box.custom_minimum_size = Vector2(420, 100)
|
||||
box.add_theme_constant_override("separation", 18)
|
||||
center.add_child(box)
|
||||
_pack_status_label = Label.new()
|
||||
_pack_status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
|
||||
_pack_status_label.add_theme_font_size_override("font_size", 22)
|
||||
box.add_child(_pack_status_label)
|
||||
_pack_progress_bar = ProgressBar.new()
|
||||
_pack_progress_bar.custom_minimum_size = Vector2(420, 14)
|
||||
_pack_progress_bar.show_percentage = false
|
||||
_pack_progress_bar.indeterminate = true
|
||||
box.add_child(_pack_progress_bar)
|
||||
_pack_overlay.show()
|
||||
_pack_status_label.text = message
|
||||
_pack_progress_bar.indeterminate = true
|
||||
|
||||
func _hide_pack_overlay() -> void:
|
||||
if is_instance_valid(_pack_overlay):
|
||||
_pack_overlay.hide()
|
||||
|
||||
func _show_pack_error(scene_name: String, message: String) -> void:
|
||||
if not is_instance_valid(_pack_overlay):
|
||||
_show_pack_overlay(message)
|
||||
_pack_status_label.text = message
|
||||
_pack_progress_bar.hide()
|
||||
scene_pack_failed.emit(scene_name, message)
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
_pack_progress_bar.show()
|
||||
_hide_pack_overlay()
|
||||
|
||||
func _format_download_progress(downloaded: int, total: int) -> String:
|
||||
var downloadedMb := float(downloaded) / 1048576.0
|
||||
if total <= 0:
|
||||
return "%.1f MB" % downloadedMb
|
||||
return "%.1f / %.1f MB" % [downloadedMb, float(total) / 1048576.0]
|
||||
|
||||
# ============ 查询方法 ============
|
||||
|
||||
# 获取当前场景名称
|
||||
|
||||
@@ -17,8 +17,8 @@ const CONFIG_PATHS: Array[String] = [
|
||||
]
|
||||
|
||||
const DEFAULT_PROFILE: String = "production"
|
||||
const DEFAULT_API_BASE_URL: String = "https://whaletownend.xinghangee.icu"
|
||||
const DEFAULT_WS_URL: String = "wss://whaletownend.xinghangee.icu/game"
|
||||
const DEFAULT_API_BASE_URL: String = "https://whaletown.novamailio.com/api"
|
||||
const DEFAULT_WS_URL: String = "wss://whaletown.novamailio.com/game"
|
||||
|
||||
const API_BASE_URL_ENV_KEY: String = "WHALETOWN_API_BASE_URL"
|
||||
const CHAT_WS_URL_ENV_KEY: String = "WHALETOWN_CHAT_WS_URL"
|
||||
@@ -35,6 +35,10 @@ static func get_api_base_url() -> String:
|
||||
if not env_url.is_empty():
|
||||
return _trim_trailing_slash(env_url)
|
||||
|
||||
var browser_url := _get_browser_same_origin_url("/api")
|
||||
if not browser_url.is_empty():
|
||||
return browser_url
|
||||
|
||||
var network_config := _get_active_network_config()
|
||||
var url: String = str(network_config.get("api_base_url", "")).strip_edges()
|
||||
if not url.is_empty():
|
||||
@@ -51,6 +55,10 @@ static func get_chat_ws_url() -> String:
|
||||
if not env_url.is_empty():
|
||||
return env_url
|
||||
|
||||
var browser_url := _get_browser_same_origin_websocket_url("/game")
|
||||
if not browser_url.is_empty():
|
||||
return browser_url
|
||||
|
||||
var network_config := _get_active_network_config()
|
||||
var url: String = str(network_config.get("chat_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||
if not url.is_empty():
|
||||
@@ -75,6 +83,10 @@ static func get_location_ws_url() -> String:
|
||||
if not game_env_url.is_empty():
|
||||
return game_env_url
|
||||
|
||||
var browser_url := _get_browser_same_origin_websocket_url("/game")
|
||||
if not browser_url.is_empty():
|
||||
return browser_url
|
||||
|
||||
var network_config := _get_active_network_config()
|
||||
var url: String = str(network_config.get("location_ws_url", network_config.get("game_ws_url", ""))).strip_edges()
|
||||
if not url.is_empty():
|
||||
@@ -154,7 +166,7 @@ static func _load_config() -> Dictionary:
|
||||
return {}
|
||||
|
||||
static func _get_browser_query_value(key: String) -> String:
|
||||
if OS.get_name() != "Web" or not OS.is_debug_build():
|
||||
if OS.get_name() != "Web":
|
||||
return ""
|
||||
|
||||
if not Engine.has_singleton("JavaScriptBridge"):
|
||||
@@ -168,6 +180,25 @@ static func _get_browser_query_value(key: String) -> String:
|
||||
var value: Variant = js_bridge.eval(script, true)
|
||||
return str(value).strip_edges()
|
||||
|
||||
static func _get_browser_same_origin_url(path: String) -> String:
|
||||
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||
return ""
|
||||
var js_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||
if js_bridge == null:
|
||||
return ""
|
||||
var origin: String = str(js_bridge.eval("window.location.origin || ''", true)).strip_edges()
|
||||
if not origin.begins_with("http://") and not origin.begins_with("https://"):
|
||||
return ""
|
||||
return _trim_trailing_slash(origin) + path
|
||||
|
||||
static func _get_browser_same_origin_websocket_url(path: String) -> String:
|
||||
var http_url := _get_browser_same_origin_url("")
|
||||
if http_url.begins_with("https://"):
|
||||
return "wss://" + http_url.substr(8) + path
|
||||
if http_url.begins_with("http://"):
|
||||
return "ws://" + http_url.substr(7) + path
|
||||
return ""
|
||||
|
||||
static func _get_browser_url_override(key: String, allowed_schemes: Array[String]) -> String:
|
||||
var value := _get_browser_query_value(key)
|
||||
if value.is_empty():
|
||||
|
||||
123
_Core/utils/WebFilePicker.gd
Normal file
123
_Core/utils/WebFilePicker.gd
Normal file
@@ -0,0 +1,123 @@
|
||||
extends RefCounted
|
||||
|
||||
var _javascript_callback: Variant
|
||||
var _result_callback: Callable
|
||||
var _callback_name: String = ""
|
||||
|
||||
func open(accept: String, max_bytes: int, result_callback: Callable) -> bool:
|
||||
if OS.get_name() != "Web" or not Engine.has_singleton("JavaScriptBridge"):
|
||||
return false
|
||||
if _result_callback.is_valid():
|
||||
return false
|
||||
|
||||
var javascript_bridge: Object = Engine.get_singleton("JavaScriptBridge")
|
||||
_callback_name = "__whaletownFilePicker%d" % get_instance_id()
|
||||
_result_callback = result_callback
|
||||
_javascript_callback = javascript_bridge.create_callback(_on_javascript_result)
|
||||
var window: Variant = javascript_bridge.get_interface("window")
|
||||
if window == null:
|
||||
_reset()
|
||||
return false
|
||||
window[_callback_name] = _javascript_callback
|
||||
|
||||
var script := """
|
||||
(() => {
|
||||
const callbackName = %s;
|
||||
const callback = window[callbackName];
|
||||
if (typeof callback !== "function") return false;
|
||||
|
||||
const previous = document.getElementById("whaletown-web-file-picker");
|
||||
if (previous) previous.remove();
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.id = "whaletown-web-file-picker";
|
||||
input.type = "file";
|
||||
input.accept = %s;
|
||||
input.style.display = "none";
|
||||
let finished = false;
|
||||
|
||||
const finish = (status, name, mime, payload) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
callback(status, name || "", mime || "", payload || "");
|
||||
input.remove();
|
||||
delete window[callbackName];
|
||||
};
|
||||
|
||||
input.addEventListener("cancel", () => finish("cancel", "", "", ""));
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (!file) {
|
||||
finish("cancel", "", "", "");
|
||||
return;
|
||||
}
|
||||
if (file.size > %d) {
|
||||
finish("too_large", file.name, file.type, "");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => finish("read_error", file.name, file.type, "");
|
||||
reader.onload = () => {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
const chunks = [];
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)));
|
||||
}
|
||||
finish("ok", file.name, file.type, btoa(chunks.join("")));
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
return true;
|
||||
})()
|
||||
""" % [JSON.stringify(_callback_name), JSON.stringify(accept), max_bytes]
|
||||
var opened: Variant = javascript_bridge.eval(script, true)
|
||||
if opened != true:
|
||||
_reset()
|
||||
return false
|
||||
return true
|
||||
|
||||
func _on_javascript_result(arguments: Array) -> void:
|
||||
var callback := _result_callback
|
||||
_reset()
|
||||
if not callback.is_valid():
|
||||
return
|
||||
var status := str(arguments[0]) if arguments.size() > 0 else "read_error"
|
||||
var file_name := str(arguments[1]) if arguments.size() > 1 else ""
|
||||
var mime_type := str(arguments[2]) if arguments.size() > 2 else ""
|
||||
var base64_data := str(arguments[3]) if arguments.size() > 3 else ""
|
||||
callback.call(status, file_name, mime_type, base64_data)
|
||||
|
||||
func _reset() -> void:
|
||||
_javascript_callback = null
|
||||
_result_callback = Callable()
|
||||
_callback_name = ""
|
||||
|
||||
static func save_base64_file(base64_data: String, file_name: String, prefix: String) -> String:
|
||||
if base64_data.is_empty():
|
||||
return ""
|
||||
var bytes := Marshalls.base64_to_raw(base64_data)
|
||||
if bytes.is_empty():
|
||||
return ""
|
||||
|
||||
var extension := file_name.get_extension().to_lower()
|
||||
if not ["png", "jpg", "jpeg", "webp"].has(extension):
|
||||
extension = "bin"
|
||||
var root := DirAccess.open("user://")
|
||||
if root == null:
|
||||
return ""
|
||||
if not root.dir_exists("web_uploads") and root.make_dir("web_uploads") != OK:
|
||||
return ""
|
||||
|
||||
var safe_prefix := prefix.to_lower().replace(" ", "_")
|
||||
var path := "user://web_uploads/%s_%d.%s" % [safe_prefix, Time.get_ticks_msec(), extension]
|
||||
var file := FileAccess.open(path, FileAccess.WRITE)
|
||||
if file == null:
|
||||
return ""
|
||||
file.store_buffer(bytes)
|
||||
file.close()
|
||||
return path
|
||||
1
_Core/utils/WebFilePicker.gd.uid
Normal file
1
_Core/utils/WebFilePicker.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dqvrn7yv0ap4y
|
||||
Reference in New Issue
Block a user