openclacky 0.9.12 → 0.9.13
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +10 -0
- data/lib/clacky/brand_config.rb +24 -5
- data/lib/clacky/cli.rb +20 -8
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +43 -33
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +41 -34
- data/lib/clacky/server/http_server.rb +74 -46
- data/lib/clacky/ui2/ui_controller.rb +3 -0
- data/lib/clacky/utils/parser_manager.rb +4 -1
- data/lib/clacky/version.rb +1 -1
- data/scripts/install.sh +493 -184
- data/scripts/install_simple.sh +582 -0
- metadata +36 -13
- data/scripts/install_original.sh +0 -891
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d21f7af79e1b66e118c909432e379375359e2bf650099c7bd17669985411409b
|
|
4
|
+
data.tar.gz: 1f927c5ce98c2560773cd0a8011ef0d523c56788fe47e17aec3dc1f952d1cd5c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a3a51590171a24e10a002d9c89ff5a46e17ff2b09080553e886f156ab53b9dca3a64050a80816678cdd8510c1aa3fe0158732139e07053503c366ddf17e8ea9d
|
|
7
|
+
data.tar.gz: 7c76475762cf8ecf506337ec80fb6aa12eac582d604f8245c801e1c6d89bea7d32f7e508e689a8a1f6bc6ed3f7ec9f7a2ce85682ffb33954d8e6e2178c1def1f
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.13] - 2026-03-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **Ruby 2.6 compatibility**: the gem now installs cleanly on Ruby 2.6 (including macOS system Ruby 2.6.x) — dependency version constraints for `faraday` and `rouge` are now capped so RubyGems automatically selects compatible versions on older Ruby environments
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **WebSocket pure-Ruby replacement**: replaced the native WebSocket dependency with a pure-Ruby implementation to improve cross-platform compatibility
|
|
17
|
+
- **Ctrl+C warning in UI suppressed**: fixed a spurious warning printed to the terminal when pressing Ctrl+C in the interactive UI
|
|
18
|
+
- **Parser stderr pollution from Bundler warnings filtered**: Ruby/Bundler version warnings no longer contaminate parser error messages
|
|
19
|
+
|
|
10
20
|
## [0.9.12] - 2026-03-27
|
|
11
21
|
|
|
12
22
|
### Added
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -102,16 +102,28 @@ module Clacky
|
|
|
102
102
|
|
|
103
103
|
# Returns true when a heartbeat should be sent (interval elapsed).
|
|
104
104
|
def heartbeat_due?
|
|
105
|
-
|
|
105
|
+
if @license_last_heartbeat.nil?
|
|
106
|
+
Clacky::Logger.debug("[Brand] heartbeat_due? => true (never sent)")
|
|
107
|
+
return true
|
|
108
|
+
end
|
|
106
109
|
|
|
107
|
-
|
|
110
|
+
elapsed = Time.now.utc - @license_last_heartbeat
|
|
111
|
+
due = elapsed >= HEARTBEAT_INTERVAL
|
|
112
|
+
Clacky::Logger.debug("[Brand] heartbeat_due? elapsed=#{elapsed.to_i}s interval=#{HEARTBEAT_INTERVAL}s => #{due}")
|
|
113
|
+
due
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
# Returns true when the grace period for missed heartbeats has expired.
|
|
111
117
|
def grace_period_exceeded?
|
|
112
|
-
|
|
118
|
+
if @license_last_heartbeat.nil?
|
|
119
|
+
Clacky::Logger.debug("[Brand] grace_period_exceeded? => false (no heartbeat recorded)")
|
|
120
|
+
return false
|
|
121
|
+
end
|
|
113
122
|
|
|
114
|
-
|
|
123
|
+
elapsed = Time.now.utc - @license_last_heartbeat
|
|
124
|
+
exceeded = elapsed >= HEARTBEAT_GRACE_PERIOD
|
|
125
|
+
Clacky::Logger.debug("[Brand] grace_period_exceeded? elapsed=#{elapsed.to_i}s grace=#{HEARTBEAT_GRACE_PERIOD}s => #{exceeded}")
|
|
126
|
+
exceeded
|
|
115
127
|
end
|
|
116
128
|
|
|
117
129
|
# Returns true when the license is bound to a specific user (user_id present).
|
|
@@ -221,7 +233,12 @@ module Clacky
|
|
|
221
233
|
# Send a heartbeat to the API and update last_heartbeat timestamp.
|
|
222
234
|
# Returns a result hash: { success: bool, message: String }
|
|
223
235
|
def heartbeat!
|
|
224
|
-
|
|
236
|
+
unless activated?
|
|
237
|
+
Clacky::Logger.debug("[Brand] heartbeat! skipped — license not activated")
|
|
238
|
+
return { success: false, message: "License not activated" }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
Clacky::Logger.info("[Brand] heartbeat! sending — last_heartbeat=#{@license_last_heartbeat&.iso8601 || "nil"} expires_at=#{@license_expires_at&.iso8601 || "nil"}")
|
|
225
242
|
|
|
226
243
|
user_id = parse_user_id_from_key(@license_key)
|
|
227
244
|
key_hash = Digest::SHA256.hexdigest(@license_key)
|
|
@@ -246,8 +263,10 @@ module Clacky
|
|
|
246
263
|
@license_expires_at = parse_time(response[:data]["expires_at"]) if response[:data]["expires_at"]
|
|
247
264
|
apply_distribution(response[:data]["distribution"])
|
|
248
265
|
save
|
|
266
|
+
Clacky::Logger.info("[Brand] heartbeat! success — expires_at=#{@license_expires_at&.iso8601} last_heartbeat=#{@license_last_heartbeat.iso8601}")
|
|
249
267
|
{ success: true, message: "Heartbeat OK" }
|
|
250
268
|
else
|
|
269
|
+
Clacky::Logger.warn("[Brand] heartbeat! failed — #{response[:error]}")
|
|
251
270
|
{ success: false, message: response[:error] || "Heartbeat failed" }
|
|
252
271
|
end
|
|
253
272
|
end
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -252,12 +252,16 @@ module Clacky
|
|
|
252
252
|
brand = Clacky::BrandConfig.load
|
|
253
253
|
return unless brand.branded?
|
|
254
254
|
|
|
255
|
+
Clacky::Logger.info("[Brand] check_brand_license_cli: activated=#{brand.activated?} expired=#{brand.expired?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"} last_heartbeat=#{brand.license_last_heartbeat&.iso8601 || "nil"}")
|
|
256
|
+
|
|
255
257
|
unless brand.activated?
|
|
258
|
+
Clacky::Logger.info("[Brand] check_brand_license_cli: not activated, prompting user")
|
|
256
259
|
cli_prompt_license_activation(brand)
|
|
257
260
|
return
|
|
258
261
|
end
|
|
259
262
|
|
|
260
263
|
if brand.expired?
|
|
264
|
+
Clacky::Logger.warn("[Brand] check_brand_license_cli: license expired at #{brand.license_expires_at&.iso8601}")
|
|
261
265
|
say ""
|
|
262
266
|
say "WARNING: Your #{brand.product_name} license has expired. Please renew to continue.", :yellow
|
|
263
267
|
say ""
|
|
@@ -265,17 +269,25 @@ module Clacky
|
|
|
265
269
|
end
|
|
266
270
|
|
|
267
271
|
if brand.heartbeat_due?
|
|
272
|
+
Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat due, sending...")
|
|
268
273
|
result = brand.heartbeat!
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
if result[:success]
|
|
275
|
+
Clacky::Logger.info("[Brand] check_brand_license_cli: heartbeat OK")
|
|
276
|
+
else
|
|
277
|
+
Clacky::Logger.warn("[Brand] check_brand_license_cli: heartbeat failed — #{result[:message]} grace_exceeded=#{brand.grace_period_exceeded?}")
|
|
278
|
+
unless result[:success]
|
|
279
|
+
if brand.grace_period_exceeded?
|
|
280
|
+
say ""
|
|
281
|
+
say "WARNING: Could not reach the #{brand.product_name} license server.", :yellow
|
|
282
|
+
say "License has been offline for more than 3 days. Please check your connection.", :yellow
|
|
283
|
+
say ""
|
|
284
|
+
else
|
|
285
|
+
say "(License heartbeat failed - will retry tomorrow.)", :cyan
|
|
286
|
+
end
|
|
277
287
|
end
|
|
278
288
|
end
|
|
289
|
+
else
|
|
290
|
+
Clacky::Logger.debug("[Brand] check_brand_license_cli: heartbeat not due yet")
|
|
279
291
|
end
|
|
280
292
|
end
|
|
281
293
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "websocket
|
|
3
|
+
require "websocket"
|
|
4
4
|
require "json"
|
|
5
5
|
require "net/http"
|
|
6
6
|
require "uri"
|
|
@@ -47,7 +47,8 @@ module Clacky
|
|
|
47
47
|
def stop
|
|
48
48
|
@running = false
|
|
49
49
|
@ping_thread&.kill
|
|
50
|
-
|
|
50
|
+
send_raw_frame(:close, "") rescue nil
|
|
51
|
+
@ws_socket&.close rescue nil
|
|
51
52
|
end
|
|
52
53
|
|
|
53
54
|
private
|
|
@@ -73,37 +74,48 @@ module Clacky
|
|
|
73
74
|
tcp
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
77
|
+
# WebSocket handshake
|
|
78
|
+
handshake = WebSocket::Handshake::Client.new(url: endpoint)
|
|
79
|
+
socket.write(handshake.to_s)
|
|
78
80
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
+
# Read until handshake complete
|
|
82
|
+
until handshake.finished?
|
|
83
|
+
handshake << socket.readpartial(4096)
|
|
81
84
|
end
|
|
85
|
+
raise "WebSocket handshake failed" unless handshake.valid?
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@ws.on :error do |event|
|
|
89
|
-
Clacky::Logger.warn("[feishu-ws] WebSocket error: #{event.message}")
|
|
90
|
-
end
|
|
87
|
+
Clacky::Logger.info("[feishu-ws] WebSocket connected")
|
|
88
|
+
@ws_version = handshake.version
|
|
89
|
+
@ws_socket = socket
|
|
90
|
+
@ws_open = true
|
|
91
|
+
@incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
|
|
91
92
|
|
|
92
|
-
@ws.on :close do |event|
|
|
93
|
-
Clacky::Logger.info("[feishu-ws] WebSocket closed (code=#{event.code}), will reconnect")
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
@ws.start
|
|
97
93
|
start_ping_thread
|
|
98
94
|
|
|
99
95
|
loop do
|
|
100
96
|
break unless @running
|
|
101
97
|
data = socket.readpartial(4096)
|
|
102
|
-
@
|
|
98
|
+
@incoming << data
|
|
99
|
+
while (frame = @incoming.next)
|
|
100
|
+
case frame.type
|
|
101
|
+
when :binary
|
|
102
|
+
raw = frame.data
|
|
103
|
+
handle_frame(raw.respond_to?(:b) ? raw.b : raw)
|
|
104
|
+
when :text
|
|
105
|
+
handle_frame(frame.data)
|
|
106
|
+
when :ping
|
|
107
|
+
send_raw_frame(:pong, frame.data)
|
|
108
|
+
when :close
|
|
109
|
+
Clacky::Logger.info("[feishu-ws] WebSocket closed, will reconnect")
|
|
110
|
+
return
|
|
111
|
+
end
|
|
112
|
+
end
|
|
103
113
|
end
|
|
104
114
|
rescue EOFError, Errno::ECONNRESET
|
|
105
115
|
Clacky::Logger.warn("[feishu-ws] Connection lost, reconnecting in #{RECONNECT_DELAY}s...")
|
|
106
116
|
ensure
|
|
117
|
+
@ws_open = false
|
|
118
|
+
@ws_socket = nil
|
|
107
119
|
socket&.close rescue nil
|
|
108
120
|
@ping_thread&.kill
|
|
109
121
|
end
|
|
@@ -211,11 +223,21 @@ module Clacky
|
|
|
211
223
|
payload: payload
|
|
212
224
|
}
|
|
213
225
|
encoded = ProtoFrame.encode(frame)
|
|
214
|
-
|
|
226
|
+
send_raw_frame(:binary, encoded)
|
|
215
227
|
rescue => e
|
|
216
228
|
warn "[feishu-ws] failed to send frame: #{e.message}"
|
|
217
229
|
end
|
|
218
230
|
|
|
231
|
+
def send_raw_frame(type, data)
|
|
232
|
+
return unless @ws_socket && @ws_open
|
|
233
|
+
outgoing = WebSocket::Frame::Outgoing::Client.new(
|
|
234
|
+
version: @ws_version || 13,
|
|
235
|
+
data: data,
|
|
236
|
+
type: type
|
|
237
|
+
)
|
|
238
|
+
@ws_socket.write(outgoing.to_s)
|
|
239
|
+
end
|
|
240
|
+
|
|
219
241
|
def start_ping_thread
|
|
220
242
|
@ping_thread&.kill
|
|
221
243
|
@ping_thread = Thread.new do
|
|
@@ -365,19 +387,7 @@ module Clacky
|
|
|
365
387
|
end
|
|
366
388
|
end
|
|
367
389
|
|
|
368
|
-
# Wraps a raw socket for websocket-driver client mode.
|
|
369
|
-
class SocketWrapper
|
|
370
|
-
attr_reader :url
|
|
371
390
|
|
|
372
|
-
def initialize(socket, url)
|
|
373
|
-
@socket = socket
|
|
374
|
-
@url = url
|
|
375
|
-
end
|
|
376
|
-
|
|
377
|
-
def write(data)
|
|
378
|
-
@socket.write(data)
|
|
379
|
-
end
|
|
380
|
-
end
|
|
381
391
|
end
|
|
382
392
|
end
|
|
383
393
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "websocket
|
|
3
|
+
require "websocket"
|
|
4
4
|
require "json"
|
|
5
5
|
require "uri"
|
|
6
6
|
require "securerandom"
|
|
@@ -58,7 +58,8 @@ module Clacky
|
|
|
58
58
|
def stop
|
|
59
59
|
@running = false
|
|
60
60
|
@ping_thread&.kill
|
|
61
|
-
|
|
61
|
+
send_raw_frame(:close, "") rescue nil
|
|
62
|
+
@ws_socket&.close rescue nil
|
|
62
63
|
end
|
|
63
64
|
|
|
64
65
|
# Proactively send a text message
|
|
@@ -130,37 +131,45 @@ module Clacky
|
|
|
130
131
|
ssl.sync_close = true
|
|
131
132
|
ssl.connect
|
|
132
133
|
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
# WebSocket handshake
|
|
135
|
+
handshake = WebSocket::Handshake::Client.new(url: @ws_url)
|
|
136
|
+
ssl.write(handshake.to_s)
|
|
135
137
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
authenticate
|
|
139
|
-
start_ping_thread
|
|
138
|
+
until handshake.finished?
|
|
139
|
+
handshake << ssl.readpartial(4096)
|
|
140
140
|
end
|
|
141
|
+
raise "WebSocket handshake failed" unless handshake.valid?
|
|
141
142
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
@
|
|
147
|
-
Clacky::Logger.error("[WecomWSClient] WS error: #{event.message}")
|
|
148
|
-
end
|
|
143
|
+
Clacky::Logger.info("[WecomWSClient] connected, authenticating")
|
|
144
|
+
@ws_version = handshake.version
|
|
145
|
+
@ws_socket = ssl
|
|
146
|
+
@ws_open = true
|
|
147
|
+
@incoming = WebSocket::Frame::Incoming::Client.new(version: @ws_version)
|
|
149
148
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
@ws.start
|
|
149
|
+
authenticate
|
|
150
|
+
start_ping_thread
|
|
155
151
|
|
|
156
152
|
loop do
|
|
157
153
|
break unless @running
|
|
158
154
|
data = ssl.readpartial(4096)
|
|
159
|
-
@
|
|
155
|
+
@incoming << data
|
|
156
|
+
while (frame = @incoming.next)
|
|
157
|
+
case frame.type
|
|
158
|
+
when :text
|
|
159
|
+
handle_message(frame.data)
|
|
160
|
+
when :ping
|
|
161
|
+
send_raw_frame(:pong, frame.data)
|
|
162
|
+
when :close
|
|
163
|
+
Clacky::Logger.info("[WecomWSClient] connection closed")
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
end
|
|
160
167
|
end
|
|
161
168
|
rescue EOFError, Errno::ECONNRESET
|
|
162
169
|
Clacky::Logger.info("[WecomWSClient] connection lost, reconnecting...")
|
|
163
170
|
ensure
|
|
171
|
+
@ws_open = false
|
|
172
|
+
@ws_socket = nil
|
|
164
173
|
ssl&.close rescue nil
|
|
165
174
|
@ping_thread&.kill
|
|
166
175
|
end
|
|
@@ -227,11 +236,21 @@ module Clacky
|
|
|
227
236
|
else
|
|
228
237
|
Clacky::Logger.info("[WecomWSClient] >> cmd=#{cmd} req_id=#{req_id}")
|
|
229
238
|
end
|
|
230
|
-
|
|
239
|
+
send_raw_frame(:text, JSON.generate(frame))
|
|
231
240
|
rescue => e
|
|
232
241
|
Clacky::Logger.error("[WecomWSClient] failed to send frame cmd=#{cmd}: #{e.message}")
|
|
233
242
|
end
|
|
234
243
|
|
|
244
|
+
def send_raw_frame(type, data)
|
|
245
|
+
return unless @ws_socket && @ws_open
|
|
246
|
+
outgoing = WebSocket::Frame::Outgoing::Client.new(
|
|
247
|
+
version: @ws_version || 13,
|
|
248
|
+
data: data,
|
|
249
|
+
type: type
|
|
250
|
+
)
|
|
251
|
+
@ws_socket.write(outgoing.to_s)
|
|
252
|
+
end
|
|
253
|
+
|
|
235
254
|
def start_ping_thread
|
|
236
255
|
@ping_thread&.kill
|
|
237
256
|
@ping_thread = Thread.new do
|
|
@@ -341,19 +360,7 @@ module Clacky
|
|
|
341
360
|
end
|
|
342
361
|
end
|
|
343
362
|
|
|
344
|
-
# Wraps a raw socket for websocket-driver client mode.
|
|
345
|
-
class SocketWrapper
|
|
346
|
-
attr_reader :url
|
|
347
363
|
|
|
348
|
-
def initialize(socket, url)
|
|
349
|
-
@socket = socket
|
|
350
|
-
@url = url
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
def write(data)
|
|
354
|
-
@socket.write(data)
|
|
355
|
-
end
|
|
356
|
-
end
|
|
357
364
|
end
|
|
358
365
|
end
|
|
359
366
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "webrick"
|
|
4
|
-
require "websocket
|
|
4
|
+
require "websocket"
|
|
5
5
|
require "json"
|
|
6
6
|
require "thread"
|
|
7
7
|
require "fileutils"
|
|
@@ -559,6 +559,23 @@ module Clacky
|
|
|
559
559
|
return
|
|
560
560
|
end
|
|
561
561
|
|
|
562
|
+
# Send heartbeat if interval has elapsed (once per day)
|
|
563
|
+
if brand.heartbeat_due?
|
|
564
|
+
Clacky::Logger.info("[Brand] api_brand_status: heartbeat due, sending...")
|
|
565
|
+
result = brand.heartbeat!
|
|
566
|
+
if result[:success]
|
|
567
|
+
Clacky::Logger.info("[Brand] api_brand_status: heartbeat OK")
|
|
568
|
+
else
|
|
569
|
+
Clacky::Logger.warn("[Brand] api_brand_status: heartbeat failed — #{result[:message]}")
|
|
570
|
+
end
|
|
571
|
+
# Reload after heartbeat to pick up updated expires_at / last_heartbeat
|
|
572
|
+
brand = Clacky::BrandConfig.load
|
|
573
|
+
else
|
|
574
|
+
Clacky::Logger.debug("[Brand] api_brand_status: heartbeat not due yet")
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
Clacky::Logger.debug("[Brand] api_brand_status: expired=#{brand.expired?} grace_exceeded=#{brand.grace_period_exceeded?} expires_at=#{brand.license_expires_at&.iso8601 || "nil"}")
|
|
578
|
+
|
|
562
579
|
warning = nil
|
|
563
580
|
if brand.expired?
|
|
564
581
|
warning = "Your #{brand.product_name} license has expired. Please renew to continue."
|
|
@@ -571,6 +588,8 @@ module Clacky
|
|
|
571
588
|
end
|
|
572
589
|
end
|
|
573
590
|
|
|
591
|
+
Clacky::Logger.debug("[Brand] api_brand_status: warning=#{warning.inspect}")
|
|
592
|
+
|
|
574
593
|
json_response(res, 200, {
|
|
575
594
|
branded: true,
|
|
576
595
|
needs_activation: false,
|
|
@@ -1579,26 +1598,27 @@ module Clacky
|
|
|
1579
1598
|
req["Upgrade"]&.downcase == "websocket"
|
|
1580
1599
|
end
|
|
1581
1600
|
|
|
1582
|
-
# Hijacks the TCP socket from WEBrick and
|
|
1601
|
+
# Hijacks the TCP socket from WEBrick and upgrades it to WebSocket.
|
|
1583
1602
|
def handle_websocket(req, res)
|
|
1584
|
-
# Prevent WEBrick from closing the socket after this handler returns
|
|
1585
1603
|
socket = req.instance_variable_get(:@socket)
|
|
1586
1604
|
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1605
|
+
# Server handshake — parse the upgrade request
|
|
1606
|
+
handshake = WebSocket::Handshake::Server.new
|
|
1607
|
+
handshake << build_handshake_request(req)
|
|
1608
|
+
unless handshake.finished? && handshake.valid?
|
|
1609
|
+
$stderr.puts "WebSocket handshake invalid"
|
|
1610
|
+
return
|
|
1611
|
+
end
|
|
1591
1612
|
|
|
1592
|
-
|
|
1613
|
+
# Send the 101 Switching Protocols response
|
|
1614
|
+
socket.write(handshake.to_s)
|
|
1593
1615
|
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
driver.on(:error) { |event| $stderr.puts "WS error: #{event.message}" }
|
|
1616
|
+
version = handshake.version
|
|
1617
|
+
incoming = WebSocket::Frame::Incoming::Server.new(version: version)
|
|
1618
|
+
conn = WebSocketConnection.new(socket, version)
|
|
1598
1619
|
|
|
1599
|
-
|
|
1620
|
+
on_ws_open(conn)
|
|
1600
1621
|
|
|
1601
|
-
# Read loop — blocks this thread until the socket closes
|
|
1602
1622
|
begin
|
|
1603
1623
|
buf = String.new("", encoding: "BINARY")
|
|
1604
1624
|
loop do
|
|
@@ -1609,14 +1629,27 @@ module Clacky
|
|
|
1609
1629
|
when nil
|
|
1610
1630
|
break # EOF
|
|
1611
1631
|
else
|
|
1612
|
-
|
|
1632
|
+
incoming << chunk.dup
|
|
1633
|
+
while (frame = incoming.next)
|
|
1634
|
+
case frame.type
|
|
1635
|
+
when :text
|
|
1636
|
+
on_ws_message(conn, frame.data)
|
|
1637
|
+
when :binary
|
|
1638
|
+
on_ws_message(conn, frame.data)
|
|
1639
|
+
when :ping
|
|
1640
|
+
conn.send_raw(:pong, frame.data)
|
|
1641
|
+
when :close
|
|
1642
|
+
conn.send_raw(:close, "")
|
|
1643
|
+
break
|
|
1644
|
+
end
|
|
1645
|
+
end
|
|
1613
1646
|
end
|
|
1614
1647
|
end
|
|
1615
1648
|
rescue IOError, Errno::ECONNRESET, Errno::EPIPE
|
|
1616
1649
|
# Client disconnected
|
|
1617
1650
|
ensure
|
|
1618
1651
|
on_ws_close(conn)
|
|
1619
|
-
|
|
1652
|
+
socket.close rescue nil
|
|
1620
1653
|
end
|
|
1621
1654
|
|
|
1622
1655
|
# Tell WEBrick not to send any response (we handled everything)
|
|
@@ -1626,6 +1659,14 @@ module Clacky
|
|
|
1626
1659
|
$stderr.puts "WebSocket handler error: #{e.class}: #{e.message}"
|
|
1627
1660
|
end
|
|
1628
1661
|
|
|
1662
|
+
# Build a raw HTTP request string from WEBrick request for WebSocket::Handshake::Server
|
|
1663
|
+
private def build_handshake_request(req)
|
|
1664
|
+
lines = ["#{req.request_method} #{req.request_uri.request_uri} HTTP/1.1\r\n"]
|
|
1665
|
+
req.each { |k, v| lines << "#{k}: #{v}\r\n" }
|
|
1666
|
+
lines << "\r\n"
|
|
1667
|
+
lines.join
|
|
1668
|
+
end
|
|
1669
|
+
|
|
1629
1670
|
def on_ws_open(conn)
|
|
1630
1671
|
@ws_mutex.synchronize { @all_ws_conns << conn }
|
|
1631
1672
|
# Client will send a "subscribe" message to bind to a session
|
|
@@ -2040,47 +2081,34 @@ module Clacky
|
|
|
2040
2081
|
|
|
2041
2082
|
# ── Inner classes ─────────────────────────────────────────────────────────
|
|
2042
2083
|
|
|
2043
|
-
#
|
|
2044
|
-
class RackEnvAdapter
|
|
2045
|
-
def initialize(req, socket)
|
|
2046
|
-
@req = req
|
|
2047
|
-
@socket = socket
|
|
2048
|
-
end
|
|
2049
|
-
|
|
2050
|
-
def env
|
|
2051
|
-
{
|
|
2052
|
-
"REQUEST_METHOD" => @req.request_method,
|
|
2053
|
-
"HTTP_HOST" => @req["Host"],
|
|
2054
|
-
"REQUEST_URI" => @req.request_uri.to_s,
|
|
2055
|
-
"HTTP_UPGRADE" => @req["Upgrade"],
|
|
2056
|
-
"HTTP_CONNECTION" => @req["Connection"],
|
|
2057
|
-
"HTTP_SEC_WEBSOCKET_KEY" => @req["Sec-WebSocket-Key"],
|
|
2058
|
-
"HTTP_SEC_WEBSOCKET_VERSION" => @req["Sec-WebSocket-Version"],
|
|
2059
|
-
"rack.hijack" => proc {},
|
|
2060
|
-
"rack.input" => StringIO.new
|
|
2061
|
-
}
|
|
2062
|
-
end
|
|
2063
|
-
|
|
2064
|
-
def write(data)
|
|
2065
|
-
@socket.write(data)
|
|
2066
|
-
end
|
|
2067
|
-
end
|
|
2068
|
-
|
|
2069
|
-
# Wraps a raw TCP socket + WebSocket driver, providing a thread-safe send method.
|
|
2084
|
+
# Wraps a raw TCP socket, providing thread-safe WebSocket frame sending.
|
|
2070
2085
|
class WebSocketConnection
|
|
2071
2086
|
attr_accessor :session_id
|
|
2072
2087
|
|
|
2073
|
-
def initialize(socket,
|
|
2088
|
+
def initialize(socket, version)
|
|
2074
2089
|
@socket = socket
|
|
2075
|
-
@
|
|
2090
|
+
@version = version
|
|
2076
2091
|
@send_mutex = Mutex.new
|
|
2077
2092
|
end
|
|
2078
2093
|
|
|
2079
2094
|
def send_json(data)
|
|
2080
|
-
|
|
2095
|
+
send_raw(:text, JSON.generate(data))
|
|
2081
2096
|
rescue => e
|
|
2082
2097
|
$stderr.puts "WS send error: #{e.message}"
|
|
2083
2098
|
end
|
|
2099
|
+
|
|
2100
|
+
def send_raw(type, data)
|
|
2101
|
+
@send_mutex.synchronize do
|
|
2102
|
+
outgoing = WebSocket::Frame::Outgoing::Server.new(
|
|
2103
|
+
version: @version,
|
|
2104
|
+
data: data,
|
|
2105
|
+
type: type
|
|
2106
|
+
)
|
|
2107
|
+
@socket.write(outgoing.to_s)
|
|
2108
|
+
end
|
|
2109
|
+
rescue => e
|
|
2110
|
+
$stderr.puts "WS send_raw error: #{e.message}"
|
|
2111
|
+
end
|
|
2084
2112
|
end
|
|
2085
2113
|
end
|
|
2086
2114
|
end
|
|
@@ -72,11 +72,14 @@ module Clacky
|
|
|
72
72
|
|
|
73
73
|
stdout, stderr, status = Open3.capture3(RbConfig.ruby, parser_path, file_path)
|
|
74
74
|
|
|
75
|
+
# Filter out Ruby/Bundler version warnings that pollute stderr
|
|
76
|
+
clean_stderr = stderr.lines.reject { |l| l.match?(/warning:|already initialized constant/) }.join.strip
|
|
77
|
+
|
|
75
78
|
if status.success? && stdout.strip.length > 0
|
|
76
79
|
{ success: true, text: stdout.strip, error: nil, parser_path: parser_path }
|
|
77
80
|
else
|
|
78
81
|
{ success: false, text: nil,
|
|
79
|
-
error:
|
|
82
|
+
error: clean_stderr.empty? ? "Parser exited with code #{status.exitstatus}" : clean_stderr,
|
|
80
83
|
parser_path: parser_path }
|
|
81
84
|
end
|
|
82
85
|
end
|
data/lib/clacky/version.rb
CHANGED