feat(network): add selectable privacy-preserving discovery

This commit is contained in:
Alexander Sellite 2026-09-01 10:21:26 -04:00
parent 9cf0c8fc9c
commit 4d31fd1517
26 changed files with 959 additions and 153 deletions

View file

@ -1,8 +1,8 @@
class_name JoinGamePage
extends Control
signal join_requested(endpoint: String)
signal join_confirmation_requested(endpoint: String)
signal join_requested(route: ConnectionRoute)
signal join_confirmation_requested(endpoint: String, relayed: bool)
signal back_requested
enum Mode {
@ -17,14 +17,23 @@ const DISCOVERY_REFRESH_SECONDS: float = 8.0
const ControllerFocusNavigationType = preload(
"res://ui/controller_focus_navigation.gd"
)
const ADDRESS_HIDDEN_ICON: Texture2D = preload(
"res://ui/icons/address_hidden.svg"
)
const ADDRESS_VISIBLE_ICON: Texture2D = preload(
"res://ui/icons/address_visible.svg"
)
const ADDRESS_FORMAT_HELP: String = (
"Hostname, IPv4, or [IPv6]. Port 7777 is used when omitted; "
+ "include the port shown by a host when it differs."
)
const DIRECT_WORKFLOW_HELP: String = (
"%s\nJoin Now connects once; Save Server stores this address locally."
% ADDRESS_FORMAT_HELP
"Direct connections expose your public IP address to the remote host. "
+ "Only connect to people you trust.\n"
+ ADDRESS_FORMAT_HELP
+ "\n"
+ "Join Now connects once; Save Server stores this address locally."
)
@onready var _main_panel: PanelContainer = %MainPanel
@ -33,6 +42,9 @@ const DIRECT_WORKFLOW_HELP: String = (
@onready var _list_content: Control = %ListContent
@onready var _list_title: Label = %ListTitle
@onready var _online_controls: Control = %OnlineControls
@onready var _address_privacy_controls: Control = %AddressPrivacyControls
@onready var _address_privacy_label: Label = %AddressPrivacyLabel
@onready var _address_visibility_button: Button = %AddressVisibilityButton
@onready var _presence_button: Button = %PresenceButton
@onready var _room_open_button: Button = %RoomOpenButton
@onready var _room_listing_button: Button = %RoomListingButton
@ -84,6 +96,7 @@ var _connection_error_latched: bool = false
var _pending_confirmation_endpoint: String = ""
var _pending_confirmation_room: Dictionary = {}
var _owns_pending_public_join: bool = false
var _addresses_visible: bool = false
func _ready() -> void:
@ -96,6 +109,9 @@ func _ready() -> void:
_presence_button.pressed.connect(_on_presence_pressed)
_room_open_button.pressed.connect(_on_room_open_pressed)
_room_listing_button.pressed.connect(_on_room_listing_pressed)
_address_visibility_button.pressed.connect(
_on_address_visibility_pressed
)
_refresh_button.pressed.connect(_request_discovery_refresh)
_join_button.pressed.connect(_request_join)
_save_button.pressed.connect(_on_save_pressed)
@ -310,6 +326,7 @@ func setup(
func open_page(preserved_endpoint: String = "") -> void:
_addresses_visible = false
if not preserved_endpoint.is_empty():
_address.text = preserved_endpoint
elif _address.text.is_empty():
@ -368,7 +385,11 @@ func confirm_pending_join() -> bool:
return false
return true
_set_status("Connecting…")
join_requested.emit(endpoint_text)
var endpoint := EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
_set_status(endpoint.error_message, true)
return false
join_requested.emit(ConnectionRoute.direct(endpoint))
return true
@ -405,7 +426,7 @@ func _default_mode_status(mode: Mode) -> String:
Mode.FRIENDS:
return "friend status is live and not stored by discovery"
Mode.DIRECT:
return "direct connection • default port 7777"
return "direct connection • your public IP is visible to the host"
Mode.SAVED:
return "saved servers are stored on this device"
Mode.RECENT:
@ -447,18 +468,26 @@ func _request_join() -> void:
if _discovery == null:
_set_status("Could not prepare the public connection.", true)
return
endpoint_text = _discovery.room_endpoint(room)
discovery_room = room.duplicate(true)
elif _mode != Mode.DIRECT and _selected_entry != null:
endpoint_text = _selected_entry.normalized_endpoint
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
_set_status(endpoint.error_message, true)
return
var endpoint: ConnectionEndpoint
if discovery_room.is_empty():
endpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
_set_status(endpoint.error_message, true)
return
if _gameplay_context:
_pending_confirmation_endpoint = endpoint.normalized_display
_pending_confirmation_endpoint = (
"public room"
if not discovery_room.is_empty()
else endpoint.normalized_display
)
_pending_confirmation_room = discovery_room
join_confirmation_requested.emit(endpoint.normalized_display)
join_confirmation_requested.emit(
_pending_confirmation_endpoint,
_room_uses_relay(discovery_room),
)
return
if not discovery_room.is_empty():
_owns_pending_public_join = true
@ -468,22 +497,20 @@ func _request_join() -> void:
return
_set_status("Connecting…")
_address.text = endpoint.normalized_display
join_requested.emit(endpoint.normalized_display)
join_requested.emit(ConnectionRoute.direct(endpoint))
func _on_public_join_prepared(endpoint_text: String) -> void:
func _on_public_join_prepared(route: ConnectionRoute) -> void:
if not _owns_pending_public_join:
return
_owns_pending_public_join = false
if _mode not in [Mode.DISCOVER, Mode.FRIENDS] or not is_visible_in_tree():
return
var endpoint: ConnectionEndpoint = EndpointParser.parse(endpoint_text)
if not endpoint.is_valid():
_set_status("Discovery returned an invalid room address.", true)
if route == null or not route.is_valid() or not route.is_discovery_join():
_set_status("Could not reach this room.", true)
return
_address.text = endpoint.normalized_display
_set_status("Connecting…")
join_requested.emit(endpoint.normalized_display)
join_requested.emit(route)
func _on_public_join_status_changed(message: String, is_error: bool) -> void:
@ -518,7 +545,7 @@ func _on_save_pressed() -> void:
)
if existing != null:
_set_status(
"Already saved locally as “%s”." % existing.display_name,
"Already saved locally as “%s”." % _display_entry_name(existing),
true,
)
_set_mode(Mode.SAVED)
@ -530,7 +557,7 @@ func _on_save_pressed() -> void:
if _mode == Mode.RECENT
and _selected_entry != null
and not _selected_entry.last_observed_server_name.is_empty()
else endpoint.host
else "Saved Server"
)
_name_entry_active = true
_name_edit.show()
@ -559,7 +586,7 @@ func _commit_name_entry() -> void:
_set_status(
"Server updated."
if was_editing
else "Saved locally as “%s”." % saved.display_name
else "Saved locally as “%s”." % _display_entry_name(saved)
)
_clear_edit_state()
_mode = Mode.SAVED
@ -572,7 +599,17 @@ func _on_edit_pressed() -> void:
return
_editing_entry_id = _selected_entry.entry_id
_address.text = _selected_entry.normalized_endpoint
_name_edit.text = _selected_entry.display_name
_name_edit.text = (
_selected_entry.display_name
if (
_addresses_visible
or not _entry_label_reveals_address(
_selected_entry.display_name,
_selected_entry,
)
)
else "Saved Server"
)
_name_entry_active = true
_name_edit.show()
_save_button.text = "save"
@ -789,8 +826,8 @@ func _refresh_entries() -> void:
"%s%s%s%s"
% [
prefix,
entry.display_name,
entry.normalized_endpoint,
_display_entry_name(entry),
_display_endpoint(entry.normalized_endpoint),
count,
]
)
@ -822,6 +859,7 @@ func _refresh() -> void:
var list_content_visible: bool = not direct_content_visible
_direct_content.visible = direct_content_visible
_list_content.visible = list_content_visible
_refresh_address_privacy_controls()
_list_title.text = _current_list_title()
_refresh_online_controls(discovery_mode, friends_mode, connecting)
_address.visible = direct or _name_entry_active
@ -978,8 +1016,10 @@ func _refresh_online_controls(
if not local_host:
return
var room_open: bool = _network_session.is_open_host()
var room_listed: bool = (
_discovery != null and _discovery.is_discoverable()
var host_discovery_mode: String = (
_discovery.get_host_discovery_mode()
if _discovery != null
else DiscoveryClient.HOST_DISCOVERY_PRIVATE
)
_room_open_button.text = "room: open" if room_open else "room: closed"
_room_open_button.disabled = connecting
@ -988,9 +1028,7 @@ func _refresh_online_controls(
if room_open
else "Open this room so other players can connect."
)
_room_listing_button.text = (
"listing: on" if room_listed else "listing: off"
)
_room_listing_button.text = "discovery | %s" % host_discovery_mode
_room_listing_button.disabled = (
connecting
or _discovery == null
@ -998,9 +1036,14 @@ func _refresh_online_controls(
or not room_open
)
_room_listing_button.tooltip_text = (
"Remove this room from the public room browser."
if room_listed
else "List this open room in the public room browser."
"Stop discovery listing. Existing relay connections remain active "
+ "until those players disconnect."
if host_discovery_mode == DiscoveryClient.HOST_CONNECTION_RELAY
else "Switch this public listing to the privacy relay. Relay hosting "
+ "hides peer IPs but may add latency and uses shared server capacity."
if host_discovery_mode == DiscoveryClient.HOST_CONNECTION_DIRECT
else "List this room through direct discovery. Joining players and the "
+ "host can see one another's public IP addresses."
if room_open and _discovery != null and _discovery.is_configured()
else "Open the room before enabling its public listing."
if _discovery != null and _discovery.is_configured()
@ -1025,6 +1068,7 @@ func _configure_controller_navigation() -> void:
online_controls.append(control)
var content_controls: Array[Control] = []
for control: Control in [
_address_visibility_button,
_address,
_name_edit,
_server_list,
@ -1056,6 +1100,7 @@ func _configure_controller_navigation() -> void:
_presence_button,
_room_open_button,
_room_listing_button,
_address_visibility_button,
_address,
_name_edit,
_server_list,
@ -1219,8 +1264,8 @@ func _recover_controller_focus(
func _format_entry_details(entry: SavedServerEntry) -> String:
var parts: Array[String] = []
if _mode == Mode.SAVED:
parts.append("Name: %s" % entry.display_name)
parts.append("Address: %s" % entry.normalized_endpoint)
parts.append("Name: %s" % _display_entry_name(entry))
parts.append("Address: %s" % _display_endpoint(entry.normalized_endpoint))
if _server_trust != null:
var trust := _server_trust.get_record(entry.get_endpoint())
if trust.is_empty():
@ -1239,7 +1284,10 @@ func _format_entry_details(entry: SavedServerEntry) -> String:
or entry.last_observed_server_name != entry.display_name
)
):
parts.append("Server name: %s" % entry.last_observed_server_name)
parts.append(
"Server name: %s"
% _display_entry_label(entry.last_observed_server_name, entry)
)
if entry.last_observed_max_players > 0:
parts.append(
"Players: %d / %d"
@ -1259,22 +1307,139 @@ func _format_entry_details(entry: SavedServerEntry) -> String:
parts.append(
"Last result: %s" % _format_result_code(entry.last_result_code)
)
parts.append("Connection: direct peer-to-peer • not relayed")
parts.append("Joining shares your public IP address with the remote host.")
return "\n".join(parts)
func _on_address_visibility_pressed() -> void:
_addresses_visible = not _addresses_visible
if _mode in [Mode.SAVED, Mode.RECENT]:
var selected_id := (
_selected_entry.entry_id if _selected_entry != null else ""
)
_refresh_entries()
if not selected_id.is_empty():
_select_entry_id(selected_id)
_refresh()
func _refresh_address_privacy_controls() -> void:
var relevant := (
_mode in [Mode.DIRECT, Mode.SAVED, Mode.RECENT]
or _name_entry_active
)
_address_privacy_controls.visible = relevant
_address.secret = not _addresses_visible
_address_privacy_label.text = (
"addresses visible"
if _addresses_visible
else "addresses hidden for streamer safety"
)
_address_visibility_button.icon = (
ADDRESS_VISIBLE_ICON if _addresses_visible else ADDRESS_HIDDEN_ICON
)
_address_visibility_button.tooltip_text = (
"Hide server addresses"
if _addresses_visible
else "Show server addresses"
)
_address_visibility_button.accessibility_name = (
"hide server addresses"
if _addresses_visible
else "show server addresses"
)
func _display_endpoint(endpoint: String) -> String:
return endpoint if _addresses_visible else "*****"
func _display_entry_name(entry: SavedServerEntry) -> String:
if entry == null:
return "server"
if (
_addresses_visible
or not _entry_label_reveals_address(entry.display_name, entry)
):
return entry.display_name
return (
"saved server"
if entry.kind == SavedServerEntry.Kind.SAVED
else "recent server"
)
func _display_entry_label(
label: String,
entry: SavedServerEntry,
) -> String:
if _addresses_visible or not _entry_label_reveals_address(label, entry):
return label
return "*****"
func _entry_label_reveals_address(
label: String,
entry: SavedServerEntry,
) -> bool:
if entry == null:
return false
var normalized_label: String = label.strip_edges().to_lower()
if normalized_label.is_empty():
return false
for candidate: String in [
entry.normalized_endpoint,
entry.normalized_host,
entry.host,
]:
var normalized_candidate: String = candidate.strip_edges().to_lower()
if normalized_candidate.is_empty():
continue
if normalized_label == normalized_candidate:
return true
if (
normalized_candidate.contains(".")
or normalized_candidate.contains(":")
) and normalized_label.contains(normalized_candidate):
return true
return false
func _format_discovery_details(room: Dictionary) -> String:
if room.is_empty():
return "Select a public room."
var relayed := _room_uses_relay(room)
var lines: Array[String] = [
"Room: %s" % str(room.get("room_name", "Public room")),
"Host: %s" % (
"dedicated server operator"
if str(room.get("host_kind", "player")) == "dedicated"
else "another player"
),
"Players: %d / %d" % [
int(room.get("current_players", 0)),
int(room.get("max_players", 0)),
],
"Connection: %s" % (
_discovery.room_endpoint(room) if _discovery != null else ""
(
"Connection: privacy relay"
if relayed
else "Connection: direct peer-to-peer • not relayed"
),
(
"IP privacy: the other player sees the relay address, not your "
+ "residential IP. The relay service still handles both addresses."
if relayed
else "IP privacy: joining shares your public IP address with the host."
),
(
"Relay failure stops the join; straywild will not silently fall back "
+ "to a direct connection. The extra network hop may increase latency."
if relayed
else "The room address is disclosed to your game only after you press "
+ "Join; it is not displayed or saved. Use a VPN if you need stronger "
+ "privacy."
),
"Direct UDP connection • ping is measured after joining",
]
if _discovery != null and _discovery.is_own_room(room):
lines.append("This is the room you are currently hosting.")
@ -1292,11 +1457,28 @@ func _format_friend_details(friend: Dictionary) -> String:
),
]
if not room.is_empty():
var relayed := _room_uses_relay(room)
lines.append("Room: %s" % str(room.get("room_name", "Public room")))
lines.append("Host: %s" % (
"dedicated server operator"
if str(room.get("host_kind", "player")) == "dedicated"
else "another player"
))
lines.append("Players: %d / %d" % [
int(room.get("current_players", 0)),
int(room.get("max_players", 0)),
])
lines.append(
"Connection: privacy relay"
if relayed
else "Connection: direct peer-to-peer • not relayed"
)
lines.append(
"The other player sees the relay address; the relay service still "
+ "handles both network addresses. The extra hop may add latency."
if relayed
else "Joining shares your public IP address with the host."
)
elif bool(friend.get("online", false)):
lines.append("This friend is not in a joinable public room.")
else:
@ -1338,6 +1520,10 @@ func _discovery_room_is_full(room: Dictionary) -> bool:
return int(room.get("current_players", 0)) >= int(room.get("max_players", 0))
func _room_uses_relay(room: Dictionary) -> bool:
return str(room.get("connection_mode", "")) == "relay"
func _request_discovery_refresh() -> void:
if (
_discovery == null
@ -1435,14 +1621,15 @@ func _on_room_open_pressed() -> void:
func _on_room_listing_pressed() -> void:
if _discovery == null:
return
var enabling: bool = not _discovery.is_discoverable()
if not _discovery.set_discoverable(enabling):
if not _discovery.cycle_host_discovery_mode():
return
_set_status(
"The room is being listed publicly."
if enabling
else "The room is no longer listed publicly."
)
match _discovery.get_host_discovery_mode():
DiscoveryClient.HOST_CONNECTION_DIRECT:
_set_status("Discovery is listing this room as a direct connection.")
DiscoveryClient.HOST_CONNECTION_RELAY:
_set_status("Discovery is listing this room through the privacy relay.")
_:
_set_status("Discovery listing is private.")
_refresh()

View file

@ -1,9 +1,11 @@
[gd_scene load_steps=5 format=3]
[gd_scene load_steps=7 format=3]
[ext_resource type="Script" path="res://ui/network/join_game_page.gd" id="1_script"]
[ext_resource type="Theme" path="res://ui/game_theme.tres" id="2_theme"]
[ext_resource type="Script" path="res://ui/components/organizer_tab.gd" id="3_tab"]
[ext_resource type="PackedScene" path="res://ui/components/bubble_menu/bubble_confirmation_page.tscn" id="4_confirmation"]
[ext_resource type="Texture2D" path="res://ui/icons/address_hidden.svg" id="5_address_hidden"]
[ext_resource type="Texture2D" path="res://ui/icons/address_visible.svg" id="6_address_visible"]
[node name="JoinGamePage" type="Control"]
visible = false
@ -117,6 +119,33 @@ theme_override_constants/margin_bottom = 20
layout_mode = 2
theme_override_constants/separation = 12
[node name="AddressPrivacyControls" type="HBoxContainer" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"]
unique_name_in_owner = true
visible = false
custom_minimum_size = Vector2(0, 40)
layout_mode = 2
theme_override_constants/separation = 10
[node name="AddressPrivacyLabel" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/AddressPrivacyControls"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
theme_override_font_sizes/font_size = 14
text = "addresses hidden for streamer safety"
horizontal_alignment = 2
vertical_alignment = 1
[node name="AddressVisibilityButton" type="Button" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/AddressPrivacyControls"]
unique_name_in_owner = true
custom_minimum_size = Vector2(44, 40)
layout_mode = 2
focus_mode = 2
tooltip_text = "Show server addresses"
icon = ExtResource("5_address_hidden")
expand_icon = true
icon_max_width = 27
accessibility_name = "show server addresses"
[node name="PageStack" type="Control" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout"]
layout_mode = 2
size_flags_vertical = 3
@ -214,6 +243,8 @@ theme_override_font_sizes/font_size = 19
placeholder_text = "example.net or 192.168.1.50:7777"
alignment = 1
max_length = 300
secret = true
secret_character = "*"
[node name="AddressHelper" type="Label" parent="MainPanel/OuterMargin/Layout/ContentPanel/ContentMargin/ContentLayout/PageStack/DirectContent"]
unique_name_in_owner = true