class_name NetworkProfilePreferences extends Node signal profile_changed const FORMAT_VERSION: int = 1 const DEFAULT_DISPLAY_NAME: String = "stray" const DEFAULT_TITLE: String = "newbie" const VoiceProfilesType = preload( "res://player/animalese_voice_profiles.gd" ) var profile_id: String = "" var display_name: String = DEFAULT_DISPLAY_NAME var title: String = DEFAULT_TITLE var voice_id: String = VoiceProfilesType.DEFAULT_ID var speech_speed_id: String = VoiceProfilesType.DEFAULT_SPEED_ID var call_id: String = VoiceProfilesType.DEFAULT_CALL_ID var sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID 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 _expected_hash = "" _future_version = false 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 _load_existing(): _expected_hash = PortableFileGuard.hash_file(_profile_path) return true if _future_version: return false var corrupt_path: String = ( "%s.corrupt.%d.json" % [_profile_path.get_basename(), int(Time.get_unix_time_from_system())] ) 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() if profile_id.is_empty(): profile_id = "%d-%d" % [ int(Time.get_unix_time_from_system()), Time.get_ticks_usec(), ] display_name = DEFAULT_DISPLAY_NAME title = DEFAULT_TITLE voice_id = VoiceProfilesType.DEFAULT_ID speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID call_id = VoiceProfilesType.DEFAULT_CALL_ID sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID created_at_unix = int(Time.get_unix_time_from_system()) return _save_atomic() func to_save_data() -> Dictionary: return { "profile_id": profile_id, "display_name": display_name, "title": title, "voice_id": voice_id, "speech_speed_id": speech_speed_id, "call_id": call_id, "sample_set_id": sample_set_id, "created_at_unix": created_at_unix, } static func validate_save_data(value: Variant) -> bool: if typeof(value) != TYPE_DICTIONARY: return false var data: Dictionary = value if ( typeof(data.get("profile_id")) != TYPE_STRING or typeof(data.get("display_name")) != TYPE_STRING or ( data.has("title") and typeof(data.get("title")) != TYPE_STRING ) or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT] ): return false var saved_profile_id := str(data.get("profile_id", "")) var saved_name := str(data.get("display_name", "")) var saved_title := str(data.get("title", DEFAULT_TITLE)) return ( not saved_profile_id.is_empty() and saved_profile_id.length() <= NetworkProtocol.MAX_PROFILE_ID_LENGTH and is_valid_display_name(saved_name) and is_valid_title(saved_title) and VoiceProfilesType.is_valid(str(data.get( "voice_id", VoiceProfilesType.DEFAULT_ID, ))) and VoiceProfilesType.is_valid_speed(str(data.get( "speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID, ))) and VoiceProfilesType.is_valid_call(str(data.get( "call_id", VoiceProfilesType.DEFAULT_CALL_ID, ))) and VoiceProfilesType.is_valid_sample_set(str(data.get( "sample_set_id", VoiceProfilesType.DEFAULT_SAMPLE_SET_ID, ))) and int(data.get("created_at_unix", -1)) >= 0 ) func restore_from_save_data( value: Variant, persist_to_profile_file: bool = true, ) -> bool: if not validate_save_data(value): return false var data: Dictionary = value var previous: Dictionary = to_save_data() profile_id = str(data["profile_id"]) display_name = str(data["display_name"]).strip_edges() title = str(data.get("title", DEFAULT_TITLE)).strip_edges() voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID)) speech_speed_id = str(data.get( "speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID, )) call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID)) sample_set_id = str(data.get( "sample_set_id", VoiceProfilesType.DEFAULT_SAMPLE_SET_ID, )) created_at_unix = int(data["created_at_unix"]) if persist_to_profile_file and not _save_atomic(): _assign_save_data(previous) return false if to_save_data() != previous: profile_changed.emit() return true func reset_to_defaults(persist_to_profile_file: bool = true) -> bool: var previous: Dictionary = to_save_data() profile_id = Crypto.new().generate_random_bytes(16).hex_encode() if profile_id.is_empty(): profile_id = "%d-%d" % [ int(Time.get_unix_time_from_system()), Time.get_ticks_usec(), ] display_name = DEFAULT_DISPLAY_NAME title = DEFAULT_TITLE voice_id = VoiceProfilesType.DEFAULT_ID speech_speed_id = VoiceProfilesType.DEFAULT_SPEED_ID call_id = VoiceProfilesType.DEFAULT_CALL_ID sample_set_id = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID created_at_unix = int(Time.get_unix_time_from_system()) if persist_to_profile_file and not _save_atomic(): _assign_save_data(previous) return false profile_changed.emit() return true func set_display_name(value: String) -> bool: return set_profile_identity( value, voice_id, speech_speed_id, call_id, sample_set_id, title, ) func set_profile_identity( value: String, selected_voice_id: String, selected_speech_speed_id: String, selected_call_id: String, selected_sample_set_id: String = VoiceProfilesType.DEFAULT_SAMPLE_SET_ID, selected_title: String = "", ) -> bool: var clean_name: String = value.strip_edges() var clean_title: String = ( title if selected_title.is_empty() else selected_title.strip_edges() ) if ( not is_valid_display_name(clean_name) or not is_valid_title(clean_title) or not VoiceProfilesType.is_valid(selected_voice_id) or not VoiceProfilesType.is_valid_speed(selected_speech_speed_id) or not VoiceProfilesType.is_valid_call(selected_call_id) or not VoiceProfilesType.is_valid_sample_set(selected_sample_set_id) ): return false var previous_name: String = display_name var previous_title: String = title var previous_voice_id: String = voice_id var previous_speech_speed_id: String = speech_speed_id var previous_call_id: String = call_id var previous_sample_set_id: String = sample_set_id display_name = clean_name title = clean_title voice_id = selected_voice_id speech_speed_id = selected_speech_speed_id call_id = selected_call_id sample_set_id = selected_sample_set_id if _save_atomic(): if ( display_name != previous_name or voice_id != previous_voice_id or title != previous_title or speech_speed_id != previous_speech_speed_id or call_id != previous_call_id or sample_set_id != previous_sample_set_id ): profile_changed.emit() return true display_name = previous_name title = previous_title voice_id = previous_voice_id speech_speed_id = previous_speech_speed_id call_id = previous_call_id sample_set_id = previous_sample_set_id return false func _assign_save_data(data: Dictionary) -> void: profile_id = str(data.get("profile_id", "")) display_name = str(data.get("display_name", DEFAULT_DISPLAY_NAME)) title = str(data.get("title", DEFAULT_TITLE)) voice_id = str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID)) speech_speed_id = str(data.get( "speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID, )) call_id = str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID)) sample_set_id = str(data.get( "sample_set_id", VoiceProfilesType.DEFAULT_SAMPLE_SET_ID, )) created_at_unix = int(data.get("created_at_unix", 0)) static func is_valid_display_name(value: String) -> bool: var clean_name := value.strip_edges() if clean_name.is_empty() or clean_name.length() > 24: return false for index: int in clean_name.length(): var codepoint: int = clean_name.unicode_at(index) if codepoint < 32 or codepoint == 127: return false return true static func is_valid_title(value: String) -> bool: var clean_title := value.strip_edges() if clean_title.is_empty() or clean_title.length() > NetworkProtocol.MAX_PLAYER_TITLE_LENGTH: return false for index: int in clean_title.length(): var codepoint: int = clean_title.unicode_at(index) if codepoint < 32 or codepoint == 127: return false return true func _load_existing() -> bool: var file := FileAccess.open(_profile_path, FileAccess.READ) if file == null: return false var json := JSON.new() var error: Error = json.parse(file.get_as_text()) file.close() if error != OK or typeof(json.data) != TYPE_DICTIONARY: return 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 or typeof(data.get("display_name")) != TYPE_STRING or ( data.has("title") and typeof(data.get("title")) != TYPE_STRING ) or typeof(data.get("created_at_unix")) not in [TYPE_INT, TYPE_FLOAT] ): return false var loaded_id: String = data["profile_id"] var loaded_name: String = data["display_name"] var loaded_title := str(data.get("title", DEFAULT_TITLE)) if ( loaded_id.is_empty() or loaded_id.length() > NetworkProtocol.MAX_PROFILE_ID_LENGTH or loaded_name.is_empty() or loaded_name.length() > NetworkProtocol.MAX_DISPLAY_NAME_LENGTH or not is_valid_title(loaded_title) ): return false profile_id = loaded_id display_name = loaded_name title = loaded_title.strip_edges() voice_id = VoiceProfilesType.sanitized_id( str(data.get("voice_id", VoiceProfilesType.DEFAULT_ID)) ) speech_speed_id = VoiceProfilesType.sanitized_speed_id( str(data.get("speech_speed_id", VoiceProfilesType.DEFAULT_SPEED_ID)) ) call_id = VoiceProfilesType.sanitized_call_id( str(data.get("call_id", VoiceProfilesType.DEFAULT_CALL_ID)) ) sample_set_id = VoiceProfilesType.sanitized_sample_set_id( str(data.get( "sample_set_id", VoiceProfilesType.DEFAULT_SAMPLE_SET_ID, )) ) created_at_unix = int(data["created_at_unix"]) return true func _save_atomic() -> bool: var data: Dictionary = { "format_version": FORMAT_VERSION, "profile_id": profile_id, "display_name": display_name, "title": title, "voice_id": voice_id, "speech_speed_id": speech_speed_id, "call_id": call_id, "sample_set_id": sample_set_id, "created_at_unix": created_at_unix, } 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()) return 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: return DirAccess.rename_absolute( ProjectSettings.globalize_path(from_path), ProjectSettings.globalize_path(to_path) ) == OK func _remove_if_present(path: String) -> bool: return ( not FileAccess.file_exists(path) or DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) == OK )