gdharness 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,21 +11,57 @@ signal tool_requested(request_id: String, tool_name: String, args: Dictionary)
11
11
  const DEFAULT_URL: String = "ws://127.0.0.1:6505/godot"
12
12
  ## Written beside the addon by the install, so it names the version this copy came from.
13
13
  const VERSION_MARKER: String = "res://addons/gdharness_editor/.gdharness-version"
14
+ ## Written by the server that serves this project, saying where its bridge actually is. Kept in
15
+ ## step with `announcementPath` in src/bridge-announce.ts.
16
+ const ANNOUNCEMENT: String = "res://.godot/gdharness-bridge.json"
17
+ const ANNOUNCE_PROTOCOL: int = 1
14
18
  const RECONNECT_DELAY: float = 3.0
15
19
  const MAX_RECONNECT_DELAY: float = 30.0
16
20
 
21
+ ## How long one attempt is given before the address is called a bad one.
22
+ ##
23
+ ## A socket pointed at a port nothing holds gives up by itself, in thirty seconds on Windows. One
24
+ ## pointed at something that accepts and then never speaks WebSocket does not give up at all:
25
+ ## measured here, still connecting after forty-five seconds, because the peer has no handshake
26
+ ## timeout of its own in 4.7. A leftover announcement can be either, and a port that has been
27
+ ## reused by some other program is the second.
28
+ const CONNECT_TIMEOUT: float = 10.0
29
+
30
+ ## How often the announcement is read again while connected, so a newer server is moved to
31
+ ## rather than waited for. A harness reconnect leaves the server it replaced running and holding
32
+ ## the old port, and an editor with no reason to look elsewhere stayed on it for the session.
33
+ const FOLLOW_INTERVAL: float = 5.0
34
+
17
35
  var socket: WebSocketPeer = WebSocketPeer.new()
18
36
  var server_url: String = DEFAULT_URL
37
+
38
+ ## What this copy was when it loaded, which is what the editor is running until it is restarted.
39
+ ## See [method _loaded_version] for why it is held rather than read when it is wanted.
40
+ var version_at_load: String = ""
41
+
19
42
  var _is_connected: bool = false
20
43
  var _reconnect_timer: Timer
21
44
  var _current_reconnect_delay: float = RECONNECT_DELAY
22
- var _should_reconnect: bool = true
45
+ ## Nothing reconnects until somebody has asked to connect once.
46
+ var _should_reconnect: bool = false
23
47
  var _project_path: String
24
48
  var _initialized: bool = false
25
49
 
50
+ ## Whether the address came from a caller rather than from the project, in which case it is not
51
+ ## this node's to change.
52
+ var _named_by_caller: bool = false
53
+ var _since_looked: float = 0.0
54
+
55
+ ## The announced address that did not answer, so the fallback gets a turn. Cleared the moment
56
+ ## anything connects or the project names a different one.
57
+ var _refused_url: String = ""
58
+ var _tried_announced: bool = false
59
+ var _connecting_for: float = 0.0
60
+
26
61
 
27
62
  func _ready() -> void:
28
63
  _project_path = ProjectSettings.globalize_path("res://")
64
+ version_at_load = _loaded_version()
29
65
 
30
66
  _reconnect_timer = Timer.new()
31
67
  _reconnect_timer.one_shot = true
@@ -43,6 +79,13 @@ func _process(_delta: float) -> void:
43
79
  if socket.get_ready_state() == WebSocketPeer.STATE_CLOSED:
44
80
  if _is_connected:
45
81
  _handle_disconnect()
82
+ elif _should_reconnect and _reconnect_timer.is_stopped():
83
+ # A connection refused never opened, so it reaches CLOSED without passing through
84
+ # _handle_disconnect and nothing would ask again. An editor opened before the server
85
+ # is the ordinary way that happens, and it then sat there for the rest of the day.
86
+ if _tried_announced:
87
+ _refused_url = server_url
88
+ _schedule_reconnect()
46
89
  return
47
90
 
48
91
  socket.poll()
@@ -51,11 +94,17 @@ func _process(_delta: float) -> void:
51
94
  WebSocketPeer.STATE_OPEN:
52
95
  if not _is_connected:
53
96
  _handle_connect()
