Refresh data management dialogs

This commit is contained in:
Alexander Sellite 2026-09-02 07:19:25 -04:00
parent 3a61056ce5
commit cab313d69d
7 changed files with 720 additions and 103 deletions

View file

@ -117,6 +117,18 @@ func resolve() -> bool:
var expected: String = str(bootstrap.get("expected_root_id", ""))
if selected.is_empty():
return _fail("The data-folder pointer is incomplete.")
if not _validate_persistent_root_path(selected):
# A historical direct validation could leave a real bootstrap pointer
# aimed at /tmp. Do not keep retrying an unsafe location on every
# launch: leave its files alone, remove only the pointer, and return to
# the normal first-run persistent-folder choice.
_clear_bootstrap_pointer()
requires_selection = true
error_message = (
"A temporary data folder was ignored. Choose a persistent folder."
)
status_changed.emit(error_message)
return false
mode = (
Mode.APP_DATA
if selected == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
@ -213,6 +225,8 @@ func select_new_root(path: String, app_data: bool = false) -> bool:
var normalized: String = _normalize(path)
if app_data:
normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
if not _validate_persistent_root_path(normalized):
return false
if not _validate_candidate(normalized, true):
return false
var manifest_path: String = _existing_manifest_path(normalized)
@ -277,6 +291,8 @@ func use_existing_root(path: String) -> bool:
if device_id.length() != 32:
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
var normalized: String = _normalize(path)
if not _validate_persistent_root_path(normalized):
return false
if not _activate_existing(normalized, "", true):
return false
if not _write_bootstrap(root_path, root_id):
@ -370,6 +386,34 @@ func _activate_existing(path: String, expected_id: String, permit_creation: bool
return true
func _validate_persistent_root_path(path: String) -> bool:
# Test fixtures intentionally use temporary folders, but they must only be
# process-local. A real saved data-root pointer should never target a
# temporary directory that the operating system can purge at any time.
if _isolated_validation_requested():
return true
if _is_temporary_directory(path):
return _fail(
"Temporary folders cannot be used for persistent straywild data."
)
return true
static func _is_temporary_directory(path: String) -> bool:
var case_insensitive: bool = OS.get_name() == "Windows"
var normalized: String = _normalize_comparison_path(
path, case_insensitive
)
var temporary: String = _normalize_comparison_path(
OS.get_temp_dir(), case_insensitive
)
return (
not normalized.is_empty()
and not temporary.is_empty()
and (normalized == temporary or normalized.begins_with(temporary + "/"))
)
func _validate_candidate(path: String, create: bool) -> bool:
var normalized: String = _normalize(path)
if normalized.is_empty() or not normalized.is_absolute_path():
@ -512,6 +556,17 @@ func _write_bootstrap(path: String, id: String) -> bool:
)
func _clear_bootstrap_pointer() -> void:
for path: String in [
BOOTSTRAP_PATH,
BOOTSTRAP_PATH + ".backup",
BOOTSTRAP_TEMP_PATH,
]:
var absolute: String = ProjectSettings.globalize_path(path)
if FileAccess.file_exists(absolute):
DirAccess.remove_absolute(absolute)
func _load_bootstrap_identity() -> void:
var data: Dictionary = _read_json(BOOTSTRAP_PATH, 64 * 1024)
if data.is_empty():

View file

@ -78,6 +78,7 @@ func _initialize() -> void:
failures,
true,
)
_expect_temporary_persistent_root_rejected(failures)
if failures.is_empty():
print("PlayerDataRoot path validation: PASS")
quit(0)
@ -90,14 +91,37 @@ func _initialize() -> void:
func _create_validation_root(path: String) -> void:
var data_root := PlayerDataRoot.new()
get_root().add_child(data_root)
if data_root.select_new_root(path):
var created: Dictionary = data_root.create_unbound_root(path)
if (
bool(created.get("ok", false))
and data_root.activate_process_root(path)
):
print("PlayerDataRoot validation root created: ", data_root.root_path)
quit(0)
return
push_error(data_root.error_message)
push_error(str(created.get("message", data_root.error_message)))
quit(1)
func _expect_temporary_persistent_root_rejected(
failures: PackedStringArray,
) -> void:
var data_root := PlayerDataRoot.new()
get_root().add_child(data_root)
var temporary_path: String = OS.get_temp_dir().path_join(
"straywild-persistent-root"
)
if data_root.select_new_root(temporary_path):
failures.append(
"temporary directory was accepted as persistent player data"
)
elif not data_root.error_message.contains("Temporary folders"):
failures.append(
"temporary directory rejection did not explain the safety requirement"
)
data_root.queue_free()
func _expect(
label: String,
candidate: String,

View file

@ -361,7 +361,10 @@ func _assert_legacy_data_manifest(parent: String) -> void:
file.close()
var legacy_data_root := PlayerDataRoot.new()
root.add_child(legacy_data_root)
assert(legacy_data_root.use_existing_root(legacy_root))
# This legacy-manifest check must never replace the user's persisted data
# folder selection when the validation is run directly outside the isolated
# runner. Activate the fixture only for this process instead.
assert(legacy_data_root.activate_process_root(legacy_root))
assert(legacy_data_root.root_id == legacy_id)
assert(FileAccess.file_exists(legacy_path))
var canonical_path: String = legacy_root.path_join(

View file

@ -0,0 +1,325 @@
class_name DataManagementDialog
extends Control
const UtilityPageStyleType = preload("res://ui/utility_page_style.gd")
signal action_selected(action_id: StringName)
signal dismissed
enum ActionTone {
SECONDARY,
PRIMARY,
DANGER,
}
const DEFAULT_SIZE := Vector2(600.0, 330.0)
const MINIMUM_SIZE := Vector2(520.0, 250.0)
const MAXIMUM_SIZE := Vector2(680.0, 500.0)
const ACTION_HEIGHT: float = 44.0
var _dimmer: ColorRect
var _panel: PanelContainer
var _title_label: Label
var _body_scroll: ScrollContainer
var _body: VBoxContainer
var _description: Label
var _custom_content: VBoxContainer
var _actions: VBoxContainer
var _action_buttons: Array[Button] = []
var _cancel_action_id: StringName = StringName("")
var _action_callback: Callable
var _presentation_generation: int = 0
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
mouse_filter = Control.MOUSE_FILTER_STOP
# Settings panels are hosted inside several differently layered title and
# pause containers. Keep the data modal above that whole presentation, not
# merely above the panel that opened it.
z_index = 250
_build_layout()
hide()
func present(
title_text: String,
body_text: String,
actions: Array[Dictionary],
action_callback: Callable = Callable(),
custom_content: Control = null,
preferred_control: Control = null,
requested_size: Vector2 = DEFAULT_SIZE,
) -> void:
if not is_inside_tree() or is_queued_for_deletion():
push_warning("Data-management dialog is not available yet.")
return
if not is_node_ready():
call_deferred(
"present",
title_text,
body_text,
actions,
action_callback,
custom_content,
preferred_control,
requested_size,
)
return
_presentation_generation += 1
_action_callback = action_callback
_title_label.text = title_text
_description.text = body_text
_description.visible = not body_text.strip_edges().is_empty()
_replace_custom_content(custom_content)
_rebuild_actions(actions)
_panel.custom_minimum_size = _clamped_size(requested_size)
show()
_focus_preferred.call_deferred(preferred_control)
func dismiss() -> void:
if not visible:
return
_presentation_generation += 1
_action_callback = Callable()
_cancel_action_id = StringName("")
hide()
dismissed.emit()
func is_open() -> bool:
return visible
func cancel() -> bool:
if not visible:
return false
if _cancel_action_id.is_empty():
dismiss()
return true
_trigger_action(_cancel_action_id)
return true
func focused_control() -> Control:
if not is_inside_tree():
return null
var focus_owner: Control = get_viewport().gui_get_focus_owner()
return focus_owner if focus_owner != null and is_ancestor_of(focus_owner) else null
func _build_layout() -> void:
_dimmer = ColorRect.new()
_dimmer.name = "Dimmer"
_dimmer.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_dimmer.color = Color(0.0, 0.04, 0.06, 0.72)
_dimmer.mouse_filter = Control.MOUSE_FILTER_STOP
add_child(_dimmer)
var center := CenterContainer.new()
center.name = "Center"
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_PASS
add_child(center)
_panel = PanelContainer.new()
_panel.name = "Panel"
_panel.custom_minimum_size = DEFAULT_SIZE
_panel.mouse_filter = Control.MOUSE_FILTER_STOP
_panel.add_theme_stylebox_override(
"panel", UtilityPageStyleType.panel_style()
)
center.add_child(_panel)
var layout := VBoxContainer.new()
layout.name = "Layout"
layout.add_theme_constant_override("separation", 14)
_panel.add_child(layout)
_title_label = Label.new()
_title_label.name = "Title"
_title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
_title_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_title_label.add_theme_font_override("font", UtilityPageStyleType.TuffyFont)
_title_label.add_theme_font_size_override("font_size", 29)
_title_label.add_theme_color_override(
"font_color", UtilityPageStyleType.OCEAN_TEXT_PRIMARY
)
layout.add_child(_title_label)
_body_scroll = ScrollContainer.new()
_body_scroll.name = "BodyScroll"
_body_scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body_scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
_body_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
_body_scroll.follow_focus = true
layout.add_child(_body_scroll)
_body = VBoxContainer.new()
_body.name = "Body"
_body.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_body.add_theme_constant_override("separation", 12)
_body_scroll.add_child(_body)
_description = Label.new()
_description.name = "Description"
_description.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_description.add_theme_font_override("font", UtilityPageStyleType.TuffyFont)
_description.add_theme_font_size_override("font_size", 18)
_description.add_theme_color_override(
"font_color", UtilityPageStyleType.OCEAN_TEXT_SECONDARY
)
_body.add_child(_description)
_custom_content = VBoxContainer.new()
_custom_content.name = "CustomContent"
_custom_content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_custom_content.add_theme_constant_override("separation", 12)
_body.add_child(_custom_content)
_actions = VBoxContainer.new()
_actions.name = "Actions"
_actions.size_flags_horizontal = Control.SIZE_EXPAND_FILL
layout.add_child(_actions)
func _replace_custom_content(content: Control) -> void:
for child: Node in _custom_content.get_children():
_custom_content.remove_child(child)
child.queue_free()
if content == null:
return
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
_custom_content.add_child(content)
func _rebuild_actions(actions: Array[Dictionary]) -> void:
for child: Node in _actions.get_children():
_actions.remove_child(child)
child.queue_free()
_action_buttons.clear()
_cancel_action_id = StringName("")
var action_ids: Array[StringName] = []
var action_layout: BoxContainer
if actions.size() <= 2:
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 12)
action_layout = row
else:
var column := VBoxContainer.new()
column.add_theme_constant_override("separation", 10)
action_layout = column
_actions.add_child(action_layout)
for entry: Dictionary in actions:
var action_id: StringName = StringName(str(entry.get("id", "")))
if action_id.is_empty():
continue
var button := Button.new()
button.name = "%sButton" % str(action_id).capitalize()
button.text = str(entry.get("text", action_id))
button.disabled = bool(entry.get("disabled", false))
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
button.custom_minimum_size = Vector2(0.0, ACTION_HEIGHT)
button.focus_mode = Control.FOCUS_ALL
_apply_action_style(button, int(entry.get("tone", ActionTone.PRIMARY)))
button.set_meta("data_action_id", action_id)
button.pressed.connect(_trigger_action.bind(action_id))
action_layout.add_child(button)
_action_buttons.append(button)
action_ids.append(action_id)
if bool(entry.get("cancel", false)):
_cancel_action_id = action_id
if _cancel_action_id.is_empty() and not action_ids.is_empty():
_cancel_action_id = action_ids.back()
func _apply_action_style(button: Button, tone: int) -> void:
UtilityPageStyleType.apply_ocean_button(button)
match tone:
ActionTone.SECONDARY:
for state: StringName in [&"normal", &"hover", &"pressed", &"focus"]:
var color := (
UtilityPageStyleType.OCEAN_PANEL_DEEP
if state == &"normal"
else UtilityPageStyleType.OCEAN_PANEL_MID
)
button.add_theme_stylebox_override(
state,
UtilityPageStyleType.ocean_button_style(color),
)
ActionTone.DANGER:
button.add_theme_stylebox_override(
"normal",
UtilityPageStyleType.ocean_button_style(
UtilityPageStyleType.OCEAN_DANGER
),
)
button.add_theme_stylebox_override(
"hover",
UtilityPageStyleType.ocean_button_style(Color("c95a64")),
)
button.add_theme_stylebox_override(
"pressed",
UtilityPageStyleType.ocean_button_style(Color("74313b")),
)
button.add_theme_stylebox_override(
"focus",
UtilityPageStyleType.ocean_button_style(Color("c95a64")),
)
func _trigger_action(action_id: StringName) -> void:
if not visible:
return
var generation: int = _presentation_generation
action_selected.emit(action_id)
if _action_callback.is_valid():
_action_callback.call(action_id)
if generation == _presentation_generation and visible:
dismiss()
func _focus_preferred(preferred_control: Control) -> void:
if not visible:
return
var focusable: Array[Control] = []
for node: Node in _custom_content.find_children("*", "Control", true, false):
var control := node as Control
if _is_focusable(control):
focusable.append(control)
for button: Button in _action_buttons:
if _is_focusable(button):
focusable.append(button)
for index: int in focusable.size():
var control: Control = focusable[index]
control.focus_previous = control.get_path_to(
focusable[wrapi(index - 1, 0, focusable.size())]
)
control.focus_next = control.get_path_to(
focusable[wrapi(index + 1, 0, focusable.size())]
)
if _is_focusable(preferred_control):
preferred_control.grab_focus()
elif not focusable.is_empty():
focusable.front().grab_focus()
func _is_focusable(control: Control) -> bool:
if (
control == null
or not control.is_visible_in_tree()
or control.focus_mode != Control.FOCUS_ALL
):
return false
var button := control as BaseButton
return button == null or not button.disabled
func _clamped_size(requested_size: Vector2) -> Vector2:
return Vector2(
clampf(requested_size.x, MINIMUM_SIZE.x, MAXIMUM_SIZE.x),
clampf(requested_size.y, MINIMUM_SIZE.y, MAXIMUM_SIZE.y),
)

View file

@ -0,0 +1 @@
uid://mcjckbgqlxd1

View file

@ -642,6 +642,9 @@ func _input(event: InputEvent) -> void:
# overlay is processed before the UI beneath it and consumes the event.
if is_controller_text_entry_open():
return
if _handle_settings_data_dialog_controller_input(event):
get_viewport().set_input_as_handled()
return
if _handle_virtual_mouse_input(event):
get_viewport().set_input_as_handled()
return
@ -722,6 +725,30 @@ func _input(event: InputEvent) -> void:
get_viewport().set_input_as_handled()
func _handle_settings_data_dialog_controller_input(
event: InputEvent,
) -> bool:
var button_event := event as InputEventJoypadButton
if button_event == null or not button_event.pressed:
return false
var accept_pressed: bool = (
_controller_mapping_manager.event_matches_role(
event, ControllerMappingManagerType.ROLE_A
)
if _controller_mapping_manager != null
else button_event.button_index == JOY_BUTTON_A
)
if not accept_pressed:
return false
for panel: SettingsPanelType in [
_title_settings_panel, _pause_settings_panel,
]:
var entry: Control = panel.get_data_management_text_entry_control()
if entry != null:
return request_controller_text_entry_for(entry)
return false
func _reconcile_radial_menu_input() -> void:
var blocked: bool = (
not _gameplay_ui_enabled

View file

@ -30,8 +30,8 @@ const KeyboardMouseMappingManagerType = preload(
const KeyboardMouseMappingPanelType = preload(
"res://ui/keyboard_mouse_mapping_panel.gd"
)
const DialogControllerNavigationType = preload(
"res://ui/file_dialog_controller_navigation.gd"
const DataManagementDialogType = preload(
"res://ui/data_management_dialog.gd"
)
signal applied
signal closed
@ -139,7 +139,7 @@ var _keyboard_mouse_mapping_panel: KeyboardMouseMappingPanelType
var _data_folder_dialog: FileDialog
var _backup_file_dialog: FileDialog
var _export_file_dialog: FileDialog
var _passphrase_dialog: ConfirmationDialog
var _data_management_dialog: DataManagementDialogType
var _passphrase_entry: LineEdit
var _passphrase_confirm: LineEdit
var _pending_identity_operation := ""
@ -150,6 +150,11 @@ var _pending_import_data: Dictionary = {}
func _notification(what: int) -> void:
if what == NOTIFICATION_VISIBILITY_CHANGED and not visible:
if (
_data_management_dialog != null
and _data_management_dialog.is_open()
):
_data_management_dialog.dismiss()
_release_owned_focus()
@ -183,6 +188,22 @@ func _ready() -> void:
_keyboard_mouse_mapping_panel.closed.connect(
_on_keyboard_mouse_mapping_panel_closed
)
_data_management_dialog = DataManagementDialogType.new()
_data_management_dialog.name = "DataManagementDialog"
_data_management_dialog.theme = theme
# Add the dialog beneath this panel first so it becomes ready while the
# scene is assembling. Moving a child directly to a sibling from _ready()
# makes Godot reject the add because the parent is still busy setting up.
add_child(_data_management_dialog)
var dialog_host := get_parent() as Control
# The pause settings panel sits inside a CenterContainer, which would size a
# direct child as a centered dialog instead of a full-screen input blocker.
# Host the overlay on the first non-container parent so it always covers the
# canonical settings presentation.
if dialog_host is Container:
dialog_host = dialog_host.get_parent() as Control
if dialog_host != null:
call_deferred("_move_data_management_dialog_to_host", dialog_host)
resized.connect(_refresh_panel_size)
var parent_control := get_parent() as Control
if parent_control != null:
@ -191,6 +212,27 @@ func _ready() -> void:
call_deferred("_refresh_panel_size")
func _exit_tree() -> void:
if (
_data_management_dialog != null
and is_instance_valid(_data_management_dialog)
and not _data_management_dialog.is_queued_for_deletion()
):
_data_management_dialog.queue_free()
func _move_data_management_dialog_to_host(dialog_host: Control) -> void:
if (
_data_management_dialog == null
or not is_instance_valid(_data_management_dialog)
or dialog_host == null
or not is_instance_valid(dialog_host)
or _data_management_dialog.get_parent() == dialog_host
):
return
_data_management_dialog.reparent(dialog_host)
func _populate_option_buttons() -> void:
for selector: OptionButton in [_world_pixelation, _ui_pixelation]:
selector.clear()
@ -481,6 +523,11 @@ func _finish_animated_close(
func _finish_panel_close(applied_result: bool) -> void:
_cancel_panel_transition()
if (
_data_management_dialog != null
and _data_management_dialog.is_open()
):
_data_management_dialog.dismiss()
if (
_controller_mapping_panel != null
and _controller_mapping_panel.is_open()
@ -512,6 +559,12 @@ func _cancel_panel_transition() -> void:
func handle_back() -> void:
if (
_data_management_dialog != null
and _data_management_dialog.is_open()
):
_data_management_dialog.cancel()
return
if (
_keyboard_mouse_mapping_panel != null
and _keyboard_mouse_mapping_panel.is_open()
@ -606,6 +659,20 @@ func is_input_mapping_capturing() -> bool:
)
func is_data_management_dialog_open() -> bool:
return (
_data_management_dialog != null
and _data_management_dialog.is_open()
)
func get_data_management_text_entry_control() -> Control:
if not is_data_management_dialog_open():
return null
var focused: Control = _data_management_dialog.focused_control()
return focused if focused is LineEdit or focused is TextEdit else null
func _refresh_data_page() -> void:
if not is_node_ready() or _data_root == null:
return
@ -651,7 +718,16 @@ func _release_owned_focus() -> void:
if not is_inside_tree():
return
var focus_owner: Control = get_viewport().gui_get_focus_owner()
if focus_owner != null and is_ancestor_of(focus_owner):
if (
focus_owner != null
and (
is_ancestor_of(focus_owner)
or (
_data_management_dialog != null
and _data_management_dialog.is_ancestor_of(focus_owner)
)
)
):
focus_owner.release_focus()
@ -716,40 +792,57 @@ func _change_data_folder(path: String) -> void:
func _show_existing_data_folder_choice(path: String) -> void:
var dialog := ConfirmationDialog.new()
dialog.title = "existing straywild data"
dialog.ok_button_text = "use selected data"
dialog.dialog_text = (
"the selected folder contains different straywild data.\n\n"
+ "choose one complete data set. data will not be merged."
_data_management_dialog.present(
"existing straywild data",
(
"the selected folder already contains different straywild data. "
+ "choose one complete data set; data is never merged."
),
[
{
"id": &"use_selected",
"text": "use selected data",
"tone": DataManagementDialogType.ActionTone.PRIMARY,
},
{
"id": &"replace",
"text": "replace selected data",
"tone": DataManagementDialogType.ActionTone.DANGER,
},
{
"id": &"cancel",
"text": "cancel",
"tone": DataManagementDialogType.ActionTone.SECONDARY,
"cancel": true,
},
],
_on_existing_data_folder_choice_action.bind(path),
null,
null,
Vector2(600.0, 390.0),
)
dialog.add_button("replace selected data", false, "replace")
dialog.confirmed.connect(func() -> void:
func _on_existing_data_folder_choice_action(
action: StringName,
path: String,
) -> void:
if action == &"use_selected":
if _data_root.use_existing_root(path):
_complete_data_root_change("data folder changed.")
else:
_feedback.text = _data_root.error_message
dialog.queue_free()
return
if action != &"replace":
return
var result: Dictionary = PortableDataMigration.replace_existing_with_active(
_data_root, path
)
dialog.custom_action.connect(func(action: StringName) -> void:
if action != &"replace":
return
var result: Dictionary = 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)):
_complete_data_root_change(_feedback.text.to_lower())
dialog.queue_free()
)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(620, 340))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
_feedback.text = str(
result.get("message", "could not replace selected data.")
)
if bool(result.get("ok", false)):
_complete_data_root_change(_feedback.text.to_lower())
func _complete_data_root_change(message: String) -> void:
@ -816,51 +909,123 @@ func _identity_import_file_selected(path: String) -> void:
_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()
func _show_passphrase_dialog(
exporting: bool,
error_message: String = "",
previous_passphrase: String = "",
previous_confirmation: String = "",
) -> void:
var fields := VBoxContainer.new()
fields.name = "PassphraseFields"
fields.add_theme_constant_override("separation", 10)
if not error_message.is_empty():
var error_label := Label.new()
error_label.name = "PassphraseError"
error_label.text = error_message
error_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
error_label.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
error_label.add_theme_font_size_override("font_size", 17)
error_label.add_theme_color_override(
"font_color", Color("f08d98")
)
fields.add_child(error_label)
var instruction := Label.new()
instruction.text = (
"set a passphrase for this encrypted backup. you will need it "
+ "to restore the identity later."
if exporting
else "enter the passphrase used to protect this identity backup."
)
instruction.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
instruction.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
instruction.add_theme_font_size_override("font_size", 18)
instruction.add_theme_color_override(
"font_color", UtilityPageStyle.OCEAN_TEXT_SECONDARY
)
fields.add_child(instruction)
if exporting:
var warning := Label.new()
warning.text = (
"anyone with this backup and passphrase can use your identity."
"keep the backup file and passphrase private. anyone with both "
+ "can use this identity."
)
warning.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
warning.add_theme_font_override("font", UtilityPageStyle.TuffyFont)
warning.add_theme_font_size_override("font_size", 17)
warning.add_theme_color_override("font_color", Color("f5d78e"))
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_entry = LineEdit.new()
_passphrase_entry.name = "Passphrase"
_passphrase_entry.placeholder_text = (
"new passphrase (12 characters minimum)"
if exporting else "backup passphrase"
)
_passphrase_entry.secret = true
_passphrase_entry.text = previous_passphrase
_passphrase_entry.custom_minimum_size = Vector2(0.0, 46.0)
_passphrase_entry.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_line_edit(_passphrase_entry)
fields.add_child(_passphrase_entry)
_passphrase_confirm = null
if exporting:
_passphrase_confirm = LineEdit.new()
_passphrase_confirm.name = "ConfirmPassphrase"
_passphrase_confirm.placeholder_text = "confirm passphrase"
_passphrase_confirm.secret = true
_passphrase_confirm.text = previous_confirmation
_passphrase_confirm.custom_minimum_size = Vector2(0.0, 46.0)
_passphrase_confirm.size_flags_horizontal = Control.SIZE_EXPAND_FILL
UtilityPageStyle.apply_ocean_line_edit(_passphrase_confirm)
fields.add_child(_passphrase_confirm)
_passphrase_dialog.add_child(fields)
_interface_fonts.apply_utility_theme(_passphrase_dialog)
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))
_configure_confirmation_dialog.call_deferred(
_passphrase_dialog, _passphrase_entry
_data_management_dialog.present(
("backup %s identity" % _pending_identity_type)
if exporting else ("restore %s identity" % _pending_identity_type),
"",
[
{
"id": &"confirm",
"text": "create backup" if exporting else "restore identity",
"tone": DataManagementDialogType.ActionTone.PRIMARY,
},
{
"id": &"cancel",
"text": "cancel",
"tone": DataManagementDialogType.ActionTone.SECONDARY,
"cancel": true,
},
],
_on_identity_passphrase_action,
fields,
_passphrase_entry,
Vector2(600.0, 385.0 if exporting else 310.0),
)
func _on_identity_passphrase_action(action: StringName) -> void:
if action == &"confirm":
_submit_identity_passphrase()
func _submit_identity_passphrase() -> void:
if _passphrase_entry == null:
return
var passphrase: String = _passphrase_entry.text
if _pending_identity_operation == "export":
_identity_backups.export_backup(
var confirmation: String = (
_passphrase_confirm.text if _passphrase_confirm != null else ""
)
if not _identity_backups.export_backup(
_pending_identity_type,
_pending_identity_path,
passphrase,
_passphrase_confirm.text,
)
confirmation,
):
_show_passphrase_dialog(
true,
_feedback.text,
passphrase,
confirmation,
)
return
var inspected: Dictionary = _identity_backups.import_backup(
_pending_identity_type,
@ -875,46 +1040,74 @@ func _submit_identity_passphrase() -> void:
"incoming": inspected["incoming_fingerprint"],
}
_show_identity_replacement_confirmation()
elif not bool(inspected.get("ok", false)):
_show_passphrase_dialog(false, _feedback.text, passphrase)
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)
_interface_fonts.apply_utility_theme(dialog)
add_child(dialog)
dialog.popup_centered(Vector2i(620, 360))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
_data_management_dialog.present(
"replace active identity?",
(
"current identity\n%s\n\nimported identity\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"]),
],
[
{
"id": &"review",
"text": "review replacement",
"tone": DataManagementDialogType.ActionTone.PRIMARY,
},
{
"id": &"cancel",
"text": "cancel",
"tone": DataManagementDialogType.ActionTone.SECONDARY,
"cancel": true,
},
],
_on_identity_replacement_review_action,
null,
null,
Vector2(620.0, 400.0),
)
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))
_configure_confirmation_dialog.call_deferred(
dialog, dialog.get_cancel_button()
)
func _on_identity_replacement_review_action(action: StringName) -> void:
if action != &"review":
_pending_import_data.clear()
return
_confirm_identity_replacement(dialog)
_data_management_dialog.present(
"replace active identity?",
(
"this permanently replaces the active identity. the current key "
+ "will be archived locally so it can be recovered later."
),
[
{
"id": &"replace",
"text": "replace identity",
"tone": DataManagementDialogType.ActionTone.DANGER,
},
{
"id": &"cancel",
"text": "cancel",
"tone": DataManagementDialogType.ActionTone.SECONDARY,
"cancel": true,
},
],
_on_identity_replacement_final_action,
null,
null,
Vector2(580.0, 315.0),
)
func _confirm_identity_replacement(dialog: ConfirmationDialog) -> void:
func _on_identity_replacement_final_action(action: StringName) -> void:
if action != &"replace":
_pending_import_data.clear()
return
_identity_backups.import_backup(
_pending_identity_type,
_pending_identity_path,
@ -922,17 +1115,6 @@ func _confirm_identity_replacement(dialog: ConfirmationDialog) -> void:
true,
)
_pending_import_data.clear()
dialog.queue_free()
func _configure_confirmation_dialog(
dialog: ConfirmationDialog,
preferred_control: Control,
) -> void:
if dialog != null and is_instance_valid(dialog) and dialog.visible:
DialogControllerNavigationType.configure_scope(
dialog, preferred_control
)
func _identity_operation_allowed() -> bool: