mimic-browser 0.1.0
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 +7 -0
- data/LICENSE +57 -0
- data/README.md +96 -0
- data/bin/mimic-sdk +29 -0
- data/lib/mimic_sdk/ferrum.rb +126 -0
- data/lib/mimic_sdk/generated.rb +878 -0
- data/lib/mimic_sdk/runtime-lock.json +57 -0
- data/lib/mimic_sdk/runtime.rb +413 -0
- data/lib/mimic_sdk/transport.rb +240 -0
- data/lib/mimic_sdk.rb +7 -0
- metadata +86 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
require 'json'
|
|
2
|
+
require 'net/http'
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'socket'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require 'timeout'
|
|
7
|
+
require 'websocket/driver'
|
|
8
|
+
|
|
9
|
+
module MimicSDK
|
|
10
|
+
UNSET = Generated::UNSET
|
|
11
|
+
|
|
12
|
+
class ProtocolError < StandardError
|
|
13
|
+
attr_reader :code, :data
|
|
14
|
+
def initialize(envelope)
|
|
15
|
+
@code = envelope.fetch('code')
|
|
16
|
+
@data = envelope.fetch('data', UNSET)
|
|
17
|
+
super(envelope.fetch('message'))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Transport
|
|
22
|
+
attr_reader :url
|
|
23
|
+
|
|
24
|
+
def self.discover(endpoint, timeout: 15)
|
|
25
|
+
uri = URI(endpoint)
|
|
26
|
+
return endpoint if %w[ws wss].include?(uri.scheme) && uri.host
|
|
27
|
+
raise ArgumentError, 'Expected an HTTP or WebSocket endpoint' unless %w[http https].include?(uri.scheme) && uri.host
|
|
28
|
+
uri.path = uri.path.sub(%r{/$}, '') + '/json/version'
|
|
29
|
+
uri.query = nil
|
|
30
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
31
|
+
http.use_ssl = uri.scheme == 'https'
|
|
32
|
+
http.open_timeout = http.read_timeout = timeout
|
|
33
|
+
response = http.get(uri.request_uri)
|
|
34
|
+
raise IOError, "CDP discovery HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
|
35
|
+
result = JSON.parse(response.body).fetch('webSocketDebuggerUrl')
|
|
36
|
+
parsed = URI(result)
|
|
37
|
+
raise IOError, 'Invalid CDP discovery websocket' unless %w[ws wss].include?(parsed.scheme) && parsed.host
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(endpoint, timeout: 15)
|
|
42
|
+
@url = self.class.discover(endpoint, timeout: timeout)
|
|
43
|
+
uri = URI(@url)
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
@send_mutex = Mutex.new
|
|
46
|
+
@write_mutex = Mutex.new
|
|
47
|
+
@changed = ConditionVariable.new
|
|
48
|
+
@pending = {}
|
|
49
|
+
@subscriptions = []
|
|
50
|
+
@next_id = 0
|
|
51
|
+
@socket = Socket.tcp(uri.host, uri.port, connect_timeout: timeout)
|
|
52
|
+
@write_timeout = timeout
|
|
53
|
+
if uri.scheme == 'wss'
|
|
54
|
+
tls = OpenSSL::SSL::SSLContext.new
|
|
55
|
+
tls.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
56
|
+
@socket = OpenSSL::SSL::SSLSocket.new(@socket, tls)
|
|
57
|
+
@socket.sync_close = true
|
|
58
|
+
@socket.hostname = uri.host
|
|
59
|
+
Timeout.timeout(timeout, IOError, 'CDP TLS handshake timed out') { @socket.connect }
|
|
60
|
+
@socket.post_connection_check(uri.host)
|
|
61
|
+
end
|
|
62
|
+
@driver = WebSocket::Driver.client(self, max_length: 64 * 1024 * 1024)
|
|
63
|
+
@driver.on(:open) { @mutex.synchronize { @opened = true; @changed.broadcast } }
|
|
64
|
+
@driver.on(:message) { |event| receive(JSON.parse(event.data)) }
|
|
65
|
+
@driver.on(:error) { |error| fail_connection(IOError.new(error.message)) }
|
|
66
|
+
@driver.on(:close) { |_event| fail_connection(IOError.new('CDP connection closed')) }
|
|
67
|
+
@driver.start
|
|
68
|
+
@reader = Thread.new do
|
|
69
|
+
begin
|
|
70
|
+
loop { @driver.parse(@socket.readpartial(16_384)) }
|
|
71
|
+
rescue StandardError => error
|
|
72
|
+
fail_connection(error)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
deadline = monotonic + timeout
|
|
76
|
+
@mutex.synchronize do
|
|
77
|
+
until @opened || @failure
|
|
78
|
+
remaining = deadline - monotonic
|
|
79
|
+
raise IOError, 'CDP handshake timed out' unless remaining.positive?
|
|
80
|
+
@changed.wait(@mutex, remaining)
|
|
81
|
+
end
|
|
82
|
+
raise @failure if @failure
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError
|
|
85
|
+
close
|
|
86
|
+
raise
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def write(bytes)
|
|
90
|
+
context = Thread.current[:mimic_sdk_write_context]
|
|
91
|
+
_, deadline, cancelled = context if context && context[0].equal?(self)
|
|
92
|
+
deadline ||= monotonic + @write_timeout
|
|
93
|
+
acquire_mutex(@write_mutex, deadline, cancelled)
|
|
94
|
+
begin
|
|
95
|
+
offset = 0
|
|
96
|
+
while offset < bytes.bytesize
|
|
97
|
+
check_deadline(deadline, cancelled)
|
|
98
|
+
written = @socket.write_nonblock(bytes.byteslice(offset..), exception: false)
|
|
99
|
+
case written
|
|
100
|
+
when :wait_readable
|
|
101
|
+
IO.select([@socket], nil, nil, [deadline - monotonic, 0.05].min.clamp(0, 0.05))
|
|
102
|
+
when :wait_writable
|
|
103
|
+
IO.select(nil, [@socket], nil, [deadline - monotonic, 0.05].min.clamp(0, 0.05))
|
|
104
|
+
else
|
|
105
|
+
offset += written
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
offset
|
|
109
|
+
ensure
|
|
110
|
+
@write_mutex.unlock
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def call_raw(method, params = UNSET, session_id: nil, timeout: 30, cancelled: nil)
|
|
115
|
+
raise ArgumentError, 'Expected a qualified CDP method' unless method.is_a?(String) && method.match?(/\A[^.\s]+\.[^\s]+\z/)
|
|
116
|
+
raise IOError, 'Call cancelled before dispatch' if cancelled&.call
|
|
117
|
+
deadline = monotonic + timeout
|
|
118
|
+
id = @mutex.synchronize do
|
|
119
|
+
raise @failure if @failure
|
|
120
|
+
@next_id += 1
|
|
121
|
+
@pending[@next_id] = { session_id: session_id, response: nil }
|
|
122
|
+
@next_id
|
|
123
|
+
end
|
|
124
|
+
envelope = { 'id' => id, 'method' => method }
|
|
125
|
+
envelope['params'] = params unless params.equal?(UNSET)
|
|
126
|
+
envelope['sessionId'] = session_id if session_id
|
|
127
|
+
encoded = JSON.generate(envelope, allow_nan: false)
|
|
128
|
+
acquire_mutex(@send_mutex, deadline, cancelled)
|
|
129
|
+
previous_write_context = Thread.current[:mimic_sdk_write_context]
|
|
130
|
+
begin
|
|
131
|
+
Thread.current[:mimic_sdk_write_context] = [self, deadline, cancelled]
|
|
132
|
+
@driver.text(encoded)
|
|
133
|
+
rescue StandardError => error
|
|
134
|
+
fail_connection(error) # A partially written frame cannot be reused.
|
|
135
|
+
raise
|
|
136
|
+
ensure
|
|
137
|
+
Thread.current[:mimic_sdk_write_context] = previous_write_context
|
|
138
|
+
@send_mutex.unlock
|
|
139
|
+
end
|
|
140
|
+
response = @mutex.synchronize do
|
|
141
|
+
loop do
|
|
142
|
+
raise @failure if @failure
|
|
143
|
+
break @pending[id][:response] if @pending[id][:response]
|
|
144
|
+
raise IOError, 'Call cancelled after dispatch; rollback is not implied' if cancelled&.call
|
|
145
|
+
remaining = deadline - monotonic
|
|
146
|
+
raise IOError, 'CDP call timed out; rollback is not implied' unless remaining.positive?
|
|
147
|
+
@changed.wait(@mutex, [remaining, 0.05].min)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
raise IOError, 'CDP response session does not match the request' unless response['sessionId'] == session_id
|
|
151
|
+
raise ProtocolError.new(response['error']) if response.key?('error')
|
|
152
|
+
response.fetch('result')
|
|
153
|
+
ensure
|
|
154
|
+
@mutex&.synchronize { @pending.delete(id) } if id
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def subscribe
|
|
158
|
+
queue = Queue.new
|
|
159
|
+
@mutex.synchronize { raise @failure if @failure; @subscriptions << queue }
|
|
160
|
+
[queue, -> { @mutex.synchronize { @subscriptions.delete(queue) }; queue.close }]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def close
|
|
164
|
+
return if @closed
|
|
165
|
+
@closed = true
|
|
166
|
+
fail_connection(IOError.new('CDP connection closed'))
|
|
167
|
+
@socket&.close rescue nil
|
|
168
|
+
@reader&.join(1) unless @reader == Thread.current
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
175
|
+
|
|
176
|
+
def check_deadline(deadline, cancelled)
|
|
177
|
+
raise IOError, 'Call cancelled during dispatch; rollback is not implied' if cancelled&.call
|
|
178
|
+
raise IOError, 'CDP write timed out; rollback is not implied' unless monotonic < deadline
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def acquire_mutex(mutex, deadline, cancelled)
|
|
182
|
+
until mutex.try_lock
|
|
183
|
+
check_deadline(deadline, cancelled)
|
|
184
|
+
sleep 0.005
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def receive(message)
|
|
189
|
+
@mutex.synchronize do
|
|
190
|
+
if message.key?('id')
|
|
191
|
+
@pending[message['id']][:response] = message if @pending.key?(message['id'])
|
|
192
|
+
@changed.broadcast
|
|
193
|
+
elsif message.key?('method')
|
|
194
|
+
@subscriptions.each do |queue|
|
|
195
|
+
raise IOError, 'CDP event subscriber overflow' if queue.length >= 1024
|
|
196
|
+
queue << message
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def fail_connection(error)
|
|
203
|
+
@mutex&.synchronize do
|
|
204
|
+
@failure ||= error
|
|
205
|
+
@changed.broadcast
|
|
206
|
+
@subscriptions.each(&:close)
|
|
207
|
+
@subscriptions.clear
|
|
208
|
+
end
|
|
209
|
+
@socket&.close unless @socket&.closed?
|
|
210
|
+
rescue IOError
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
class Experimental
|
|
216
|
+
def initialize(sender) = (@sender = sender)
|
|
217
|
+
def call(name, params = UNSET, **options)
|
|
218
|
+
raise ArgumentError, 'Expected an exact unqualified wire command leaf' unless name.is_a?(String) && name.match?(/\A[^.\s]+\z/)
|
|
219
|
+
@sender.call("Mimic.#{name}", params, **options)
|
|
220
|
+
end
|
|
221
|
+
def method_missing(name, *args, **options)
|
|
222
|
+
return super if name.to_s.start_with?('_') || %i[to_ary to_hash to_json inspect].include?(name)
|
|
223
|
+
raise ArgumentError, 'Expected at most one params value' if args.length > 1
|
|
224
|
+
call(name.to_s, args.empty? ? UNSET : args.first, **options)
|
|
225
|
+
end
|
|
226
|
+
def respond_to_missing?(_name, _private = false) = false
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
class Client < Generated::MimicCommands
|
|
230
|
+
attr_reader :experimental
|
|
231
|
+
def initialize(transport, session_id: nil)
|
|
232
|
+
@transport, @session_id = transport, session_id
|
|
233
|
+
super(self)
|
|
234
|
+
@experimental = Experimental.new(self)
|
|
235
|
+
end
|
|
236
|
+
def call(method, params = UNSET, **options)
|
|
237
|
+
@transport.call_raw(method, params, session_id: @session_id, **options)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
data/lib/mimic_sdk.rb
ADDED
metadata
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mimic-browser
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Mimic Browser contributors
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: websocket-driver
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.8'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.8'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rubyzip
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.4'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '4'
|
|
37
|
+
type: :runtime
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '2.4'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '4'
|
|
47
|
+
description:
|
|
48
|
+
email:
|
|
49
|
+
executables:
|
|
50
|
+
- mimic-sdk
|
|
51
|
+
extensions: []
|
|
52
|
+
extra_rdoc_files: []
|
|
53
|
+
files:
|
|
54
|
+
- LICENSE
|
|
55
|
+
- README.md
|
|
56
|
+
- bin/mimic-sdk
|
|
57
|
+
- lib/mimic_sdk.rb
|
|
58
|
+
- lib/mimic_sdk/ferrum.rb
|
|
59
|
+
- lib/mimic_sdk/generated.rb
|
|
60
|
+
- lib/mimic_sdk/runtime-lock.json
|
|
61
|
+
- lib/mimic_sdk/runtime.rb
|
|
62
|
+
- lib/mimic_sdk/transport.rb
|
|
63
|
+
homepage: https://github.com/mimic-browser/sdk
|
|
64
|
+
licenses:
|
|
65
|
+
- Prosperity-3.0.0
|
|
66
|
+
metadata: {}
|
|
67
|
+
post_install_message:
|
|
68
|
+
rdoc_options: []
|
|
69
|
+
require_paths:
|
|
70
|
+
- lib
|
|
71
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - ">="
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.2'
|
|
76
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
77
|
+
requirements:
|
|
78
|
+
- - ">="
|
|
79
|
+
- !ruby/object:Gem::Version
|
|
80
|
+
version: '0'
|
|
81
|
+
requirements: []
|
|
82
|
+
rubygems_version: 3.4.19
|
|
83
|
+
signing_key:
|
|
84
|
+
specification_version: 4
|
|
85
|
+
summary: Pinned Mimic runtimes and typed capabilities beside native browser clients
|
|
86
|
+
test_files: []
|