2026-07-29 23:51:25 -04:00
|
|
|
class_name PlayerDataRoot
|
|
|
|
|
extends Node
|
|
|
|
|
|
|
|
|
|
signal status_changed(message: String)
|
|
|
|
|
signal conflict_detected(message: String, conflict_path: String)
|
|
|
|
|
|
|
|
|
|
const BOOTSTRAP_PATH := "user://data_root_bootstrap.json"
|
|
|
|
|
const BOOTSTRAP_TEMP_PATH := "user://data_root_bootstrap.json.tmp"
|
|
|
|
|
const BOOTSTRAP_VERSION := 1
|
|
|
|
|
const MANIFEST_VERSION := 1
|
|
|
|
|
const LAYOUT_VERSION := 1
|
|
|
|
|
const MANIFEST_FILENAME := "netfishing_data.json"
|
|
|
|
|
const README_FILENAME := "README.txt"
|
|
|
|
|
const ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
|
|
|
|
|
const APPLICATION_ID := "netfishing"
|
|
|
|
|
const APP_DATA_PORTABLE_PATH := "user://portable-data"
|
2026-07-30 22:17:00 -04:00
|
|
|
const PATH_ALLOWED: StringName = &"allowed"
|
|
|
|
|
const PATH_SOURCE_PROJECT: StringName = &"source_project"
|
|
|
|
|
const PATH_INSTALLATION: StringName = &"installation"
|
2026-07-29 23:51:25 -04:00
|
|
|
|
|
|
|
|
enum Mode {
|
|
|
|
|
UNRESOLVED,
|
|
|
|
|
SELECTED_FOLDER,
|
|
|
|
|
APP_DATA,
|
|
|
|
|
ENVIRONMENT_OVERRIDE,
|
|
|
|
|
COMMAND_LINE_OVERRIDE,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var mode := Mode.UNRESOLVED
|
|
|
|
|
var root_path := ""
|
|
|
|
|
var root_id := ""
|
|
|
|
|
var device_id := ""
|
|
|
|
|
var error_message := ""
|
|
|
|
|
var requires_selection := false
|
|
|
|
|
var override_active := false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func resolve() -> bool:
|
|
|
|
|
_load_bootstrap_identity()
|
2026-07-30 00:54:53 -04:00
|
|
|
var command_line: String = _command_line_override()
|
2026-07-29 23:51:25 -04:00
|
|
|
if not command_line.is_empty():
|
|
|
|
|
override_active = true
|
|
|
|
|
mode = Mode.COMMAND_LINE_OVERRIDE
|
|
|
|
|
return _activate_existing(command_line, "", false)
|
|
|
|
|
if OS.has_environment(ENVIRONMENT_VARIABLE):
|
|
|
|
|
override_active = true
|
|
|
|
|
mode = Mode.ENVIRONMENT_OVERRIDE
|
2026-07-30 00:54:53 -04:00
|
|
|
var environment_path: String = OS.get_environment(ENVIRONMENT_VARIABLE)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not environment_path.is_absolute_path():
|
|
|
|
|
return _fail("NETFISHING_DATA_DIR must be an absolute path.")
|
|
|
|
|
return _activate_existing(environment_path, "", false)
|
2026-07-30 00:54:53 -04:00
|
|
|
var bootstrap: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
|
2026-07-29 23:51:25 -04:00
|
|
|
if bootstrap.is_empty() and FileAccess.file_exists(BOOTSTRAP_PATH + ".backup"):
|
|
|
|
|
bootstrap = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
|
|
|
|
|
if not bootstrap.is_empty():
|
|
|
|
|
if bootstrap.get("format_version") != BOOTSTRAP_VERSION:
|
|
|
|
|
return _fail("The data-folder pointer uses an unsupported version.")
|
2026-07-30 00:54:53 -04:00
|
|
|
var selected: String = str(bootstrap.get("selected_absolute_path", ""))
|
|
|
|
|
var expected: String = str(bootstrap.get("expected_root_id", ""))
|
2026-07-29 23:51:25 -04:00
|
|
|
if selected.is_empty():
|
|
|
|
|
return _fail("The data-folder pointer is incomplete.")
|
|
|
|
|
mode = (
|
|
|
|
|
Mode.APP_DATA
|
|
|
|
|
if selected == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
|
|
|
|
|
else Mode.SELECTED_FOLDER
|
|
|
|
|
)
|
|
|
|
|
return _activate_existing(selected, expected, false)
|
|
|
|
|
requires_selection = true
|
|
|
|
|
error_message = ""
|
|
|
|
|
return false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func default_visible_path() -> String:
|
2026-07-30 00:54:53 -04:00
|
|
|
var documents: String = OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
|
2026-07-29 23:51:25 -04:00
|
|
|
return documents.path_join("NETFISHING") if not documents.is_empty() else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func select_new_root(path: String, app_data: bool = false) -> bool:
|
|
|
|
|
if override_active:
|
|
|
|
|
return _fail("The data folder is controlled by a process override.")
|
|
|
|
|
if device_id.length() != 32:
|
|
|
|
|
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = _normalize(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if app_data:
|
|
|
|
|
normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
|
|
|
|
|
if not _validate_candidate(normalized, true):
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(manifest_path):
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest: Dictionary = _read_json(manifest_path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _valid_manifest(manifest):
|
2026-07-30 10:46:15 -04:00
|
|
|
return _fail("The selected folder has a malformed NETfishing manifest.")
|
2026-07-29 23:51:25 -04:00
|
|
|
root_id = str(manifest["root_id"])
|
|
|
|
|
else:
|
|
|
|
|
if not _directory_is_empty(normalized) and not app_data:
|
|
|
|
|
return _fail(
|
2026-07-30 10:46:15 -04:00
|
|
|
"Choose an empty folder or an existing NETfishing data folder."
|
2026-07-29 23:51:25 -04:00
|
|
|
)
|
|
|
|
|
root_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
|
|
|
|
if not _create_layout(normalized, root_id):
|
2026-07-30 10:46:15 -04:00
|
|
|
return _fail("The NETfishing data folder could not be created.")
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _write_bootstrap(normalized, root_id):
|
|
|
|
|
return _fail("The data-folder pointer could not be saved.")
|
|
|
|
|
root_path = normalized
|
|
|
|
|
mode = Mode.APP_DATA if app_data else Mode.SELECTED_FOLDER
|
|
|
|
|
requires_selection = false
|
|
|
|
|
error_message = ""
|
|
|
|
|
status_changed.emit("Data folder ready.")
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func create_unbound_root(path: String) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = _normalize(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _validate_candidate(normalized, true):
|
|
|
|
|
return {"ok": false, "message": error_message}
|
|
|
|
|
if not _directory_is_empty(normalized):
|
|
|
|
|
return {"ok": false, "message": "The destination staging folder is not empty."}
|
2026-07-30 00:54:53 -04:00
|
|
|
var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _create_layout(normalized, id):
|
|
|
|
|
return {"ok": false, "message": "The portable layout could not be created."}
|
|
|
|
|
return {"ok": true, "root_id": id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func create_app_data_layout_for_migration(path: String) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = _normalize(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _validate_candidate(normalized, false):
|
|
|
|
|
return {"ok": false, "message": error_message}
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(manifest_path):
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest: Dictionary = _read_json(manifest_path)
|
2026-07-29 23:51:25 -04:00
|
|
|
return (
|
|
|
|
|
{"ok": true, "root_id": str(manifest.get("root_id", ""))}
|
|
|
|
|
if _valid_manifest(manifest)
|
|
|
|
|
else {"ok": false, "message": "The app-data manifest is malformed."}
|
|
|
|
|
)
|
2026-07-30 00:54:53 -04:00
|
|
|
var id: String = Crypto.new().generate_random_bytes(16).hex_encode()
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _create_layout(normalized, id):
|
|
|
|
|
return {"ok": false, "message": "The app-data layout could not be created."}
|
|
|
|
|
return {"ok": true, "root_id": id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func use_existing_root(path: String) -> bool:
|
|
|
|
|
if override_active:
|
|
|
|
|
return _fail("The data folder is controlled by a process override.")
|
|
|
|
|
if device_id.length() != 32:
|
|
|
|
|
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = _normalize(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _activate_existing(normalized, "", true):
|
|
|
|
|
return false
|
|
|
|
|
if not _write_bootstrap(root_path, root_id):
|
|
|
|
|
root_path = ""
|
|
|
|
|
root_id = ""
|
|
|
|
|
return _fail("The data-folder pointer could not be saved.")
|
|
|
|
|
mode = (
|
|
|
|
|
Mode.APP_DATA
|
|
|
|
|
if normalized == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
|
|
|
|
|
else Mode.SELECTED_FOLDER
|
|
|
|
|
)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
2026-07-30 00:54:53 -04:00
|
|
|
func path_for(store_owner: StringName) -> String:
|
2026-07-29 23:51:25 -04:00
|
|
|
var relative: String = {
|
|
|
|
|
&"player_save": "player/player_save.json",
|
|
|
|
|
&"network_profile": "player/network_profile.json",
|
|
|
|
|
&"player_appearance": "player/player_appearance.json",
|
|
|
|
|
&"saved_servers": "social/saved_servers.json",
|
|
|
|
|
&"known_players": "social/known_players.json",
|
|
|
|
|
&"player_relationships": "social/player_relationships.json",
|
|
|
|
|
&"server_trust": "social/server_trust.json",
|
|
|
|
|
&"host_bans": "social/host_bans.json",
|
2026-07-30 00:54:53 -04:00
|
|
|
}.get(store_owner, "")
|
2026-07-29 23:51:25 -04:00
|
|
|
return root_path.path_join(relative) if not relative.is_empty() else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func conflict_directory() -> String:
|
|
|
|
|
return root_path.path_join("backups/conflicts")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func identity_backup_directory() -> String:
|
|
|
|
|
return root_path.path_join("identity-backups")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func migration_backup_directory() -> String:
|
|
|
|
|
return root_path.path_join("backups/migrations")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func storage_mode_text() -> String:
|
|
|
|
|
return {
|
|
|
|
|
Mode.SELECTED_FOLDER: "Selected folder",
|
|
|
|
|
Mode.APP_DATA: "App data",
|
|
|
|
|
Mode.ENVIRONMENT_OVERRIDE: "Environment override",
|
|
|
|
|
Mode.COMMAND_LINE_OVERRIDE: "Command-line override",
|
|
|
|
|
}.get(mode, "Unavailable")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func open_folder() -> bool:
|
|
|
|
|
return not root_path.is_empty() and OS.shell_open(root_path) == OK
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func report_conflict(message: String, conflict_path: String) -> void:
|
|
|
|
|
error_message = message
|
|
|
|
|
conflict_detected.emit(message, conflict_path)
|
|
|
|
|
status_changed.emit(message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _activate_existing(path: String, expected_id: String, permit_creation: bool) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var normalized: String = _normalize(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _validate_candidate(normalized, permit_creation):
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest_path: String = normalized.path_join(MANIFEST_FILENAME)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not FileAccess.file_exists(manifest_path):
|
2026-07-30 10:46:15 -04:00
|
|
|
return _fail("The selected folder is not a NETfishing data folder.")
|
2026-07-30 00:54:53 -04:00
|
|
|
var manifest: Dictionary = _read_json(manifest_path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if not _valid_manifest(manifest):
|
2026-07-30 10:46:15 -04:00
|
|
|
return _fail("The NETfishing data-folder manifest is malformed.")
|
2026-07-30 00:54:53 -04:00
|
|
|
var found_id: String = str(manifest["root_id"])
|
2026-07-29 23:51:25 -04:00
|
|
|
if not expected_id.is_empty() and expected_id != found_id:
|
|
|
|
|
return _fail("The selected data folder does not match this device pointer.")
|
|
|
|
|
if not _test_writable(normalized):
|
2026-07-30 10:46:15 -04:00
|
|
|
return _fail("The NETfishing data folder is unavailable or unwritable.")
|
2026-07-29 23:51:25 -04:00
|
|
|
root_path = normalized
|
|
|
|
|
root_id = found_id
|
|
|
|
|
requires_selection = false
|
|
|
|
|
error_message = ""
|
|
|
|
|
if PortableFileGuard.has_syncthing_conflict(root_path.path_join("player")):
|
|
|
|
|
status_changed.emit("Syncthing conflict copies were found. Review the data folder.")
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _validate_candidate(path: String, create: bool) -> bool:
|
2026-07-30 22:17:00 -04:00
|
|
|
var normalized: String = _normalize(path)
|
|
|
|
|
if normalized.is_empty() or not normalized.is_absolute_path():
|
2026-07-29 23:51:25 -04:00
|
|
|
return _fail("Choose an absolute filesystem folder.")
|
2026-07-30 22:17:00 -04:00
|
|
|
var classification: StringName = classify_candidate_path(
|
|
|
|
|
normalized,
|
|
|
|
|
ProjectSettings.globalize_path("res://"),
|
|
|
|
|
OS.get_executable_path().get_base_dir(),
|
|
|
|
|
)
|
|
|
|
|
if classification == PATH_SOURCE_PROJECT:
|
2026-07-29 23:51:25 -04:00
|
|
|
return _fail("The project folder cannot be used as the player data folder.")
|
2026-07-30 22:17:00 -04:00
|
|
|
if classification == PATH_INSTALLATION:
|
|
|
|
|
return _fail(
|
|
|
|
|
"The application installation folder cannot be used as the player data folder."
|
|
|
|
|
)
|
|
|
|
|
if not DirAccess.dir_exists_absolute(normalized):
|
|
|
|
|
if not create or DirAccess.make_dir_recursive_absolute(normalized) != OK:
|
2026-07-29 23:51:25 -04:00
|
|
|
return _fail("The selected data folder is unavailable.")
|
2026-07-30 22:17:00 -04:00
|
|
|
return _test_writable(normalized)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func classify_candidate_path(
|
|
|
|
|
candidate_path: String,
|
|
|
|
|
project_reference: String,
|
|
|
|
|
installation_reference: String,
|
|
|
|
|
case_insensitive: bool = OS.get_name() == "Windows",
|
|
|
|
|
) -> StringName:
|
|
|
|
|
var candidate: String = _normalize_comparison_path(
|
|
|
|
|
candidate_path, case_insensitive
|
|
|
|
|
)
|
|
|
|
|
if candidate.is_empty() or not candidate.is_absolute_path():
|
|
|
|
|
return PATH_ALLOWED
|
|
|
|
|
var project: String = _normalize_reference_path(
|
|
|
|
|
project_reference, case_insensitive
|
|
|
|
|
)
|
|
|
|
|
if not project.is_empty() and _is_same_or_child_path(candidate, project):
|
|
|
|
|
return PATH_SOURCE_PROJECT
|
|
|
|
|
var installation: String = _normalize_reference_path(
|
|
|
|
|
installation_reference, case_insensitive
|
|
|
|
|
)
|
|
|
|
|
if (
|
|
|
|
|
not installation.is_empty()
|
|
|
|
|
and _is_same_or_child_path(candidate, installation)
|
|
|
|
|
):
|
|
|
|
|
return PATH_INSTALLATION
|
|
|
|
|
return PATH_ALLOWED
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _normalize_reference_path(path: String, case_insensitive: bool) -> String:
|
|
|
|
|
var normalized: String = _normalize_comparison_path(path, case_insensitive)
|
|
|
|
|
if normalized.is_empty() or not normalized.is_absolute_path():
|
|
|
|
|
return ""
|
|
|
|
|
if normalized == "/" or _is_windows_drive_root(normalized):
|
|
|
|
|
return ""
|
|
|
|
|
return normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _normalize_comparison_path(path: String, case_insensitive: bool) -> String:
|
|
|
|
|
var normalized: String = path.strip_edges().replace("\\", "/").simplify_path()
|
|
|
|
|
while normalized.length() > 1 and normalized.ends_with("/"):
|
|
|
|
|
if _is_windows_drive_root(normalized):
|
|
|
|
|
break
|
|
|
|
|
normalized = normalized.left(-1)
|
|
|
|
|
return normalized.to_lower() if case_insensitive else normalized
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _is_windows_drive_root(path: String) -> bool:
|
|
|
|
|
return path.length() == 3 and path[1] == ":" and path[2] == "/"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _is_same_or_child_path(candidate: String, reference: String) -> bool:
|
|
|
|
|
return candidate == reference or candidate.begins_with(reference + "/")
|
2026-07-29 23:51:25 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _test_writable(path: String) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var probe: String = path.path_join(
|
|
|
|
|
".netfishing-write-%s.tmp" % device_id.left(12)
|
|
|
|
|
)
|
|
|
|
|
var file: FileAccess = FileAccess.open(probe, FileAccess.WRITE)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null:
|
|
|
|
|
return false
|
|
|
|
|
file.store_string("probe")
|
|
|
|
|
file.close()
|
|
|
|
|
return DirAccess.remove_absolute(probe) == OK
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _create_layout(path: String, id: String) -> bool:
|
|
|
|
|
for relative: String in [
|
|
|
|
|
"player", "social", "backups/saves", "backups/migrations",
|
|
|
|
|
"backups/conflicts", "identity-backups",
|
|
|
|
|
]:
|
|
|
|
|
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var now: int = int(Time.get_unix_time_from_system())
|
|
|
|
|
var manifest: Dictionary = {
|
2026-07-29 23:51:25 -04:00
|
|
|
"format_version": MANIFEST_VERSION,
|
|
|
|
|
"layout_version": LAYOUT_VERSION,
|
|
|
|
|
"application": APPLICATION_ID,
|
|
|
|
|
"root_id": id,
|
|
|
|
|
"created_at_unix": now,
|
|
|
|
|
"last_opened_at_unix": now,
|
|
|
|
|
}
|
|
|
|
|
if not _write_text_atomic(
|
|
|
|
|
path.path_join(MANIFEST_FILENAME), JSON.stringify(manifest, "\t")
|
|
|
|
|
):
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var readme: String = (
|
2026-07-30 10:46:15 -04:00
|
|
|
"NETfishing player data\n\n"
|
2026-07-29 23:51:25 -04:00
|
|
|
+ "This folder is safe to synchronize with tools such as Syncthing.\n"
|
|
|
|
|
+ "Active private identity keys remain device-local.\n"
|
|
|
|
|
+ "Encrypted identity backups require their passphrase.\n"
|
|
|
|
|
+ "Chat and Session Mail are not stored here.\n"
|
|
|
|
|
+ "Do not play the same profile on two devices at the same time.\n"
|
|
|
|
|
+ "Conflicting edits are preserved under backups/conflicts; they are not merged.\n"
|
|
|
|
|
)
|
|
|
|
|
return _write_text_atomic(path.path_join(README_FILENAME), readme)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _write_bootstrap(path: String, id: String) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var now: int = int(Time.get_unix_time_from_system())
|
|
|
|
|
var data: Dictionary = {
|
2026-07-29 23:51:25 -04:00
|
|
|
"format_version": BOOTSTRAP_VERSION,
|
|
|
|
|
"selected_absolute_path": path,
|
|
|
|
|
"expected_root_id": id,
|
|
|
|
|
"device_id": device_id,
|
|
|
|
|
"selected_at_unix": now,
|
|
|
|
|
"last_successfully_opened_at_unix": now,
|
|
|
|
|
}
|
|
|
|
|
return _write_text_atomic(
|
|
|
|
|
ProjectSettings.globalize_path(BOOTSTRAP_PATH),
|
|
|
|
|
JSON.stringify(data, "\t"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _load_bootstrap_identity() -> void:
|
2026-07-30 00:54:53 -04:00
|
|
|
var data: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
|
2026-07-29 23:51:25 -04:00
|
|
|
if data.is_empty():
|
|
|
|
|
data = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
|
|
|
|
|
device_id = str(data.get("device_id", ""))
|
|
|
|
|
if device_id.length() != 32:
|
|
|
|
|
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _command_line_override() -> String:
|
2026-07-30 00:54:53 -04:00
|
|
|
var args: PackedStringArray = OS.get_cmdline_user_args()
|
2026-07-29 23:51:25 -04:00
|
|
|
for index: int in args.size():
|
2026-07-30 00:54:53 -04:00
|
|
|
var value: String = args[index]
|
2026-07-29 23:51:25 -04:00
|
|
|
if value.begins_with("--data-dir="):
|
|
|
|
|
return value.trim_prefix("--data-dir=")
|
|
|
|
|
if value == "--data-dir" and index + 1 < args.size():
|
|
|
|
|
return args[index + 1]
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _valid_manifest(data: Dictionary) -> bool:
|
|
|
|
|
return (
|
|
|
|
|
data.get("format_version") == MANIFEST_VERSION
|
|
|
|
|
and data.get("layout_version") == LAYOUT_VERSION
|
|
|
|
|
and data.get("application") == APPLICATION_ID
|
|
|
|
|
and typeof(data.get("root_id")) == TYPE_STRING
|
|
|
|
|
and str(data["root_id"]).length() == 32
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _directory_is_empty(path: String) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var access: DirAccess = DirAccess.open(path)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access == null:
|
|
|
|
|
return true
|
|
|
|
|
access.list_dir_begin()
|
2026-07-30 00:54:53 -04:00
|
|
|
var entry_name: String = access.get_next()
|
|
|
|
|
while entry_name in [".", ".."]:
|
|
|
|
|
entry_name = access.get_next()
|
2026-07-29 23:51:25 -04:00
|
|
|
access.list_dir_end()
|
2026-07-30 00:54:53 -04:00
|
|
|
return entry_name.is_empty()
|
2026-07-29 23:51:25 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _normalize(path: String) -> String:
|
2026-07-30 22:17:00 -04:00
|
|
|
var normalized: String = path.replace("\\", "/").simplify_path()
|
|
|
|
|
while normalized.length() > 1 and normalized.ends_with("/"):
|
|
|
|
|
if _is_windows_drive_root(normalized):
|
|
|
|
|
break
|
|
|
|
|
normalized = normalized.left(-1)
|
|
|
|
|
return normalized
|
2026-07-29 23:51:25 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
func _read_json(path: String, maximum := 1024 * 1024) -> Dictionary:
|
|
|
|
|
if not FileAccess.file_exists(path):
|
|
|
|
|
return {}
|
2026-07-30 00:54:53 -04:00
|
|
|
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null or file.get_length() > maximum:
|
|
|
|
|
return {}
|
2026-07-30 00:54:53 -04:00
|
|
|
var json: JSON = JSON.new()
|
|
|
|
|
var error: Error = json.parse(file.get_as_text())
|
2026-07-29 23:51:25 -04:00
|
|
|
file.close()
|
|
|
|
|
return json.data if error == OK and typeof(json.data) == TYPE_DICTIONARY else {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _write_text_atomic(path: String, text: String) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var absolute: String = (
|
2026-07-29 23:51:25 -04:00
|
|
|
ProjectSettings.globalize_path(path)
|
|
|
|
|
if path.begins_with("user://")
|
|
|
|
|
else path
|
|
|
|
|
)
|
|
|
|
|
if DirAccess.make_dir_recursive_absolute(absolute.get_base_dir()) != OK:
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var temporary: String = absolute + ".tmp"
|
|
|
|
|
var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null:
|
|
|
|
|
return false
|
|
|
|
|
file.store_string(text)
|
|
|
|
|
file.flush()
|
2026-07-30 00:54:53 -04:00
|
|
|
var ok: bool = file.get_error() == OK
|
2026-07-29 23:51:25 -04:00
|
|
|
file.close()
|
|
|
|
|
if not ok:
|
|
|
|
|
return false
|
2026-07-30 00:54:53 -04:00
|
|
|
var backup: String = absolute + ".backup"
|
2026-07-29 23:51:25 -04:00
|
|
|
if FileAccess.file_exists(backup):
|
|
|
|
|
DirAccess.remove_absolute(backup)
|
2026-07-30 00:54:53 -04:00
|
|
|
var had_primary: bool = FileAccess.file_exists(absolute)
|
2026-07-29 23:51:25 -04:00
|
|
|
if had_primary and DirAccess.rename_absolute(absolute, backup) != OK:
|
|
|
|
|
DirAccess.remove_absolute(temporary)
|
|
|
|
|
return false
|
|
|
|
|
if DirAccess.rename_absolute(temporary, absolute) != OK:
|
|
|
|
|
if had_primary:
|
|
|
|
|
DirAccess.rename_absolute(backup, absolute)
|
|
|
|
|
return false
|
|
|
|
|
if FileAccess.file_exists(backup):
|
|
|
|
|
DirAccess.remove_absolute(backup)
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
|
|
|
error_message = message
|
|
|
|
|
status_changed.emit(message)
|
|
|
|
|
return false
|