97
+ _follow_whoever_is_newest(_delta)
54
98
 
55
99
  while socket.get_available_packet_count() > 0:
56
100
  var packet: PackedByteArray = socket.get_packet()
57
101
  _handle_message(packet.get_string_from_utf8())
58
102
 
103
+ WebSocketPeer.STATE_CONNECTING:
104
+ _connecting_for += _delta
105
+ if _connecting_for >= CONNECT_TIMEOUT:
106
+ _give_up_on_this_address()
107
+
59
108
  WebSocketPeer.STATE_CLOSING:
60
109
  pass
61
110
 
@@ -64,7 +113,38 @@ func _process(_delta: float) -> void:
64
113
  _handle_disconnect()
65
114
 
66
115
 
116
+ ## Moves to the server the project now names, when that is not the one this is talking to.
117
+ ##
118
+ ## A harness reconnect leaves the server it replaced running, still holding the port it bound and
119
+ ## still answering: the editor has no reason to notice, and stayed on a server nothing was
120
+ ## speaking to for the rest of the session. The replacement announces where it landed, so the
121
+ ## editor can go to it rather than anybody ending a process.
122
+ ##
123
+ ## Only while connected by a URL this worked out for itself. A caller that named one is holding
124
+ ## this to that address, which is what every fixture does.
125
+ func _follow_whoever_is_newest(delta: float) -> void:
126
+ if _named_by_caller:
127
+ return
128
+ _since_looked += delta
129
+ if _since_looked < FOLLOW_INTERVAL:
130
+ return
131
+ _since_looked = 0.0
132
+
133
+ var announced: String = announced_url()
134
+ if announced == "" or announced == server_url or announced == _refused_url:
135
+ return
136
+ server_url = announced
137
+ # Put down before it is picked up again, because the arrival is what tells a server who this
138
+ # editor is: left standing, the open socket on the new address would never be greeted and the
139
+ # server would report no editor while holding one.
140
+ _is_connected = false
141
+ disconnected.emit()
142
+ _current_reconnect_delay = RECONNECT_DELAY
143
+ _attempt_connection()
144
+
145
+
67
146
  func connect_to_server(url: String = "") -> void:
147
+ _named_by_caller = url != ""
68
148
  server_url = _resolve_server_url(url)
69
149
  _should_reconnect = true
70
150
  _current_reconnect_delay = RECONNECT_DELAY
@@ -75,6 +155,12 @@ func _resolve_server_url(explicit_url: String) -> String:
75
155
  if explicit_url != "":
76
156
  return explicit_url
77
157
 
158
+ var announced: String = announced_url()
159
+ if announced != "" and announced != _refused_url:
160
+ _tried_announced = true
161
+ return announced
162
+ _tried_announced = false
163
+
78
164
  # The same variable the server reads, so the two agree on the port by construction.
79
165
  var raw: String = OS.get_environment("GDHARNESS_BRIDGE_PORT")
80
166
  if raw != "":
@@ -85,6 +171,48 @@ func _resolve_server_url(explicit_url: String) -> String:
85
171
  return DEFAULT_URL
86
172
 
87
173
 
174
+ ## Stops waiting on an address that is not answering, and asks again elsewhere.
175
+ func _give_up_on_this_address() -> void:
176
+ if _tried_announced:
177
+ _refused_url = server_url
178
+ _connecting_for = 0.0
179
+ socket.close()
180
+ _schedule_reconnect()
181
+
182
+
183
+ ## Where the server says its bridge is, or "" when nothing has said.
184
+ ##
185
+ ## Written by a server that knows which project it serves, inside that project, so the two sides
186
+ ## agree by construction rather than by deriving a temporary directory the same way: they do not
187
+ ## share an environment, and the runtime's own announcement cost a session learning that.
188
+ ##
189
+ ## A leftover is found out by trying it rather than by asking whether its process is still there.
190
+ ## `OS.is_process_running` answers that only for a child of the caller on Unix, where it prints
191
+ ## "does not exist or is not a child of the calling process" and says no about every server there
192
+ ## is: it works on Windows and quietly disables the whole thing everywhere else. So an
193
+ ## announcement that does not answer is set aside, the fallback gets the next turn, and a
194
+ ## different announcement puts it back in play.
195
+ func announced_url() -> String:
196
+ if not FileAccess.file_exists(ANNOUNCEMENT):
197
+ return ""
198
+ var file: FileAccess = FileAccess.open(ANNOUNCEMENT, FileAccess.READ)
199
+ if file == null:
200
+ return ""
201
+ var said: Variant = JSON.parse_string(file.get_as_text())
202
+ file.close()
203
+ if not said is Dictionary:
204
+ return ""
205
+
206
+ var announcement: Dictionary = said
207
+ if int(announcement.get("protocol", 0)) != ANNOUNCE_PROTOCOL:
208
+ return ""
209
+ var port: int = int(announcement.get("port", 0))
210
+ if port < 1 or port > 65535:
211
+ return ""
212
+ var host: String = str(announcement.get("host", "127.0.0.1"))
213
+ return "ws://%s:%d/godot" % [host, port]
214
+
215
+
88
216
  func disconnect_from_server() -> void:
