webkit_remote 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.
@@ -0,0 +1,114 @@
1
+ module WebkitRemote
2
+
3
+ # Client for the Webkit remote debugging protocol
4
+ #
5
+ # A client manages a single tab.
6
+ class Client
7
+ # Connects to the remote debugging server in a Webkit tab.
8
+ #
9
+ # @param [Hash] opts info on the tab to connect to
10
+ # @option opts [WebkitRemote::Tab] tab reference to the tab whose debugger
11
+ # server this RPC client connects to
12
+ # @option opts [Boolean] close_browser if true, the session to the brower
13
+ # that the tab belongs to will be closed when this RPC client's connection
14
+ # is closed
15
+ def initialize(opts = {})
16
+ unless tab = opts[:tab]
17
+ raise ArgumentError, 'Target tab not specified'
18
+ end
19
+ @rpc = WebkitRemote::Rpc.new opts
20
+ @browser = tab.browser
21
+ @close_browser = opts.fetch :close_browser, false
22
+ @closed = false
23
+ initialize_modules
24
+ end
25
+
26
+ # Closes the remote debugging connection.
27
+ #
28
+ # Call this method to avoid leaking resources.
29
+ #
30
+ # @return [WebkitRemote::Rpc] self
31
+ def close
32
+ return if @closed
33
+ @closed = true
34
+ @rpc.close
35
+ @rpc = nil
36
+ @browser.close if @close_browser
37
+ self
38
+ end
39
+
40
+ # @return [Boolean] if true, the connection to the remote debugging server
41
+ # has been closed, and this instance is mostly useless
42
+ attr_reader :closed
43
+ alias_method :closed?, :closed
44
+
45
+ # @return [Boolean] if true, the master debugging connection to the browser
46
+ # associated with the client's tab will be automatically closed when this
47
+ # RPC client's connection is closed; in turn, this might stop the browser
48
+ # process
49
+ attr_accessor :close_browser
50
+ alias_method :close_browser?, :close_browser
51
+
52
+ # Continuously reports events sent by the remote debugging server.
53
+ #
54
+ # @yield once for each RPC event received from the remote debugger; break to
55
+ # stop the event listening loop
56
+ # @yieldparam [WebkitRemote::Event] event an instance of an Event sub-class
57
+ # that best represents the received event
58
+ # @return [WebkitRemote::Client] self
59
+ def each_event
60
+ @rpc.each_event do |rpc_event|
61
+ yield WebkitRemote::Event.for(rpc_event)
62
+ end
63
+ self
64
+ end
65
+
66
+ # Waits for the remote debugging server to send a specific event.
67
+ #
68
+ # @param (see WebkitRemote::Event#matches?)
69
+ # @return [WebkitRemote::Event] the event that matches the class requirement
70
+ def wait_for(conditions)
71
+ unless WebkitRemote::Event.can_receive? self, conditions
72
+ raise ArgumentError, "Cannot receive event with #{conditions.inspect}"
73
+ end
74
+
75
+ returned_event = nil
76
+ each_event do |event|
77
+ if event.matches? conditions
78
+ returned_event = event
79
+ break
80
+ end
81
+ end
82
+ returned_event
83
+ end
84
+
85
+ # @return [WebkitRemote::Rpc] the WebSocket RPC client; useful for making raw
86
+ # RPC calls to unsupported methods
87
+ attr_reader :rpc
88
+
89
+ # @return [WebkitRemote::Browser] master session to the browser that owns the
90
+ # tab debugged by this client
91
+ attr_reader :browser
92
+
93
+ # Call by the constructor. Replaced by the module initializers.
94
+ #
95
+ # @private Hook for module initializers to do their own setups.
96
+ def initialize_modules
97
+ # NOTE: this gets called after all the module initializers complete
98
+ end
99
+
100
+ # Registers a module initializer.
101
+ def self.initializer(name)
102
+ before_name = :"initialize_modules_before_#{name}"
103
+ alias_method before_name, :initialize_modules
104
+ remove_method :initialize_modules
105
+ eval <<END_METHOD
106
+ def initialize_modules
107
+ #{name}
108
+ #{before_name.to_s}
109
+ end
110
+ END_METHOD
111
+ end
112
+ end # class WebkitRemote::Client
113
+
114
+ end # namespace WebkitRemote
@@ -0,0 +1,138 @@
1
+ module WebkitRemote
2
+
3
+ # An event received via a RPC notification from a Webkit remote debugger.
4
+ #
5
+ # This is a generic super-class for events.
6
+ class Event
7
+ # @return [String] event's domain, e.g. "Page", "DOM".
8
+ attr_reader :domain
9
+
10
+ # @return [String] event's name, e.g. "Page.loadEventFired".
11
+ attr_reader :name
12
+
13
+ # @return [Hash<String, Object>] the raw event information provided by the
14
+ # RPC client
15
+ attr_reader :raw_data
16
+
17
+ # Checks if the event meets a set of conditions.
18
+ #
19
+ # This is used in WebkitRemote::Client#wait_for.
20
+ #
21
+ # @param [Hash<Symbol, Object>] conditions the conditions that must be met
22
+ # by an event to get out of the waiting loop
23
+ # @option conditions [Class] class the class of events to wait for; this
24
+ # condition is met if the event's class is a sub-class of the given class
25
+ # @option conditions [Class] type synonym for class that can be used with the
26
+ # Ruby 1.9 hash syntax
27
+ # @option conditions [String] name the event's name, e.g.
28
+ # "Page.loadEventFired"
29
+ # @return [Boolean] true if this event matches all the given conditions
30
+ def matches?(conditions)
31
+ conditions.all? do |key, value|
32
+ case key
33
+ when :class, :type
34
+ kind_of? value
35
+ when :name
36
+ name == value
37
+ else
38
+ # Simple cop-out.
39
+ send(key) == value
40
+ end
41
+ end
42
+ end
43
+
44
+ # Checks if a client can possibly meet an event meeting the given conditions.
45
+ #
46
+ # @private This is used by Client#wait_for to prevent hard-to-find bugs.
47
+ #
48
+ # @param [WebkitRemote::Client] client the client to be checked
49
+ # @param (see WebkitRemote::Event#matches?)
50
+ # @return [Boolean] false if calling WebkitRemote::Client#wait_for with the
51
+ # given conditions would get the client stuck
52
+ def self.can_receive?(client, conditions)
53
+ conditions.all? do |key, value|
54
+ case key
55
+ when :class, :type
56
+ value.can_reach?(client)
57
+ when :name
58
+ class_for(value).can_reach?(client)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Wraps raw event data received via a RPC notification.
64
+ #
65
+ # @param [Hash<Symbol, Object>] rpc_event event information yielded by a call
66
+ # to WebkitRemote::Rpc.each_event
67
+ # @return [WebkitRemote::Event] an instance of an Event subclass that best
68
+ # represents the given event
69
+ def self.for(rpc_event)
70
+ klass = class_for rpc_event[:name]
71
+ klass.new rpc_event
72
+ end
73
+
74
+ # The WebkitRemote::Event subclass registered to handle an event.
75
+ #
76
+ # @private Use WebkitRemote::Event#for instead of calling this directly.
77
+ #
78
+ # @param [String] rpc_event_name the value of the 'name' property of an event
79
+ # notice received via the remote debugging RPC
80
+ # @return [Class] WebkitRemote::Event or one of its subclasses
81
+ def self.class_for(rpc_event_name)
82
+ @registry[rpc_event_name] || Event
83
+ end
84
+
85
+ # Wraps raw event data received via a RPC notification.
86
+ #
87
+ # @private API clients should use Event#for instead of calling the
88
+ # constructor directly.
89
+ #
90
+ # @param [Hash<Symbol, Object>] rpc_event event information yielded by a call
91
+ # to WebkitRemote::Rpc.each_event
92
+ def initialize(rpc_event)
93
+ @name = rpc_event[:name]
94
+ @domain = rpc_event[:name].split('.', 2).first
95
+ @raw_data = rpc_event[:data] || {}
96
+ end
97
+
98
+ # Registers an Event sub-class for to be instantiated when parsing an event.
99
+ #
100
+ # @private Only Event sub-classes should use this API.
101
+ #
102
+ # @param [String] name fully qualified event name, e.g. "Page.loadEventFired"
103
+ # @return [Class] self
104
+ def self.register(name)
105
+ WebkitRemote::Event.register_class self, name
106
+ self
107
+ end
108
+
109
+ # Registers an Event sub-class for to be instantiated when parsing an event.
110
+ #
111
+ # @private Event sub-classes should call #register on themselves instead of
112
+ # calling this directly.
113
+ #
114
+ # @param [String] klass the Event subclass to be registered
115
+ # @param [String] name fully qualified event name, e.g. "Page.loadEventFired"
116
+ # @return [Class] self
117
+ def self.register_class(klass, name)
118
+ if @registry.has_key? name
119
+ raise ArgumentError, "#{@registry[name].name} already registered #{name}"
120
+ end
121
+ @registry[name] = klass
122
+ self
123
+ end
124
+ @registry = {}
125
+
126
+ # Checks if a client is set up to receive an event of this class.
127
+ #
128
+ # @private Use Event# instead of calling this directly.
129
+ #
130
+ # This method is overridden in Event sub-classes. For example, events in the
131
+ # Page domain can only be received if WebkitRemote::Client::Page#page_events
132
+ # is true.
133
+ def self.can_reach?(client)
134
+ true
135
+ end
136
+ end # class WebkitRemote::Event
137
+
138
+ end # namespace WebkitRemote
@@ -0,0 +1,160 @@
1
+ require 'fileutils'
2
+ require 'net/http'
3
+ require 'posix/spawn'
4
+ require 'tmpdir'
5
+
6
+ module WebkitRemote
7
+
8
+ # Tracks a Webkit process.
9
+ class Process
10
+ # Tracker for a yet-unlaunched process.
11
+ #
12
+ # @param [Hash] opts tweak the options below
13
+ # @option opts [Integer] port the port used by the remote debugging server;
14
+ # the default port is 9292
15
+ # @option opts [Number] timeout number of seconds to wait for the browser
16
+ # to start; the default timeout is 10 seconds
17
+ def initialize(opts = {})
18
+ @port = opts[:port] || 9292
19
+ @timeout = opts[:timeout] || 10
20
+ @running = false
21
+ @data_dir = Dir.mktmpdir 'webkit-remote'
22
+ @pid = nil
23
+ @cli = chrome_cli opts
24
+ end
25
+
26
+ # Starts the browser process.
27
+ #
28
+ # @return [WebkitRemote::Browser] self
29
+ def start
30
+ return self if running?
31
+ unless @pid = POSIX::Spawn.spawn(*@cli)
32
+ # The launch failed
33
+ return nil
34
+ end
35
+
36
+ (@timeout * 20).times do
37
+ # Check if the browser exited.
38
+ begin
39
+ break if status = ::Process.wait(@pid, ::Process::WNOHANG)
40
+ rescue SystemCallError # no children
41
+ break
42
+ end
43
+
44
+ # Check if the browser finished starting up.
45
+ begin
46
+ browser = WebkitRemote::Browser.new process: self
47
+ @running = true
48
+ return browser
49
+ rescue SystemCallError # most likely ECONNREFUSED
50
+ Kernel.sleep 0.05
51
+ end
52
+ end
53
+ # The browser failed, or was too slow to start.
54
+ nil
55
+ end
56
+
57
+ # @return [Boolean] true if the Webkit process is running
58
+ attr_reader :running
59
+ alias_method :running?, :running
60
+
61
+ # Stops the browser process.
62
+ #
63
+ # Only call this after you're done with the process.
64
+ #
65
+ # @return [WebkitRemote::Process] self
66
+ def stop
67
+ return self unless running?
68
+ begin
69
+ ::Process.kill 'TERM', @pid
70
+ ::Process.wait @pid
71
+ end
72
+ FileUtils.rm_rf @data_dir if File.exists?(@data_dir)
73
+ @running = false
74
+ self
75
+ end
76
+
77
+ # @return [Integer] port that the process' remote debugging server listens to
78
+ attr_reader :port
79
+
80
+ # Remove temporary directory if it's still there at garbage collection time.
81
+ def finalize
82
+ PathUtils.rm_rf @data_dir if File.exists?(@data_dir)
83
+ end
84
+
85
+ # Command-line that launches Google Chrome / Chromium
86
+ #
87
+ # @param [Hash] opts options passed to the WebkitRemote::Process constructor
88
+ # @return [Array<String>] command line for launching Chrome
89
+ def chrome_cli(opts)
90
+ # The Chromium wiki recommends this page for available flags:
91
+ # http://peter.sh/experiments/chromium-command-line-switches/
92
+ [
93
+ self.class.chrome_binary,
94
+ '--disable-default-apps', # no bundled apps
95
+ '--disable-desktop-shortcuts', # don't mess with the desktop
96
+ '--disable-extensions', # no extensions
97
+ '--disable-internal-flash', # no plugins
98
+ '--disable-java', # no plugins
99
+ '--disable-logging', # don't trash stdout / stderr
100
+ '--disable-plugins', # no native content
101
+ '--disable-prompt-on-repost', # no confirmation dialog on POST refresh
102
+ '--disable-sync', # no talking with the Google servers
103
+ '--incognito', # don't use old state, don't preserve state
104
+ '--homepage=about:blank', # don't go to Google in new tabs
105
+ '--keep-alive-for-test', # don't kill process if the last window dies
106
+ '--lang=en-US', # set a default language
107
+ '--log-level=3', # FATAL, because there's no setting for "none"
108
+ '--no-default-browser-check', # don't hang when Chrome isn't default
109
+ '--no-experiments', # not sure this is useful
110
+ '--no-first-run', # don't show the help UI
111
+ '--no-js-randomness', # consistent Date.now() and Math.random()
112
+ '--no-message-box', # don't let user scripts show dialogs
113
+ '--no-service-autorun', # don't mess with autorun settings
114
+ '--noerrdialogs', # don't hang on error dialogs
115
+ "--remote-debugging-port=#{@port}", # Webkit remote debugging
116
+ "--user-data-dir=#{@data_dir}", # really ensure a clean slate
117
+ '--window-position=0,0', # remove randomness source
118
+ '--window-size=128,128', # remove randomness source
119
+ 'about:blank' # don't load the homepage
120
+ ]
121
+ end
122
+
123
+ # Path to a Google Chrome / Chromium binary.
124
+ #
125
+ # @return [String] full-qualified path to a binary that launches Chrome
126
+ def self.chrome_binary
127
+ return @chrome_binary unless @chrome_binary == false
128
+
129
+ case RUBY_PLATFORM
130
+ when /linux/
131
+ [
132
+ 'google-chrome',
133
+ 'google-chromium',
134
+ ].each do |binary|
135
+ path = `which #{binary}`
136
+ unless path.empty?
137
+ @chrome_binary = path.strip
138
+ break
139
+ end
140
+ end
141
+ when /darwin/
142
+ [
143
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
144
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
145
+ '/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
146
+ ].each do |path|
147
+ if File.exist? path
148
+ @chrome_binary = path
149
+ break
150
+ end
151
+ end
152
+ else
153
+ raise "Unsupported platform #{RUBY_PLATFORM}"
154
+ end
155
+ @chrome_binary ||= nil
156
+ end
157
+ @chrome_binary = false
158
+ end # class WebkitRemote::Browser
159
+
160
+ end # namespace WebkitRemote
@@ -0,0 +1,205 @@
1
+ require 'eventmachine'
2
+ require 'faye/websocket'
3
+ require 'json'
4
+ require 'thread'
5
+
6
+ module WebkitRemote
7
+
8
+ # RPC client for the Webkit remote debugging protocol.
9
+ class Rpc
10
+ # Connects to the remote debugging server in a Webkit tab.
11
+ #
12
+ # @param [Hash] opts info on the tab to connect to
13
+ # @option opts [WebkitRemote::Tab] tab reference to the tab whose debugger
14
+ # server this RPC client connects to
15
+ def initialize(opts = {})
16
+ unless tab = opts[:tab]
17
+ raise ArgumentError, 'Target tab not specified'
18
+ end
19
+ @closed = false
20
+ @send_queue = EventMachine::Queue.new
21
+ @recv_queue = Queue.new
22
+ @next_id = 2
23
+ @events = []
24
+
25
+ self.class.em_start
26
+ @web_socket = Faye::WebSocket::Client.new tab.debug_url
27
+ setup_web_socket
28
+ end
29
+
30
+ # Remote debugging RPC call.
31
+ #
32
+ # See the following URL for implemented calls.
33
+ # https://developers.google.com/chrome-developer-tools/docs/protocol/1.0/index
34
+ #
35
+ # @param [String] method name of the RPC method to be invoked
36
+ # @param [Hash<String, Object>, nil] params parameters for the RPC method to
37
+ # be invoked
38
+ # @return [Hash<String, Object>] the return value of the RPC method
39
+ def call(method, params = nil)
40
+ request_id = @next_id
41
+ @next_id += 1
42
+ request = {
43
+ jsonrpc: '2.0',
44
+ id: request_id,
45
+ method: method,
46
+ }
47
+ request[:params] = params if params
48
+ request_json = JSON.dump request
49
+ @send_queue.push request_json
50
+
51
+ loop do
52
+ result = receive_message request_id
53
+ return result if result
54
+ end
55
+ end
56
+
57
+ # Continuously reports events sent by the remote debugging server.
58
+ #
59
+ # @yield once for each RPC event received from the remote debugger; break to
60
+ # stop the event listening loop
61
+ # @yieldparam [Hash<Symbol, Object>] event the name and information hash of
62
+ # the event, under the keys :name and :data
63
+ # @return [WebkitRemote::Rpc] self
64
+ def each_event
65
+ loop do
66
+ if @events.empty?
67
+ receive_message nil
68
+ else
69
+ yield @events.shift
70
+ end
71
+ end
72
+ self
73
+ end
74
+
75
+ # Closes the connection to the remote debugging server.
76
+ #
77
+ # Call this method to avoid leaking resources.
78
+ #
79
+ # @return [WebkitRemote::Rpc] self
80
+ def close
81
+ return if @closed
82
+ @closed = true
83
+ @web_socket.close
84
+ @web_socket = nil
85
+ self.class.em_stop
86
+ self
87
+ end
88
+
89
+ # @return [Boolean] if true, the connection to the remote debugging server
90
+ # has been closed, and this instance is mostly useless
91
+ attr_reader :closed
92
+ alias_method :closed?, :closed
93
+
94
+ # Hooks up the event handlers of the WebSocket client.
95
+ def setup_web_socket
96
+ @web_socket.onopen = lambda do |event|
97
+ send_request
98
+ @web_socket.onmessage = lambda do |event|
99
+ data = event.data
100
+ EventMachine.defer do
101
+ @recv_queue.push data
102
+ end
103
+ end
104
+ @web_socket.onclose = lambda do |event|
105
+ code = event.code
106
+ EventMachine.defer do
107
+ @recv_queue.push code
108
+ end
109
+ end
110
+ end
111
+ end
112
+ private :setup_web_socket
113
+
114
+ # One iteration of the request sending loop.
115
+ #
116
+ # RPC requests are JSON-serialized on the sending thread, then pushed into
117
+ # the send queue, which is an EventMachine queue. On the reactor thread, the
118
+ # serialized message is sent as a WebSocket frame.
119
+ def send_request
120
+ @send_queue.pop do |json|
121
+ @web_socket.send json
122
+ send_request
123
+ end
124
+ end
125
+ private :send_request
126
+
127
+ # Blocks until a WebKit message is received, then parses it.
128
+ #
129
+ # RPC notifications are added to the @events array.
130
+ #
131
+ # @param [Integer, nil] expected_id if a RPC response is expected, this
132
+ # argument has the response id; otherwise, the argument should be nil
133
+ # @return [Hash<String, Object>, nil] a Hash containing the RPC result if an
134
+ # expected RPC response was received; nil if an RPC notice was received
135
+ def receive_message(expected_id)
136
+ json = @recv_queue.pop
137
+ unless json.respond_to? :to_str
138
+ close
139
+ raise RuntimeError, 'The Webkit debugging server closed the WebSocket'
140
+ end
141
+ begin
142
+ data = JSON.parse json
143
+ rescue JSONError
144
+ close
145
+ raise RuntimeError, 'Invalid JSON received'
146
+ end
147
+ if data['id']
148
+ # RPC result.
149
+ if data['id'] != expected_id
150
+ close
151
+ raise RuntimeError, 'Out of sequence RPC response id'
152
+ end
153
+ if data['error']
154
+ raise RuntimeError, "Error #{data['error']['code']}"
155
+ end
156
+ return data['result']
157
+ elsif data['method']
158
+ # RPC notice.
159
+ event = { name: data['method'], data: data['params'] }
160
+ @events << event
161
+ return nil
162
+ else
163
+ close
164
+ raise RuntimeError, "Unexpected / invalid RPC message #{data.inspect}"
165
+ end
166
+ end
167
+ private :receive_message
168
+
169
+ # Sets up an EventMachine reactor if necessary.
170
+ def self.em_start
171
+ @em_start_lock.synchronize do
172
+ if @em_clients == 0 and @em_thread.nil?
173
+ em_ready = ConditionVariable.new
174
+ @em_thread = Thread.new do
175
+ EventMachine.run do
176
+ @em_start_lock.synchronize { em_ready.signal }
177
+ end
178
+ end
179
+ em_ready.wait @em_start_lock
180
+ end
181
+ @em_clients += 1
182
+ end
183
+ end
184
+ @em_clients = 0
185
+ @em_start_lock = Mutex.new
186
+ @em_thread = nil
187
+
188
+ # Shuts down an EventMachine reactor if necessary.
189
+ def self.em_stop
190
+ @em_start_lock.synchronize do
191
+ @em_clients -= 1
192
+ if @em_clients == 0
193
+ if @em_thread
194
+ EventMachine.stop_event_loop
195
+ # HACK(pwnall): having these in slows down the code a lot
196
+ # EventMachine.reactor_thread.join
197
+ # @em_thread.join
198
+ end
199
+ @em_thread = nil
200
+ end
201
+ end
202
+ end
203
+ end # class WebkitRemote::Rpc
204
+
205
+ end # namespace webkitRemote