webkit_remote 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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