netfishing/network/saved_server_store.gd

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
)