diff --git a/main/main.gd b/main/main.gd index 70517ec..590aca9 100644 --- a/main/main.gd +++ b/main/main.gd @@ -97,6 +97,8 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 %WorldPixelationPostprocess ) @onready var _network_session: NetworkSessionType = %NetworkSession +@onready var _data_root: PlayerDataRoot = %PlayerDataRoot +@onready var _identity_backups: IdentityBackupService = %IdentityBackupService @onready var _network_profile: NetworkProfilePreferencesType = ( %NetworkProfilePreferences ) @@ -142,9 +144,51 @@ var _pending_join_endpoint: String = "" var _server_trust_dialog: ConfirmationDialog var _pending_trust_changed: bool = false var _identity_notice_dialog: AcceptDialog +var _data_setup_dialog: ConfirmationDialog +var _data_folder_dialog: FileDialog +var _application_initialized := false +var _pending_existing_root_path := "" func _ready() -> void: + if _data_root.resolve(): + _configure_portable_stores() + _initialize_after_data_root() + return + _show_data_root_setup() + + +func _configure_portable_stores() -> void: + _save_manager.configure_storage( + _data_root.path_for(&"player_save"), _data_root + ) + _network_profile.configure_storage( + _data_root.path_for(&"network_profile"), _data_root + ) + _appearance_store.configure_storage( + _data_root.path_for(&"player_appearance"), _data_root + ) + _saved_servers.configure_storage( + _data_root.path_for(&"saved_servers"), _data_root + ) + _known_players.configure_storage( + _data_root.path_for(&"known_players"), _data_root + ) + _relationships.configure_storage( + _data_root.path_for(&"player_relationships"), _data_root + ) + _server_trust.configure_storage( + _data_root.path_for(&"server_trust"), _data_root + ) + _host_bans.configure_storage( + _data_root.path_for(&"host_bans"), _data_root + ) + + +func _initialize_after_data_root() -> void: + if _application_initialized: + return + _application_initialized = true _player.global_transform = _test_world.get_player_spawn_transform() _player.velocity = Vector3.ZERO _player_spawn_service.setup( @@ -162,6 +206,7 @@ func _ready() -> void: _server_trust, _host_bans, ) + _identity_backups.setup(_data_root, _player_identity, _host_identity) _network_profile_service.setup( _network_session, _network_profile, @@ -326,6 +371,27 @@ func _ready() -> void: _network_profile_service, _network_player_list, ) + _game_ui.setup_data_and_identity( + _data_root, + _identity_backups, + _player_identity, + _host_identity, + _network_session, + ) + _data_root.conflict_detected.connect(_on_portable_conflict) + _data_root.status_changed.connect(_on_data_root_status) + if ( + PortableFileGuard.has_syncthing_conflict( + _data_root.root_path.path_join("player") + ) + or PortableFileGuard.has_syncthing_conflict( + _data_root.root_path.path_join("social") + ) + ): + call_deferred( + "_on_data_root_status", + "Syncthing conflict copies were found. Review the data folder.", + ) _water_recovery.setup( _player, _fishing_spot, @@ -410,6 +476,178 @@ func _ready() -> void: _show_title_music(true) +func _show_data_root_setup() -> void: + if _data_setup_dialog == null: + _data_setup_dialog = ConfirmationDialog.new() + _data_setup_dialog.title = "NETFISHING player data" + _data_setup_dialog.ok_button_text = "Use This Folder" + _data_setup_dialog.cancel_button_text = "Quit" + _data_setup_dialog.confirmed.connect(_use_default_data_root) + _data_setup_dialog.canceled.connect(get_tree().quit) + _data_setup_dialog.add_button( + "Choose Another Folder", false, "choose" + ) + _data_setup_dialog.add_button( + "Keep Current Location", false, "current" + ) + _data_setup_dialog.custom_action.connect(_on_data_setup_action) + add_child(_data_setup_dialog) + var default_path := _data_root.default_visible_path() + var legacy := PortableDataMigration.legacy_files_present() + _data_setup_dialog.dialog_text = ( + ("Move your existing NETFISHING data to an easy-to-find folder." + if legacy else "Choose where NETFISHING stores your player data.") + + "\n\n%s\n\nYou can choose a Syncthing folder." + % (default_path if not default_path.is_empty() else "Choose a folder") + ) + _data_setup_dialog.ok_button_text = ( + "Move to Documents/NETFISHING" if legacy else "Use This Folder" + ) + if not _data_root.error_message.is_empty(): + _data_setup_dialog.dialog_text = ( + "Your NETFISHING data folder is unavailable.\n\n%s" + % _data_root.error_message + ) + _data_setup_dialog.popup_centered(Vector2i(640, 360)) + + +func _use_default_data_root() -> void: + var path := _data_root.default_visible_path() + if path.is_empty(): + _show_folder_picker() + return + _activate_selected_data_path(path) + + +func _on_data_setup_action(action: StringName) -> void: + if action == &"choose": + _show_folder_picker() + elif action == &"current": + _activate_selected_data_path( + ProjectSettings.globalize_path(PlayerDataRoot.APP_DATA_PORTABLE_PATH), true + ) + + +func _show_folder_picker() -> void: + if _data_folder_dialog == null: + _data_folder_dialog = FileDialog.new() + _data_folder_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + _data_folder_dialog.access = FileDialog.ACCESS_FILESYSTEM + _data_folder_dialog.use_native_dialog = false + _data_folder_dialog.dir_selected.connect(_activate_selected_data_path) + add_child(_data_folder_dialog) + _data_folder_dialog.current_dir = ( + _data_root.default_visible_path().get_base_dir() + if not _data_root.default_visible_path().is_empty() + else OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) + ) + _data_folder_dialog.popup_centered_ratio(0.75) + + +func _activate_selected_data_path(path: String, app_data: bool = false) -> void: + var ok := false + if PortableDataMigration.legacy_files_present(): + var result := ( + PortableDataMigration.adopt_legacy_app_data(_data_root) + if app_data + else PortableDataMigration.migrate_legacy_to(_data_root, path) + ) + ok = bool(result.get("ok", false)) + if not ok: + if bool(result.get("requires_existing_root_decision", false)): + _show_existing_root_choice(path) + return + _show_data_error(str(result.get("message", "Migration failed."))) + return + else: + ok = _data_root.select_new_root(path, app_data) + if not ok: + _show_data_error(_data_root.error_message) + return + _data_setup_dialog.hide() + _configure_portable_stores() + _initialize_after_data_root() + + +func _show_existing_root_choice(path: String) -> void: + _pending_existing_root_path = path + var dialog := ConfirmationDialog.new() + dialog.title = "Existing NETFISHING data" + dialog.ok_button_text = "Use Data Already in Selected Folder" + dialog.cancel_button_text = "Cancel" + dialog.dialog_text = ( + "This folder already contains NETFISHING player data.\n\n" + + "Choose which complete data set to use. Data is never merged automatically." + ) + dialog.add_button( + "Replace with This Device's Data", false, "replace" + ) + dialog.confirmed.connect(func() -> void: + if _data_root.use_existing_root(_pending_existing_root_path): + dialog.queue_free() + _data_setup_dialog.hide() + _configure_portable_stores() + _initialize_after_data_root() + else: + _show_data_error(_data_root.error_message) + ) + dialog.custom_action.connect(func(action: StringName) -> void: + if action != &"replace": + return + var result := PortableDataMigration.replace_existing_with_legacy( + _data_root, _pending_existing_root_path + ) + if bool(result.get("ok", false)): + dialog.queue_free() + _data_setup_dialog.hide() + _configure_portable_stores() + _initialize_after_data_root() + else: + _show_data_error(str(result.get("message", "Migration failed."))) + ) + add_child(dialog) + dialog.popup_centered(Vector2i(680, 360)) + + +func _show_data_error(message: String) -> void: + if _data_setup_dialog != null: + _data_setup_dialog.dialog_text = message + _data_setup_dialog.popup_centered(Vector2i(640, 300)) + + +func _on_portable_conflict(message: String, _path: String) -> void: + var dialog := AcceptDialog.new() + dialog.title = "Player data conflict" + dialog.dialog_text = ( + message + + "\n\nDo not play the same profile on two devices at the same time." + ) + dialog.add_button("Open Data Folder", false, "open") + dialog.custom_action.connect(func(action: StringName) -> void: + if action == &"open": + _data_root.open_folder() + ) + dialog.confirmed.connect(dialog.queue_free) + _game_ui.add_child(dialog) + dialog.popup_centered(Vector2i(560, 300)) + + +func _on_data_root_status(message: String) -> void: + if "Syncthing conflict" not in message: + return + var dialog := AcceptDialog.new() + dialog.title = "Synced data needs review" + dialog.dialog_text = message + dialog.add_button("Open Data Folder", false, "open") + dialog.custom_action.connect(func(action: StringName) -> void: + if action == &"open": + _data_root.open_folder() + ) + dialog.confirmed.connect(dialog.queue_free) + _game_ui.add_child(dialog) + dialog.popup_centered(Vector2i(560, 260)) + + func _on_server_trust_required( endpoint: String, expected_fingerprint: String, diff --git a/main/main.tscn b/main/main.tscn index df5662a..576dc80 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -1,4 +1,4 @@ -[gd_scene load_steps=37 format=3] +[gd_scene load_steps=39 format=3] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] @@ -36,6 +36,8 @@ [ext_resource type="Script" path="res://network/player_relationship_store.gd" id="34_relationships"] [ext_resource type="Script" path="res://network/host_ban_store.gd" id="35_bans"] [ext_resource type="Script" path="res://network/network_player_list_service.gd" id="36_player_list"] +[ext_resource type="Script" path="res://network/player_data_root.gd" id="37_data_root"] +[ext_resource type="Script" path="res://network/identity_backup_service.gd" id="38_identity_backup"] [node name="Main" type="Node3D"] script = ExtResource("3_main") @@ -44,6 +46,14 @@ pelican_buyer_profile = ExtResource("7_pelicans") main_shop_buyer_profile = ExtResource("12_main_shop") item_catalog = ExtResource("11_items") +[node name="PlayerDataRoot" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("37_data_root") + +[node name="IdentityBackupService" type="Node" parent="."] +unique_name_in_owner = true +script = ExtResource("38_identity_backup") + [node name="NetworkSession" type="Node" parent="."] unique_name_in_owner = true script = ExtResource("17_network_session") diff --git a/network/host_ban_store.gd b/network/host_ban_store.gd index 4a8d937..716a60e 100644 --- a/network/host_ban_store.gd +++ b/network/host_ban_store.gd @@ -4,13 +4,19 @@ extends Node signal bans_changed const FORMAT_VERSION := 1 -const STORE_PATH := "user://host_bans.json" -const TEMP_PATH := STORE_PATH + ".tmp" const MAX_BANS := 500 var _namespaces: Dictionary = {} var _loaded := false var _write_blocked := false +var _store_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _store_path = path + _data_root = data_root func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool: @@ -83,9 +89,9 @@ func _ensure_loaded() -> void: if _loaded: return _loaded = true - if not FileAccess.file_exists(STORE_PATH): + if _store_path.is_empty() or not FileAccess.file_exists(_store_path): return - var file := FileAccess.open(STORE_PATH, FileAccess.READ) + var file := FileAccess.open(_store_path, FileAccess.READ) if file == null: return var json := JSON.new() @@ -106,27 +112,23 @@ func _ensure_loaded() -> void: var records: Dictionary = _namespaces.get(host, {}) records[target] = record.duplicate(true) _namespaces[host] = records + _expected_hash = PortableFileGuard.hash_file(_store_path) func _save() -> bool: var values: Array = [] for records: Dictionary in _namespaces.values(): values.append_array(records.values()) - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify({ + var bytes := JSON.stringify({ "format_version": FORMAT_VERSION, "records": values, - }, "\t")) - file.flush() - var ok := file.get_error() == OK - file.close() - if not ok: - return false - if FileAccess.file_exists(STORE_PATH): - DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) - return DirAccess.rename_absolute( - ProjectSettings.globalize_path(TEMP_PATH), - ProjectSettings.globalize_path(STORE_PATH), - ) == OK + }, "\t").to_utf8_buffer() + var result := PortableFileGuard.write_guarded( + _store_path, bytes, _expected_hash, _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", ""))) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) diff --git a/network/identity_backup_service.gd b/network/identity_backup_service.gd new file mode 100644 index 0000000..98f6106 --- /dev/null +++ b/network/identity_backup_service.gd @@ -0,0 +1,207 @@ +class_name IdentityBackupService +extends Node + +signal operation_finished(success: bool, message: String) + +const MAGIC := "NETFISHING_IDENTITY_BACKUP" +const FORMAT_VERSION := 1 +const MAX_BACKUP_BYTES := 256 * 1024 +const MIN_PASSPHRASE_LENGTH := 12 +const MAX_PASSPHRASE_LENGTH := 256 + +var _data_root: PlayerDataRoot +var _player_identity: PlayerIdentityStore +var _host_identity: HostIdentityStore + + +func setup( + data_root: PlayerDataRoot, + player_identity: PlayerIdentityStore, + host_identity: HostIdentityStore, +) -> void: + _data_root = data_root + _player_identity = player_identity + _host_identity = host_identity + + +func default_export_path(identity_type: String) -> String: + var store := _store(identity_type) + if store == null: + return "" + if not store.is_ready() and not store.load_or_create(): + return "" + var timestamp := Time.get_datetime_string_from_system().replace(":", "-") + return _data_root.identity_backup_directory().path_join( + "%s-%s-%s.nfidentity" % [ + identity_type, + NetworkIdentityCrypto.compact_suffix(store.fingerprint), + timestamp, + ] + ) + + +func export_backup( + identity_type: String, + path: String, + passphrase: String, + confirmation: String, +) -> bool: + if not _valid_passphrase(passphrase) or passphrase != confirmation: + return _finish(false, "Passphrases must match and contain at least 12 characters.") + if FileAccess.file_exists(path): + return _finish(false, "Choose a new backup filename.") + var store := _store(identity_type) + if store == null or (not store.is_ready() and not store.load_or_create()): + return _finish(false, "The active identity is unavailable.") + var material := store.export_identity_material() + var proof := store.sign("identity_backup_self_test", [ + identity_type, material["fingerprint"], + ]) + var envelope := { + "magic": MAGIC, + "format_version": FORMAT_VERSION, + "identity_type": identity_type, + "algorithm": NetworkIdentityCrypto.ALGORITHM, + "private_pem": material["private_pem"], + "public_pem": material["public_pem"], + "fingerprint": material["fingerprint"], + "created_at_unix": int(Time.get_unix_time_from_system()), + "source_device_id": _data_root.device_id, + "self_signature": Marshalls.raw_to_base64(proof), + } + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + var file := FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, passphrase) + if file == null: + return _finish(false, "Could not write this identity backup.") + file.store_string(JSON.stringify(envelope)) + file.flush() + var ok := file.get_error() == OK + file.close() + if not ok: + return _finish(false, "Could not write this identity backup.") + var verified := inspect_backup(path, passphrase, identity_type) + if not bool(verified.get("ok", false)): + DirAccess.remove_absolute(path) + return _finish(false, "Could not verify this identity backup.") + return _finish(true, "Encrypted identity backup created.") + + +func inspect_backup( + path: String, + passphrase: String, + expected_type: String, +) -> Dictionary: + if ( + not _valid_passphrase(passphrase) + or not FileAccess.file_exists(path) + or FileAccess.get_size(path) > MAX_BACKUP_BYTES + ): + return {"ok": false} + var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, passphrase) + if file == null: + return {"ok": false} + var text := file.get_as_text() + file.close() + var json := JSON.new() + if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY: + return {"ok": false} + var data: Dictionary = json.data + if ( + data.get("magic") != MAGIC + or data.get("format_version") != FORMAT_VERSION + or data.get("identity_type") != expected_type + or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM + ): + return {"ok": false} + var private_pem := str(data.get("private_pem", "")) + var public_pem := NetworkIdentityCrypto.normalize_public_pem( + str(data.get("public_pem", "")) + ) + var fingerprint := str(data.get("fingerprint", "")) + if ( + private_pem.length() > 128 * 1024 + or public_pem.length() > 32 * 1024 + or NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint + ): + return {"ok": false} + var key := CryptoKey.new() + if key.load_from_string(private_pem) != OK: + return {"ok": false} + var signature := Marshalls.base64_to_raw(str(data.get("self_signature", ""))) + if not NetworkIdentityCrypto.verify_fields( + NetworkIdentityCrypto.load_public_key(public_pem), + "identity_backup_self_test", + [expected_type, fingerprint], + signature, + ): + return {"ok": false} + var fresh := NetworkIdentityCrypto.sign_fields( + key, "identity_import_self_test", [fingerprint] + ) + if not NetworkIdentityCrypto.verify_fields( + NetworkIdentityCrypto.load_public_key(public_pem), + "identity_import_self_test", + [fingerprint], + fresh, + ): + return {"ok": false} + return { + "ok": true, + "identity_type": expected_type, + "fingerprint": fingerprint, + "private_pem": private_pem, + "public_pem": public_pem, + } + + +func import_backup( + identity_type: String, + path: String, + passphrase: String, + confirmed_replacement: bool, +) -> Dictionary: + var inspected := inspect_backup(path, passphrase, identity_type) + if not bool(inspected.get("ok", false)): + _finish(false, "Could not open this identity backup.") + return {"ok": false} + var store := _store(identity_type) + var incoming := str(inspected["fingerprint"]) + if store.fingerprint == incoming: + _finish(true, "This identity is already active.") + return {"ok": true, "same": true} + if not confirmed_replacement: + return { + "ok": false, + "requires_confirmation": true, + "current_fingerprint": store.fingerprint, + "incoming_fingerprint": incoming, + } + var result := store.install_identity_material( + str(inspected["private_pem"]), + str(inspected["public_pem"]), + incoming, + ) + _finish( + bool(result.get("ok", false)), + "Identity imported. Restart NETFISHING before multiplayer." + if bool(result.get("ok", false)) + else "Could not install this identity backup.", + ) + return result + + +func _store(identity_type: String) -> LocalSigningIdentityStore: + if identity_type == "player": + return _player_identity + if identity_type == "host": + return _host_identity + return null + + +func _valid_passphrase(value: String) -> bool: + return value.length() >= MIN_PASSPHRASE_LENGTH and value.length() <= MAX_PASSPHRASE_LENGTH + + +func _finish(success: bool, message: String) -> bool: + operation_finished.emit(success, message) + return success diff --git a/network/identity_backup_service.gd.uid b/network/identity_backup_service.gd.uid new file mode 100644 index 0000000..05f1de0 --- /dev/null +++ b/network/identity_backup_service.gd.uid @@ -0,0 +1 @@ +uid://bu5q4a8jfvxkf diff --git a/network/known_player_store.gd b/network/known_player_store.gd index cc36f33..0c6b859 100644 --- a/network/known_player_store.gd +++ b/network/known_player_store.gd @@ -2,13 +2,19 @@ class_name KnownPlayerStore extends Node const FORMAT_VERSION: int = 1 -const STORE_PATH: String = "user://known_players.json" -const TEMP_PATH: String = STORE_PATH + ".tmp" const MAX_RECORDS: int = 500 var _records: Dictionary = {} var _loaded: bool = false var _write_blocked: bool = false +var _store_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _store_path = path + _data_root = data_root func observe(fingerprint: String, display_name: String) -> String: @@ -61,9 +67,9 @@ func _ensure_loaded() -> void: if _loaded: return _loaded = true - if not FileAccess.file_exists(STORE_PATH): + if _store_path.is_empty() or not FileAccess.file_exists(_store_path): return - var file := FileAccess.open(STORE_PATH, FileAccess.READ) + var file := FileAccess.open(_store_path, FileAccess.READ) if file == null: return var json := JSON.new() @@ -82,29 +88,25 @@ func _ensure_loaded() -> void: var fingerprint := str(record.get("fingerprint", "")) if NetworkIdentityCrypto.valid_fingerprint(fingerprint): _records[fingerprint] = record.duplicate(true) + _expected_hash = PortableFileGuard.hash_file(_store_path) func _save() -> bool: if _write_blocked: return false - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify({ + var bytes := JSON.stringify({ "format_version": FORMAT_VERSION, "records": _records.values(), - }, "\t")) - file.flush() - var ok := file.get_error() == OK - file.close() - if not ok: - return false - if FileAccess.file_exists(STORE_PATH): - DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) - return DirAccess.rename_absolute( - ProjectSettings.globalize_path(TEMP_PATH), - ProjectSettings.globalize_path(STORE_PATH), - ) == OK + }, "\t").to_utf8_buffer() + var result := PortableFileGuard.write_guarded( + _store_path, bytes, _expected_hash, _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", ""))) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) func _bound_records() -> void: diff --git a/network/local_signing_identity_store.gd b/network/local_signing_identity_store.gd index 4cd41b4..87fc4c6 100644 --- a/network/local_signing_identity_store.gd +++ b/network/local_signing_identity_store.gd @@ -52,6 +52,69 @@ func verify(domain: String, fields: Array, signature: PackedByteArray) -> bool: ) +func export_identity_material() -> Dictionary: + if not is_ready(): + return {} + return { + "private_pem": _private_key.save_to_string(), + "public_pem": public_pem, + "fingerprint": fingerprint, + } + + +func install_identity_material( + private_pem: String, + public_value: String, + expected_fingerprint: String, +) -> Dictionary: + var normalized_public := NetworkIdentityCrypto.normalize_public_pem(public_value) + var derived := NetworkIdentityCrypto.fingerprint_public_pem(normalized_public) + var key := CryptoKey.new() + if ( + derived != expected_fingerprint + or key.load_from_string(private_pem) != OK + ): + return {"ok": false} + var probe := NetworkIdentityCrypto.sign_fields( + key, "identity_import_self_test", [derived] + ) + if not NetworkIdentityCrypto.verify_fields( + NetworkIdentityCrypto.load_public_key(normalized_public), + "identity_import_self_test", + [derived], + probe, + ): + return {"ok": false} + if derived == fingerprint: + return {"ok": true, "same": true, "archive_path": ""} + var had_active_identity := is_ready() + var archive := _archive_current_identity() if had_active_identity else "" + if had_active_identity and archive.is_empty(): + return {"ok": false} + var metadata := { + "format_version": FORMAT_VERSION, + "algorithm": NetworkIdentityCrypto.ALGORITHM, + "fingerprint": derived, + "created_at_unix": int(Time.get_unix_time_from_system()), + } + if ( + not _write_atomic(_path(".key"), private_pem) + or not _write_atomic(_path(".pub"), normalized_public) + or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t")) + ): + if not archive.is_empty(): + _restore_archive(archive) + _load_existing() + return {"ok": false} + FileAccess.set_unix_permissions(_path(".key"), 384) + if not _load_existing() or fingerprint != derived: + if not archive.is_empty(): + _restore_archive(archive) + _load_existing() + return {"ok": false} + return {"ok": true, "same": false, "archive_path": archive} + + func _generate_new() -> bool: var key := Crypto.new().generate_rsa(3072) if key == null: @@ -165,6 +228,46 @@ func _path(extension: String) -> String: return "user://%s%s" % [_prefix, extension] +func identity_type() -> String: + return "player" if _prefix == "player_identity" else "host" + + +func _archive_current_identity() -> String: + var timestamp := Time.get_datetime_string_from_system().replace(":", "-") + var archive := ProjectSettings.globalize_path( + "user://identity-recovery/%s-%s" % [_prefix, timestamp] + ) + if DirAccess.make_dir_recursive_absolute(archive) != OK: + return "" + for extension: String in [".key", ".pub", ".json"]: + var source := _path(extension) + var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024) + if bytes.is_empty(): + return "" + var file := FileAccess.open(archive.path_join(_prefix + extension), FileAccess.WRITE) + if file == null: + return "" + file.store_buffer(bytes) + file.close() + FileAccess.set_unix_permissions( + archive.path_join(_prefix + ".key"), 384 + ) + return archive + + +func _restore_archive(archive: String) -> void: + for extension: String in [".key", ".pub", ".json"]: + var source := archive.path_join(_prefix + extension) + var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024) + if bytes.is_empty(): + continue + var file := FileAccess.open(_path(extension), FileAccess.WRITE) + if file != null: + file.store_buffer(bytes) + file.close() + FileAccess.set_unix_permissions(_path(".key"), 384) + + func _rename(from_path: String, to_path: String) -> bool: return DirAccess.rename_absolute( ProjectSettings.globalize_path(from_path), diff --git a/network/network_profile_preferences.gd b/network/network_profile_preferences.gd index ef69f9a..c90cd7d 100644 --- a/network/network_profile_preferences.gd +++ b/network/network_profile_preferences.gd @@ -2,25 +2,43 @@ class_name NetworkProfilePreferences extends Node const FORMAT_VERSION: int = 1 -const PROFILE_PATH: String = "user://network_profile.json" -const TEMP_PATH: String = "user://network_profile.json.tmp" -const BACKUP_PATH: String = "user://network_profile.json.backup" - var profile_id: String = "" var display_name: String = "Player" var created_at_unix: int = 0 +var _profile_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot +var _future_version := false + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _profile_path = path + _data_root = data_root + + +func _temp_path() -> String: + return _profile_path + ".tmp" + + +func _backup_path() -> String: + return _profile_path + ".backup" func load_or_create() -> bool: + if _profile_path.is_empty(): + return false _recover_interrupted_write() - if FileAccess.file_exists(PROFILE_PATH): + if FileAccess.file_exists(_profile_path): if _load_existing(): + _expected_hash = PortableFileGuard.hash_file(_profile_path) return true + if _future_version: + return false var corrupt_path: String = ( - "user://network_profile.corrupt.%d.json" - % int(Time.get_unix_time_from_system()) + "%s.corrupt.%d.json" + % [_profile_path.get_basename(), int(Time.get_unix_time_from_system())] ) - if not _rename(PROFILE_PATH, corrupt_path): + if not _rename(_profile_path, corrupt_path): push_warning("Invalid network profile was preserved and not overwritten.") return false profile_id = Crypto.new().generate_random_bytes(16).hex_encode() @@ -58,7 +76,7 @@ static func is_valid_display_name(value: String) -> bool: func _load_existing() -> bool: - var file := FileAccess.open(PROFILE_PATH, FileAccess.READ) + var file := FileAccess.open(_profile_path, FileAccess.READ) if file == null: return false var json := JSON.new() @@ -67,6 +85,12 @@ func _load_existing() -> bool: if error != OK or typeof(json.data) != TYPE_DICTIONARY: return false var data: Dictionary = json.data + if ( + typeof(data.get("format_version")) in [TYPE_INT, TYPE_FLOAT] + and int(data.get("format_version")) > FORMAT_VERSION + ): + _future_version = true + return false if ( data.get("format_version") != FORMAT_VERSION or typeof(data.get("profile_id")) != TYPE_STRING @@ -96,37 +120,31 @@ func _save_atomic() -> bool: "display_name": display_name, "created_at_unix": created_at_unix, } - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify(data, "\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(PROFILE_PATH) - if had_primary and not _rename(PROFILE_PATH, BACKUP_PATH): - _remove_if_present(TEMP_PATH) - return false - if not _rename(TEMP_PATH, PROFILE_PATH): - if had_primary: - _rename(BACKUP_PATH, PROFILE_PATH) - return false - _remove_if_present(BACKUP_PATH) - return true + var result := PortableFileGuard.write_guarded( + _profile_path, + JSON.stringify(data, "\t").to_utf8_buffer(), + _expected_hash, + _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict( + str(result.get("message", "")), + str(result.get("conflict_path", "")), + ) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) func _recover_interrupted_write() -> void: - if FileAccess.file_exists(PROFILE_PATH): - _remove_if_present(TEMP_PATH) - _remove_if_present(BACKUP_PATH) + if FileAccess.file_exists(_profile_path): + _remove_if_present(_temp_path()) + _remove_if_present(_backup_path()) return - if FileAccess.file_exists(BACKUP_PATH): - _rename(BACKUP_PATH, PROFILE_PATH) - _remove_if_present(TEMP_PATH) + if FileAccess.file_exists(_backup_path()): + _rename(_backup_path(), _profile_path) + _remove_if_present(_temp_path()) func _rename(from_path: String, to_path: String) -> bool: diff --git a/network/network_session.gd b/network/network_session.gd index 2afff76..3844ea3 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -292,6 +292,10 @@ func is_joined_client() -> bool: return state == State.JOINED_CLIENT +func is_session_active() -> bool: + return is_host() or is_joined_client() + + func get_player_count() -> int: return _registry.size() diff --git a/network/player_data_root.gd b/network/player_data_root.gd new file mode 100644 index 0000000..b2f8a65 --- /dev/null +++ b/network/player_data_root.gd @@ -0,0 +1,393 @@ +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" + +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() + var command_line := _command_line_override() + 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 + var environment_path := OS.get_environment(ENVIRONMENT_VARIABLE) + if not environment_path.is_absolute_path(): + return _fail("NETFISHING_DATA_DIR must be an absolute path.") + return _activate_existing(environment_path, "", false) + var bootstrap := _read_json(BOOTSTRAP_PATH, 64 * 1024) + 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.") + var selected := str(bootstrap.get("selected_absolute_path", "")) + var expected := str(bootstrap.get("expected_root_id", "")) + 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: + var documents := OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS) + 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() + var normalized := _normalize(path) + if app_data: + normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH) + if not _validate_candidate(normalized, true): + return false + var manifest_path := normalized.path_join(MANIFEST_FILENAME) + if FileAccess.file_exists(manifest_path): + var manifest := _read_json(manifest_path) + if not _valid_manifest(manifest): + return _fail("The selected folder has a malformed NETFISHING manifest.") + root_id = str(manifest["root_id"]) + else: + if not _directory_is_empty(normalized) and not app_data: + return _fail( + "Choose an empty folder or an existing NETFISHING data folder." + ) + root_id = Crypto.new().generate_random_bytes(16).hex_encode() + if not _create_layout(normalized, root_id): + return _fail("The NETFISHING data folder could not be created.") + 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: + var normalized := _normalize(path) + 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."} + var id := Crypto.new().generate_random_bytes(16).hex_encode() + 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: + var normalized := _normalize(path) + if not _validate_candidate(normalized, false): + return {"ok": false, "message": error_message} + var manifest_path := normalized.path_join(MANIFEST_FILENAME) + if FileAccess.file_exists(manifest_path): + var manifest := _read_json(manifest_path) + return ( + {"ok": true, "root_id": str(manifest.get("root_id", ""))} + if _valid_manifest(manifest) + else {"ok": false, "message": "The app-data manifest is malformed."} + ) + var id := Crypto.new().generate_random_bytes(16).hex_encode() + 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() + var normalized := _normalize(path) + 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 + + +func path_for(owner: StringName) -> String: + 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", + }.get(owner, "") + 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: + var normalized := _normalize(path) + if not _validate_candidate(normalized, permit_creation): + return false + var manifest_path := normalized.path_join(MANIFEST_FILENAME) + if not FileAccess.file_exists(manifest_path): + return _fail("The selected folder is not a NETFISHING data folder.") + var manifest := _read_json(manifest_path) + if not _valid_manifest(manifest): + return _fail("The NETFISHING data-folder manifest is malformed.") + var found_id := str(manifest["root_id"]) + 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): + return _fail("The NETFISHING data folder is unavailable or unwritable.") + 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: + if path.is_empty() or not path.is_absolute_path(): + return _fail("Choose an absolute filesystem folder.") + var project := ProjectSettings.globalize_path("res://").trim_suffix("/") + if path == project or path.begins_with(project + "/"): + return _fail("The project folder cannot be used as the player data folder.") + if not DirAccess.dir_exists_absolute(path): + if not create or DirAccess.make_dir_recursive_absolute(path) != OK: + return _fail("The selected data folder is unavailable.") + return _test_writable(path) + + +func _test_writable(path: String) -> bool: + var probe := path.path_join(".netfishing-write-%s.tmp" % device_id.left(12)) + var file := FileAccess.open(probe, FileAccess.WRITE) + 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 + var now := int(Time.get_unix_time_from_system()) + var manifest := { + "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 + var readme := ( + "NETFISHING player data\n\n" + + "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: + var now := int(Time.get_unix_time_from_system()) + var data := { + "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: + var data := _read_json(BOOTSTRAP_PATH, 64 * 1024) + 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: + var args := OS.get_cmdline_user_args() + for index: int in args.size(): + var value := args[index] + 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: + var access := DirAccess.open(path) + if access == null: + return true + access.list_dir_begin() + var name := access.get_next() + while name in [".", ".."]: + name = access.get_next() + access.list_dir_end() + return name.is_empty() + + +func _normalize(path: String) -> String: + return path.simplify_path().trim_suffix("/") + + +func _read_json(path: String, maximum := 1024 * 1024) -> Dictionary: + if not FileAccess.file_exists(path): + return {} + var file := FileAccess.open(path, FileAccess.READ) + if file == null or file.get_length() > maximum: + return {} + var json := JSON.new() + var error := json.parse(file.get_as_text()) + file.close() + return json.data if error == OK and typeof(json.data) == TYPE_DICTIONARY else {} + + +func _write_text_atomic(path: String, text: String) -> bool: + var absolute := ( + ProjectSettings.globalize_path(path) + if path.begins_with("user://") + else path + ) + if DirAccess.make_dir_recursive_absolute(absolute.get_base_dir()) != OK: + return false + var temporary := absolute + ".tmp" + var file := FileAccess.open(temporary, FileAccess.WRITE) + if file == null: + return false + file.store_string(text) + file.flush() + var ok := file.get_error() == OK + file.close() + if not ok: + return false + var backup := absolute + ".backup" + if FileAccess.file_exists(backup): + DirAccess.remove_absolute(backup) + var had_primary := FileAccess.file_exists(absolute) + 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 diff --git a/network/player_data_root.gd.uid b/network/player_data_root.gd.uid new file mode 100644 index 0000000..0aa1857 --- /dev/null +++ b/network/player_data_root.gd.uid @@ -0,0 +1 @@ +uid://dpiv6wttry0j4 diff --git a/network/player_relationship_store.gd b/network/player_relationship_store.gd index ab1d53a..7ef7b6a 100644 --- a/network/player_relationship_store.gd +++ b/network/player_relationship_store.gd @@ -4,13 +4,19 @@ extends Node signal relationship_changed(fingerprint: String) const FORMAT_VERSION := 1 -const STORE_PATH := "user://player_relationships.json" -const TEMP_PATH := STORE_PATH + ".tmp" const MAX_RECORDS := 500 var _records: Dictionary = {} var _loaded := false var _write_blocked := false +var _store_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _store_path = path + _data_root = data_root func is_muted(fingerprint: String) -> bool: @@ -98,9 +104,9 @@ func _ensure_loaded() -> void: if _loaded: return _loaded = true - if not FileAccess.file_exists(STORE_PATH): + if _store_path.is_empty() or not FileAccess.file_exists(_store_path): return - var file := FileAccess.open(STORE_PATH, FileAccess.READ) + var file := FileAccess.open(_store_path, FileAccess.READ) if file == null: return var json := JSON.new() @@ -120,24 +126,20 @@ func _ensure_loaded() -> void: record["blocked"] = bool(record.get("blocked", false)) record["muted"] = bool(record.get("muted", false)) or record["blocked"] _records[fingerprint] = record.duplicate(true) + _expected_hash = PortableFileGuard.hash_file(_store_path) func _save() -> bool: - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify({ + var bytes := JSON.stringify({ "format_version": FORMAT_VERSION, "records": _records.values(), - }, "\t")) - file.flush() - var ok := file.get_error() == OK - file.close() - if not ok: - return false - if FileAccess.file_exists(STORE_PATH): - DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) - return DirAccess.rename_absolute( - ProjectSettings.globalize_path(TEMP_PATH), - ProjectSettings.globalize_path(STORE_PATH), - ) == OK + }, "\t").to_utf8_buffer() + var result := PortableFileGuard.write_guarded( + _store_path, bytes, _expected_hash, _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", ""))) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) diff --git a/network/portable_data_migration.gd b/network/portable_data_migration.gd new file mode 100644 index 0000000..7ab2b94 --- /dev/null +++ b/network/portable_data_migration.gd @@ -0,0 +1,325 @@ +class_name PortableDataMigration +extends RefCounted + +const LEGACY_FILES := { + "player_save.json": "player/player_save.json", + "network_profile.json": "player/network_profile.json", + "player_appearance.json": "player/player_appearance.json", + "saved_servers.json": "social/saved_servers.json", + "known_players.json": "social/known_players.json", + "player_relationships.json": "social/player_relationships.json", + "server_trust.json": "social/server_trust.json", + "host_bans.json": "social/host_bans.json", +} + + +static func legacy_files_present() -> bool: + for filename: String in LEGACY_FILES: + if FileAccess.file_exists("user://".path_join(filename)): + return true + return false + + +static func migrate_legacy_to( + data_root: PlayerDataRoot, + destination: String, +) -> Dictionary: + var normalized := destination.simplify_path().trim_suffix("/") + if DirAccess.dir_exists_absolute(normalized): + var existing_manifest := normalized.path_join( + PlayerDataRoot.MANIFEST_FILENAME + ) + if FileAccess.file_exists(existing_manifest): + return { + "ok": false, + "requires_existing_root_decision": true, + "message": "The selected folder already contains NETFISHING data.", + } + if not _directory_empty(normalized): + return { + "ok": false, + "message": "Choose an empty folder or create a NETFISHING subfolder.", + } + var staging := "%s.migration-%s" % [ + normalized, Crypto.new().generate_random_bytes(8).hex_encode() + ] + if DirAccess.make_dir_recursive_absolute(staging) != OK: + return {"ok": false, "message": "Migration staging could not be created."} + var created := data_root.create_unbound_root(staging) + if not bool(created.get("ok", false)): + _remove_tree(staging) + return {"ok": false, "message": str(created.get("message", ""))} + var copied: Array[String] = [] + for source_name: String in LEGACY_FILES: + var source := ProjectSettings.globalize_path( + "user://".path_join(source_name) + ) + if not FileAccess.file_exists(source): + continue + var target := staging.path_join(LEGACY_FILES[source_name]) + var result := _copy_verified_json(source, target) + if not bool(result.get("ok", false)): + _remove_tree(staging) + return { + "ok": false, + "message": "Migration failed while validating %s." % source_name, + } + copied.append(source_name) + if DirAccess.dir_exists_absolute(normalized): + DirAccess.remove_absolute(normalized) + if DirAccess.rename_absolute(staging, normalized) != OK: + _remove_tree(staging) + return {"ok": false, "message": "Migration could not activate its destination."} + var manifest := _read_json(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)) + var migrated_root_id := str(manifest.get("root_id", "")) + if migrated_root_id.is_empty() or not data_root.use_existing_root(normalized): + return {"ok": false, "message": "Migration completed but could not switch roots."} + var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join( + Time.get_datetime_string_from_system().replace(":", "-") + ) + DirAccess.make_dir_recursive_absolute(recovery) + for source_name: String in copied: + _copy_bytes( + ProjectSettings.globalize_path("user://".path_join(source_name)), + recovery.path_join(source_name), + ) + return { + "ok": true, + "message": "Player data moved successfully.", + "recovery_path": recovery, + } + + +static func migrate_active_to( + data_root: PlayerDataRoot, + destination: String, +) -> Dictionary: + var normalized := destination.simplify_path().trim_suffix("/") + if normalized == data_root.root_path: + return {"ok": true, "message": "This data folder is already active."} + if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized): + if FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)): + return { + "ok": false, + "requires_existing_root_decision": true, + "message": ( + "The selected folder already contains NETFISHING data. " + + "Choose it through the existing-data recovery flow." + ), + } + return {"ok": false, "message": "Choose an empty folder or a NETFISHING subfolder."} + var staging := "%s.migration-%s" % [ + normalized, Crypto.new().generate_random_bytes(8).hex_encode() + ] + if DirAccess.make_dir_recursive_absolute(staging) != OK: + return {"ok": false, "message": "Migration staging could not be created."} + var created := data_root.create_unbound_root(staging) + if not bool(created.get("ok", false)): + _remove_tree(staging) + return {"ok": false, "message": str(created.get("message", ""))} + for relative: String in [ + "player/player_save.json", + "player/network_profile.json", + "player/player_appearance.json", + "social/saved_servers.json", + "social/known_players.json", + "social/player_relationships.json", + "social/server_trust.json", + "social/host_bans.json", + ]: + var source := data_root.root_path.path_join(relative) + if not FileAccess.file_exists(source): + continue + if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)): + _remove_tree(staging) + return {"ok": false, "message": "Migration validation failed for %s." % relative} + if DirAccess.dir_exists_absolute(normalized): + DirAccess.remove_absolute(normalized) + if DirAccess.rename_absolute(staging, normalized) != OK: + _remove_tree(staging) + return {"ok": false, "message": "Migration could not activate its destination."} + var old_root := data_root.root_path + if not data_root.use_existing_root(normalized): + return {"ok": false, "message": "Migration copied data but did not change the pointer."} + return { + "ok": true, + "message": "Data folder changed. Previous data was preserved.", + "previous_root": old_root, + } + + +static func adopt_legacy_app_data(data_root: PlayerDataRoot) -> Dictionary: + var source_root := ProjectSettings.globalize_path("user://").trim_suffix("/") + var root := ProjectSettings.globalize_path(PlayerDataRoot.APP_DATA_PORTABLE_PATH) + if DirAccess.make_dir_recursive_absolute(root) != OK: + return {"ok": false, "message": "The app-data folder could not be created."} + var created := data_root.create_app_data_layout_for_migration(root) + if not bool(created.get("ok", false)): + return created + for source_name: String in LEGACY_FILES: + var source := source_root.path_join(source_name) + if not FileAccess.file_exists(source): + continue + var target := root.path_join(LEGACY_FILES[source_name]) + if not bool(_copy_verified_json(source, target).get("ok", false)): + return { + "ok": false, + "message": "Could not validate %s in app-data mode." % source_name, + } + if not data_root.use_existing_root(root): + return {"ok": false, "message": data_root.error_message} + return { + "ok": true, + "message": "Existing player data remains in app data.", + "recovery_path": source_root, + } + + +static func replace_existing_with_legacy( + data_root: PlayerDataRoot, + destination: String, +) -> Dictionary: + var normalized := destination.simplify_path().trim_suffix("/") + var manifest := normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME) + if not FileAccess.file_exists(manifest): + return {"ok": false, "message": "The selected folder is not a NETFISHING data root."} + var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join( + "replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-") + ) + if not _copy_tree(normalized, recovery): + return {"ok": false, "message": "The selected data could not be backed up."} + _remove_tree(normalized) + var result := migrate_legacy_to(data_root, normalized) + if bool(result.get("ok", false)): + result["replaced_root_backup"] = recovery + return result + + +static func replace_existing_with_active( + data_root: PlayerDataRoot, + destination: String, +) -> Dictionary: + var normalized := destination.simplify_path().trim_suffix("/") + if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)): + return {"ok": false, "message": "The selected folder is not a NETFISHING data root."} + var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join( + "replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-") + ) + if not _copy_tree(normalized, recovery): + return {"ok": false, "message": "The selected data could not be backed up."} + _remove_tree(normalized) + var result := migrate_active_to(data_root, normalized) + if bool(result.get("ok", false)): + result["replaced_root_backup"] = recovery + return result + + +static func _copy_verified_json(source: String, destination: String) -> Dictionary: + var bytes := PortableFileGuard.read_bytes(source) + if bytes.is_empty() and FileAccess.get_open_error() != OK: + return {"ok": false} + var json := JSON.new() + if ( + json.parse(bytes.get_string_from_utf8()) != OK + or typeof(json.data) != TYPE_DICTIONARY + or not _valid_owned_data(source.get_file(), json.data) + ): + return {"ok": false} + if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK: + return {"ok": false} + if not _write_bytes(destination, bytes): + return {"ok": false} + var copied := PortableFileGuard.read_bytes(destination) + return { + "ok": ( + PortableFileGuard.hash_bytes(copied) + == PortableFileGuard.hash_bytes(bytes) + ) + } + + +static func _valid_owned_data(filename: String, data: Dictionary) -> bool: + if filename == "player_save.json": + var version := int(data.get("save_version", -1)) + return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION + return ( + filename not in LEGACY_FILES + or int(data.get("format_version", -1)) == 1 + ) + + +static func _copy_bytes(source: String, destination: String) -> bool: + return _write_bytes(destination, PortableFileGuard.read_bytes(source)) + + +static func _copy_tree(source: String, destination: String) -> bool: + if DirAccess.make_dir_recursive_absolute(destination) != OK: + return false + var access := DirAccess.open(source) + if access == null: + return false + access.list_dir_begin() + var name := access.get_next() + while not name.is_empty(): + var from := source.path_join(name) + var to := destination.path_join(name) + if access.current_is_dir(): + if not _copy_tree(from, to): + access.list_dir_end() + return false + elif not _copy_bytes(from, to): + access.list_dir_end() + return false + name = access.get_next() + access.list_dir_end() + return true + + +static func _write_bytes(path: String, bytes: PackedByteArray) -> bool: + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + return false + file.store_buffer(bytes) + file.flush() + var ok := file.get_error() == OK + file.close() + return ok + + +static func _read_json(path: String) -> Dictionary: + var bytes := PortableFileGuard.read_bytes(path) + var json := JSON.new() + return ( + json.data + if json.parse(bytes.get_string_from_utf8()) == OK + and typeof(json.data) == TYPE_DICTIONARY + else {} + ) + + +static func _directory_empty(path: String) -> bool: + var access := DirAccess.open(path) + if access == null: + return true + access.list_dir_begin() + var name := access.get_next() + access.list_dir_end() + return name.is_empty() + + +static func _remove_tree(path: String) -> void: + var access := DirAccess.open(path) + if access == null: + return + access.list_dir_begin() + var name := access.get_next() + while not name.is_empty(): + var child := path.path_join(name) + if access.current_is_dir(): + _remove_tree(child) + else: + DirAccess.remove_absolute(child) + name = access.get_next() + access.list_dir_end() + DirAccess.remove_absolute(path) diff --git a/network/portable_data_migration.gd.uid b/network/portable_data_migration.gd.uid new file mode 100644 index 0000000..1eb9103 --- /dev/null +++ b/network/portable_data_migration.gd.uid @@ -0,0 +1 @@ +uid://bjkaght4qyylm diff --git a/network/portable_file_guard.gd b/network/portable_file_guard.gd new file mode 100644 index 0000000..95f21aa --- /dev/null +++ b/network/portable_file_guard.gd @@ -0,0 +1,146 @@ +class_name PortableFileGuard +extends RefCounted + +const MAX_PORTABLE_FILE_BYTES := 16 * 1024 * 1024 + + +static func hash_file(path: String) -> String: + if not FileAccess.file_exists(path): + return "" + var file := FileAccess.open(path, FileAccess.READ) + if file == null or file.get_length() > MAX_PORTABLE_FILE_BYTES: + return "" + var bytes := file.get_buffer(file.get_length()) + file.close() + return hash_bytes(bytes) + + +static func hash_bytes(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + if context.start(HashingContext.HASH_SHA256) != OK: + return "" + if context.update(bytes) != OK: + return "" + return context.finish().hex_encode() + + +static func read_bytes(path: String, maximum_bytes: int = MAX_PORTABLE_FILE_BYTES) -> PackedByteArray: + if not FileAccess.file_exists(path): + return PackedByteArray() + var file := FileAccess.open(path, FileAccess.READ) + if file == null or file.get_length() > maximum_bytes: + return PackedByteArray() + var bytes := file.get_buffer(file.get_length()) + file.close() + return bytes + + +static func write_guarded( + path: String, + bytes: PackedByteArray, + expected_hash: String, + conflict_directory: String, + device_id: String, +) -> Dictionary: + var current_exists := FileAccess.file_exists(path) + var current_hash := hash_file(path) if current_exists else "" + if current_hash != expected_hash: + var conflict_path := _write_conflict_copy( + path, bytes, conflict_directory, device_id + ) + return { + "ok": false, + "conflict": true, + "hash": expected_hash, + "conflict_path": conflict_path, + "message": ( + "This file changed on another device.\n" + + "Your current data was preserved as a conflict copy." + ), + } + if not _ensure_parent(path): + return {"ok": false, "conflict": false, "hash": expected_hash} + var temporary := path + ".tmp" + var backup := path + ".backup" + var file := FileAccess.open(temporary, FileAccess.WRITE) + if file == null: + return {"ok": false, "conflict": false, "hash": expected_hash} + file.store_buffer(bytes) + file.flush() + var write_error := file.get_error() + file.close() + if write_error != OK: + _remove(temporary) + return {"ok": false, "conflict": false, "hash": expected_hash} + _remove(backup) + if current_exists and not _rename(path, backup): + _remove(temporary) + return {"ok": false, "conflict": false, "hash": expected_hash} + if not _rename(temporary, path): + if current_exists: + _rename(backup, path) + return {"ok": false, "conflict": false, "hash": expected_hash} + _remove(backup) + return { + "ok": true, + "conflict": false, + "hash": hash_bytes(bytes), + "conflict_path": "", + } + + +static func has_syncthing_conflict(directory: String) -> bool: + var access := DirAccess.open(directory) + if access == null: + return false + access.list_dir_begin() + var name := access.get_next() + while not name.is_empty(): + var lower := name.to_lower() + if "sync-conflict" in lower or ".syncthing." in lower: + access.list_dir_end() + return true + name = access.get_next() + access.list_dir_end() + return false + + +static func _write_conflict_copy( + canonical_path: String, + bytes: PackedByteArray, + conflict_directory: String, + device_id: String, +) -> String: + if DirAccess.make_dir_recursive_absolute(conflict_directory) != OK: + return "" + var filename := canonical_path.get_file() + var safe_device := device_id.left(16) + var timestamp := Time.get_datetime_string_from_system().replace(":", "-") + var destination := conflict_directory.path_join( + "%s.local-%s-%s" % [filename, safe_device, timestamp] + ) + var file := FileAccess.open(destination, FileAccess.WRITE) + if file == null: + return "" + file.store_buffer(bytes) + file.flush() + var ok := file.get_error() == OK + file.close() + return destination if ok else "" + + +static func _ensure_parent(path: String) -> bool: + var parent := path.get_base_dir() + return ( + DirAccess.dir_exists_absolute(parent) + or DirAccess.make_dir_recursive_absolute(parent) == OK + ) + + +static func _rename(from_path: String, to_path: String) -> bool: + return DirAccess.rename_absolute(from_path, to_path) == OK + + +static func _remove(path: String) -> void: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(path) diff --git a/network/portable_file_guard.gd.uid b/network/portable_file_guard.gd.uid new file mode 100644 index 0000000..ea9a4b8 --- /dev/null +++ b/network/portable_file_guard.gd.uid @@ -0,0 +1 @@ +uid://biw72bdyctluv diff --git a/network/saved_server_store.gd b/network/saved_server_store.gd index d17f82d..eadc281 100644 --- a/network/saved_server_store.gd +++ b/network/saved_server_store.gd @@ -2,9 +2,6 @@ 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_SAVED_ENTRIES: int = 100 const MAX_RECENT_ENTRIES: int = 20 const MAX_DISPLAY_NAME_LENGTH: int = 80 @@ -17,6 +14,22 @@ var _recent_entries: Array[Dictionary] = [] var _loaded: bool = false var _recovery_warning: String = "" var _write_blocked: bool = false +var _store_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _store_path = path + _data_root = data_root + + +func _temp_path() -> String: + return _store_path + ".tmp" + + +func _backup_path() -> String: + return _store_path + ".backup" func get_saved_entries() -> Array[SavedServerEntry]: @@ -242,15 +255,15 @@ func _ensure_loaded() -> void: return _loaded = true _recover_interrupted_write() - if not FileAccess.file_exists(STORE_PATH): + if _store_path.is_empty() or not FileAccess.file_exists(_store_path): return - var data: Dictionary = _read_store(STORE_PATH) - if data.is_empty() and FileAccess.file_exists(BACKUP_PATH): - data = _read_store(BACKUP_PATH) + var data: Dictionary = _read_store(_store_path) + if data.is_empty() and FileAccess.file_exists(_backup_path()): + data = _read_store(_backup_path()) if not data.is_empty(): _set_warning("Recovered saved servers from the local backup.") if data.is_empty(): - if FileAccess.file_exists(STORE_PATH): + if FileAccess.file_exists(_store_path): _set_warning( "Saved servers could not be read. Direct connection is still available." ) @@ -263,6 +276,7 @@ func _ensure_loaded() -> void: return _load_collection(data.get("saved_entries", data.get("entries", [])), true) _load_collection(data.get("recent_entries", []), false) + _expected_hash = PortableFileGuard.hash_file(_store_path) func _load_collection(raw: Variant, is_saved: bool) -> void: @@ -389,31 +403,20 @@ func _sort_saved_entries( func _save_atomic() -> bool: if _write_blocked: return false - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify({ + var bytes := JSON.stringify({ "format_version": FORMAT_VERSION, "saved_entries": _saved_entries, "recent_entries": _recent_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 + }, "\t").to_utf8_buffer() + var result := PortableFileGuard.write_guarded( + _store_path, bytes, _expected_hash, _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", ""))) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) func _read_store(path: String) -> Dictionary: @@ -427,13 +430,13 @@ func _read_store(path: String) -> Dictionary: func _recover_interrupted_write() -> void: - if FileAccess.file_exists(STORE_PATH): - _remove_if_present(TEMP_PATH) + if FileAccess.file_exists(_store_path): + _remove_if_present(_temp_path()) return - if FileAccess.file_exists(BACKUP_PATH): - _rename(BACKUP_PATH, STORE_PATH) + if FileAccess.file_exists(_backup_path()): + _rename(_backup_path(), _store_path) _set_warning("Recovered saved servers after an interrupted write.") - _remove_if_present(TEMP_PATH) + _remove_if_present(_temp_path()) func _set_warning(message: String) -> void: diff --git a/network/server_trust_store.gd b/network/server_trust_store.gd index 1238d6b..fda774b 100644 --- a/network/server_trust_store.gd +++ b/network/server_trust_store.gd @@ -2,9 +2,6 @@ class_name ServerTrustStore extends Node const FORMAT_VERSION: int = 1 -const STORE_PATH: String = "user://server_trust.json" -const TEMP_PATH: String = STORE_PATH + ".tmp" - enum Verification { FIRST_SEEN, MATCH, @@ -14,6 +11,14 @@ enum Verification { var _records: Dictionary = {} var _loaded: bool = false var _write_blocked: bool = false +var _store_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _store_path = path + _data_root = data_root func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification: @@ -80,9 +85,9 @@ func _ensure_loaded() -> void: if _loaded: return _loaded = true - if not FileAccess.file_exists(STORE_PATH): + if _store_path.is_empty() or not FileAccess.file_exists(_store_path): return - var file := FileAccess.open(STORE_PATH, FileAccess.READ) + var file := FileAccess.open(_store_path, FileAccess.READ) if file == null: return var json := JSON.new() @@ -102,26 +107,22 @@ func _ensure_loaded() -> void: var fingerprint := str(record.get("fingerprint", "")) if not endpoint.is_empty() and NetworkIdentityCrypto.valid_fingerprint(fingerprint): _records[endpoint] = record.duplicate(true) + _expected_hash = PortableFileGuard.hash_file(_store_path) func _save() -> bool: if _write_blocked: return false - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify({ + var bytes := JSON.stringify({ "format_version": FORMAT_VERSION, "records": _records.values(), - }, "\t")) - file.flush() - var ok := file.get_error() == OK - file.close() - if not ok: - return false - if FileAccess.file_exists(STORE_PATH): - DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH)) - return DirAccess.rename_absolute( - ProjectSettings.globalize_path(TEMP_PATH), - ProjectSettings.globalize_path(STORE_PATH), - ) == OK + }, "\t").to_utf8_buffer() + var result := PortableFileGuard.write_guarded( + _store_path, bytes, _expected_hash, _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", ""))) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) diff --git a/progression/player_appearance_store.gd b/progression/player_appearance_store.gd index bab34b0..7ac8767 100644 --- a/progression/player_appearance_store.gd +++ b/progression/player_appearance_store.gd @@ -2,22 +2,36 @@ class_name PlayerAppearanceStore extends Node const FORMAT_VERSION: int = 1 -const PROFILE_PATH: String = "user://player_appearance.json" -const TEMP_PATH: String = "user://player_appearance.json.tmp" -const BACKUP_PATH: String = "user://player_appearance.json.backup" - var _snapshot: Dictionary = CharacterCustomizationCatalog.default_snapshot() var _loaded: bool = false var _future_version: bool = false +var _profile_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _profile_path = path + _data_root = data_root + + +func _temp_path() -> String: + return _profile_path + ".tmp" + + +func _backup_path() -> String: + return _profile_path + ".backup" func load_preferences() -> bool: + if _profile_path.is_empty(): + return false _recover_interrupted_write() - if not FileAccess.file_exists(PROFILE_PATH): + if not FileAccess.file_exists(_profile_path): _snapshot = CharacterCustomizationCatalog.default_snapshot() _loaded = true return true - var file := FileAccess.open(PROFILE_PATH, FileAccess.READ) + var file := FileAccess.open(_profile_path, FileAccess.READ) if file == null: return false var json := JSON.new() @@ -37,6 +51,7 @@ func load_preferences() -> bool: return _recover_backup() _snapshot = CharacterCustomizationCatalog.sanitized_snapshot(data["appearance"]) _loaded = true + _expected_hash = PortableFileGuard.hash_file(_profile_path) return true @@ -61,50 +76,36 @@ func is_loaded() -> bool: func _save_atomic() -> bool: - var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if file == null: - return false - file.store_string(JSON.stringify({ + var bytes := JSON.stringify({ "format_version": FORMAT_VERSION, "appearance": _snapshot, - }, "\t")) - file.flush() - var error := file.get_error() - file.close() - if error != OK: - _remove(TEMP_PATH) - return false - _remove(BACKUP_PATH) - var had_primary := FileAccess.file_exists(PROFILE_PATH) - if had_primary and not _rename(PROFILE_PATH, BACKUP_PATH): - _remove(TEMP_PATH) - return false - if not _rename(TEMP_PATH, PROFILE_PATH): - if had_primary: - _rename(BACKUP_PATH, PROFILE_PATH) - return false - _remove(BACKUP_PATH) - return true + }, "\t").to_utf8_buffer() + var result := PortableFileGuard.write_guarded( + _profile_path, bytes, _expected_hash, _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", ""))) + if bool(result.get("ok", false)): + _expected_hash = str(result["hash"]) + return bool(result.get("ok", false)) func _recover_interrupted_write() -> void: - if FileAccess.file_exists(PROFILE_PATH): - _remove(TEMP_PATH) + if FileAccess.file_exists(_profile_path): + _remove(_temp_path()) return - if FileAccess.file_exists(BACKUP_PATH): - _rename(BACKUP_PATH, PROFILE_PATH) - _remove(TEMP_PATH) + if FileAccess.file_exists(_backup_path()): + _rename(_backup_path(), _profile_path) + _remove(_temp_path()) func _recover_backup() -> bool: - if not FileAccess.file_exists(BACKUP_PATH): + if not FileAccess.file_exists(_backup_path()): return false - var primary_path := ProjectSettings.globalize_path(PROFILE_PATH) - var backup_path := ProjectSettings.globalize_path(BACKUP_PATH) - var corrupt_path := ProjectSettings.globalize_path( - "user://player_appearance.corrupt.%d.json" - % int(Time.get_unix_time_from_system()) - ) + var primary_path := _profile_path + var backup_path := _backup_path() + var corrupt_path := "%s.corrupt.%d.json" % [_profile_path.get_basename(), int(Time.get_unix_time_from_system())] DirAccess.rename_absolute(primary_path, corrupt_path) if DirAccess.rename_absolute(backup_path, primary_path) != OK: return false diff --git a/save/player_save_manager.gd b/save/player_save_manager.gd index 26c8ed6..fa270fa 100644 --- a/save/player_save_manager.gd +++ b/save/player_save_manager.gd @@ -20,9 +20,6 @@ const PlayerCoolerCapacityType = preload( const SAVE_VERSION: int = 4 const BASIC_ROD_ID: StringName = &"basic_fishing_rod" -const SAVE_PATH: String = "user://player_save.json" -const TEMP_PATH: String = "user://player_save.json.tmp" -const BACKUP_PATH: String = "user://player_save.json.backup" const MAX_SAFE_BALANCE: int = 1000000000000 class LoadSnapshot: @@ -57,6 +54,22 @@ var _is_restoring: bool = false var _is_dirty: bool = false var _automatic_saving_blocked: bool = false var _autosave_enabled: bool = false +var _save_path := "" +var _expected_hash := "" +var _data_root: PlayerDataRoot + + +func configure_storage(path: String, data_root: PlayerDataRoot) -> void: + _save_path = path + _data_root = data_root + + +func _temp_path() -> String: + return _save_path + ".tmp" + + +func _backup_path() -> String: + return _save_path + ".backup" func _ready() -> void: @@ -135,16 +148,17 @@ func load_player_data() -> bool: return false _automatic_saving_blocked = false _recover_interrupted_write() - if not FileAccess.file_exists(SAVE_PATH): + if _save_path.is_empty() or not FileAccess.file_exists(_save_path): _is_dirty = false return true - var save_file := FileAccess.open(SAVE_PATH, FileAccess.READ) + var save_file := FileAccess.open(_save_path, FileAccess.READ) if save_file == null: _handle_corrupt_save("Unable to open player save.") return false var json_text: String = save_file.get_as_text() save_file.close() + _expected_hash = PortableFileGuard.hash_file(_save_path) var json := JSON.new() var parse_error: Error = json.parse(json_text) @@ -224,12 +238,12 @@ func load_player_data() -> bool: func inspect_save() -> SaveInspectionType: var result := SaveInspectionType.new() _recover_interrupted_write() - result.has_primary_file = FileAccess.file_exists(SAVE_PATH) + result.has_primary_file = FileAccess.file_exists(_save_path) if not result.has_primary_file: result.status = SaveInspectionType.Status.MISSING result.message = "no save found." return result - var save_file := FileAccess.open(SAVE_PATH, FileAccess.READ) + var save_file := FileAccess.open(_save_path, FileAccess.READ) if save_file == null: result.status = SaveInspectionType.Status.IO_ERROR result.message = "the save could not be read." @@ -279,15 +293,16 @@ func initialize_new_game() -> bool: func delete_progression_save() -> bool: - if FileAccess.file_exists(SAVE_PATH) and not _remove_if_present(SAVE_PATH): + if FileAccess.file_exists(_save_path) and not _remove_if_present(_save_path): return false - for path: String in [TEMP_PATH, BACKUP_PATH]: + for path: String in [_temp_path(), _backup_path()]: if FileAccess.file_exists(path) and not _remove_if_present(path): push_warning("Unable to remove stale player-save auxiliary file.") if _autosave_timer != null: _autosave_timer.stop() _is_dirty = false _automatic_saving_blocked = false + _expected_hash = "" return true @@ -325,31 +340,22 @@ func save_now() -> bool: if json_text.is_empty(): return false - var temp_file := FileAccess.open(TEMP_PATH, FileAccess.WRITE) - if temp_file == null: - push_warning("Unable to open temporary player save for writing.") + var result := PortableFileGuard.write_guarded( + _save_path, + json_text.to_utf8_buffer(), + _expected_hash, + _data_root.conflict_directory(), + _data_root.device_id, + ) + if bool(result.get("conflict", false)): + _data_root.report_conflict( + str(result.get("message", "")), + str(result.get("conflict_path", "")), + ) + if not bool(result.get("ok", false)): + push_warning("Unable to safely write the player save.") return false - temp_file.store_string(json_text) - temp_file.flush() - var write_error: Error = temp_file.get_error() - temp_file.close() - if write_error != OK: - _remove_if_present(TEMP_PATH) - push_warning("Unable to finish writing temporary player save.") - return false - - var had_primary: bool = FileAccess.file_exists(SAVE_PATH) - _remove_if_present(BACKUP_PATH) - if had_primary and not _rename_file(SAVE_PATH, BACKUP_PATH): - _remove_if_present(TEMP_PATH) - push_warning("Unable to preserve the previous player save.") - return false - if not _rename_file(TEMP_PATH, SAVE_PATH): - if had_primary: - _rename_file(BACKUP_PATH, SAVE_PATH) - push_warning("Unable to promote the temporary player save.") - return false - _remove_if_present(BACKUP_PATH) + _expected_hash = str(result["hash"]) _is_dirty = false return true @@ -744,27 +750,27 @@ func _on_autosave_timeout() -> void: func _recover_interrupted_write() -> void: - if FileAccess.file_exists(SAVE_PATH): - _remove_if_present(TEMP_PATH) - _remove_if_present(BACKUP_PATH) + if FileAccess.file_exists(_save_path): + _remove_if_present(_temp_path()) + _remove_if_present(_backup_path()) return - if FileAccess.file_exists(BACKUP_PATH): - _rename_file(BACKUP_PATH, SAVE_PATH) - _remove_if_present(TEMP_PATH) + if FileAccess.file_exists(_backup_path()): + _rename_file(_backup_path(), _save_path) + _remove_if_present(_temp_path()) func _handle_corrupt_save(message: String) -> void: push_warning(message) _restore_defaults() - if not FileAccess.file_exists(SAVE_PATH): + if not FileAccess.file_exists(_save_path): return var timestamp: int = int(Time.get_unix_time_from_system()) var corrupt_path: String = ( - "%s.corrupt-%d" % [SAVE_PATH, timestamp] + "%s.corrupt-%d" % [_save_path, timestamp] ) if FileAccess.file_exists(corrupt_path): corrupt_path += "-%d" % Time.get_ticks_usec() - if not _rename_file(SAVE_PATH, corrupt_path): + if not _rename_file(_save_path, corrupt_path): _automatic_saving_blocked = true push_warning( "Corrupt player save was left in place; automatic saving is " diff --git a/ui/game_ui.gd b/ui/game_ui.gd index c3e8e57..4f3ec51 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -178,6 +178,25 @@ func setup( _fishing_shop.menu_visibility_changed.connect(_on_shop_visibility_changed) +func setup_data_and_identity( + data_root: PlayerDataRoot, + identity_backups: IdentityBackupService, + player_identity: PlayerIdentityStore, + host_identity: HostIdentityStore, + network_session: NetworkSession, +) -> void: + for panel: SettingsPanelType in [ + _title_settings_panel, _pause_settings_panel + ]: + panel.setup_data_and_identity( + data_root, + identity_backups, + player_identity, + host_identity, + network_session, + ) + + func _process(_delta: float) -> void: if _item_effects == null or not _gameplay_ui_enabled: _effect_status.hide() diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index b315214..e7a6875 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -7,6 +7,7 @@ const PAGE_ROOT: StringName = &"root" const PAGE_DISPLAY: StringName = &"display" const PAGE_CONTROLS: StringName = &"controls" const PAGE_ACCESSIBILITY: StringName = &"accessibility" +const PAGE_DATA: StringName = &"data" const TITLE_HOST_OUTGOING_DURATION: float = 1.70 const TITLE_HOST_INCOMING_DURATION: float = 1.80 const PIXELATION_NAMES: PackedStringArray = [ @@ -37,6 +38,7 @@ enum PresentationMode { @onready var _display_page: SettingsBubblePage = %DisplayPage @onready var _controls_page: SettingsBubblePage = %ControlsPage @onready var _accessibility_page: SettingsBubblePage = %AccessibilityPage +@onready var _data_page: SettingsBubblePage = %DataPage @onready var _feedback: Label = %SettingsFeedback @onready var _world_value: BubbleButton = %WorldValue @@ -77,6 +79,20 @@ var _controller_sensitivity: float = 2.5 var _invert_camera_y: bool = false var _network_profile: NetworkProfilePreferences var _network_session: NetworkSession +var _data_root: PlayerDataRoot +var _identity_backups: IdentityBackupService +var _player_identity: PlayerIdentityStore +var _host_identity: HostIdentityStore +var _data_folder_dialog: FileDialog +var _backup_file_dialog: FileDialog +var _export_file_dialog: FileDialog +var _passphrase_dialog: ConfirmationDialog +var _passphrase_entry: LineEdit +var _passphrase_confirm: LineEdit +var _pending_identity_operation := "" +var _pending_identity_type := "" +var _pending_identity_path := "" +var _pending_import_data: Dictionary = {} func _ready() -> void: @@ -85,21 +101,31 @@ func _ready() -> void: PAGE_DISPLAY: _display_page, PAGE_CONTROLS: _controls_page, PAGE_ACCESSIBILITY: _accessibility_page, + PAGE_DATA: _data_page, } %DisplayCategory.pressed.connect(_push_page.bind(PAGE_DISPLAY)) %ControlsCategory.pressed.connect(_push_page.bind(PAGE_CONTROLS)) %AccessibilityCategory.pressed.connect( _push_page.bind(PAGE_ACCESSIBILITY) ) + %DataCategory.pressed.connect(_push_page.bind(PAGE_DATA)) %ApplySettingsButton.pressed.connect(_apply_settings) %RootBackButton.pressed.connect(close_panel) %DisplayBackButton.pressed.connect(handle_back) %ControlsBackButton.pressed.connect(handle_back) %AccessibilityBackButton.pressed.connect(handle_back) + %DataBackButton.pressed.connect(handle_back) %RootBackButton.gui_input.connect(_on_back_bubble_gui_input) %DisplayBackButton.gui_input.connect(_on_back_bubble_gui_input) %ControlsBackButton.gui_input.connect(_on_back_bubble_gui_input) %AccessibilityBackButton.gui_input.connect(_on_back_bubble_gui_input) + %DataBackButton.gui_input.connect(_on_back_bubble_gui_input) + %OpenDataFolder.pressed.connect(_open_data_folder) + %ChangeDataFolder.pressed.connect(_choose_data_folder) + %ExportPlayerIdentity.pressed.connect(_choose_identity_export.bind("player")) + %ImportPlayerIdentity.pressed.connect(_choose_identity_import.bind("player")) + %ExportHostIdentity.pressed.connect(_choose_identity_export.bind("host")) + %ImportHostIdentity.pressed.connect(_choose_identity_import.bind("host")) for index: int in _world_options.size(): _world_options[index].pressed.connect( _set_world_pixelation.bind(index + 1) @@ -153,6 +179,22 @@ func setup_network_profile( _network_session = session +func setup_data_and_identity( + data_root: PlayerDataRoot, + identity_backups: IdentityBackupService, + player_identity: PlayerIdentityStore, + host_identity: HostIdentityStore, + session: NetworkSession, +) -> void: + _data_root = data_root + _identity_backups = identity_backups + _player_identity = player_identity + _host_identity = host_identity + _network_session = session + _identity_backups.operation_finished.connect(_on_identity_operation_finished) + _refresh_data_page() + + func open_panel( settings_manager: SettingsManagerType, presentation_mode: PresentationMode = PresentationMode.GAMEPLAY_MODAL, @@ -169,6 +211,7 @@ func open_panel( _settings_manager = settings_manager _load_controls() _feedback.text = "" + _refresh_data_page() show() _page_stack.clear() _page_stack.append(PAGE_ROOT) @@ -334,6 +377,247 @@ func _show_active_page(should_focus: bool) -> void: page.show_page(should_focus) else: page.hide_page() + if get_active_page_id() == PAGE_DATA: + _refresh_data_page() + if should_focus: + %OpenDataFolder.call_deferred("grab_focus") + + +func _refresh_data_page() -> void: + if not is_node_ready() or _data_root == null: + return + %DataFolderPath.text = _data_root.root_path + %DataStorageMode.text = "storage mode: " + _data_root.storage_mode_text() + %ChangeDataFolder.disabled = ( + _data_root.override_active + or (_network_session != null and _network_session.is_session_active()) + ) + + +func _open_data_folder() -> void: + if _data_root == null or not _data_root.open_folder(): + _feedback.text = "Could not open the data folder." + + +func _choose_data_folder() -> void: + if _data_root == null or _data_root.override_active: + _feedback.text = "The data folder is externally managed." + return + if _network_session != null and _network_session.is_session_active(): + _feedback.text = "Return to title to change the data folder." + return + if _data_folder_dialog == null: + _data_folder_dialog = FileDialog.new() + _data_folder_dialog.file_mode = FileDialog.FILE_MODE_OPEN_DIR + _data_folder_dialog.access = FileDialog.ACCESS_FILESYSTEM + _data_folder_dialog.dir_selected.connect(_change_data_folder) + add_child(_data_folder_dialog) + _data_folder_dialog.current_dir = _data_root.root_path.get_base_dir() + _data_folder_dialog.popup_centered_ratio(0.75) + + +func _change_data_folder(path: String) -> void: + var result := PortableDataMigration.migrate_active_to(_data_root, path) + if bool(result.get("requires_existing_root_decision", false)): + _show_existing_data_folder_choice(path) + return + if bool(result.get("ok", false)): + _feedback.text = "Data folder changed. NETFISHING will close safely." + _refresh_data_page() + get_tree().call_deferred("quit") + else: + _feedback.text = str(result.get("message", "Could not change the data folder.")) + + +func _show_existing_data_folder_choice(path: String) -> void: + var dialog := ConfirmationDialog.new() + dialog.title = "Existing NETFISHING data" + dialog.ok_button_text = "Use Selected Data" + dialog.dialog_text = ( + "The selected folder contains different NETFISHING data.\n\n" + + "Choose one complete data set. Data will not be merged." + ) + dialog.add_button("Replace Selected Data", false, "replace") + dialog.confirmed.connect(func() -> void: + if _data_root.use_existing_root(path): + _feedback.text = "Data folder changed. NETFISHING will close safely." + get_tree().call_deferred("quit") + else: + _feedback.text = _data_root.error_message + dialog.queue_free() + ) + dialog.custom_action.connect(func(action: StringName) -> void: + if action != &"replace": + return + var result := PortableDataMigration.replace_existing_with_active( + _data_root, path + ) + _feedback.text = str(result.get("message", "Could not replace selected data.")) + if bool(result.get("ok", false)): + get_tree().call_deferred("quit") + dialog.queue_free() + ) + add_child(dialog) + dialog.popup_centered(Vector2i(620, 340)) + + +func _choose_identity_export(identity_type: String) -> void: + if not _identity_operation_allowed(): + return + _pending_identity_operation = "export" + _pending_identity_type = identity_type + var suggested := _identity_backups.default_export_path(identity_type) + if _export_file_dialog == null: + _export_file_dialog = FileDialog.new() + _export_file_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + _export_file_dialog.access = FileDialog.ACCESS_FILESYSTEM + _export_file_dialog.filters = PackedStringArray(["*.nfidentity ; NETFISHING identity backup"]) + _export_file_dialog.file_selected.connect(_identity_export_file_selected) + add_child(_export_file_dialog) + _export_file_dialog.current_dir = suggested.get_base_dir() + _export_file_dialog.current_file = suggested.get_file() + _export_file_dialog.popup_centered_ratio(0.75) + + +func _identity_export_file_selected(path: String) -> void: + _pending_identity_path = ( + path if path.ends_with(".nfidentity") else path + ".nfidentity" + ) + _show_passphrase_dialog(true) + + +func _choose_identity_import(identity_type: String) -> void: + if not _identity_operation_allowed(): + return + _pending_identity_operation = "import" + _pending_identity_type = identity_type + if _backup_file_dialog == null: + _backup_file_dialog = FileDialog.new() + _backup_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + _backup_file_dialog.access = FileDialog.ACCESS_FILESYSTEM + _backup_file_dialog.filters = PackedStringArray(["*.nfidentity ; NETFISHING identity backup"]) + _backup_file_dialog.file_selected.connect(_identity_import_file_selected) + add_child(_backup_file_dialog) + _backup_file_dialog.current_dir = _data_root.identity_backup_directory() + _backup_file_dialog.popup_centered_ratio(0.75) + + +func _identity_import_file_selected(path: String) -> void: + _pending_identity_path = path + _show_passphrase_dialog(false) + + +func _show_passphrase_dialog(exporting: bool) -> void: + if _passphrase_dialog == null: + _passphrase_dialog = ConfirmationDialog.new() + _passphrase_dialog.title = "Encrypted identity backup" + _passphrase_dialog.confirmed.connect(_submit_identity_passphrase) + var fields := VBoxContainer.new() + var warning := Label.new() + warning.text = ( + "Anyone with this backup and passphrase can use your identity." + ) + warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + fields.add_child(warning) + _passphrase_entry = LineEdit.new() + _passphrase_entry.placeholder_text = "Passphrase (12 characters minimum)" + _passphrase_entry.secret = true + fields.add_child(_passphrase_entry) + _passphrase_confirm = LineEdit.new() + _passphrase_confirm.placeholder_text = "Confirm passphrase" + _passphrase_confirm.secret = true + fields.add_child(_passphrase_confirm) + _passphrase_dialog.add_child(fields) + add_child(_passphrase_dialog) + _passphrase_confirm.visible = exporting + _passphrase_entry.clear() + _passphrase_confirm.clear() + _passphrase_dialog.dialog_text = ( + "Create a passphrase-encrypted backup." + if exporting else "Enter the backup passphrase." + ) + _passphrase_dialog.popup_centered(Vector2i(560, 300)) + _passphrase_entry.grab_focus() + + +func _submit_identity_passphrase() -> void: + var passphrase := _passphrase_entry.text + if _pending_identity_operation == "export": + _identity_backups.export_backup( + _pending_identity_type, + _pending_identity_path, + passphrase, + _passphrase_confirm.text, + ) + return + var inspected := _identity_backups.import_backup( + _pending_identity_type, + _pending_identity_path, + passphrase, + false, + ) + if bool(inspected.get("requires_confirmation", false)): + _pending_import_data = { + "passphrase": passphrase, + "current": inspected["current_fingerprint"], + "incoming": inspected["incoming_fingerprint"], + } + _show_identity_replacement_confirmation() + + +func _show_identity_replacement_confirmation() -> void: + var dialog := ConfirmationDialog.new() + dialog.title = "Replace active identity?" + dialog.ok_button_text = "Review Replacement" + dialog.dialog_text = ( + "Current:\n%s\n\nIncoming:\n%s\n\n" + + "Other players will recognize this device as the imported identity." + ) % [ + NetworkIdentityCrypto.format_fingerprint(_pending_import_data["current"]), + NetworkIdentityCrypto.format_fingerprint(_pending_import_data["incoming"]), + ] + dialog.set_meta("confirmation_step", 1) + dialog.confirmed.connect(_advance_identity_replacement.bind(dialog)) + dialog.canceled.connect(dialog.queue_free) + add_child(dialog) + dialog.popup_centered(Vector2i(620, 360)) + + +func _advance_identity_replacement(dialog: ConfirmationDialog) -> void: + if int(dialog.get_meta("confirmation_step", 1)) == 1: + dialog.set_meta("confirmation_step", 2) + dialog.ok_button_text = "Replace Identity" + dialog.dialog_text = ( + "Replace the active identity and archive the current key locally?" + ) + dialog.call_deferred("popup_centered", Vector2i(560, 280)) + return + _confirm_identity_replacement(dialog) + + +func _confirm_identity_replacement(dialog: ConfirmationDialog) -> void: + _identity_backups.import_backup( + _pending_identity_type, + _pending_identity_path, + str(_pending_import_data["passphrase"]), + true, + ) + _pending_import_data.clear() + dialog.queue_free() + + +func _identity_operation_allowed() -> bool: + if _identity_backups == null: + _feedback.text = "Identity backup is unavailable." + return false + if _network_session != null and _network_session.is_session_active(): + _feedback.text = "Return to title to change an identity." + return false + return true + + +func _on_identity_operation_finished(success: bool, message: String) -> void: + _feedback.text = message func _get_active_page() -> SettingsBubblePage: diff --git a/ui/settings_panel.tscn b/ui/settings_panel.tscn index a6bc71c..3055d3f 100644 --- a/ui/settings_panel.tscn +++ b/ui/settings_panel.tscn @@ -49,8 +49,8 @@ grow_horizontal = 2 grow_vertical = 2 script = ExtResource("3_page") page_id = &"root" -bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DisplayCategory"), NodePath("BubbleCluster/ControlsCategory"), NodePath("BubbleCluster/AccessibilityCategory"), NodePath("BubbleCluster/ApplySettingsButton"), NodePath("BubbleCluster/RootBackButton")]) -focus_paths = Array[NodePath]([NodePath("BubbleCluster/DisplayCategory"), NodePath("BubbleCluster/ControlsCategory"), NodePath("BubbleCluster/AccessibilityCategory"), NodePath("BubbleCluster/ApplySettingsButton"), NodePath("BubbleCluster/RootBackButton")]) +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DisplayCategory"), NodePath("BubbleCluster/ControlsCategory"), NodePath("BubbleCluster/AccessibilityCategory"), NodePath("BubbleCluster/DataCategory"), NodePath("BubbleCluster/ApplySettingsButton"), NodePath("BubbleCluster/RootBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/DisplayCategory"), NodePath("BubbleCluster/ControlsCategory"), NodePath("BubbleCluster/AccessibilityCategory"), NodePath("BubbleCluster/DataCategory"), NodePath("BubbleCluster/ApplySettingsButton"), NodePath("BubbleCluster/RootBackButton")]) initial_focus_path = NodePath("BubbleCluster/DisplayCategory") back_focus_path = NodePath("BubbleCluster/RootBackButton") compact_maximum_layout_size = Vector2(544, 400) @@ -128,6 +128,18 @@ minimum_font_size = 15 maximum_font_size = 19 motion_phase = 3.7 +[node name="DataCategory" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +custom_minimum_size = Vector2(0, 0) +text = "data &\nidentity" +neutral_size = Vector2(140, 132) +desktop_anchor = Vector2(610, 220) +compact_anchor = Vector2(540, 205) +compact_minimum_size = Vector2(116, 108) +minimum_font_size = 14 +maximum_font_size = 21 +motion_phase = 5.4 + [node name="RootBackButton" parent="RootPage/BubbleCluster" instance=ExtResource("4_bubble")] unique_name_in_owner = true custom_minimum_size = Vector2(0, 0) @@ -561,3 +573,118 @@ compact_minimum_size = Vector2(82, 78) minimum_font_size = 13 maximum_font_size = 16 motion_phase = 5.0 + +[node name="DataPage" type="Control" parent="."] +unique_name_in_owner = true +visible = false +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("3_page") +page_id = &"data" +bubble_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")]) +focus_paths = Array[NodePath]([NodePath("BubbleCluster/DataBackButton")]) +initial_focus_path = NodePath("BubbleCluster/DataBackButton") +back_focus_path = NodePath("BubbleCluster/DataBackButton") + +[node name="BubbleCluster" type="Control" parent="DataPage"] +layout_mode = 0 +offset_right = 720.0 +offset_bottom = 520.0 +script = ExtResource("5_cluster") +profile = ExtResource("6_profile") +desktop_reference_size = Vector2(720, 520) +compact_reference_size = Vector2(608, 448) + +[node name="Paper" type="PanelContainer" parent="DataPage"] +layout_mode = 1 +anchors_preset = 8 +anchor_left = 0.5 +anchor_top = 0.5 +anchor_right = 0.5 +anchor_bottom = 0.5 +offset_left = -330.0 +offset_top = -255.0 +offset_right = 330.0 +offset_bottom = 210.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="VBoxContainer" parent="DataPage/Paper"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="Heading" type="Label" parent="DataPage/Paper/Content"] +layout_mode = 2 +theme_override_font_sizes/font_size = 28 +text = "data & identity" +horizontal_alignment = 1 + +[node name="DataFolderLabel" type="Label" parent="DataPage/Paper/Content"] +layout_mode = 2 +text = "Data Folder" + +[node name="DataFolderPath" type="Label" parent="DataPage/Paper/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Unavailable" +autowrap_mode = 2 + +[node name="DataStorageMode" type="Label" parent="DataPage/Paper/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "storage mode: unavailable" + +[node name="OpenDataFolder" type="Button" parent="DataPage/Paper/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Open Data Folder" + +[node name="ChangeDataFolder" type="Button" parent="DataPage/Paper/Content"] +unique_name_in_owner = true +layout_mode = 2 +text = "Change Data Folder" + +[node name="IdentityHelper" type="Label" parent="DataPage/Paper/Content"] +layout_mode = 2 +text = "Active identity keys stay on this device.\nEncrypted backups can be stored in your synced data folder.\nDo not play the same profile on two devices at the same time." +autowrap_mode = 2 + +[node name="IdentityGrid" type="GridContainer" parent="DataPage/Paper/Content"] +layout_mode = 2 +columns = 2 + +[node name="ExportPlayerIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"] +unique_name_in_owner = true +layout_mode = 2 +text = "Export Player Identity" + +[node name="ImportPlayerIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"] +unique_name_in_owner = true +layout_mode = 2 +text = "Import Player Identity" + +[node name="ExportHostIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"] +unique_name_in_owner = true +layout_mode = 2 +text = "Export Host Identity" + +[node name="ImportHostIdentity" type="Button" parent="DataPage/Paper/Content/IdentityGrid"] +unique_name_in_owner = true +layout_mode = 2 +text = "Import Host Identity" + +[node name="DataBackButton" parent="DataPage/BubbleCluster" instance=ExtResource("4_bubble")] +unique_name_in_owner = true +layout_mode = 0 +text = "back" +neutral_size = Vector2(96, 90) +desktop_anchor = Vector2(135, 470) +compact_anchor = Vector2(115, 420) +compact_minimum_size = Vector2(82, 78) +minimum_font_size = 13 +maximum_font_size = 16 +motion_phase = 5.0