89
217
  _should_reconnect = false
90
218
  if _reconnect_timer:
@@ -98,6 +226,7 @@ func _attempt_connection() -> void:
98
226
  if socket.get_ready_state() != WebSocketPeer.STATE_CLOSED:
99
227
  socket.close()
100
228
 
229
+ _connecting_for = 0.0
101
230
  var err: Error = socket.connect_to_url(server_url)
102
231
  if err != OK:
103
232
  push_error("[gdharness] Failed to connect to %s: %s" % [server_url, error_string(err)])
@@ -107,6 +236,7 @@ func _attempt_connection() -> void:
107
236
  func _handle_connect() -> void:
108
237
  _is_connected = true
109
238
  _current_reconnect_delay = RECONNECT_DELAY
239
+ _refused_url = ""
110
240
 
111
241
  # The version reported is the one this editor loaded at startup, not the one on disk: an
112
242
  # upgrade replaces the files under a running editor, which goes on serving the old code until
@@ -117,7 +247,7 @@ func _handle_connect() -> void:
117
247
  {
118
248
  "type": "godot_ready",
119
249
  "project_path": _project_path,
120
- "addon_version": _loaded_version(),
250
+ "addon_version": version_at_load,
121
251
  "editor_pid": OS.get_process_id()
122
252
  }
123
253
  )
@@ -126,6 +256,13 @@ func _handle_connect() -> void:
126
256
 
127
257
 
128
258
  ## The version marker beside this addon, or "" when the copy was not installed by gdharness.
259
+ ##
260
+ ## Read once, when this copy loads, and never again: an upgrade replaces the files under a
261
+ ## running editor and rewrites the marker with them, so reading it at connect time answers with
262
+ ## the version on disk rather than the one in memory. That is the wrong answer at the one moment
263
+ ## it matters. An upgrade ends with the harness reconnecting, the editor reconnecting behind it,
264
+ ## and `addonIsStale` reporting false over an editor still running the old code, which is exactly
265
+ ## what it exists to catch.
129
266
  func _loaded_version() -> String:
130
267
  if not FileAccess.file_exists(VERSION_MARKER):
131
268
  return ""
@@ -153,6 +290,10 @@ func _schedule_reconnect() -> void:
153
290
 
154
291
 
155
292
  func _on_reconnect_timer() -> void:
293
+ # Asked again rather than remembered: the reason a connection dropped is often that its server
294
+ # did, and the one that replaced it has said where it is since.
295
+ if not _named_by_caller:
296
+ server_url = _resolve_server_url("")
156
297
  _attempt_connection()
157
298
 
158
299
 
@@ -0,0 +1 @@
1
+ uid://t1telvjuccb0
@@ -0,0 +1 @@
1
+ uid://bxebcmjteyrwl
@@ -0,0 +1 @@
1
+ uid://dmjxtcqxfrure
@@ -0,0 +1 @@
1
+ uid://bbmtwsaoi4bh0
@@ -0,0 +1 @@
1
+ uid://bpusjmmbajpxq
@@ -0,0 +1 @@
1
+ uid://d27rjl7bpjvss
@@ -0,0 +1 @@
1
+ uid://bdfggiknkfi0f
@@ -66,6 +66,7 @@ func _init() -> void:
66
66
  "capture_viewport": _capture.capture_viewport,
67
67
  "inject_action": _input.inject_action,
68
68
  "inject_key": _input.inject_key,
69
+ "inject_text": _input.inject_text,
69
70
  "inject_mouse_click": _input.inject_mouse_click,
70
71
  "inject_mouse_motion": _input.inject_mouse_motion,
71
72
  "click": _input.click,
@@ -0,0 +1 @@
1
+ uid://c5crv4n4t7c5
@@ -0,0 +1 @@
1
+ uid://cxhjoq814ox42
@@ -5,6 +5,13 @@ extends RefCounted
5
5
 
6
6
  const Values = preload("runtime_values.gd")
7
7
 
8
+ ## The distance from a capital letter to its small one in Unicode. A keycode holds the capital.
9
+ const TO_SMALL: int = 32
10
+
11
+ ## The two characters a field reads as keys rather than as text.
12
+ const NEWLINE: int = 10
13
+ const TAB: int = 9
14
+
8
15
  var _host: Node
9
16
  var _values: Values
10
17
 
@@ -69,6 +76,12 @@ func inject_key(params: Dictionary) -> Dictionary:
69
76
  event.ctrl_pressed = bool(params.get("ctrl", false))
70
77
  event.alt_pressed = bool(params.get("alt", false))
71
78
 
79
+ # The fourth thing a real event carries, and the only one a text field reads: LineEdit and
80
+ # TextEdit insert `unicode` and never consult the keycode, so an injected key could press any
81
+ # action in the map and still type nothing into a search box. Godot's keycodes for printable
82
+ # keys are the code points themselves, which is what makes this a mapping and not a table.
83
+ event.unicode = _glyph_of(event.keycode, event.shift_pressed)
84
+
72
85
  Input.parse_input_event(event)
73
86
 
74
87
  return {
@@ -79,10 +92,73 @@ func inject_key(params: Dictionary) -> Dictionary:
79
92
  "shift": event.shift_pressed,
80
93
  "ctrl": event.ctrl_pressed,
81
94
  "alt": event.alt_pressed,
95
+ "unicode": event.unicode,
82
96
  "pressed": pressed
83
97
  }
84
98
 
85
99
 
100
+ ## Types [param text] wherever the focus is, one key event per character.
101
+ ##
102
+ ## A key on its own cannot do this and should not try: which character a key produces is the
103
+ ## keyboard layout's business, and shift over a digit is an exclamation mark on one layout and
104
+ ## something else on the next. Given the character instead there is nothing to guess, so a field
105
+ ## can be filled with anything a player could type, this project's own two typefaces included.
106
+ ##
107
+ ## A newline and a tab are the two characters a field reads as keys rather than as text, so they
108
+ ## are sent as those keys and carry no character of their own: typing a name and submitting it is
109
+ ## one call rather than two.
110
+ ##
111
+ ## Pushed into the viewport for the reason [method click] is, and it matters more here: the focus
112
+ ## is what decides where a character lands, so a caller that clicked a field and then typed would
113
+ ## otherwise have both waiting in the same queue with nothing said about the order.
114
+ func inject_text(params: Dictionary) -> Dictionary:
115
+ var text: String = String(params.get("text", ""))
116
+ if text.is_empty():
117
+ return {"type": "error", "message": "text required"}
118
+
119
+ var viewport: Viewport = _host.get_tree().root
120
+ for index: int in text.length():
121
+ var down: InputEventKey = _typed(text.unicode_at(index))
122
+ viewport.push_input(down)
123
+ # The release as well, so nothing is left held down behind the caller.
124
+ var up: InputEventKey = down.duplicate()
125
+ up.pressed = false
126
+ viewport.push_input(up)
127
+
128
+ return {"type": "input_injected", "input_type": "text", "text": text, "characters": text.length()}
129
+
130
+
131
+ ## The key press that produces [param glyph], as a keyboard would send it.
132
+ func _typed(glyph: int) -> InputEventKey:
133
+ var event: InputEventKey = InputEventKey.new()
134
+ event.pressed = true
135
+ if glyph == NEWLINE:
136
+ event.keycode = KEY_ENTER
137
+ elif glyph == TAB:
138
+ event.keycode = KEY_TAB
139
+ else:
140
+ var capital: int = String.chr(glyph).to_upper().unicode_at(0)
141
+ event.keycode = capital as Key
142
+ event.shift_pressed = capital != glyph
143
+ event.unicode = glyph
144
+ event.physical_keycode = event.keycode
145
+ event.key_label = event.keycode
146
+ return event
147
+
148
+
149
+ ## The character a key produces, or nothing for a key that produces none.
150
+ ##
151
+ ## Godot's keycodes below [constant KEY_SPECIAL] are the Unicode code points of the keys that
152
+ ## print something, so the mapping is the value itself. Letters are held as their capitals, which
153
+ ## is the one place the shift a caller asked for changes the answer rather than the key.
154
+ func _glyph_of(keycode: Key, shifted: bool) -> int:
155
+ if keycode >= KEY_SPECIAL:
156
+ return 0
157
+ if keycode >= KEY_A and keycode <= KEY_Z and not shifted:
158
+ return keycode + TO_SMALL
159
+ return keycode
160
+
161
+
86
162
  ## A point the tool schema sends as two flat numbers, or the older form of one [x, y] value.
