Add portable player data and identity backups
This commit is contained in:
parent
141b54261d
commit
ba4476511f
23 changed files with 2132 additions and 237 deletions
238
main/main.gd
238
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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
207
network/identity_backup_service.gd
Normal file
207
network/identity_backup_service.gd
Normal file
|
|
@ -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
|
||||
1
network/identity_backup_service.gd.uid
Normal file
1
network/identity_backup_service.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bu5q4a8jfvxkf
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
393
network/player_data_root.gd
Normal file
393
network/player_data_root.gd
Normal file
|
|
@ -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
|
||||
1
network/player_data_root.gd.uid
Normal file
1
network/player_data_root.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://dpiv6wttry0j4
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
325
network/portable_data_migration.gd
Normal file
325
network/portable_data_migration.gd
Normal file
|
|
@ -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)
|
||||
1
network/portable_data_migration.gd.uid
Normal file
1
network/portable_data_migration.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://bjkaght4qyylm
|
||||
146
network/portable_file_guard.gd
Normal file
146
network/portable_file_guard.gd
Normal file
|
|
@ -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)
|
||||
1
network/portable_file_guard.gd.uid
Normal file
1
network/portable_file_guard.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
|||
uid://biw72bdyctluv
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue