terminalwire 0.1.0 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/terminalwire.rb CHANGED
@@ -3,17 +3,10 @@
3
3
  require_relative "terminalwire/version"
4
4
 
5
5
  require 'socket'
6
- require 'msgpack'
7
- require 'launchy'
8
- require 'logger'
9
- require 'io/console'
10
6
  require 'forwardable'
11
7
  require 'uri'
12
8
  require 'zeitwerk'
13
9
 
14
- require 'thor'
15
- require 'fileutils'
16
-
17
10
  require 'async'
18
11
  require 'async/http/endpoint'
19
12
  require 'async/websocket/client'
@@ -23,673 +16,39 @@ module Terminalwire
23
16
  class Error < StandardError; end
24
17
 
25
18
  Loader = Zeitwerk::Loader.for_gem.tap do |loader|
19
+ loader.ignore("#{__dir__}/generators")
26
20
  loader.setup
27
21
  end
28
22
 
29
- module Logging
30
- DEVICE = Logger.new($stdout, level: ENV.fetch("LOG_LEVEL", "info"))
31
- def logger = DEVICE
32
- end
33
-
34
- module Thor
35
- class Shell < ::Thor::Shell::Basic
36
- extend Forwardable
37
-
38
- # Encapsulates all of the IO devices for a Terminalwire connection.
39
- attr_reader :session
40
-
41
- def_delegators :@session, :stdin, :stdout, :stderr
42
-
43
- def initialize(session)
44
- @session = session
45
- super()
46
- end
47
- end
48
-
49
- def self.included(base)
50
- base.extend ClassMethods
51
-
52
- # I have to do this in a block to deal with some of Thor's DSL
53
- base.class_eval do
54
- extend Forwardable
55
-
56
- protected
57
-
58
- no_commands do
59
- def_delegators :shell, :session
60
- def_delegators :session, :stdout, :stdin, :stderr, :browser
61
- def_delegators :stdout, :puts, :print
62
- def_delegators :stdin, :gets
63
- end
64
- end
65
- end
66
-
67
- module ClassMethods
68
- def start(given_args = ARGV, config = {})
69
- session = config.delete(:session)
70
- config[:shell] = Shell.new(session) if session
71
- super(given_args, config)
72
- end
73
- end
74
- end
75
-
76
- module Transport
77
- class Base
78
- def initialize
79
- raise NotImplementedError, "This is an abstract base class"
80
- end
81
-
82
- def read
83
- raise NotImplementedError, "Subclass must implement #read"
84
- end
85
-
86
- def write(data)
87
- raise NotImplementedError, "Subclass must implement #write"
88
- end
89
-
90
- def close
91
- raise NotImplementedError, "Subclass must implement #close"
92
- end
93
- end
94
-
95
- class WebSocket
96
- def initialize(websocket)
97
- @websocket = websocket
98
- end
99
-
100
- def read
101
- @websocket.read&.buffer
102
- end
103
-
104
- def write(data)
105
- @websocket.write(data)
106
- end
107
-
108
- def close
109
- @websocket.close
110
- end
111
- end
112
-
113
- class Socket < Base
114
- def initialize(socket)
115
- @socket = socket
116
- end
117
-
118
- def read
119
- length = @socket.read(4)
120
- return nil if length.nil?
121
- length = length.unpack('L>')[0]
122
- @socket.read(length)
123
- end
124
-
125
- def write(data)
126
- length = [data.bytesize].pack('L>')
127
- @socket.write(length + data)
128
- end
129
-
130
- def close
131
- @socket.close
132
- end
133
- end
134
- end
135
-
136
- class Connection
137
- include Logging
138
-
139
- attr_reader :transport
140
-
141
- def initialize(transport)
142
- @transport = transport
143
- end
144
-
145
- def write(data)
146
- logger.debug "Connection: Sending #{data.inspect}"
147
- packed_data = MessagePack.pack(data, symbolize_keys: true)
148
- @transport.write(packed_data)
149
- end
150
-
151
- def recv
152
- logger.debug "Connection: Reading"
153
- packed_data = @transport.read
154
- return nil if packed_data.nil?
155
- data = MessagePack.unpack(packed_data, symbolize_keys: true)
156
- logger.debug "Connection: Recieved #{data.inspect}"
157
- data
158
- end
159
-
160
- def close
161
- @transport.close
162
- end
163
- end
164
-
165
- class ResourceRegistry
166
- def initialize
167
- @resources = Hash.new
168
- end
169
-
170
- def register(name, to: nil)
171
- @resources[name.to_s] = to
172
- end
173
-
174
- def <<(resource_class)
175
- register resource_class.protocol_key, to: resource_class
176
- end
177
-
178
- def find(name)
179
- @resources.fetch(name.to_s)
180
- end
181
- end
182
-
183
23
  module Resource
184
24
  class Base
185
- attr_reader :id, :connection
25
+ attr_reader :name, :adapter
186
26
 
187
- def initialize(id, connection)
188
- @id = Integer(id)
189
- @connection = connection
27
+ def initialize(name, adapter)
28
+ @name = name.to_s
29
+ @adapter = adapter
190
30
  end
191
31
 
192
32
  def connect; end
193
- def dispatch(action, data); end
194
33
  def disconnect; end
195
34
 
196
- def respond(response, status: :success)
197
- connection.write(event: "device", id: @id, status:, response:)
35
+ def fail(response, **data)
36
+ respond(status: "failure", response:, **data)
198
37
  end
199
38
 
200
- def self.protocol_key
201
- name.split("::").last.downcase
202
- end
203
- end
204
- end
205
-
206
- module Client
207
- module Resource
208
- class IO < Terminalwire::Resource::Base
209
- def dispatch(action, data)
210
- if @device.respond_to?(action)
211
- respond @device.public_send(action, data)
212
- else
213
- raise "Unknown action #{action} for device ID #{@id}"
214
- end
215
- end
39
+ def succeed(response, **data)
40
+ respond(status: "success", response:, **data)
216
41
  end
217
42
 
218
- class STDOUT < IO
219
- def connect
220
- @device = $stdout
221
- end
222
- end
223
-
224
- class STDIN < IO
225
- def connect
226
- @device = $stdin
227
- end
228
-
229
- def dispatch(action, data)
230
- respond case action
231
- when "puts"
232
- @device.puts(data)
233
- when "gets"
234
- @device.gets
235
- when "getpass"
236
- @device.getpass
237
- end
238
- end
239
- end
240
-
241
- class STDERR < IO
242
- def connect
243
- @device = $stderr
244
- end
245
- end
246
-
247
- class File < Terminalwire::Resource::Base
248
- def connect
249
- @files = {}
250
- end
251
-
252
- def dispatch(action, data)
253
- respond case action
254
- when "read"
255
- read_file(data)
256
- when "write"
257
- write_file(data.fetch(:path), data.fetch(:content))
258
- when "append"
259
- append_to_file(data.fetch(:path), data.fetch(:content))
260
- when "mkdir"
261
- mkdir(data.fetch(:path))
262
- when "exist"
263
- exist?(data.fetch(:path))
264
- else
265
- raise "Unknown action #{action} for file device"
266
- end
267
- end
268
-
269
- def mkdir(path)
270
- FileUtils.mkdir_p(::File.expand_path(path))
271
- end
272
-
273
- def exist?(path)
274
- ::File.exist? ::File.expand_path(path)
275
- end
276
-
277
- def read_file(path)
278
- ::File.read ::File.expand_path(path)
279
- end
280
-
281
- def write_file(path, content)
282
- ::File.open(::File.expand_path(path), "w") { |f| f.write(content) }
283
- end
284
-
285
- def append_to_file(path, content)
286
- ::File.open(::File.expand_path(path), "a") { |f| f.write(content) }
287
- end
288
-
289
- def disconnect
290
- @files.clear
291
- end
292
- end
293
-
294
- class Browser < Terminalwire::Resource::Base
295
- def dispatch(action, data)
296
- respond case action
297
- when "launch"
298
- Launchy.open(data)
299
- "Launched browser with URL: #{data}"
300
- else
301
- raise "Unknown action #{action} for browser device"
302
- end
303
- end
304
- end
305
- end
306
-
307
- class ResourceMapper
308
- def initialize(connection, resources)
309
- @connection = connection
310
- @resources = resources
311
- @devices = Hash.new { |h,k| h[Integer(k)] }
312
- end
313
-
314
- def connect_device(id, type)
315
- klass = @resources.find(type)
316
- if klass
317
- device = klass.new(id, @connection)
318
- device.connect
319
- @devices[id] = device
320
- @connection.write(event: "device", action: "connect", status: "success", id: id, type: type)
321
- else
322
- @connection.write(event: "device", action: "connect", status: "failure", id: id, type: type, message: "Unknown device type")
323
- end
324
- end
325
-
326
- def dispatch(id, action, data)
327
- device = @devices[id]
328
- if device
329
- device.dispatch(action, data)
330
- else
331
- raise "Unknown device ID: #{id}"
332
- end
333
- end
334
-
335
- def disconnect_device(id)
336
- device = @devices.delete(id)
337
- device&.disconnect
338
- @connection.write(event: "device", action: "disconnect", id: id)
339
- end
340
- end
341
-
342
- class Handler
343
- include Logging
344
-
345
- def initialize(connection, resources = self.class.resources)
346
- @connection = connection
347
- @resources = resources
348
- end
349
-
350
- def connect
351
- @devices = ResourceMapper.new(@connection, @resources)
352
-
353
- @connection.write(event: "initialize", protocol: { version: "0.1.0" }, arguments: ARGV, program_name: $0)
354
-
355
- loop do
356
- handle @connection.recv
357
- end
358
- end
359
-
360
- def handle(message)
361
- case message
362
- in { event: "device", action: "connect", id:, type: }
363
- @devices.connect_device(id, type)
364
- in { event: "device", action: "command", id:, command:, data: }
365
- @devices.dispatch(id, command, data)
366
- in { event: "device", action: "disconnect", id: }
367
- @devices.disconnect_device(id)
368
- in { event: "exit", status: }
369
- exit Integer(status)
370
- end
371
- end
372
-
373
- def self.resources
374
- ResourceRegistry.new.tap do |resources|
375
- resources << Client::Resource::STDOUT
376
- resources << Client::Resource::STDIN
377
- resources << Client::Resource::STDERR
378
- resources << Client::Resource::Browser
379
- resources << Client::Resource::File
380
- end
381
- end
382
- end
383
-
384
- def self.tcp(...)
385
- socket = TCPSocket.new(...)
386
- transport = Terminalwire::Transport::Socket.new(socket)
387
- connection = Terminalwire::Connection.new(transport)
388
- Terminalwire::Client::Handler.new(connection)
389
- end
390
-
391
- def self.socket(...)
392
- socket = UNIXSocket.new(...)
393
- transport = Terminalwire::Transport::Socket.new(socket)
394
- connection = Terminalwire::Connection.new(transport)
395
- Terminalwire::Client::Handler.new(connection)
396
- end
397
-
398
- def self.websocket(url)
399
- url = URI(url)
400
-
401
- Async do |task|
402
- endpoint = Async::HTTP::Endpoint.parse(url)
403
-
404
- Async::WebSocket::Client.connect(endpoint) do |connection|
405
- transport = Terminalwire::Transport::WebSocket.new(connection)
406
- connection = Terminalwire::Connection.new(transport)
407
- Terminalwire::Client::Handler.new(connection).connect
408
- end
409
- end
410
- end
411
- end
412
-
413
- module Server
414
- module Resource
415
- class IO < Terminalwire::Resource::Base
416
- def puts(data)
417
- command("puts", data: data)
418
- end
419
-
420
- def print(data)
421
- command("print", data: data)
422
- end
423
-
424
- def gets
425
- command("gets")
426
- end
427
-
428
- def flush
429
- # @connection.flush
430
- end
431
-
432
- private
433
-
434
- def command(command, data: nil)
435
- @connection.write(event: "device", id: @id, action: "command", command: command, data: data)
436
- @connection.recv&.fetch(:response)
437
- end
438
- end
439
-
440
- class STDOUT < IO
441
- end
442
-
443
- class STDIN < IO
444
- def getpass
445
- command("getpass")
446
- end
447
- end
448
-
449
- class STDERR < IO
450
- end
451
-
452
- class File < Terminalwire::Resource::Base
453
- def read(path)
454
- command("read", path.to_s)
455
- end
456
-
457
- def write(path, content)
458
- command("write", { 'path' => path.to_s, 'content' => content })
459
- end
460
-
461
- def append(path, content)
462
- command("append", { 'path' => path.to_s, 'content' => content })
463
- end
464
-
465
- def mkdir(path)
466
- command("mkdir", { 'path' => path.to_s })
467
- end
468
-
469
- def exist?(path)
470
- command("exist", { 'path' => path.to_s })
471
- end
472
-
473
- private
474
-
475
- def command(action, data)
476
- @connection.write(event: "device", id: @id, action: "command", command: action, data: data)
477
- response = @connection.recv
478
- response.fetch(:response)
479
- end
480
- end
481
-
482
- class Browser < Terminalwire::Resource::Base
483
- def launch(url)
484
- command("launch", data: url)
485
- end
486
-
487
- private
488
-
489
- def command(command, data: nil)
490
- @connection.write(event: "device", id: @id, action: "command", command: command, data: data)
491
- @connection.recv.fetch(:response)
492
- end
493
- end
494
- end
495
-
496
- class ResourceMapper
497
- include Logging
498
-
499
- def initialize(connection, resources = self.class.resources)
500
- @id = -1
501
- @resources = resources
502
- @devices = Hash.new { |h,k| h[Integer(k)] }
503
- @connection = connection
504
- end
505
-
506
- def connect_device(type)
507
- id = next_id
508
- logger.debug "Server: Requesting client to connect device #{type} with ID #{id}"
509
- @connection.write(event: "device", action: "connect", id: id, type: type)
510
- response = @connection.recv
511
- case response
512
- in { status: "success" }
513
- logger.debug "Server: Resource #{type} connected with ID #{id}."
514
- @devices[id] = @resources.find(type).new(id, @connection)
515
- else
516
- logger.debug "Server: Failed to connect device #{type} with ID #{id}."
517
- end
518
- end
519
-
520
- private
521
-
522
- def next_id
523
- @id += 1
524
- end
525
-
526
- def self.resources
527
- ResourceRegistry.new.tap do |resources|
528
- resources << Server::Resource::STDOUT
529
- resources << Server::Resource::STDIN
530
- resources << Server::Resource::STDERR
531
- resources << Server::Resource::Browser
532
- resources << Server::Resource::File
533
- end
534
- end
535
- end
536
-
537
- class Session
538
- extend Forwardable
539
-
540
- attr_reader :stdout, :stdin, :stderr, :browser, :file
541
-
542
- def_delegators :@stdout, :puts, :print
543
- def_delegators :@stdin, :gets, :getpass
544
-
545
- def initialize(connection:)
546
- @connection = connection
547
- @devices = ResourceMapper.new(@connection)
548
- @stdout = @devices.connect_device("stdout")
549
- @stdin = @devices.connect_device("stdin")
550
- @stderr = @devices.connect_device("stderr")
551
- @browser = @devices.connect_device("browser")
552
- @file = @devices.connect_device("file")
553
-
554
- if block_given?
555
- begin
556
- yield self
557
- ensure
558
- exit
559
- end
560
- end
561
- end
562
-
563
- def exec(&shell)
564
- instance_eval(&shell)
565
- ensure
566
- exit
567
- end
568
-
569
- def exit(status = 0)
570
- @connection.write(event: "exit", status: status)
571
- end
572
-
573
- def close
574
- @connection.close
575
- end
576
- end
577
-
578
- class MyCLI < ::Thor
579
- include Terminalwire::Thor
580
-
581
- desc "greet NAME", "Greet a person"
582
- def greet(name)
583
- name = ask "What's your name?"
584
- say "Hello, #{name}!"
585
- end
586
- end
587
-
588
- class Socket
589
- include Logging
590
-
591
- def initialize(server_socket)
592
- @server_socket = server_socket
593
- end
594
-
595
- def listen
596
- logger.info "Socket: Sistening..."
597
- loop do
598
- client_socket = @server_socket.accept
599
- logger.debug "Socket: Client #{client_socket.inspect} connected"
600
- handle_client(client_socket)
601
- end
602
- end
603
-
604
- private
605
-
606
- def handle_client(socket)
607
- transport = Transport::Socket.new(socket)
608
- connection = Connection.new(transport)
609
-
610
- Thread.new do
611
- handler = Handler.new(connection)
612
- handler.run
613
- end
614
- end
615
- end
616
-
617
- class Handler
618
- include Logging
619
-
620
- def initialize(connection)
621
- @connection = connection
622
- end
623
-
624
- def run
625
- logger.info "Server Handler: Running"
626
- loop do
627
- message = @connection.recv
628
- case message
629
- in { event: "initialize", arguments:, program_name: }
630
- Session.new(connection: @connection) do |session|
631
- MyCLI.start(arguments, session: session)
632
- end
633
- end
634
- end
635
- rescue EOFError, Errno::ECONNRESET
636
- logger.info "Server Handler: Client disconnected"
637
- ensure
638
- @connection.close
639
- end
640
- end
641
-
642
- def self.tcp(...)
643
- Server::Socket.new(TCPServer.new(...))
644
- end
645
-
646
- def self.socket(...)
647
- Server::Socket.new(UNIXServer.new(...))
648
- end
649
- end
650
-
651
- module WebSocket
652
- class Server
653
- include Logging
654
-
655
- def call(env)
656
- Async::WebSocket::Adapters::Rack.open(env, protocols: ['ws']) do |connection|
657
- run(Connection.new(Terminalwire::Transport::WebSocket.new(connection)))
658
- end or [200, { "Content-Type" => "text/plain" }, ["Connect via WebSockets"]]
43
+ def self.protocol_key
44
+ name.split("::").last.downcase
659
45
  end
660
46
 
661
47
  private
662
48
 
663
- def run(connection)
664
- while message = connection.recv
665
- puts message
666
- end
667
- end
668
- end
669
-
670
- class ThorServer < Server
671
- include Logging
672
-
673
- def initialize(cli_class)
674
- @cli_class = cli_class
675
-
676
- # Check if the Terminalwire::Thor module is already included
677
- unless @cli_class.included_modules.include?(Terminalwire::Thor)
678
- raise 'Add `include Terminalwire::Thor` to the #{@cli_class.inspect} class.'
679
- end
680
- end
681
-
682
- def run(connection)
683
- logger.info "ThorServer: Running #{@cli_class.inspect}"
684
- while message = connection.recv
685
- case message
686
- in { event: "initialize", protocol: { version: _ }, arguments:, program_name: }
687
- Terminalwire::Server::Session.new(connection: connection) do |session|
688
- @cli_class.start(arguments, session: session)
689
- end
690
- end
691
- end
49
+ def respond(**response)
50
+ adapter.write(event: "resource", name: @name, **response)
692
51
  end
693
52
  end
694
53
  end
695
- end
54
+ end