87
163
  ## Answers a Vector2, or the String that says what was wrong with it.
88
164
  func _read_point(params: Dictionary, x_key: String, y_key: String, pair_key: String) -> Variant:
@@ -0,0 +1 @@
1
+ uid://d1shdw1mlyig7
@@ -0,0 +1 @@
1
+ uid://bueql5kulh1dm
@@ -28,7 +28,16 @@ const SERIALISERS: Dictionary = {
28
28
 
29
29
  ## Converts a Godot value into something JSON can carry. A type with no entry in the table
30
30
  ## passes through as itself, which is what the JSON-native ones want.
31
+ ##
32
+ ## A method or property typed as a class answers a null with a Variant of type OBJECT that has
33
+ ## nothing behind it, and so does one whose object has been freed. Asking either for its class is
34
+ ## a script error, and a script error here costs far more than the one answer: the request is
35
+ ## never replied to at all, and an editor playing the game stops it dead on the error, so every
36
+ ## question after it times out as well. `find_child` for a name nothing has cost a whole session
37
+ ## that way.
31
38
  func serialize(value: Variant) -> Variant:
39
+ if typeof(value) == TYPE_OBJECT and not is_instance_valid(value):
40
+ return null
32
41
  var serialiser: String = SERIALISERS.get(typeof(value), "")
33
42
  return call(serialiser, value) if not serialiser.is_empty() else value
34
43
 
@@ -0,0 +1 @@
1
+ uid://bamdnyhpw2umv
@@ -0,0 +1 @@
1
+ uid://cpx5hgxdn8dwd
@@ -27,7 +27,15 @@ const SERIALISERS: Dictionary = {
27
27
 
28
28
  # Converts a Godot value into something JSON can carry. A type with no entry in the table
29
29
  # passes through as itself, which is what the JSON-native ones want.
30
+ #
31
+ # A property that holds no object is Object-typed and null rather than nil, which is most of what
32
+ # a node's property list is, and an object that has been freed is the same shape again. Both are
33
+ # refused here rather than inside _serialize_object, because a freed one never reaches it: the
34
+ # call itself fails on the argument, with "previously freed is not a subclass of the expected
35
+ # argument class", and a guard behind that is a guard that never runs.
30
36
  func serialize_value(value: Variant) -> Variant:
37
+ if typeof(value) == TYPE_OBJECT and not is_instance_valid(value):
38
+ return null
31
39
  var serialiser: String = SERIALISERS.get(typeof(value), "")
32
40
  return call(serialiser, value) if not serialiser.is_empty() else value
33
41
 
@@ -140,12 +148,7 @@ func _serialize_dictionary(value: Dictionary) -> Dictionary:
140
148
 
141
149
 
142
150
  # A pathless Resource says so rather than inventing an empty path for the caller to load.
143
- #
144
- # A property that holds no object is Object-typed and null rather than nil, which is most of
145
- # what a node's property list is, so this is the branch that runs the most.
146
151
  func _serialize_object(value: Object) -> Variant:
147
- if not is_instance_valid(value):
148
- return null
149
152
  if not value is Resource:
150
153
  return {"_type": "Object", "class": value.get_class()}
151
154
  var resource: Resource = value