187 lines
5.2 KiB
GDScript
187 lines
5.2 KiB
GDScript
class_name SavedServerStore
|
|
extends Node
|
|
|
|
const FORMAT_VERSION: int = 1
|
|
const STORE_PATH: String = "user://saved_servers.json"
|
|
const TEMP_PATH: String = "user://saved_servers.json.tmp"
|
|
const BACKUP_PATH: String = "user://saved_servers.json.backup"
|
|
const MAX_ENTRIES: int = 100
|
|
|
|
var _entries: Array[Dictionary] = []
|
|
var _loaded: bool = false
|
|
|
|
|
|
func list_entries() -> Array[Dictionary]:
|
|
_ensure_loaded()
|
|
return _entries.duplicate(true)
|
|
|
|
|
|
func find_by_normalized_endpoint(value: String) -> Dictionary:
|
|
_ensure_loaded()
|
|
for entry: Dictionary in _entries:
|
|
if entry.get("normalized_endpoint", "") == value:
|
|
return entry.duplicate(true)
|
|
return {}
|
|
|
|
|
|
func save_entry(
|
|
display_name: String,
|
|
endpoint: ConnectionEndpoint,
|
|
) -> bool:
|
|
_ensure_loaded()
|
|
if (
|
|
endpoint == null
|
|
or not endpoint.is_valid()
|
|
or display_name.strip_edges().is_empty()
|
|
or display_name.length() > 80
|
|
):
|
|
return false
|
|
var now: int = int(Time.get_unix_time_from_system())
|
|
for entry: Dictionary in _entries:
|
|
if entry.get("normalized_endpoint", "") == endpoint.normalized_display:
|
|
entry["display_name"] = display_name.strip_edges()
|
|
entry["host"] = endpoint.host
|
|
entry["port"] = endpoint.port
|
|
entry["updated_at_unix"] = now
|
|
return _save_atomic()
|
|
if _entries.size() >= MAX_ENTRIES:
|
|
return false
|
|
_entries.append({
|
|
"entry_id": Crypto.new().generate_random_bytes(16).hex_encode(),
|
|
"display_name": display_name.strip_edges(),
|
|
"route_kind": "DIRECT",
|
|
"host": endpoint.host,
|
|
"port": endpoint.port,
|
|
"normalized_endpoint": endpoint.normalized_display,
|
|
"favorite": false,
|
|
"created_at_unix": now,
|
|
"updated_at_unix": now,
|
|
"last_success_at_unix": 0,
|
|
"last_observed_max_players": 0,
|
|
})
|
|
return _save_atomic()
|
|
|
|
|
|
func remove_entry(entry_id: String) -> bool:
|
|
_ensure_loaded()
|
|
for index: int in range(_entries.size()):
|
|
if _entries[index].get("entry_id", "") == entry_id:
|
|
_entries.remove_at(index)
|
|
return _save_atomic()
|
|
return false
|
|
|
|
|
|
func record_successful_connection(
|
|
endpoint: ConnectionEndpoint,
|
|
observed_max_players: int,
|
|
) -> bool:
|
|
_ensure_loaded()
|
|
if endpoint == null or not endpoint.is_valid():
|
|
return false
|
|
for entry: Dictionary in _entries:
|
|
if entry.get("normalized_endpoint", "") == endpoint.normalized_display:
|
|
entry["last_success_at_unix"] = int(Time.get_unix_time_from_system())
|
|
entry["last_observed_max_players"] = maxi(observed_max_players, 0)
|
|
entry["updated_at_unix"] = int(Time.get_unix_time_from_system())
|
|
return _save_atomic()
|
|
# Successful manual connections are deliberately not auto-saved.
|
|
return true
|
|
|
|
|
|
func _ensure_loaded() -> void:
|
|
if _loaded:
|
|
return
|
|
_loaded = true
|
|
_recover_interrupted_write()
|
|
if not FileAccess.file_exists(STORE_PATH):
|
|
return
|
|
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
|
|
if file == null:
|
|
return
|
|
var json := JSON.new()
|
|
var error: Error = json.parse(file.get_as_text())
|
|
file.close()
|
|
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
|
|
return
|
|
var data: Dictionary = json.data
|
|
if data.get("format_version") != FORMAT_VERSION:
|
|
return
|
|
var raw_entries: Variant = data.get("entries")
|
|
if typeof(raw_entries) != TYPE_ARRAY:
|
|
return
|
|
var seen: Dictionary[String, bool] = {}
|
|
for value: Variant in raw_entries:
|
|
if typeof(value) != TYPE_DICTIONARY:
|
|
continue
|
|
var entry: Dictionary = value
|
|
var stored_host: String = str(entry.get("host", ""))
|
|
var stored_port: int = int(entry.get("port", 0))
|
|
var endpoint_text: String = (
|
|
"[%s]:%d" % [stored_host, stored_port]
|
|
if stored_host.contains(":")
|
|
else "%s:%d" % [stored_host, stored_port]
|
|
)
|
|
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
|
|
if (
|
|
not endpoint.is_valid()
|
|
or seen.has(endpoint.normalized_display)
|
|
or typeof(entry.get("entry_id")) != TYPE_STRING
|
|
or typeof(entry.get("display_name")) != TYPE_STRING
|
|
):
|
|
continue
|
|
entry["normalized_endpoint"] = endpoint.normalized_display
|
|
seen[endpoint.normalized_display] = true
|
|
_entries.append(entry.duplicate(true))
|
|
if _entries.size() >= MAX_ENTRIES:
|
|
break
|
|
|
|
|
|
func _save_atomic() -> bool:
|
|
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
|
|
if file == null:
|
|
return false
|
|
file.store_string(JSON.stringify({
|
|
"format_version": FORMAT_VERSION,
|
|
"entries": _entries,
|
|
}, "\t"))
|
|
file.flush()
|
|
var error: Error = file.get_error()
|
|
file.close()
|
|
if error != OK:
|
|
_remove_if_present(TEMP_PATH)
|
|
return false
|
|
_remove_if_present(BACKUP_PATH)
|
|
var had_primary: bool = FileAccess.file_exists(STORE_PATH)
|
|
if had_primary and not _rename(STORE_PATH, BACKUP_PATH):
|
|
_remove_if_present(TEMP_PATH)
|
|
return false
|
|
if not _rename(TEMP_PATH, STORE_PATH):
|
|
if had_primary:
|
|
_rename(BACKUP_PATH, STORE_PATH)
|
|
return false
|
|
_remove_if_present(BACKUP_PATH)
|
|
return true
|
|
|
|
|
|
func _recover_interrupted_write() -> void:
|
|
if FileAccess.file_exists(STORE_PATH):
|
|
_remove_if_present(TEMP_PATH)
|
|
_remove_if_present(BACKUP_PATH)
|
|
return
|
|
if FileAccess.file_exists(BACKUP_PATH):
|
|
_rename(BACKUP_PATH, STORE_PATH)
|
|
_remove_if_present(TEMP_PATH)
|
|
|
|
|
|
func _rename(from_path: String, to_path: String) -> bool:
|
|
return DirAccess.rename_absolute(
|
|
ProjectSettings.globalize_path(from_path),
|
|
ProjectSettings.globalize_path(to_path)
|
|
) == OK
|
|
|
|
|
|
func _remove_if_present(path: String) -> bool:
|
|
return (
|
|
not FileAccess.file_exists(path)
|
|
or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK
|
|
)
|