From cab313d69d856ea8b92b03781b6f775a9163b90f Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 2 Sep 2026 07:19:25 -0400 Subject: [PATCH] Refresh data management dialogs --- network/player_data_root.gd | 55 ++++ scripts/test_player_data_root_paths.gd | 28 +- tests/progression_archive_validation.gd | 5 +- ui/data_management_dialog.gd | 325 ++++++++++++++++++++ ui/data_management_dialog.gd.uid | 1 + ui/game_ui.gd | 27 ++ ui/settings_panel.gd | 382 +++++++++++++++++------- 7 files changed, 720 insertions(+), 103 deletions(-) create mode 100644 ui/data_management_dialog.gd create mode 100644 ui/data_management_dialog.gd.uid diff --git a/network/player_data_root.gd b/network/player_data_root.gd index fd1265a..141cc36 100644 --- a/network/player_data_root.gd +++ b/network/player_data_root.gd @@ -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(): diff --git a/scripts/test_player_data_root_paths.gd b/scripts/test_player_data_root_paths.gd index f357b9a..0476606 100644 --- a/scripts/test_player_data_root_paths.gd +++ b/scripts/test_player_data_root_paths.gd @@ -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, diff --git a/tests/progression_archive_validation.gd b/tests/progression_archive_validation.gd index 0daae87..624c8ca 100644 --- a/tests/progression_archive_validation.gd +++ b/tests/progression_archive_validation.gd @@ -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( diff --git a/ui/data_management_dialog.gd b/ui/data_management_dialog.gd new file mode 100644 index 0000000..f3f6141 --- /dev/null +++ b/ui/data_management_dialog.gd @@ -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), + ) diff --git a/ui/data_management_dialog.gd.uid b/ui/data_management_dialog.gd.uid new file mode 100644 index 0000000..337ecbe --- /dev/null +++ b/ui/data_management_dialog.gd.uid @@ -0,0 +1 @@ +uid://mcjckbgqlxd1 diff --git a/ui/game_ui.gd b/ui/game_ui.gd index 4f4b271..65b72b6 100644 --- a/ui/game_ui.gd +++ b/ui/game_ui.gd @@ -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 diff --git a/ui/settings_panel.gd b/ui/settings_panel.gd index 7055d96..ad56ac2 100644 --- a/ui/settings_panel.gd +++ b/ui/settings_panel.gd @@ -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: